Add task links for attendance records
This commit is contained in:
parent
8f85ee7c1c
commit
0cb40e992f
@ -1,3 +1,4 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from rest_framework import serializers
|
||||
|
||||
from attendance.models import (
|
||||
@ -12,6 +13,53 @@ from api.v1.employees.serializers import EmployeeBriefSerializer
|
||||
from employees.constants import ACTION_REMOTE
|
||||
|
||||
|
||||
def _linked_task_payloads(record: AttendanceRecord, user) -> list[dict]:
|
||||
if not user or not getattr(user, "is_authenticated", False):
|
||||
return []
|
||||
|
||||
try:
|
||||
from tasks.access import task_board_access_q
|
||||
from tasks.models import (
|
||||
TaskBoard,
|
||||
TaskLinkedObject,
|
||||
TaskLinkedObjectKind,
|
||||
)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
content_type = ContentType.objects.get_for_model(AttendanceRecord)
|
||||
accessible_boards = TaskBoard.objects.filter(
|
||||
is_archived=False,
|
||||
).filter(task_board_access_q(user))
|
||||
|
||||
links = (
|
||||
TaskLinkedObject.objects.filter(
|
||||
kind=TaskLinkedObjectKind.ATTENDANCE_RECORD,
|
||||
content_type=content_type,
|
||||
object_id=record.id,
|
||||
task__board__in=accessible_boards,
|
||||
)
|
||||
.select_related("task", "task__board", "task__column")
|
||||
.order_by("task__title", "task_id")
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
"link_id": link.id,
|
||||
"id": link.task_id,
|
||||
"title": link.task.title,
|
||||
"board_id": link.task.board_id,
|
||||
"board_name": link.task.board.name,
|
||||
"column_id": link.task.column_id,
|
||||
"column_name": link.task.column.name,
|
||||
"column_color": link.task.column.color,
|
||||
"priority": link.task.priority,
|
||||
"priority_display": link.task.get_priority_display(),
|
||||
}
|
||||
for link in links
|
||||
]
|
||||
|
||||
|
||||
class DateOverrideSerializer(serializers.Serializer):
|
||||
date = serializers.DateField()
|
||||
is_workday = serializers.BooleanField()
|
||||
@ -310,6 +358,7 @@ class AttendanceRecordSerializer(serializers.ModelSerializer):
|
||||
analysis_run_id = serializers.IntegerField(read_only=True)
|
||||
comments_count = serializers.IntegerField(read_only=True, default=0)
|
||||
non_working_reason = serializers.SerializerMethodField()
|
||||
linked_tasks = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = AttendanceRecord
|
||||
@ -347,6 +396,7 @@ class AttendanceRecordSerializer(serializers.ModelSerializer):
|
||||
"manual_edited_at",
|
||||
"raw_data",
|
||||
"comments_count",
|
||||
"linked_tasks",
|
||||
"updated_at",
|
||||
)
|
||||
read_only_fields = fields
|
||||
@ -360,6 +410,11 @@ class AttendanceRecordSerializer(serializers.ModelSerializer):
|
||||
return "Выходной по графику/календарю"
|
||||
return "Нерабочий день"
|
||||
|
||||
def get_linked_tasks(self, obj):
|
||||
request = self.context.get("request")
|
||||
user = getattr(request, "user", None)
|
||||
return _linked_task_payloads(obj, user)
|
||||
|
||||
|
||||
class AttendanceRecordUpdateSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
|
||||
@ -587,7 +587,9 @@ class AttendanceRecordDetailAPIView(APIView):
|
||||
.annotate(**_comments_count_annotation()),
|
||||
pk=record_id,
|
||||
)
|
||||
return Response(AttendanceRecordSerializer(record).data)
|
||||
return Response(
|
||||
AttendanceRecordSerializer(record, context={"request": request}).data
|
||||
)
|
||||
|
||||
def patch(self, request, record_id):
|
||||
if not request.user.is_staff:
|
||||
@ -650,7 +652,9 @@ class AttendanceRecordDetailAPIView(APIView):
|
||||
.annotate(**_comments_count_annotation())
|
||||
.get(pk=record.pk)
|
||||
)
|
||||
return Response(AttendanceRecordSerializer(record).data)
|
||||
return Response(
|
||||
AttendanceRecordSerializer(record, context={"request": request}).data
|
||||
)
|
||||
|
||||
|
||||
class AttendanceRecordDayEventsAPIView(APIView):
|
||||
|
||||
@ -5,6 +5,7 @@ from django.utils import timezone
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework import serializers
|
||||
|
||||
from attendance.models import AttendanceRecord
|
||||
from api.v1.documents.serializers import DocumentReadSerializer
|
||||
from api.v1.employees.serializers import EmployeeBriefSerializer
|
||||
from api.v1.employees.serializers.department import DepartmentBriefSerializer
|
||||
@ -27,6 +28,7 @@ from tasks.access import (
|
||||
user_can_access_employee_request,
|
||||
user_can_access_guest,
|
||||
user_can_access_guest_visit,
|
||||
user_can_access_attendance_record,
|
||||
user_can_access_procurement_request,
|
||||
user_can_access_task_board,
|
||||
)
|
||||
@ -154,6 +156,7 @@ class TaskSerializer(serializers.ModelSerializer):
|
||||
linked_employees_count = serializers.SerializerMethodField()
|
||||
linked_guests_count = serializers.SerializerMethodField()
|
||||
linked_guest_visits_count = serializers.SerializerMethodField()
|
||||
linked_attendance_records_count = serializers.SerializerMethodField()
|
||||
linked_objects_count = serializers.SerializerMethodField()
|
||||
comments_count = serializers.SerializerMethodField()
|
||||
|
||||
@ -186,6 +189,7 @@ class TaskSerializer(serializers.ModelSerializer):
|
||||
"linked_employees_count",
|
||||
"linked_guests_count",
|
||||
"linked_guest_visits_count",
|
||||
"linked_attendance_records_count",
|
||||
"linked_objects_count",
|
||||
"comments_count",
|
||||
"created_at",
|
||||
@ -207,6 +211,7 @@ class TaskSerializer(serializers.ModelSerializer):
|
||||
"linked_employees_count",
|
||||
"linked_guests_count",
|
||||
"linked_guest_visits_count",
|
||||
"linked_attendance_records_count",
|
||||
"linked_objects_count",
|
||||
"comments_count",
|
||||
"created_at",
|
||||
@ -277,6 +282,14 @@ class TaskSerializer(serializers.ModelSerializer):
|
||||
kind=TaskLinkedObjectKind.GUEST_VISIT,
|
||||
).count()
|
||||
|
||||
@extend_schema_field(serializers.IntegerField())
|
||||
def get_linked_attendance_records_count(self, obj):
|
||||
if hasattr(obj, "linked_attendance_records_count"):
|
||||
return obj.linked_attendance_records_count
|
||||
return obj.linked_objects.filter(
|
||||
kind=TaskLinkedObjectKind.ATTENDANCE_RECORD,
|
||||
).count()
|
||||
|
||||
@extend_schema_field(serializers.IntegerField())
|
||||
def get_linked_objects_count(self, obj):
|
||||
if hasattr(obj, "linked_objects_count"):
|
||||
@ -992,3 +1005,88 @@ class TaskLinkedGuestVisitSerializer(serializers.ModelSerializer):
|
||||
|
||||
def get_guest_visit_content_type():
|
||||
return ContentType.objects.get_for_model(GuestVisit)
|
||||
|
||||
|
||||
class TaskLinkedAttendanceRecordSerializer(serializers.ModelSerializer):
|
||||
created_by = EmployeeBriefSerializer(read_only=True)
|
||||
attendance_record = serializers.SerializerMethodField()
|
||||
attendance_record_id = serializers.IntegerField(source="object_id", read_only=True)
|
||||
can_open = serializers.SerializerMethodField()
|
||||
object_url = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = TaskLinkedObject
|
||||
fields = [
|
||||
"id",
|
||||
"kind",
|
||||
"attendance_record_id",
|
||||
"attendance_record",
|
||||
"can_open",
|
||||
"object_url",
|
||||
"created_by",
|
||||
"created_at",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
@extend_schema_field(serializers.JSONField(allow_null=True))
|
||||
def get_attendance_record(self, obj):
|
||||
if obj.kind != TaskLinkedObjectKind.ATTENDANCE_RECORD:
|
||||
return None
|
||||
|
||||
record = getattr(obj, "content_object", None)
|
||||
if record is None:
|
||||
return None
|
||||
|
||||
return {
|
||||
"id": record.id,
|
||||
"employee_id": record.employee_id,
|
||||
"employee": EmployeeBriefSerializer(record.employee).data,
|
||||
"date": record.date,
|
||||
"display_name": record.display_name,
|
||||
"arrival_time": record.arrival_time,
|
||||
"departure_time": record.departure_time,
|
||||
"work_hours": record.work_hours,
|
||||
"expected_hours": record.expected_hours,
|
||||
"is_workday": record.is_workday,
|
||||
"effective_is_workday": record.effective_is_workday,
|
||||
"is_late": record.is_late,
|
||||
"late_minutes": record.late_minutes,
|
||||
"is_early_leave": record.is_early_leave,
|
||||
"early_leave_minutes": record.early_leave_minutes,
|
||||
"is_underwork": record.is_underwork,
|
||||
"underwork_hours": record.underwork_hours,
|
||||
"is_overtime": record.is_overtime,
|
||||
"overtime_hours": record.overtime_hours,
|
||||
"is_absent": record.is_absent,
|
||||
"statuses": record.statuses,
|
||||
"employee_issues": record.employee_issues,
|
||||
"technical_issues": record.technical_issues,
|
||||
"personnel_status": record.personnel_status,
|
||||
"personnel_status_label": record.personnel_status_label,
|
||||
"comments_count": get_comment_count(record),
|
||||
"updated_at": record.updated_at,
|
||||
}
|
||||
|
||||
@extend_schema_field(serializers.BooleanField())
|
||||
def get_can_open(self, obj):
|
||||
record = getattr(obj, "content_object", None)
|
||||
request = self.context.get("request")
|
||||
user = getattr(request, "user", None)
|
||||
if (
|
||||
obj.kind != TaskLinkedObjectKind.ATTENDANCE_RECORD
|
||||
or record is None
|
||||
or not user
|
||||
):
|
||||
return False
|
||||
return user_can_access_attendance_record(user, record)
|
||||
|
||||
@extend_schema_field(serializers.CharField(allow_null=True))
|
||||
def get_object_url(self, obj):
|
||||
record = getattr(obj, "content_object", None)
|
||||
if record is None or not self.get_can_open(obj):
|
||||
return None
|
||||
return f"/attendance?record={record.id}"
|
||||
|
||||
|
||||
def get_attendance_record_content_type():
|
||||
return ContentType.objects.get_for_model(AttendanceRecord)
|
||||
|
||||
@ -10,6 +10,7 @@ from django.db.models import (
|
||||
Value,
|
||||
)
|
||||
from django.db.models.functions import Coalesce
|
||||
from attendance.models import AttendanceRecord
|
||||
from communications import comments_helpers
|
||||
from communications.models import Message
|
||||
from communications.utils import user_can_access_chat
|
||||
@ -31,6 +32,7 @@ from tasks.access import (
|
||||
user_can_access_employee_request,
|
||||
user_can_access_guest,
|
||||
user_can_access_guest_visit,
|
||||
user_can_access_attendance_record,
|
||||
user_can_access_procurement_request,
|
||||
)
|
||||
from tasks.models import (
|
||||
@ -55,6 +57,7 @@ from .serializers import (
|
||||
TaskColumnSerializer,
|
||||
TaskLabelSerializer,
|
||||
TaskLinkedCalendarEventSerializer,
|
||||
TaskLinkedAttendanceRecordSerializer,
|
||||
TaskLinkedDocumentSerializer,
|
||||
TaskLinkedEmployeeSerializer,
|
||||
TaskLinkedGuestSerializer,
|
||||
@ -64,6 +67,7 @@ from .serializers import (
|
||||
TaskLinkedRequestSerializer,
|
||||
TaskSerializer,
|
||||
create_default_columns,
|
||||
get_attendance_record_content_type,
|
||||
get_calendar_event_content_type,
|
||||
get_document_content_type,
|
||||
get_employee_content_type,
|
||||
@ -333,6 +337,15 @@ class TaskViewSet(viewsets.ModelViewSet):
|
||||
),
|
||||
distinct=True,
|
||||
),
|
||||
linked_attendance_records_count=Count(
|
||||
"linked_objects",
|
||||
filter=Q(
|
||||
linked_objects__kind=(
|
||||
TaskLinkedObjectKind.ATTENDANCE_RECORD
|
||||
),
|
||||
),
|
||||
distinct=True,
|
||||
),
|
||||
linked_objects_count=Count("linked_objects", distinct=True),
|
||||
comments_count=Coalesce(
|
||||
Subquery(comments_subquery, output_field=IntegerField()),
|
||||
@ -862,6 +875,45 @@ class TaskViewSet(viewsets.ModelViewSet):
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=False, methods=["get"], url_path="linked-attendance-record-tasks")
|
||||
def linked_attendance_record_tasks(self, request):
|
||||
attendance_record_id = request.query_params.get("attendance_record_id")
|
||||
try:
|
||||
attendance_record_id = int(attendance_record_id)
|
||||
except (TypeError, ValueError):
|
||||
return Response(
|
||||
{"attendance_record_id": ["Укажите ID записи посещаемости."]},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
record = AttendanceRecord.objects.select_related("employee").get(
|
||||
id=attendance_record_id,
|
||||
)
|
||||
except AttendanceRecord.DoesNotExist:
|
||||
return Response(
|
||||
{"attendance_record_id": ["Запись посещаемости не найдена."]},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
if not user_can_access_attendance_record(request.user, record):
|
||||
return Response(
|
||||
{"detail": "Нет доступа к этой записи посещаемости."},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
queryset = (
|
||||
self.get_queryset()
|
||||
.filter(
|
||||
linked_objects__kind=TaskLinkedObjectKind.ATTENDANCE_RECORD,
|
||||
linked_objects__content_type=get_attendance_record_content_type(),
|
||||
linked_objects__object_id=record.id,
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
def _message_links_queryset(self, task):
|
||||
return (
|
||||
TaskLinkedObject.objects.filter(
|
||||
@ -942,6 +994,16 @@ class TaskViewSet(viewsets.ModelViewSet):
|
||||
.order_by("-created_at", "-id")
|
||||
)
|
||||
|
||||
def _attendance_record_links_queryset(self, task):
|
||||
return (
|
||||
TaskLinkedObject.objects.filter(
|
||||
task=task,
|
||||
kind=TaskLinkedObjectKind.ATTENDANCE_RECORD,
|
||||
)
|
||||
.select_related("created_by", "content_type")
|
||||
.order_by("-created_at", "-id")
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["get", "post"], url_path="linked-messages")
|
||||
def linked_messages(self, request, pk=None):
|
||||
task = self.get_object()
|
||||
@ -1707,6 +1769,112 @@ class TaskViewSet(viewsets.ModelViewSet):
|
||||
)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@action(detail=True, methods=["get", "post"], url_path="linked-attendance-records")
|
||||
def linked_attendance_records(self, request, pk=None):
|
||||
task = self.get_object()
|
||||
if request.method == "GET":
|
||||
serializer = TaskLinkedAttendanceRecordSerializer(
|
||||
self._attendance_record_links_queryset(task),
|
||||
many=True,
|
||||
context={"request": request},
|
||||
)
|
||||
return Response(serializer.data)
|
||||
|
||||
attendance_record_id = request.data.get("attendance_record_id")
|
||||
try:
|
||||
attendance_record_id = int(attendance_record_id)
|
||||
except (TypeError, ValueError):
|
||||
return Response(
|
||||
{"attendance_record_id": ["Укажите ID записи посещаемости."]},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
record = AttendanceRecord.objects.select_related("employee").get(
|
||||
id=attendance_record_id,
|
||||
)
|
||||
except AttendanceRecord.DoesNotExist:
|
||||
return Response(
|
||||
{"attendance_record_id": ["Запись посещаемости не найдена."]},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
if not user_can_access_attendance_record(request.user, record):
|
||||
return Response(
|
||||
{"detail": "Нет доступа к этой записи посещаемости."},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
link, created = TaskLinkedObject.objects.get_or_create(
|
||||
task=task,
|
||||
kind=TaskLinkedObjectKind.ATTENDANCE_RECORD,
|
||||
content_type=get_attendance_record_content_type(),
|
||||
object_id=record.id,
|
||||
defaults={"created_by": request.user},
|
||||
)
|
||||
serializer = TaskLinkedAttendanceRecordSerializer(
|
||||
link,
|
||||
context={"request": request},
|
||||
)
|
||||
if created:
|
||||
employee_name = str(record.employee) if record.employee_id else ""
|
||||
create_task_activity(
|
||||
task,
|
||||
request.user,
|
||||
TaskActivityAction.LINKED,
|
||||
object_kind=TaskLinkedObjectKind.ATTENDANCE_RECORD,
|
||||
object_id=record.id,
|
||||
metadata={
|
||||
"object_label": f"{employee_name} {record.date}".strip(),
|
||||
"object_type": "Запись посещаемости",
|
||||
},
|
||||
)
|
||||
send_task_board_update(
|
||||
task.board,
|
||||
"linked",
|
||||
"attendance_record",
|
||||
record.id,
|
||||
extra={"task_id": task.id, "link_id": link.id},
|
||||
)
|
||||
return Response(
|
||||
serializer.data,
|
||||
status=status.HTTP_201_CREATED if created else status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@action(
|
||||
detail=True,
|
||||
methods=["delete"],
|
||||
url_path=r"linked-attendance-records/(?P<link_id>\d+)",
|
||||
)
|
||||
def unlink_attendance_record(self, request, pk=None, link_id=None):
|
||||
task = self.get_object()
|
||||
try:
|
||||
link = self._attendance_record_links_queryset(task).get(id=link_id)
|
||||
except TaskLinkedObject.DoesNotExist:
|
||||
return Response(
|
||||
{"detail": "Связь не найдена."},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
attendance_record_id = link.object_id
|
||||
link.delete()
|
||||
create_task_activity(
|
||||
task,
|
||||
request.user,
|
||||
TaskActivityAction.UNLINKED,
|
||||
object_kind=TaskLinkedObjectKind.ATTENDANCE_RECORD,
|
||||
object_id=attendance_record_id,
|
||||
metadata={"object_type": "Запись посещаемости"},
|
||||
)
|
||||
send_task_board_update(
|
||||
task.board,
|
||||
"unlinked",
|
||||
"attendance_record",
|
||||
attendance_record_id,
|
||||
extra={"task_id": task.id, "link_id": int(link_id)},
|
||||
)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@action(detail=True, methods=["get", "post"], url_path="linked-events")
|
||||
def linked_events(self, request, pk=None):
|
||||
task = self.get_object()
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
from django.db.models import Q
|
||||
from attendance.models import AttendanceRecord
|
||||
from documents.models import Document
|
||||
from employees.models import Department, EmployeeDepartment, RoleAssignment
|
||||
from requests_app.enums import RequestStatus
|
||||
@ -161,3 +162,16 @@ def user_can_access_guest_visit(user, visit) -> bool:
|
||||
from guests.permissions import is_guest_admin
|
||||
|
||||
return is_guest_admin(user) or visit.inviter_id == user.id
|
||||
|
||||
|
||||
def user_can_access_attendance_record(
|
||||
user,
|
||||
record: AttendanceRecord | None,
|
||||
) -> bool:
|
||||
if not user or not user.is_authenticated or record is None:
|
||||
return False
|
||||
return bool(
|
||||
user.is_staff
|
||||
or user.is_superuser
|
||||
or record.employee_id == user.id
|
||||
)
|
||||
|
||||
@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.4 on 2026-07-06 19:03
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('tasks', '0009_add_guest_link_kinds'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='taskactivity',
|
||||
name='object_kind',
|
||||
field=models.CharField(blank=True, choices=[('message', 'Сообщение'), ('calendar_event', 'Календарное событие'), ('document', 'Документ'), ('request', 'Заявление'), ('procurement_request', 'Заявка на закупку'), ('employee', 'Сотрудник'), ('guest', 'Гость'), ('guest_visit', 'Заявка на гостевой визит'), ('attendance_record', 'Запись посещаемости')], max_length=32, verbose_name='Тип объекта'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='tasklinkedobject',
|
||||
name='kind',
|
||||
field=models.CharField(choices=[('message', 'Сообщение'), ('calendar_event', 'Календарное событие'), ('document', 'Документ'), ('request', 'Заявление'), ('procurement_request', 'Заявка на закупку'), ('employee', 'Сотрудник'), ('guest', 'Гость'), ('guest_visit', 'Заявка на гостевой визит'), ('attendance_record', 'Запись посещаемости')], max_length=32, verbose_name='Тип связи'),
|
||||
),
|
||||
]
|
||||
@ -182,6 +182,7 @@ class TaskLinkedObjectKind(models.TextChoices):
|
||||
EMPLOYEE = "employee", "Сотрудник"
|
||||
GUEST = "guest", "Гость"
|
||||
GUEST_VISIT = "guest_visit", "Заявка на гостевой визит"
|
||||
ATTENDANCE_RECORD = "attendance_record", "Запись посещаемости"
|
||||
|
||||
|
||||
class TaskActivityAction(models.TextChoices):
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
from unittest.mock import patch
|
||||
from io import BytesIO
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from openpyxl import load_workbook
|
||||
@ -22,6 +23,13 @@ from employees.constants import (
|
||||
ACTION_RETURNED_FROM_LEAVE,
|
||||
)
|
||||
from employees.models import EmployeeAction
|
||||
from tasks.models import (
|
||||
Task,
|
||||
TaskBoard,
|
||||
TaskColumn,
|
||||
TaskLinkedObject,
|
||||
TaskLinkedObjectKind,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@ -185,6 +193,68 @@ def test_analyze_saves_run_and_records(auth_client_factory, user_factory):
|
||||
assert record.raw_data["employee_id"] == "42"
|
||||
|
||||
|
||||
def test_attendance_records_include_visible_linked_tasks(
|
||||
auth_client_factory,
|
||||
user_factory,
|
||||
):
|
||||
employee = user_factory()
|
||||
client = auth_client_factory(employee)
|
||||
run = AttendanceAnalysisRun.objects.create(
|
||||
employee=employee,
|
||||
period_start=date(2026, 4, 20),
|
||||
period_end=date(2026, 4, 20),
|
||||
)
|
||||
record = AttendanceRecord.objects.create(
|
||||
analysis_run=run,
|
||||
employee=employee,
|
||||
date=date(2026, 4, 20),
|
||||
display_name="Сотрудник Посещаемость",
|
||||
)
|
||||
board = TaskBoard.objects.create(
|
||||
name="Доска посещаемости",
|
||||
created_by=employee,
|
||||
)
|
||||
column = TaskColumn.objects.create(
|
||||
board=board,
|
||||
name="Новые",
|
||||
color="#38bdf8",
|
||||
position=1000,
|
||||
)
|
||||
task = Task.objects.create(
|
||||
board=board,
|
||||
column=column,
|
||||
title="Разобрать посещаемость",
|
||||
created_by=employee,
|
||||
priority="high",
|
||||
)
|
||||
link = TaskLinkedObject.objects.create(
|
||||
task=task,
|
||||
kind=TaskLinkedObjectKind.ATTENDANCE_RECORD,
|
||||
content_type=ContentType.objects.get_for_model(AttendanceRecord),
|
||||
object_id=record.id,
|
||||
created_by=employee,
|
||||
)
|
||||
|
||||
response = client.get(_records_url(), {"employee_id": employee.id})
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.data["results"][0]
|
||||
assert payload["linked_tasks"] == [
|
||||
{
|
||||
"link_id": link.id,
|
||||
"id": task.id,
|
||||
"title": "Разобрать посещаемость",
|
||||
"board_id": board.id,
|
||||
"board_name": "Доска посещаемости",
|
||||
"column_id": column.id,
|
||||
"column_name": "Новые",
|
||||
"column_color": "#38bdf8",
|
||||
"priority": "high",
|
||||
"priority_display": "Высокий",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_weekly_summary_counts_present_and_absent_people(
|
||||
auth_client_factory,
|
||||
user_factory,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
from unittest.mock import patch
|
||||
from datetime import timedelta
|
||||
from datetime import date, timedelta
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth import get_user_model
|
||||
@ -9,6 +9,7 @@ from django.utils import timezone
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from attendance.models import AttendanceAnalysisRun, AttendanceRecord
|
||||
from communications.models import Chat, ChatMembership, Message
|
||||
from documents.models import Document
|
||||
from employees.models import Department, EmployeeDepartment, RoleAssignment
|
||||
@ -892,6 +893,92 @@ def test_task_linked_guest_visit_respects_visit_access(api_client):
|
||||
assert reverse_linked_response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
def test_task_linked_attendance_record_respects_record_access(api_client):
|
||||
owner = make_user("task-attendance-owner@example.com", "+79994440042")
|
||||
employee = make_user(
|
||||
"task-attendance-employee@example.com",
|
||||
"+79994440043",
|
||||
first_name="Петр",
|
||||
last_name="Посещаемый",
|
||||
)
|
||||
outsider = make_user("task-attendance-outsider@example.com", "+79994440044")
|
||||
run = AttendanceAnalysisRun.objects.create(
|
||||
employee=employee,
|
||||
period_start=date(2026, 4, 20),
|
||||
period_end=date(2026, 4, 20),
|
||||
triggered_by=owner,
|
||||
)
|
||||
record = AttendanceRecord.objects.create(
|
||||
analysis_run=run,
|
||||
employee=employee,
|
||||
date=date(2026, 4, 20),
|
||||
display_name="Посещаемый Петр",
|
||||
arrival_time="09:10",
|
||||
departure_time="18:05",
|
||||
work_hours=8.9,
|
||||
expected_hours=9,
|
||||
is_late=True,
|
||||
late_minutes=10,
|
||||
)
|
||||
board = TaskBoard.objects.create(
|
||||
name="Доска с посещаемостью",
|
||||
created_by=owner,
|
||||
)
|
||||
board.members.add(employee)
|
||||
column = TaskColumn.objects.create(
|
||||
board=board,
|
||||
name="Новые",
|
||||
position=1000,
|
||||
color="#38bdf8",
|
||||
)
|
||||
task = Task.objects.create(
|
||||
board=board,
|
||||
column=column,
|
||||
title="Проверить посещаемость",
|
||||
created_by=owner,
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=employee)
|
||||
link_response = api_client.post(
|
||||
reverse(
|
||||
"api:v1:tasks:task-linked-attendance-records",
|
||||
kwargs={"pk": task.id},
|
||||
),
|
||||
{"attendance_record_id": record.id},
|
||||
format="json",
|
||||
)
|
||||
assert link_response.status_code == status.HTTP_201_CREATED
|
||||
assert link_response.data["attendance_record_id"] == record.id
|
||||
assert link_response.data["attendance_record"]["employee"]["id"] == employee.id
|
||||
assert str(link_response.data["attendance_record"]["date"]) == "2026-04-20"
|
||||
assert link_response.data["can_open"] is True
|
||||
assert link_response.data["object_url"] == f"/attendance?record={record.id}"
|
||||
|
||||
linked_response = api_client.get(
|
||||
reverse(
|
||||
"api:v1:tasks:task-linked-attendance-records",
|
||||
kwargs={"pk": task.id},
|
||||
)
|
||||
)
|
||||
assert linked_response.status_code == status.HTTP_200_OK
|
||||
assert linked_response.data[0]["attendance_record"]["arrival_time"] == "09:10"
|
||||
|
||||
reverse_linked_response = api_client.get(
|
||||
reverse("api:v1:tasks:task-linked-attendance-record-tasks"),
|
||||
{"attendance_record_id": record.id},
|
||||
)
|
||||
assert reverse_linked_response.status_code == status.HTTP_200_OK
|
||||
assert reverse_linked_response.data[0]["id"] == task.id
|
||||
assert reverse_linked_response.data[0]["linked_attendance_records_count"] == 1
|
||||
|
||||
api_client.force_authenticate(user=outsider)
|
||||
reverse_linked_response = api_client.get(
|
||||
reverse("api:v1:tasks:task-linked-attendance-record-tasks"),
|
||||
{"attendance_record_id": record.id},
|
||||
)
|
||||
assert reverse_linked_response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
def test_task_activity_records_core_actions(api_client, user):
|
||||
api_client.force_authenticate(user=user)
|
||||
board_response = api_client.get(
|
||||
|
||||
@ -856,9 +856,7 @@ export default function AttendancePage() {
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const shouldOpenEvents = params.get("events") === "1";
|
||||
const shouldOpenComments = params.get("comments") === "1";
|
||||
if (!shouldOpenEvents && !shouldOpenComments) return;
|
||||
const recordId = Number(params.get("record"));
|
||||
if (!Number.isInteger(recordId) || recordId <= 0) return;
|
||||
|
||||
|
||||
@ -56,6 +56,7 @@ import type {
|
||||
TaskCard,
|
||||
TaskColumn,
|
||||
TaskComment,
|
||||
TaskLinkedAttendanceRecord,
|
||||
TaskLinkedDocument,
|
||||
TaskLinkedEmployee,
|
||||
TaskLinkedCalendarEvent,
|
||||
@ -1353,6 +1354,123 @@ function LinkedGuestVisitCard({
|
||||
);
|
||||
}
|
||||
|
||||
function formatAttendanceHours(value: unknown) {
|
||||
const numeric = Number(value || 0);
|
||||
if (!Number.isFinite(numeric)) return "0";
|
||||
return Number.isInteger(numeric) ? String(numeric) : numeric.toFixed(1);
|
||||
}
|
||||
|
||||
function LinkedAttendanceRecordCard({
|
||||
link,
|
||||
onUnlink,
|
||||
disabled,
|
||||
}: {
|
||||
link: TaskLinkedAttendanceRecord;
|
||||
onUnlink: (linkId: number) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const record = link.attendance_record;
|
||||
const canOpen = Boolean(link.can_open && link.object_url);
|
||||
const employeeName = record?.display_name
|
||||
|| (record?.employee ? displayUserName(record.employee) : "")
|
||||
|| "Сотрудник не указан";
|
||||
const issueLabels = [
|
||||
record?.is_late ? "Опоздание" : "",
|
||||
record?.is_early_leave ? "Ранний уход" : "",
|
||||
record?.is_underwork ? "Недоработка" : "",
|
||||
record?.is_overtime ? "Переработка" : "",
|
||||
record?.is_absent ? "Отсутствие" : "",
|
||||
].filter(Boolean);
|
||||
const mainContent = (
|
||||
<>
|
||||
<div className="mb-2 min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="app-pill inline-flex items-center gap-1.5 rounded-full px-2 py-1 text-xs font-medium">
|
||||
<CalendarDays size={12} />
|
||||
Посещаемость
|
||||
</span>
|
||||
{record?.personnel_status_label ? (
|
||||
<span className="app-badge rounded-full px-2 py-1 text-[11px]">
|
||||
{record.personnel_status_label}
|
||||
</span>
|
||||
) : null}
|
||||
{canOpen ? (
|
||||
<span className="app-badge rounded-full px-2 py-1 text-[11px]">
|
||||
Открыть
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-2 text-sm font-semibold text-[var(--foreground)]">
|
||||
{employeeName}
|
||||
</p>
|
||||
{record?.date ? (
|
||||
<p className="app-text-muted mt-0.5 text-xs">
|
||||
{formatDate(record.date)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="app-text-muted mt-2 flex flex-wrap items-center gap-2 text-[11px]">
|
||||
<span className="app-badge inline-flex max-w-full items-center gap-1 rounded-full px-2 py-1 text-[11px]">
|
||||
Приход: {record?.arrival_time || "Нет"}
|
||||
</span>
|
||||
<span className="app-badge inline-flex max-w-full items-center gap-1 rounded-full px-2 py-1 text-[11px]">
|
||||
Уход: {record?.departure_time || "Нет"}
|
||||
</span>
|
||||
<span className="app-badge inline-flex max-w-full items-center gap-1 rounded-full px-2 py-1 text-[11px]">
|
||||
Часы: {formatAttendanceHours(record?.work_hours)}
|
||||
{record?.expected_hours !== undefined ? ` / ${formatAttendanceHours(record.expected_hours)}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{issueLabels.length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{issueLabels.map((label) => (
|
||||
<span key={label} className="app-feedback-warning rounded-full px-2 py-1 text-[11px]">
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<p className="app-text-muted mt-2 text-[11px]">
|
||||
Связал: {link.created_by ? displayUserName(link.created_by) : "не указано"}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<article className="app-surface-muted rounded-xl border border-[var(--border-subtle)] p-3">
|
||||
<div className="relative">
|
||||
{canOpen && link.object_url ? (
|
||||
<Link
|
||||
href={link.object_url}
|
||||
className="block rounded-lg pr-10 transition hover:bg-[var(--surface-elevated)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-primary)]"
|
||||
title="Открыть запись посещаемости"
|
||||
>
|
||||
{mainContent}
|
||||
</Link>
|
||||
) : (
|
||||
<div className="pr-10">
|
||||
{mainContent}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onUnlink(link.id)}
|
||||
disabled={disabled}
|
||||
className="app-icon-button absolute right-0 top-0 z-10 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg disabled:opacity-50"
|
||||
title="Убрать связь"
|
||||
aria-label="Убрать связь с записью посещаемости"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
const activityFieldLabels: Record<string, string> = {
|
||||
title: "название",
|
||||
description: "описание",
|
||||
@ -1459,6 +1577,7 @@ export default function TasksPage() {
|
||||
const [linkedEmployees, setLinkedEmployees] = useState<TaskLinkedEmployee[]>([]);
|
||||
const [linkedGuests, setLinkedGuests] = useState<TaskLinkedGuest[]>([]);
|
||||
const [linkedGuestVisits, setLinkedGuestVisits] = useState<TaskLinkedGuestVisit[]>([]);
|
||||
const [linkedAttendanceRecords, setLinkedAttendanceRecords] = useState<TaskLinkedAttendanceRecord[]>([]);
|
||||
const [linkedObjectsLoading, setLinkedObjectsLoading] = useState(false);
|
||||
const [commentsOpen, setCommentsOpen] = useState(false);
|
||||
const [taskComments, setTaskComments] = useState<TaskComment[]>([]);
|
||||
@ -1512,6 +1631,7 @@ export default function TasksPage() {
|
||||
employees,
|
||||
guests,
|
||||
guestVisits,
|
||||
attendanceRecords,
|
||||
] = await Promise.all([
|
||||
apiClient.getTaskLinkedMessages(taskId),
|
||||
apiClient.getTaskLinkedEvents(taskId),
|
||||
@ -1521,6 +1641,7 @@ export default function TasksPage() {
|
||||
apiClient.getTaskLinkedEmployees(taskId),
|
||||
apiClient.getTaskLinkedGuests(taskId),
|
||||
apiClient.getTaskLinkedGuestVisits(taskId),
|
||||
apiClient.getTaskLinkedAttendanceRecords(taskId),
|
||||
]);
|
||||
setLinkedMessages(messages);
|
||||
setLinkedEvents(events);
|
||||
@ -1530,6 +1651,7 @@ export default function TasksPage() {
|
||||
setLinkedEmployees(employees);
|
||||
setLinkedGuests(guests);
|
||||
setLinkedGuestVisits(guestVisits);
|
||||
setLinkedAttendanceRecords(attendanceRecords);
|
||||
} catch (linksError) {
|
||||
setError(getTaskError(linksError, "Не удалось загрузить связанные объекты"));
|
||||
} finally {
|
||||
@ -1862,7 +1984,7 @@ export default function TasksPage() {
|
||||
() => activeColumns.find((column) => column.id === viewTask?.column) || null,
|
||||
[activeColumns, viewTask?.column],
|
||||
);
|
||||
const linkedObjectsCount = linkedMessages.length + linkedEvents.length + linkedDocuments.length + linkedRequests.length + linkedProcurementRequests.length + linkedEmployees.length + linkedGuests.length + linkedGuestVisits.length;
|
||||
const linkedObjectsCount = linkedMessages.length + linkedEvents.length + linkedDocuments.length + linkedRequests.length + linkedProcurementRequests.length + linkedEmployees.length + linkedGuests.length + linkedGuestVisits.length + linkedAttendanceRecords.length;
|
||||
const linkedObjectsBadgeCount = viewTask?.linked_objects_count ?? linkedObjectsCount;
|
||||
const commentsBadgeCount = viewTask?.comments_count ?? taskComments.length;
|
||||
|
||||
@ -1885,6 +2007,7 @@ export default function TasksPage() {
|
||||
setLinkedEmployees([]);
|
||||
setLinkedGuests([]);
|
||||
setLinkedGuestVisits([]);
|
||||
setLinkedAttendanceRecords([]);
|
||||
setTaskActivities([]);
|
||||
setTaskComments([]);
|
||||
setCommentDraft("");
|
||||
@ -1935,6 +2058,7 @@ export default function TasksPage() {
|
||||
setLinkedEmployees([]);
|
||||
setLinkedGuests([]);
|
||||
setLinkedGuestVisits([]);
|
||||
setLinkedAttendanceRecords([]);
|
||||
setTaskComments([]);
|
||||
setTaskActivities([]);
|
||||
setCommentDraft("");
|
||||
@ -1958,6 +2082,7 @@ export default function TasksPage() {
|
||||
setLinkedEmployees([]);
|
||||
setLinkedGuests([]);
|
||||
setLinkedGuestVisits([]);
|
||||
setLinkedAttendanceRecords([]);
|
||||
setTaskComments([]);
|
||||
setTaskActivities([]);
|
||||
setLinkedObjectsOpen(false);
|
||||
@ -2424,6 +2549,23 @@ export default function TasksPage() {
|
||||
}
|
||||
}, [activityOpen, board, loadBoard, loadTaskActivity, loadTaskLinkedObjects, saving, viewTask]);
|
||||
|
||||
const unlinkAttendanceRecordFromViewedTask = useCallback(async (linkId: number) => {
|
||||
if (!board || !viewTask || saving) return;
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await apiClient.unlinkTaskAttendanceRecord(viewTask.id, linkId);
|
||||
await loadTaskLinkedObjects(viewTask.id);
|
||||
await loadBoard(board.id);
|
||||
if (activityOpen) await loadTaskActivity(viewTask.id);
|
||||
} catch (unlinkError) {
|
||||
setError(getTaskError(unlinkError, "Не удалось убрать связь с записью посещаемости"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [activityOpen, board, loadBoard, loadTaskActivity, loadTaskLinkedObjects, saving, viewTask]);
|
||||
|
||||
const changeViewedTaskColumn = useCallback(async (columnId: number) => {
|
||||
if (!board || !viewTask || saving || viewTask.column === columnId) {
|
||||
setViewColumnMenuOpen(false);
|
||||
@ -3274,6 +3416,14 @@ export default function TasksPage() {
|
||||
disabled={saving}
|
||||
/>
|
||||
))}
|
||||
{linkedAttendanceRecords.map((link) => (
|
||||
<LinkedAttendanceRecordCard
|
||||
key={link.id}
|
||||
link={link}
|
||||
onUnlink={unlinkAttendanceRecordFromViewedTask}
|
||||
disabled={saving}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div className="app-surface-muted rounded-xl border border-dashed border-[var(--border-subtle)] px-3 py-4 text-center">
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
ChevronRight,
|
||||
Clock,
|
||||
Edit3,
|
||||
Link2,
|
||||
Loader2,
|
||||
LogIn,
|
||||
LogOut,
|
||||
@ -21,6 +22,8 @@ import {
|
||||
type AttendanceDayEventsPreview,
|
||||
} from "@/components/attendance/AttendanceDayEventsModal";
|
||||
import { CommentComposer, CommentDeleteButton } from "@/components/shared/CommentControls";
|
||||
import TaskLinkPill from "@/components/tasks/TaskLinkPill";
|
||||
import { RelatedTaskLinks } from "@/components/tasks/RelatedTaskLinks";
|
||||
import { Modal } from "@/components/ui";
|
||||
import { useUser } from "@/contexts/UserContext";
|
||||
import { apiClient } from "@/lib/api";
|
||||
@ -32,7 +35,7 @@ import type {
|
||||
AttendanceRecordUpdatePayload,
|
||||
PaginatedAttendanceRecords,
|
||||
} from "@/lib/api/attendance";
|
||||
import type { EmployeeAction } from "@/types/api";
|
||||
import type { EmployeeAction, TaskCard } from "@/types/api";
|
||||
import { getPersonnelDayMeta } from "./personnel-day-meta.js";
|
||||
|
||||
type EmployeeAttendanceCardProps = {
|
||||
@ -444,6 +447,7 @@ type AttendanceCardApiClient = typeof apiClient & {
|
||||
addAttendanceRecordComment?: unknown;
|
||||
analyzeAttendance?: unknown;
|
||||
deleteAttendanceRecordComment?: unknown;
|
||||
getAttendanceRecord?: unknown;
|
||||
getAttendanceRecordComments?: unknown;
|
||||
getAttendanceRecords?: unknown;
|
||||
updateAttendanceRecord?: unknown;
|
||||
@ -496,6 +500,14 @@ function getAttendanceRecords(params: {
|
||||
);
|
||||
}
|
||||
|
||||
function getAttendanceRecord(recordId: number) {
|
||||
const client = attendanceApiClient();
|
||||
if (typeof client.getAttendanceRecord === "function") {
|
||||
return client.getAttendanceRecord(recordId) as Promise<AttendanceRecord>;
|
||||
}
|
||||
return client.request<AttendanceRecord>(`/api/v1/attendance/records/${recordId}/`);
|
||||
}
|
||||
|
||||
function getAttendanceRecordComments(recordId: number) {
|
||||
const client = attendanceApiClient();
|
||||
if (typeof client.getAttendanceRecordComments === "function") {
|
||||
@ -577,6 +589,7 @@ export default function EmployeeAttendanceCard({
|
||||
const [aliasError, setAliasError] = useState<string | null>(null);
|
||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||
const [editForm, setEditForm] = useState<AttendanceRecordEditForm | null>(null);
|
||||
const [taskLinkRecord, setTaskLinkRecord] = useState<AttendanceRecord | null>(null);
|
||||
|
||||
const canManageAttendance = Boolean(
|
||||
currentUser?.auth?.is_staff || currentUser?.auth?.is_superuser,
|
||||
@ -744,6 +757,23 @@ export default function EmployeeAttendanceCard({
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshRecord(recordId: number) {
|
||||
const updated = await getAttendanceRecord(recordId);
|
||||
setResult((current) => {
|
||||
if (!current?.records) return current;
|
||||
return {
|
||||
...current,
|
||||
records: current.records.map((record) =>
|
||||
getRecordId(record) === recordId ? { ...record, ...updated } : record,
|
||||
),
|
||||
};
|
||||
});
|
||||
setTaskLinkRecord((current) => (
|
||||
current && getRecordId(current) === recordId ? updated : current
|
||||
));
|
||||
return updated;
|
||||
}
|
||||
|
||||
async function ensureCommentsLoaded(recordId: number) {
|
||||
if (commentsMap[recordId] || commentsLoadingMap[recordId]) return;
|
||||
|
||||
@ -942,6 +972,23 @@ export default function EmployeeAttendanceCard({
|
||||
}
|
||||
}
|
||||
|
||||
const taskLinkRecordId = taskLinkRecord ? getRecordId(taskLinkRecord) : 0;
|
||||
const taskLinkEmployeeName = taskLinkRecord?.display_name
|
||||
|| displayUserName(currentUser)
|
||||
|| `Сотрудник ${employeeId}`;
|
||||
const taskLinkDate = taskLinkRecord?.date || "Дата не указана";
|
||||
const taskLinkDescription = taskLinkRecord
|
||||
? [
|
||||
`Сотрудник: ${taskLinkEmployeeName}`,
|
||||
`Дата: ${taskLinkDate}`,
|
||||
`Приход: ${formatTime(taskLinkRecord.arrival_time)}`,
|
||||
`Уход: ${formatTime(taskLinkRecord.departure_time)}`,
|
||||
`Часы: ${formatHours(taskLinkRecord.work_hours)} / ${formatHours(taskLinkRecord.expected_hours)}`,
|
||||
...issueLabels(taskLinkRecord, getPersonnelDayMeta(employeeActions, taskLinkRecord.date, taskLinkRecord))
|
||||
.map((label) => `Замечание: ${formatAttendanceIssueLabel(label)}`),
|
||||
].join("\n")
|
||||
: "";
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="app-surface rounded-2xl p-4 sm:p-5">
|
||||
@ -1116,6 +1163,7 @@ export default function EmployeeAttendanceCard({
|
||||
const commentsCount = comments.length || Number(record.comments_count || 0);
|
||||
const commentsLoading = recordId ? Boolean(commentsLoadingMap[recordId]) : false;
|
||||
const commentDraft = recordId ? (commentDrafts[recordId] || "") : "";
|
||||
const linkedTasks = record.linked_tasks || [];
|
||||
|
||||
return (
|
||||
<article key={key} className="app-surface-muted overflow-hidden rounded-xl transition hover:border-[var(--border-strong)]">
|
||||
@ -1147,6 +1195,16 @@ export default function EmployeeAttendanceCard({
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => recordId ? setTaskLinkRecord(record) : undefined}
|
||||
disabled={!recordId}
|
||||
className="app-action-secondary inline-flex h-8 w-8 items-center justify-center rounded-lg disabled:cursor-not-allowed disabled:opacity-50"
|
||||
title="Связать с задачей"
|
||||
aria-label="Связать запись посещаемости с задачей"
|
||||
>
|
||||
<Link2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@ -1206,6 +1264,22 @@ export default function EmployeeAttendanceCard({
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{linkedTasks.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5 px-3 pb-3 sm:px-4">
|
||||
{linkedTasks.slice(0, 3).map((task) => (
|
||||
<TaskLinkPill
|
||||
key={task.link_id || task.id}
|
||||
task={task}
|
||||
maxTitleClassName="max-w-44"
|
||||
/>
|
||||
))}
|
||||
{linkedTasks.length > 3 ? (
|
||||
<span className="app-badge inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium">
|
||||
+{linkedTasks.length - 3}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{(expanded || commentsOpen) ? (
|
||||
<div className="border-t border-[var(--border-subtle)] px-3 pb-3 sm:px-4 sm:pb-4">
|
||||
@ -1334,6 +1408,24 @@ export default function EmployeeAttendanceCard({
|
||||
record={dayEventsRecordPreview}
|
||||
/>
|
||||
|
||||
{taskLinkRecord && taskLinkRecordId ? (
|
||||
<RelatedTaskLinks
|
||||
key={`attendance-record-task-link-${taskLinkRecordId}`}
|
||||
entityLabel="Запись посещаемости"
|
||||
entityTitle={`${taskLinkEmployeeName} · ${taskLinkDate}`}
|
||||
entitySubtitle={`Приход ${formatTime(taskLinkRecord.arrival_time)} · уход ${formatTime(taskLinkRecord.departure_time)} · ${formatHours(taskLinkRecord.work_hours)} ч.`}
|
||||
defaultTaskTitle={`Задача по посещаемости: ${taskLinkEmployeeName}, ${taskLinkDate}`}
|
||||
defaultTaskDescription={taskLinkDescription}
|
||||
successMessage="Запись посещаемости связана с задачей"
|
||||
variant="dialog"
|
||||
open
|
||||
loadLinkedTasks={() => apiClient.getAttendanceRecordLinkedTasks(taskLinkRecordId) as Promise<TaskCard[]>}
|
||||
linkTask={(taskId) => apiClient.linkTaskAttendanceRecord(taskId, taskLinkRecordId)}
|
||||
onClose={() => setTaskLinkRecord(null)}
|
||||
onLinked={() => void refreshRecord(taskLinkRecordId)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Modal
|
||||
isOpen={aliasModalOpen}
|
||||
onClose={() => setAliasModalOpen(false)}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { buildQuery, type GetTokenFn, type RequestFn } from './utils';
|
||||
import type { User } from '@/types/api';
|
||||
import type { MessageLinkedTask, User } from '@/types/api';
|
||||
|
||||
export type AttendanceDateOverridePayload = {
|
||||
date: string;
|
||||
@ -85,6 +85,7 @@ export type AttendanceRecord = {
|
||||
employee_issues?: string[];
|
||||
technical_issues?: string[];
|
||||
statuses?: string[];
|
||||
linked_tasks?: MessageLinkedTask[];
|
||||
comments_count?: number;
|
||||
personnel_status?: string;
|
||||
personnel_status_label?: string;
|
||||
|
||||
@ -6,6 +6,7 @@ import type {
|
||||
TaskActivity,
|
||||
TaskComment,
|
||||
TaskLabel,
|
||||
TaskLinkedAttendanceRecord,
|
||||
TaskLinkedCalendarEvent,
|
||||
TaskLinkedDocument,
|
||||
TaskLinkedEmployee,
|
||||
@ -92,6 +93,8 @@ export function createTasksApi(request: RequestFn) {
|
||||
request(`/api/v1/tasks/linked-guest-tasks/?guest_id=${guestId}`),
|
||||
getGuestVisitLinkedTasks: (guestVisitId: number): Promise<TaskCard[]> =>
|
||||
request(`/api/v1/tasks/linked-guest-visit-tasks/?guest_visit_id=${guestVisitId}`),
|
||||
getAttendanceRecordLinkedTasks: (attendanceRecordId: number): Promise<TaskCard[]> =>
|
||||
request(`/api/v1/tasks/linked-attendance-record-tasks/?attendance_record_id=${attendanceRecordId}`),
|
||||
getTaskLinkedMessages: (id: number): Promise<TaskLinkedMessage[]> =>
|
||||
request(`/api/v1/tasks/${id}/linked-messages/`),
|
||||
linkTaskMessage: (id: number, messageId: number): Promise<TaskLinkedMessage> =>
|
||||
@ -169,6 +172,17 @@ export function createTasksApi(request: RequestFn) {
|
||||
request(`/api/v1/tasks/${id}/linked-guest-visits/${linkId}/`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
getTaskLinkedAttendanceRecords: (id: number): Promise<TaskLinkedAttendanceRecord[]> =>
|
||||
request(`/api/v1/tasks/${id}/linked-attendance-records/`),
|
||||
linkTaskAttendanceRecord: (id: number, attendanceRecordId: number): Promise<TaskLinkedAttendanceRecord> =>
|
||||
request(`/api/v1/tasks/${id}/linked-attendance-records/`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ attendance_record_id: attendanceRecordId }),
|
||||
}),
|
||||
unlinkTaskAttendanceRecord: (id: number, linkId: number): Promise<void> =>
|
||||
request(`/api/v1/tasks/${id}/linked-attendance-records/${linkId}/`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
getTaskLinkedEvents: (id: number): Promise<TaskLinkedCalendarEvent[]> =>
|
||||
request(`/api/v1/tasks/${id}/linked-events/`),
|
||||
linkTaskCalendarEvent: (id: number, eventId: number): Promise<TaskLinkedCalendarEvent> =>
|
||||
|
||||
@ -188,6 +188,7 @@ export interface TaskCard {
|
||||
linked_employees_count?: number;
|
||||
linked_guests_count?: number;
|
||||
linked_guest_visits_count?: number;
|
||||
linked_attendance_records_count?: number;
|
||||
linked_objects_count?: number;
|
||||
comments_count?: number;
|
||||
created_at: string;
|
||||
@ -367,12 +368,51 @@ export interface TaskLinkedGuestVisit {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface TaskLinkedAttendanceRecord {
|
||||
id: number;
|
||||
kind: "attendance_record";
|
||||
attendance_record_id: number;
|
||||
attendance_record: {
|
||||
id: number;
|
||||
employee_id?: number;
|
||||
employee?: User | null;
|
||||
date?: string;
|
||||
display_name?: string;
|
||||
arrival_time?: string | null;
|
||||
departure_time?: string | null;
|
||||
work_hours?: number | string | null;
|
||||
expected_hours?: number | string | null;
|
||||
is_workday?: boolean;
|
||||
effective_is_workday?: boolean;
|
||||
is_late?: boolean;
|
||||
late_minutes?: number | null;
|
||||
is_early_leave?: boolean;
|
||||
early_leave_minutes?: number | null;
|
||||
is_underwork?: boolean;
|
||||
underwork_hours?: number | string | null;
|
||||
is_overtime?: boolean;
|
||||
overtime_hours?: number | string | null;
|
||||
is_absent?: boolean;
|
||||
statuses?: string[];
|
||||
employee_issues?: string[];
|
||||
technical_issues?: string[];
|
||||
personnel_status?: string;
|
||||
personnel_status_label?: string;
|
||||
comments_count?: number;
|
||||
updated_at?: string;
|
||||
} | null;
|
||||
can_open?: boolean;
|
||||
object_url?: string | null;
|
||||
created_by?: User;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface TaskActivity {
|
||||
id: number;
|
||||
action: "created" | "updated" | "moved" | "linked" | "unlinked";
|
||||
action_display?: string;
|
||||
actor?: User | null;
|
||||
object_kind?: "message" | "calendar_event" | "document" | "request" | "procurement_request" | "employee" | "guest" | "guest_visit" | "";
|
||||
object_kind?: "message" | "calendar_event" | "document" | "request" | "procurement_request" | "employee" | "guest" | "guest_visit" | "attendance_record" | "";
|
||||
object_id?: number | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
created_at: string;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user