Add task links for attendance records

This commit is contained in:
lizerk 2026-07-06 22:12:22 +03:00
parent 8f85ee7c1c
commit 0cb40e992f
15 changed files with 825 additions and 10 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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='Тип связи'),
),
]

View File

@ -182,6 +182,7 @@ class TaskLinkedObjectKind(models.TextChoices):
EMPLOYEE = "employee", "Сотрудник"
GUEST = "guest", "Гость"
GUEST_VISIT = "guest_visit", "Заявка на гостевой визит"
ATTENDANCE_RECORD = "attendance_record", "Запись посещаемости"
class TaskActivityAction(models.TextChoices):

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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