From 8f85ee7c1c99f097df3dbc1eba5b54399c30f493 Mon Sep 17 00:00:00 2001 From: lizerk Date: Mon, 6 Jul 2026 21:57:37 +0300 Subject: [PATCH] Add task links for guests --- backend/api/v1/guests/serializers.py | 79 +++ backend/api/v1/tasks/serializers.py | 171 +++++ backend/api/v1/tasks/views.py | 325 ++++++++++ backend/tasks/access.py | 13 + .../migrations/0009_add_guest_link_kinds.py | 47 ++ backend/tasks/models.py | 2 + .../api/v1/guests/test_guest_visits_api.py | 197 ++++++ backend/tests/api/v1/tasks/test_tasks_api.py | 143 +++++ frontend/src/app/guests/page.tsx | 274 +++++++- frontend/src/app/tasks/page.tsx | 264 +++++++- .../src/components/tasks/RelatedTaskLinks.tsx | 584 ++++++++++++++++++ frontend/src/lib/api/tasks.ts | 28 + frontend/src/types/api.ts | 47 +- 13 files changed, 2143 insertions(+), 31 deletions(-) create mode 100644 backend/tasks/migrations/0009_add_guest_link_kinds.py create mode 100644 frontend/src/components/tasks/RelatedTaskLinks.tsx diff --git a/backend/api/v1/guests/serializers.py b/backend/api/v1/guests/serializers.py index 62c554a4..97bc2461 100644 --- a/backend/api/v1/guests/serializers.py +++ b/backend/api/v1/guests/serializers.py @@ -2,6 +2,8 @@ from __future__ import annotations from django.conf import settings from django.contrib.auth import get_user_model +from django.contrib.contenttypes.models import ContentType +from django.utils import timezone from rest_framework import serializers from communications import comments_helpers @@ -28,6 +30,53 @@ from guests.services import ( User = get_user_model() +def _linked_task_payloads(obj, kind: str, 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(obj.__class__) + accessible_boards = TaskBoard.objects.filter( + is_archived=False, + ).filter(task_board_access_q(user)) + + links = ( + TaskLinkedObject.objects.filter( + kind=kind, + content_type=content_type, + object_id=obj.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 GuestBriefSerializer(serializers.ModelSerializer): full_name = serializers.CharField(read_only=True) avatar = Base64ImageField(read_only=True) @@ -65,6 +114,7 @@ class GuestSerializer(serializers.ModelSerializer): documents = DocumentReadSerializer(many=True, read_only=True) comments_count = serializers.SerializerMethodField() visits_count = serializers.SerializerMethodField() + linked_tasks = serializers.SerializerMethodField() class Meta: model = Guest @@ -82,6 +132,7 @@ class GuestSerializer(serializers.ModelSerializer): "position", "comments_count", "visits_count", + "linked_tasks", "document_folder", "documents", "created_by", @@ -99,6 +150,7 @@ class GuestSerializer(serializers.ModelSerializer): "full_name", "comments_count", "visits_count", + "linked_tasks", "document_folder", "documents", "created_by", @@ -124,6 +176,11 @@ class GuestSerializer(serializers.ModelSerializer): return annotated_count return obj.visits.count() + def get_linked_tasks(self, obj): + request = self.context.get("request") + user = getattr(request, "user", None) + return _linked_task_payloads(obj, "guest", user) + class GuestVisitEventSerializer(serializers.ModelSerializer): actor = EmployeeBriefSerializer(read_only=True) @@ -164,6 +221,7 @@ class GuestVisitReadSerializer(serializers.ModelSerializer): can_delete = serializers.SerializerMethodField() comments_count = serializers.IntegerField(read_only=True, default=0) has_unread_info_response = serializers.SerializerMethodField() + linked_tasks = serializers.SerializerMethodField() class Meta: model = GuestVisit @@ -210,6 +268,7 @@ class GuestVisitReadSerializer(serializers.ModelSerializer): "can_delete", "comments_count", "has_unread_info_response", + "linked_tasks", "events", ) @@ -224,6 +283,14 @@ class GuestVisitReadSerializer(serializers.ModelSerializer): and getattr(obj.inviter, "is_actually_active", True) ) + @staticmethod + def _period_is_not_past(obj): + return bool( + obj.unlimited + or not obj.access_expires_at + or obj.access_expires_at > timezone.now() + ) + def get_can_edit(self, obj): return obj.can_edit_by(self._user()) @@ -233,6 +300,7 @@ class GuestVisitReadSerializer(serializers.ModelSerializer): user and obj.inviter_id == user.id and obj.status in {GuestVisitStatus.DRAFT, GuestVisitStatus.NEEDS_INFO} + and self._period_is_not_past(obj) ) def get_can_decide(self, obj): @@ -244,6 +312,7 @@ class GuestVisitReadSerializer(serializers.ModelSerializer): and obj.status in {GuestVisitStatus.PENDING, GuestVisitStatus.REJECTED} and not obj.inviter_inactive and self._inviter_is_active(obj) + and self._period_is_not_past(obj) ) def get_can_reject(self, obj): @@ -293,6 +362,7 @@ class GuestVisitReadSerializer(serializers.ModelSerializer): } and not obj.inviter_inactive and self._inviter_is_active(obj) + and self._period_is_not_past(obj) ) def get_can_sync_ldap(self, obj): @@ -305,6 +375,15 @@ class GuestVisitReadSerializer(serializers.ModelSerializer): def get_has_unread_info_response(self, obj): return has_unread_info_response_for_user(obj, self._user()) + def get_linked_tasks(self, obj): + from tasks.models import TaskLinkedObjectKind + + return _linked_task_payloads( + obj, + TaskLinkedObjectKind.GUEST_VISIT, + self._user(), + ) + class GuestVisitWriteSerializer(serializers.Serializer): guest_id = serializers.IntegerField(required=False) diff --git a/backend/api/v1/tasks/serializers.py b/backend/api/v1/tasks/serializers.py index 1a8585fc..c8226a25 100644 --- a/backend/api/v1/tasks/serializers.py +++ b/backend/api/v1/tasks/serializers.py @@ -15,6 +15,7 @@ from communications.utils import user_can_access_chat from documents.models import Document from employees.models import Department, EmployeeDepartment from employees.services.personnel_state import resolve_employee_personnel_state +from guests.models import Guest, GuestVisit from procurement.models import ProcurementRequest from requests_app.models import Request as EmployeeRequest from schedule.models import Event @@ -24,6 +25,8 @@ from tasks.access import ( user_can_access_document, user_can_access_employee, user_can_access_employee_request, + user_can_access_guest, + user_can_access_guest_visit, user_can_access_procurement_request, user_can_access_task_board, ) @@ -149,6 +152,8 @@ class TaskSerializer(serializers.ModelSerializer): linked_requests_count = serializers.SerializerMethodField() linked_procurement_requests_count = serializers.SerializerMethodField() linked_employees_count = serializers.SerializerMethodField() + linked_guests_count = serializers.SerializerMethodField() + linked_guest_visits_count = serializers.SerializerMethodField() linked_objects_count = serializers.SerializerMethodField() comments_count = serializers.SerializerMethodField() @@ -179,6 +184,8 @@ class TaskSerializer(serializers.ModelSerializer): "linked_requests_count", "linked_procurement_requests_count", "linked_employees_count", + "linked_guests_count", + "linked_guest_visits_count", "linked_objects_count", "comments_count", "created_at", @@ -198,6 +205,8 @@ class TaskSerializer(serializers.ModelSerializer): "linked_requests_count", "linked_procurement_requests_count", "linked_employees_count", + "linked_guests_count", + "linked_guest_visits_count", "linked_objects_count", "comments_count", "created_at", @@ -252,6 +261,22 @@ class TaskSerializer(serializers.ModelSerializer): kind=TaskLinkedObjectKind.EMPLOYEE, ).count() + @extend_schema_field(serializers.IntegerField()) + def get_linked_guests_count(self, obj): + if hasattr(obj, "linked_guests_count"): + return obj.linked_guests_count + return obj.linked_objects.filter( + kind=TaskLinkedObjectKind.GUEST, + ).count() + + @extend_schema_field(serializers.IntegerField()) + def get_linked_guest_visits_count(self, obj): + if hasattr(obj, "linked_guest_visits_count"): + return obj.linked_guest_visits_count + return obj.linked_objects.filter( + kind=TaskLinkedObjectKind.GUEST_VISIT, + ).count() + @extend_schema_field(serializers.IntegerField()) def get_linked_objects_count(self, obj): if hasattr(obj, "linked_objects_count"): @@ -821,3 +846,149 @@ class TaskLinkedEmployeeSerializer(serializers.ModelSerializer): def get_employee_content_type(): return ContentType.objects.get_for_model(User) + + +def _guest_payload(guest): + return { + "id": guest.id, + "full_name": guest.full_name, + "last_name": guest.last_name, + "first_name": guest.first_name, + "patronymic": guest.patronymic, + "birth_date": guest.birth_date, + "phone": guest.phone, + "email": guest.email, + "avatar": guest.avatar.url if getattr(guest, "avatar", None) else None, + "organization": guest.organization, + "position": guest.position, + "is_active": guest.is_active, + "is_blacklisted": guest.is_blacklisted, + "ldap_username": guest.ldap_username, + "ldap_upn": guest.ldap_upn, + "created_at": guest.created_at, + "updated_at": guest.updated_at, + } + + +class TaskLinkedGuestSerializer(serializers.ModelSerializer): + created_by = EmployeeBriefSerializer(read_only=True) + guest = serializers.SerializerMethodField() + guest_id = serializers.IntegerField(source="object_id", read_only=True) + can_open = serializers.SerializerMethodField() + object_url = serializers.SerializerMethodField() + + class Meta: + model = TaskLinkedObject + fields = [ + "id", + "kind", + "guest_id", + "guest", + "can_open", + "object_url", + "created_by", + "created_at", + ] + read_only_fields = fields + + @extend_schema_field(serializers.JSONField(allow_null=True)) + def get_guest(self, obj): + if obj.kind != TaskLinkedObjectKind.GUEST: + return None + + guest = getattr(obj, "content_object", None) + if guest is None: + return None + return _guest_payload(guest) + + @extend_schema_field(serializers.BooleanField()) + def get_can_open(self, obj): + guest = getattr(obj, "content_object", None) + request = self.context.get("request") + user = getattr(request, "user", None) + if obj.kind != TaskLinkedObjectKind.GUEST or guest is None or not user: + return False + return user_can_access_guest(user, guest) + + @extend_schema_field(serializers.CharField(allow_null=True)) + def get_object_url(self, obj): + guest = getattr(obj, "content_object", None) + if guest is None or not self.get_can_open(obj): + return None + return f"/guests?guest={guest.id}" + + +def get_guest_content_type(): + return ContentType.objects.get_for_model(Guest) + + +class TaskLinkedGuestVisitSerializer(serializers.ModelSerializer): + created_by = EmployeeBriefSerializer(read_only=True) + guest_visit = serializers.SerializerMethodField() + guest_visit_id = serializers.IntegerField(source="object_id", read_only=True) + can_open = serializers.SerializerMethodField() + object_url = serializers.SerializerMethodField() + + class Meta: + model = TaskLinkedObject + fields = [ + "id", + "kind", + "guest_visit_id", + "guest_visit", + "can_open", + "object_url", + "created_by", + "created_at", + ] + read_only_fields = fields + + @extend_schema_field(serializers.JSONField(allow_null=True)) + def get_guest_visit(self, obj): + if obj.kind != TaskLinkedObjectKind.GUEST_VISIT: + return None + + visit = getattr(obj, "content_object", None) + if visit is None: + return None + + return { + "id": visit.id, + "guest": _guest_payload(visit.guest), + "inviter": EmployeeBriefSerializer(visit.inviter).data, + "inviter_snapshot_name": visit.inviter_snapshot_name, + "inviter_snapshot_email": visit.inviter_snapshot_email, + "purpose": visit.purpose, + "visit_comment": visit.visit_comment, + "status": visit.status, + "status_display": visit.get_status_display(), + "access_starts_at": visit.access_starts_at, + "access_expires_at": visit.access_expires_at, + "all_day": visit.all_day, + "unlimited": visit.unlimited, + "submitted_at": visit.submitted_at, + "is_active_now": visit.is_active_now, + "is_expired": visit.is_expired, + "created_at": visit.created_at, + "updated_at": visit.updated_at, + } + + @extend_schema_field(serializers.BooleanField()) + def get_can_open(self, obj): + visit = getattr(obj, "content_object", None) + request = self.context.get("request") + user = getattr(request, "user", None) + if obj.kind != TaskLinkedObjectKind.GUEST_VISIT or visit is None or not user: + return False + return user_can_access_guest_visit(user, visit) + + @extend_schema_field(serializers.CharField(allow_null=True)) + def get_object_url(self, obj): + visit = getattr(obj, "content_object", None) + if visit is None or not self.get_can_open(obj): + return None + return f"/guests?visit={visit.id}" + + +def get_guest_visit_content_type(): + return ContentType.objects.get_for_model(GuestVisit) diff --git a/backend/api/v1/tasks/views.py b/backend/api/v1/tasks/views.py index 0beee5e1..978e7e30 100644 --- a/backend/api/v1/tasks/views.py +++ b/backend/api/v1/tasks/views.py @@ -14,6 +14,7 @@ from communications import comments_helpers from communications.models import Message from communications.utils import user_can_access_chat from documents.models import Document +from guests.models import Guest, GuestVisit from procurement.models import ProcurementRequest from requests_app.models import Request as EmployeeRequest from rest_framework import filters, status, viewsets @@ -28,6 +29,8 @@ from tasks.access import ( user_can_access_document, user_can_access_employee, user_can_access_employee_request, + user_can_access_guest, + user_can_access_guest_visit, user_can_access_procurement_request, ) from tasks.models import ( @@ -54,6 +57,8 @@ from .serializers import ( TaskLinkedCalendarEventSerializer, TaskLinkedDocumentSerializer, TaskLinkedEmployeeSerializer, + TaskLinkedGuestSerializer, + TaskLinkedGuestVisitSerializer, TaskLinkedMessageSerializer, TaskLinkedProcurementRequestSerializer, TaskLinkedRequestSerializer, @@ -62,6 +67,8 @@ from .serializers import ( get_calendar_event_content_type, get_document_content_type, get_employee_content_type, + get_guest_content_type, + get_guest_visit_content_type, get_message_content_type, get_procurement_request_content_type, get_request_content_type, @@ -312,6 +319,20 @@ class TaskViewSet(viewsets.ModelViewSet): ), distinct=True, ), + linked_guests_count=Count( + "linked_objects", + filter=Q( + linked_objects__kind=TaskLinkedObjectKind.GUEST, + ), + distinct=True, + ), + linked_guest_visits_count=Count( + "linked_objects", + filter=Q( + linked_objects__kind=TaskLinkedObjectKind.GUEST_VISIT, + ), + distinct=True, + ), linked_objects_count=Count("linked_objects", distinct=True), comments_count=Coalesce( Subquery(comments_subquery, output_field=IntegerField()), @@ -765,6 +786,82 @@ class TaskViewSet(viewsets.ModelViewSet): serializer = self.get_serializer(queryset, many=True) return Response(serializer.data) + @action(detail=False, methods=["get"], url_path="linked-guest-tasks") + def linked_guest_tasks(self, request): + guest_id = request.query_params.get("guest_id") + try: + guest_id = int(guest_id) + except (TypeError, ValueError): + return Response( + {"guest_id": ["Укажите ID гостя."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + guest = Guest.objects.get(id=guest_id) + except Guest.DoesNotExist: + return Response( + {"guest_id": ["Гость не найден."]}, + status=status.HTTP_404_NOT_FOUND, + ) + + if not user_can_access_guest(request.user, guest): + return Response( + {"detail": "Нет доступа к этому гостю."}, + status=status.HTTP_403_FORBIDDEN, + ) + + queryset = ( + self.get_queryset() + .filter( + linked_objects__kind=TaskLinkedObjectKind.GUEST, + linked_objects__content_type=get_guest_content_type(), + linked_objects__object_id=guest.id, + ) + .distinct() + ) + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) + + @action(detail=False, methods=["get"], url_path="linked-guest-visit-tasks") + def linked_guest_visit_tasks(self, request): + guest_visit_id = request.query_params.get("guest_visit_id") + try: + guest_visit_id = int(guest_visit_id) + except (TypeError, ValueError): + return Response( + {"guest_visit_id": ["Укажите ID заявки на гостевой визит."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + visit = GuestVisit.objects.select_related("guest", "inviter").get( + id=guest_visit_id, + ) + except GuestVisit.DoesNotExist: + return Response( + {"guest_visit_id": ["Заявка на гостевой визит не найдена."]}, + status=status.HTTP_404_NOT_FOUND, + ) + + if not user_can_access_guest_visit(request.user, visit): + return Response( + {"detail": "Нет доступа к этой заявке на гостевой визит."}, + status=status.HTTP_403_FORBIDDEN, + ) + + queryset = ( + self.get_queryset() + .filter( + linked_objects__kind=TaskLinkedObjectKind.GUEST_VISIT, + linked_objects__content_type=get_guest_visit_content_type(), + linked_objects__object_id=visit.id, + ) + .distinct() + ) + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) + def _message_links_queryset(self, task): return ( TaskLinkedObject.objects.filter( @@ -825,6 +922,26 @@ class TaskViewSet(viewsets.ModelViewSet): .order_by("-created_at", "-id") ) + def _guest_links_queryset(self, task): + return ( + TaskLinkedObject.objects.filter( + task=task, + kind=TaskLinkedObjectKind.GUEST, + ) + .select_related("created_by", "content_type") + .order_by("-created_at", "-id") + ) + + def _guest_visit_links_queryset(self, task): + return ( + TaskLinkedObject.objects.filter( + task=task, + kind=TaskLinkedObjectKind.GUEST_VISIT, + ) + .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() @@ -1382,6 +1499,214 @@ class TaskViewSet(viewsets.ModelViewSet): ) return Response(status=status.HTTP_204_NO_CONTENT) + @action(detail=True, methods=["get", "post"], url_path="linked-guests") + def linked_guests(self, request, pk=None): + task = self.get_object() + if request.method == "GET": + serializer = TaskLinkedGuestSerializer( + self._guest_links_queryset(task), + many=True, + context={"request": request}, + ) + return Response(serializer.data) + + guest_id = request.data.get("guest_id") + try: + guest_id = int(guest_id) + except (TypeError, ValueError): + return Response( + {"guest_id": ["Укажите ID гостя."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + guest = Guest.objects.get(id=guest_id) + except Guest.DoesNotExist: + return Response( + {"guest_id": ["Гость не найден."]}, + status=status.HTTP_404_NOT_FOUND, + ) + + if not user_can_access_guest(request.user, guest): + return Response( + {"detail": "Нет доступа к этому гостю."}, + status=status.HTTP_403_FORBIDDEN, + ) + + link, created = TaskLinkedObject.objects.get_or_create( + task=task, + kind=TaskLinkedObjectKind.GUEST, + content_type=get_guest_content_type(), + object_id=guest.id, + defaults={"created_by": request.user}, + ) + serializer = TaskLinkedGuestSerializer( + link, + context={"request": request}, + ) + if created: + create_task_activity( + task, + request.user, + TaskActivityAction.LINKED, + object_kind=TaskLinkedObjectKind.GUEST, + object_id=guest.id, + metadata={ + "object_label": guest.full_name, + "object_type": "Гость", + }, + ) + send_task_board_update( + task.board, + "linked", + "guest", + guest.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-guests/(?P\d+)", + ) + def unlink_guest(self, request, pk=None, link_id=None): + task = self.get_object() + try: + link = self._guest_links_queryset(task).get(id=link_id) + except TaskLinkedObject.DoesNotExist: + return Response( + {"detail": "Связь не найдена."}, + status=status.HTTP_404_NOT_FOUND, + ) + + guest_id = link.object_id + link.delete() + create_task_activity( + task, + request.user, + TaskActivityAction.UNLINKED, + object_kind=TaskLinkedObjectKind.GUEST, + object_id=guest_id, + metadata={"object_type": "Гость"}, + ) + send_task_board_update( + task.board, + "unlinked", + "guest", + guest_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-guest-visits") + def linked_guest_visits(self, request, pk=None): + task = self.get_object() + if request.method == "GET": + serializer = TaskLinkedGuestVisitSerializer( + self._guest_visit_links_queryset(task), + many=True, + context={"request": request}, + ) + return Response(serializer.data) + + guest_visit_id = request.data.get("guest_visit_id") + try: + guest_visit_id = int(guest_visit_id) + except (TypeError, ValueError): + return Response( + {"guest_visit_id": ["Укажите ID заявки на гостевой визит."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + visit = GuestVisit.objects.select_related("guest", "inviter").get( + id=guest_visit_id, + ) + except GuestVisit.DoesNotExist: + return Response( + {"guest_visit_id": ["Заявка на гостевой визит не найдена."]}, + status=status.HTTP_404_NOT_FOUND, + ) + + if not user_can_access_guest_visit(request.user, visit): + return Response( + {"detail": "Нет доступа к этой заявке на гостевой визит."}, + status=status.HTTP_403_FORBIDDEN, + ) + + link, created = TaskLinkedObject.objects.get_or_create( + task=task, + kind=TaskLinkedObjectKind.GUEST_VISIT, + content_type=get_guest_visit_content_type(), + object_id=visit.id, + defaults={"created_by": request.user}, + ) + serializer = TaskLinkedGuestVisitSerializer( + link, + context={"request": request}, + ) + if created: + create_task_activity( + task, + request.user, + TaskActivityAction.LINKED, + object_kind=TaskLinkedObjectKind.GUEST_VISIT, + object_id=visit.id, + metadata={ + "object_label": visit.guest.full_name, + "object_type": "Заявка на гостевой визит", + }, + ) + send_task_board_update( + task.board, + "linked", + "guest_visit", + visit.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-guest-visits/(?P\d+)", + ) + def unlink_guest_visit(self, request, pk=None, link_id=None): + task = self.get_object() + try: + link = self._guest_visit_links_queryset(task).get(id=link_id) + except TaskLinkedObject.DoesNotExist: + return Response( + {"detail": "Связь не найдена."}, + status=status.HTTP_404_NOT_FOUND, + ) + + guest_visit_id = link.object_id + link.delete() + create_task_activity( + task, + request.user, + TaskActivityAction.UNLINKED, + object_kind=TaskLinkedObjectKind.GUEST_VISIT, + object_id=guest_visit_id, + metadata={"object_type": "Заявка на гостевой визит"}, + ) + send_task_board_update( + task.board, + "unlinked", + "guest_visit", + guest_visit_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() diff --git a/backend/tasks/access.py b/backend/tasks/access.py index d1cd0375..c6c85184 100644 --- a/backend/tasks/access.py +++ b/backend/tasks/access.py @@ -148,3 +148,16 @@ def user_can_access_procurement_request(user, procurement_request) -> bool: def user_can_access_employee(user, employee) -> bool: return bool(user and user.is_authenticated and employee is not None) + + +def user_can_access_guest(user, guest) -> bool: + return bool(user and user.is_authenticated and guest is not None) + + +def user_can_access_guest_visit(user, visit) -> bool: + if not user or not user.is_authenticated or visit is None: + return False + + from guests.permissions import is_guest_admin + + return is_guest_admin(user) or visit.inviter_id == user.id diff --git a/backend/tasks/migrations/0009_add_guest_link_kinds.py b/backend/tasks/migrations/0009_add_guest_link_kinds.py new file mode 100644 index 00000000..44bf4c3d --- /dev/null +++ b/backend/tasks/migrations/0009_add_guest_link_kinds.py @@ -0,0 +1,47 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("tasks", "0008_add_employee_link_kind"), + ] + + 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", "Заявка на гостевой визит"), + ], + 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", "Заявка на гостевой визит"), + ], + max_length=32, + verbose_name="Тип связи", + ), + ), + ] diff --git a/backend/tasks/models.py b/backend/tasks/models.py index eaf4982a..41af9c41 100644 --- a/backend/tasks/models.py +++ b/backend/tasks/models.py @@ -180,6 +180,8 @@ class TaskLinkedObjectKind(models.TextChoices): REQUEST = "request", "Заявление" PROCUREMENT_REQUEST = "procurement_request", "Заявка на закупку" EMPLOYEE = "employee", "Сотрудник" + GUEST = "guest", "Гость" + GUEST_VISIT = "guest_visit", "Заявка на гостевой визит" class TaskActivityAction(models.TextChoices): diff --git a/backend/tests/api/v1/guests/test_guest_visits_api.py b/backend/tests/api/v1/guests/test_guest_visits_api.py index 5774610c..44fe10dc 100644 --- a/backend/tests/api/v1/guests/test_guest_visits_api.py +++ b/backend/tests/api/v1/guests/test_guest_visits_api.py @@ -10,6 +10,13 @@ from documents.models import Document from filer.models import Folder from guests.constants import GuestVisitStatus from guests.models import Guest, GuestVisit +from tasks.models import ( + Task, + TaskBoard, + TaskColumn, + TaskLinkedObject, + TaskLinkedObjectKind, +) TINY_PNG_BASE64 = ( @@ -792,6 +799,61 @@ def test_guest_visit_action_flags_match_valid_transitions( } +@pytest.mark.django_db +def test_expired_guest_visit_period_hides_blocked_actions( + auth_client_factory, + user_factory, +): + inviter = user_factory() + admin = user_factory(staff=True) + inviter_client = auth_client_factory(inviter) + admin_client = auth_client_factory(admin) + guest = Guest.objects.create(first_name="Expired", last_name="Actions") + starts_at = timezone.now() - timedelta(days=2) + expires_at = timezone.now() - timedelta(days=1) + + draft = GuestVisit.objects.create( + guest=guest, + inviter=inviter, + purpose="Draft expired", + status=GuestVisitStatus.DRAFT, + access_starts_at=starts_at, + access_expires_at=expires_at, + ) + pending = GuestVisit.objects.create( + guest=guest, + inviter=inviter, + purpose="Pending expired", + status=GuestVisitStatus.PENDING, + access_starts_at=starts_at, + access_expires_at=expires_at, + ) + expired = GuestVisit.objects.create( + guest=guest, + inviter=inviter, + purpose="Closed expired", + status=GuestVisitStatus.EXPIRED, + access_starts_at=starts_at, + access_expires_at=expires_at, + ) + + draft_response = inviter_client.get(f"/api/v1/guests/visits/{draft.id}/") + pending_response = admin_client.get(f"/api/v1/guests/visits/{pending.id}/") + expired_response = inviter_client.get(f"/api/v1/guests/visits/{expired.id}/") + + assert draft_response.status_code == 200 + assert pending_response.status_code == 200 + assert expired_response.status_code == 200 + + draft_payload = draft_response.json() + pending_payload = pending_response.json() + expired_payload = expired_response.json() + + assert draft_payload["can_submit"] is False + assert pending_payload["can_approve"] is False + assert expired_payload["can_return_to_work"] is False + + @pytest.mark.django_db def test_invalid_api_transitions_return_400(auth_client_factory, user_factory): user = user_factory() @@ -1347,6 +1409,141 @@ def test_guest_admin_endpoints_permissions_and_actions( assert restored.json()["is_blacklisted"] is False +@pytest.mark.django_db +def test_guest_and_visit_lists_include_visible_linked_tasks_only( + auth_client_factory, + user_factory, +): + user = user_factory() + hidden_member = user_factory() + client = auth_client_factory(user) + guest = Guest.objects.create( + first_name="Linked", + last_name="Guest", + created_by=user, + ) + visit = GuestVisit.objects.create( + guest=guest, + inviter=user, + purpose="Linked visit", + access_starts_at=timezone.now(), + access_expires_at=timezone.now() + timedelta(days=1), + ) + visible_board = TaskBoard.objects.create( + name="Видимая гостевая доска", + created_by=user, + ) + visible_column = TaskColumn.objects.create( + board=visible_board, + name="Новые", + position=1000, + color="#38bdf8", + ) + visible_guest_task = Task.objects.create( + board=visible_board, + column=visible_column, + title="Видимая задача гостя", + created_by=user, + priority="high", + ) + visible_visit_task = Task.objects.create( + board=visible_board, + column=visible_column, + title="Видимая задача визита", + created_by=user, + priority="medium", + ) + hidden_board = TaskBoard.objects.create( + name="Скрытая гостевая доска", + created_by=hidden_member, + ) + hidden_board.members.add(hidden_member) + hidden_column = TaskColumn.objects.create( + board=hidden_board, + name="Скрытые", + position=1000, + color="#ef4444", + ) + hidden_guest_task = Task.objects.create( + board=hidden_board, + column=hidden_column, + title="Скрытая задача гостя", + created_by=hidden_member, + priority="critical", + ) + hidden_visit_task = Task.objects.create( + board=hidden_board, + column=hidden_column, + title="Скрытая задача визита", + created_by=hidden_member, + priority="critical", + ) + guest_ct = ContentType.objects.get_for_model(Guest) + visit_ct = ContentType.objects.get_for_model(GuestVisit) + visible_guest_link = TaskLinkedObject.objects.create( + task=visible_guest_task, + kind=TaskLinkedObjectKind.GUEST, + content_type=guest_ct, + object_id=guest.id, + created_by=user, + ) + TaskLinkedObject.objects.create( + task=hidden_guest_task, + kind=TaskLinkedObjectKind.GUEST, + content_type=guest_ct, + object_id=guest.id, + created_by=hidden_member, + ) + visible_visit_link = TaskLinkedObject.objects.create( + task=visible_visit_task, + kind=TaskLinkedObjectKind.GUEST_VISIT, + content_type=visit_ct, + object_id=visit.id, + created_by=user, + ) + TaskLinkedObject.objects.create( + task=hidden_visit_task, + kind=TaskLinkedObjectKind.GUEST_VISIT, + content_type=visit_ct, + object_id=visit.id, + created_by=hidden_member, + ) + + guests = _results(client.get("/api/v1/guests/").json()) + visits = _results(client.get("/api/v1/guests/visits/").json()) + + guest_payload = next(item for item in guests if item["id"] == guest.id) + visit_payload = next(item for item in visits if item["id"] == visit.id) + assert guest_payload["linked_tasks"] == [ + { + "link_id": visible_guest_link.id, + "id": visible_guest_task.id, + "title": "Видимая задача гостя", + "board_id": visible_board.id, + "board_name": "Видимая гостевая доска", + "column_id": visible_column.id, + "column_name": "Новые", + "column_color": "#38bdf8", + "priority": "high", + "priority_display": "Высокий", + } + ] + assert visit_payload["linked_tasks"] == [ + { + "link_id": visible_visit_link.id, + "id": visible_visit_task.id, + "title": "Видимая задача визита", + "board_id": visible_board.id, + "board_name": "Видимая гостевая доска", + "column_id": visible_column.id, + "column_name": "Новые", + "column_color": "#38bdf8", + "priority": "medium", + "priority_display": "Средний", + } + ] + + @pytest.mark.django_db def test_guest_update_triggers_ldap_sync( settings, diff --git a/backend/tests/api/v1/tasks/test_tasks_api.py b/backend/tests/api/v1/tasks/test_tasks_api.py index f1740419..ff3383e8 100644 --- a/backend/tests/api/v1/tasks/test_tasks_api.py +++ b/backend/tests/api/v1/tasks/test_tasks_api.py @@ -12,6 +12,7 @@ from rest_framework.test import APIClient from communications.models import Chat, ChatMembership, Message from documents.models import Document from employees.models import Department, EmployeeDepartment, RoleAssignment +from guests.models import Guest, GuestVisit from procurement.constants import ProcurementStatus, UrgencyLevel from procurement.models import ProcurementRequest from requests_app.models import Request as EmployeeRequest @@ -749,6 +750,148 @@ def test_task_linked_employee_respects_task_board_access(api_client): assert reverse_linked_response.data == [] +def test_task_linked_guest_respects_task_board_access(api_client): + owner = make_user("task-guest-owner@example.com", "+79994440036") + board_member = make_user("task-guest-member@example.com", "+79994440037") + outsider = make_user("task-guest-outsider@example.com", "+79994440038") + guest = Guest.objects.create( + first_name="Иван", + last_name="Гость", + organization="Vendor", + email="guest@example.com", + created_by=owner, + ) + board = TaskBoard.objects.create( + name="Доска с гостями", + created_by=owner, + ) + board.members.add(board_member) + 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=board_member) + link_response = api_client.post( + reverse("api:v1:tasks:task-linked-guests", kwargs={"pk": task.id}), + {"guest_id": guest.id}, + format="json", + ) + assert link_response.status_code == status.HTTP_201_CREATED + assert link_response.data["guest_id"] == guest.id + assert link_response.data["guest"]["full_name"] == "Гость Иван" + assert link_response.data["can_open"] is True + assert link_response.data["object_url"] == f"/guests?guest={guest.id}" + + linked_response = api_client.get( + reverse("api:v1:tasks:task-linked-guests", kwargs={"pk": task.id}) + ) + assert linked_response.status_code == status.HTTP_200_OK + assert linked_response.data[0]["guest"]["email"] == guest.email + + reverse_linked_response = api_client.get( + reverse("api:v1:tasks:task-linked-guest-tasks"), + {"guest_id": guest.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_guests_count"] == 1 + + api_client.force_authenticate(user=outsider) + task_detail_response = api_client.get( + reverse("api:v1:tasks:task-linked-guests", kwargs={"pk": task.id}) + ) + assert task_detail_response.status_code == status.HTTP_404_NOT_FOUND + + reverse_linked_response = api_client.get( + reverse("api:v1:tasks:task-linked-guest-tasks"), + {"guest_id": guest.id}, + ) + assert reverse_linked_response.status_code == status.HTTP_200_OK + assert reverse_linked_response.data == [] + + +def test_task_linked_guest_visit_respects_visit_access(api_client): + owner = make_user("task-guest-visit-owner@example.com", "+79994440039") + inviter = make_user("task-guest-visit-inviter@example.com", "+79994440040") + outsider = make_user("task-guest-visit-outsider@example.com", "+79994440041") + guest = Guest.objects.create( + first_name="Анна", + last_name="Визит", + created_by=owner, + ) + visit = GuestVisit.objects.create( + guest=guest, + inviter=inviter, + purpose="Встреча с подрядчиком", + access_starts_at=timezone.now(), + access_expires_at=timezone.now() + timedelta(days=1), + ) + board = TaskBoard.objects.create( + name="Доска с визитами", + created_by=owner, + ) + board.members.add(inviter) + 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=inviter) + link_response = api_client.post( + reverse("api:v1:tasks:task-linked-guest-visits", kwargs={"pk": task.id}), + {"guest_visit_id": visit.id}, + format="json", + ) + assert link_response.status_code == status.HTTP_201_CREATED + assert link_response.data["guest_visit_id"] == visit.id + assert link_response.data["guest_visit"]["guest"]["full_name"] == "Визит Анна" + assert link_response.data["can_open"] is True + assert link_response.data["object_url"] == f"/guests?visit={visit.id}" + + linked_response = api_client.get( + reverse("api:v1:tasks:task-linked-guest-visits", kwargs={"pk": task.id}) + ) + assert linked_response.status_code == status.HTTP_200_OK + assert linked_response.data[0]["guest_visit"]["purpose"] == "Встреча с подрядчиком" + + reverse_linked_response = api_client.get( + reverse("api:v1:tasks:task-linked-guest-visit-tasks"), + {"guest_visit_id": visit.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_guest_visits_count"] == 1 + + api_client.force_authenticate(user=outsider) + task_detail_response = api_client.get( + reverse("api:v1:tasks:task-linked-guest-visits", kwargs={"pk": task.id}) + ) + assert task_detail_response.status_code == status.HTTP_404_NOT_FOUND + + reverse_linked_response = api_client.get( + reverse("api:v1:tasks:task-linked-guest-visit-tasks"), + {"guest_visit_id": visit.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( diff --git a/frontend/src/app/guests/page.tsx b/frontend/src/app/guests/page.tsx index 084e2ab9..2c85b0ab 100644 --- a/frontend/src/app/guests/page.tsx +++ b/frontend/src/app/guests/page.tsx @@ -21,6 +21,7 @@ import { History, IdCard, Info, + Link2, Loader2, Mail, MessageSquare, @@ -43,13 +44,15 @@ import { import { AppShell } from "@/components/AppShell"; import { CommentComposer, CommentDeleteButton } from "@/components/shared/CommentControls"; import { SearchableSelectSingle } from "@/components/shared/SearchableSelect"; +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"; import { canManageGuestAccounts, canViewAllGuestVisits } from "@/lib/permissions"; import { displayUserName, extractNextPage, formatDateTime } from "@/lib/shared"; import { resolveMediaUrl } from "@/lib/url"; -import type { Document, Guest, GuestVisit, GuestVisitComment, GuestVisitStatus } from "@/types/api"; +import type { Document, Guest, GuestVisit, GuestVisitComment, GuestVisitStatus, TaskCard } from "@/types/api"; const AvatarCropper = dynamic(() => import("@/components/AvatarCropper"), { ssr: false, @@ -258,6 +261,26 @@ const toApiDateTime = (value: string): string | null => { return Number.isNaN(date.getTime()) ? null : date.toISOString(); }; +const isGuestVisitPeriodActionable = (visit: GuestVisit): boolean => { + if (visit.unlimited) return true; + if (visit.is_expired) return false; + if (!visit.access_expires_at) return true; + const expiresAt = new Date(visit.access_expires_at).getTime(); + return Number.isNaN(expiresAt) || expiresAt > Date.now(); +}; + +const canSubmitGuestVisit = (visit: GuestVisit): boolean => ( + Boolean(visit.can_submit && isGuestVisitPeriodActionable(visit)) +); + +const canApproveGuestVisit = (visit: GuestVisit): boolean => ( + Boolean(visit.can_approve && isGuestVisitPeriodActionable(visit)) +); + +const canReturnGuestVisitToWork = (visit: GuestVisit): boolean => ( + Boolean(visit.can_return_to_work && isGuestVisitPeriodActionable(visit)) +); + const guestName = (guest?: Guest | null): string => ( guest?.full_name || [guest?.last_name, guest?.first_name, guest?.patronymic].filter(Boolean).join(" ") || "Гость" ); @@ -404,6 +427,8 @@ function GuestsPageContent() { const [photoGuest, setPhotoGuest] = useState(null); const [editingGuest, setEditingGuest] = useState(null); const [guestEditForm, setGuestEditForm] = useState(null); + const [taskLinkGuest, setTaskLinkGuest] = useState(null); + const [taskLinkVisit, setTaskLinkVisit] = useState(null); const [visitMenuOpenId, setVisitMenuOpenId] = useState(null); const [visitCommentsOpenId, setVisitCommentsOpenId] = useState(null); const [visitCommentDrafts, setVisitCommentDrafts] = useState>({}); @@ -411,6 +436,7 @@ function GuestsPageContent() { const [guestCommentDrafts, setGuestCommentDrafts] = useState>({}); const openedVisitParamRef = useRef(null); + const openedGuestParamRef = useRef(null); const visitMenuRef = useRef(null); useEffect(() => { @@ -621,6 +647,26 @@ function GuestsPageContent() { })(); }, [loadComments, refreshVisit, searchParams]); + useEffect(() => { + const guestParam = searchParams.get("guest"); + if (!guestParam || openedGuestParamRef.current === guestParam) return; + const guestId = Number(guestParam); + if (!Number.isFinite(guestId) || guestId <= 0) return; + openedGuestParamRef.current = guestParam; + (async () => { + try { + setBusyKey(`open-guest-${guestId}`); + const guest = await apiClient.getGuest(guestId) as Guest; + setActiveTab("guests"); + setDetailsGuest(guest); + } catch (err) { + setActionError(getReadableError(err, "Не удалось открыть гостя")); + } finally { + setBusyKey(null); + } + })(); + }, [searchParams]); + const documentOptions = useMemo( () => { const map = new Map(); @@ -1089,6 +1135,17 @@ function GuestsPageContent() { setDetailsVisit((current) => current?.guest.id === updated.id ? { ...current, guest: updated } : current); setDetailsGuest((current) => current?.id === updated.id ? updated : current); setEditingGuest((current) => current?.id === updated.id ? updated : current); + setTaskLinkGuest((current) => current?.id === updated.id ? updated : current); + }; + + const handleGuestTaskLinked = async (guestId: number) => { + const updated = await apiClient.getGuest(guestId) as Guest; + applyGuestUpdate(updated); + }; + + const handleVisitTaskLinked = async (visitId: number) => { + const updated = await refreshVisit(visitId); + setTaskLinkVisit((current) => current?.id === updated.id ? updated : current); }; const saveGuestEdit = async () => { @@ -1310,6 +1367,10 @@ function GuestsPageContent() { setActionComment(""); }} onEdit={openEdit} + onLinkTask={(nextVisit) => { + setVisitMenuOpenId(null); + setTaskLinkVisit(nextVisit); + }} onOpen={openDetails} onAddComment={addListComment} onCommentDraftChange={updateVisitCommentDraft} @@ -1355,6 +1416,9 @@ function GuestsPageContent() { guests={guests} loading={guestsLoading} onEditGuest={openGuestEdit} + onLinkTask={(guest) => { + setTaskLinkGuest(guest); + }} onOpenGuest={openGuestDetails} onOpenGuestPhoto={setPhotoGuest} onOpenGuestVisits={openGuestVisits} @@ -1409,9 +1473,56 @@ function GuestsPageContent() { onClose={closeDetails} onDeleteComment={deleteComment} onEdit={openEdit} + onLinkTask={(visit) => { + setDetailsVisit(null); + setTaskLinkVisit(visit); + }} onRunAction={runVisitAction} /> + {taskLinkGuest ? ( + apiClient.getGuestLinkedTasks(taskLinkGuest.id) as Promise} + linkTask={(taskId) => apiClient.linkTaskGuest(taskId, taskLinkGuest.id)} + onClose={() => setTaskLinkGuest(null)} + onLinked={() => void handleGuestTaskLinked(taskLinkGuest.id)} + /> + ) : null} + + {taskLinkVisit ? ( + apiClient.getGuestVisitLinkedTasks(taskLinkVisit.id) as Promise} + linkTask={(taskId) => apiClient.linkTaskGuestVisit(taskId, taskLinkVisit.id)} + onClose={() => setTaskLinkVisit(null)} + onLinked={() => void handleVisitTaskLinked(taskLinkVisit.id)} + /> + ) : null} + setDetailsGuest(null)} + onClose={() => { + setDetailsGuest(null); + if (searchParams.get("guest")) { + router.replace("/guests"); + openedGuestParamRef.current = null; + } + }} onEdit={(guest) => { setDetailsGuest(null); openGuestEdit(guest); }} + onLinkTask={(guest) => { + setDetailsGuest(null); + setTaskLinkGuest(guest); + }} /> + {tasks.slice(0, max).map((task) => ( + + ))} + {tasks.length > max ? ( + + +{tasks.length - max} + + ) : null} + + ); +} + function GuestVisitRow({ busyKey, canDeleteAllComments, @@ -1693,6 +1842,7 @@ function GuestVisitRow({ onCommentDraftChange, onDeleteComment, onEdit, + onLinkTask, onOpen, onOpenGuestPhoto, onRunAction, @@ -1713,6 +1863,7 @@ function GuestVisitRow({ onCommentDraftChange: (visitId: number, value: string) => void; onDeleteComment: (visit: GuestVisit, commentId: number) => void | Promise; onEdit: (visit: GuestVisit) => void; + onLinkTask: (visit: GuestVisit) => void; onOpen: (visit: GuestVisit) => void | Promise; onOpenGuestPhoto: (guest: Guest) => void; onRunAction: (visit: GuestVisit, action: "submit" | "sync") => void | Promise; @@ -1770,6 +1921,7 @@ function GuestVisitRow({ visit={visit} onActionDialog={onActionDialog} onEdit={onEdit} + onLinkTask={onLinkTask} onRunAction={onRunAction} onToggleMenu={onToggleMenu} /> @@ -1784,6 +1936,7 @@ function GuestVisitRow({

{visit.purpose}

+ {visit.documents?.length ? (
{visit.documents.map((document) => { @@ -1913,9 +2066,11 @@ function VisitQuickActionButtons({ onActionDialog: (kind: ActionKind) => void; onRunAction: (visit: GuestVisit, action: "submit" | "sync") => void | Promise; }) { - const canSubmitDirectly = visit.can_submit && visit.status !== "needs_info"; - const canProvideInfo = visit.status === "needs_info" && visit.can_submit; - if (!canSubmitDirectly && !canProvideInfo && !visit.can_approve && !visit.can_reject) return null; + const canSubmit = canSubmitGuestVisit(visit); + const canApprove = canApproveGuestVisit(visit); + const canSubmitDirectly = canSubmit && visit.status !== "needs_info"; + const canProvideInfo = visit.status === "needs_info" && canSubmit; + if (!canSubmitDirectly && !canProvideInfo && !canApprove && !visit.can_reject) return null; return (
@@ -1940,7 +2095,7 @@ function VisitQuickActionButtons({ ) : null} - {visit.can_approve ? ( + {canApprove ? ( {isMenuOpen ? (
+ {visit.can_edit ? ( {open ? (
- {visit.can_submit ? ( + + {canSubmitGuestVisit(visit) ? ( ) : null} - {visit.status === "needs_info" && visit.can_submit ? ( + {visit.status === "needs_info" && canSubmitGuestVisit(visit) ? ( ) : null} - {visit.can_return_to_work ? ( + {canReturnGuestVisitToWork(visit) ? (
@@ -2990,6 +3161,7 @@ function GuestVisitDetailModal({ visit={visit} onActionDialog={(kind) => onActionDialog(kind, visit)} onEdit={onEdit} + onLinkTask={onLinkTask} onRunAction={onRunAction} />
@@ -3057,6 +3229,17 @@ function GuestVisitDetailModal({
@@ -3546,6 +3732,17 @@ function GuestRegistryPanel({ {isGuestMenuOpen ? (
+ {canEditGuests ? ( + {canEdit ? ( +
+ + ); +} + +function LinkedGuestVisitCard({ + link, + onUnlink, + disabled, +}: { + link: TaskLinkedGuestVisit; + onUnlink: (linkId: number) => void; + disabled?: boolean; +}) { + const visit = link.guest_visit; + const canOpen = Boolean(link.can_open && link.object_url); + const period = visit?.unlimited + ? "Бессрочно" + : `${formatDateTime(visit?.access_starts_at) || "—"} - ${formatDateTime(visit?.access_expires_at) || "—"}`; + const mainContent = ( + <> +
+
+ + + Гостевой визит + + {visit?.status_display ? ( + + {visit.status_display} + + ) : null} + {canOpen ? ( + + Открыть + + ) : null} +
+

+ {getLinkedGuestName(visit?.guest)} +

+ {visit?.purpose ? ( +

+ {visit.purpose} +

+ ) : null} +
+ +
+ + + {period} + + + Связал: {link.created_by ? displayUserName(link.created_by) : "не указано"} + +
+ + ); + + return ( +
+
+ {canOpen && link.object_url ? ( + + {mainContent} + + ) : ( +
+ {mainContent} +
+ )} + + +
+
+ ); +} + const activityFieldLabels: Record = { title: "название", description: "описание", @@ -1268,6 +1457,8 @@ export default function TasksPage() { const [linkedRequests, setLinkedRequests] = useState([]); const [linkedProcurementRequests, setLinkedProcurementRequests] = useState([]); const [linkedEmployees, setLinkedEmployees] = useState([]); + const [linkedGuests, setLinkedGuests] = useState([]); + const [linkedGuestVisits, setLinkedGuestVisits] = useState([]); const [linkedObjectsLoading, setLinkedObjectsLoading] = useState(false); const [commentsOpen, setCommentsOpen] = useState(false); const [taskComments, setTaskComments] = useState([]); @@ -1312,13 +1503,24 @@ export default function TasksPage() { const loadTaskLinkedObjects = useCallback(async (taskId: number) => { setLinkedObjectsLoading(true); try { - const [messages, events, documents, requests, procurementRequests, employees] = await Promise.all([ + const [ + messages, + events, + documents, + requests, + procurementRequests, + employees, + guests, + guestVisits, + ] = await Promise.all([ apiClient.getTaskLinkedMessages(taskId), apiClient.getTaskLinkedEvents(taskId), apiClient.getTaskLinkedDocuments(taskId), apiClient.getTaskLinkedRequests(taskId), apiClient.getTaskLinkedProcurementRequests(taskId), apiClient.getTaskLinkedEmployees(taskId), + apiClient.getTaskLinkedGuests(taskId), + apiClient.getTaskLinkedGuestVisits(taskId), ]); setLinkedMessages(messages); setLinkedEvents(events); @@ -1326,6 +1528,8 @@ export default function TasksPage() { setLinkedRequests(requests); setLinkedProcurementRequests(procurementRequests); setLinkedEmployees(employees); + setLinkedGuests(guests); + setLinkedGuestVisits(guestVisits); } catch (linksError) { setError(getTaskError(linksError, "Не удалось загрузить связанные объекты")); } finally { @@ -1658,7 +1862,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; + const linkedObjectsCount = linkedMessages.length + linkedEvents.length + linkedDocuments.length + linkedRequests.length + linkedProcurementRequests.length + linkedEmployees.length + linkedGuests.length + linkedGuestVisits.length; const linkedObjectsBadgeCount = viewTask?.linked_objects_count ?? linkedObjectsCount; const commentsBadgeCount = viewTask?.comments_count ?? taskComments.length; @@ -1679,6 +1883,8 @@ export default function TasksPage() { setLinkedRequests([]); setLinkedProcurementRequests([]); setLinkedEmployees([]); + setLinkedGuests([]); + setLinkedGuestVisits([]); setTaskActivities([]); setTaskComments([]); setCommentDraft(""); @@ -1727,6 +1933,8 @@ export default function TasksPage() { setLinkedRequests([]); setLinkedProcurementRequests([]); setLinkedEmployees([]); + setLinkedGuests([]); + setLinkedGuestVisits([]); setTaskComments([]); setTaskActivities([]); setCommentDraft(""); @@ -1748,6 +1956,8 @@ export default function TasksPage() { setLinkedRequests([]); setLinkedProcurementRequests([]); setLinkedEmployees([]); + setLinkedGuests([]); + setLinkedGuestVisits([]); setTaskComments([]); setTaskActivities([]); setLinkedObjectsOpen(false); @@ -2180,6 +2390,40 @@ export default function TasksPage() { } }, [activityOpen, board, loadBoard, loadTaskActivity, loadTaskLinkedObjects, saving, viewTask]); + const unlinkGuestFromViewedTask = useCallback(async (linkId: number) => { + if (!board || !viewTask || saving) return; + + setSaving(true); + setError(null); + try { + await apiClient.unlinkTaskGuest(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 unlinkGuestVisitFromViewedTask = useCallback(async (linkId: number) => { + if (!board || !viewTask || saving) return; + + setSaving(true); + setError(null); + try { + await apiClient.unlinkTaskGuestVisit(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); @@ -3014,6 +3258,22 @@ export default function TasksPage() { disabled={saving} /> ))} + {linkedGuests.map((link) => ( + + ))} + {linkedGuestVisits.map((link) => ( + + ))} ) : (
diff --git a/frontend/src/components/tasks/RelatedTaskLinks.tsx b/frontend/src/components/tasks/RelatedTaskLinks.tsx new file mode 100644 index 00000000..92885e19 --- /dev/null +++ b/frontend/src/components/tasks/RelatedTaskLinks.tsx @@ -0,0 +1,584 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { ChevronRight, Link2, Loader2, Plus } from "lucide-react"; +import { toast } from "sonner"; + +import TaskLinkPill from "@/components/tasks/TaskLinkPill"; +import { Modal } from "@/components/ui"; +import { apiClient } from "@/lib/api"; +import { displayUserName } from "@/lib/shared"; +import type { TaskBoard, TaskCard, TaskPriority, User } from "@/types/api"; + +type RelatedTaskLinksProps = { + entityLabel: string; + entityTitle: string; + entitySubtitle?: string; + defaultTaskTitle: string; + defaultTaskDescription?: string; + successMessage: string; + loadLinkedTasks: () => Promise; + linkTask: (taskId: number) => Promise; + variant?: "section" | "dialog"; + open?: boolean; + onClose?: () => void; + onLinked?: () => void; +}; + +const taskPriorityOptions: { value: TaskPriority; label: string }[] = [ + { value: "low", label: "Низкая" }, + { value: "medium", label: "Средняя" }, + { value: "high", label: "Высокая" }, + { value: "critical", label: "Критическая" }, +]; + +function getTaskOptionLabel(task: TaskCard) { + return `#${task.id} - ${task.title}`; +} + +function taskMatchesSearch(task: TaskCard, query: string) { + const normalized = query.trim().toLowerCase(); + if (!normalized) return true; + return [ + String(task.id), + task.title, + task.description || "", + task.assignee ? displayUserName(task.assignee) : "", + ] + .join(" ") + .toLowerCase() + .includes(normalized); +} + +export function RelatedTaskLinks({ + entityLabel, + entityTitle, + entitySubtitle, + defaultTaskTitle, + defaultTaskDescription = "", + successMessage, + loadLinkedTasks, + linkTask, + variant = "section", + open = false, + onClose, + onLinked, +}: RelatedTaskLinksProps) { + const [linkedTasks, setLinkedTasks] = useState([]); + const [loading, setLoading] = useState(true); + const [linkModalOpen, setLinkModalOpen] = useState(false); + const [boards, setBoards] = useState([]); + const [employees, setEmployees] = useState([]); + const [boardId, setBoardId] = useState(""); + const [taskId, setTaskId] = useState(""); + const [taskSearch, setTaskSearch] = useState(""); + const [mode, setMode] = useState<"existing" | "create">("existing"); + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [detailsOpen, setDetailsOpen] = useState(false); + const [columnId, setColumnId] = useState(""); + const [assigneeId, setAssigneeId] = useState(""); + const [priority, setPriority] = useState("medium"); + const [dueDate, setDueDate] = useState(""); + const [labelIds, setLabelIds] = useState([]); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const selectedBoard = boards.find((board) => board.id === boardId) || null; + const selectedTask = (selectedBoard?.tasks || []).find((task) => task.id === taskId) || null; + const selectedColumn = ( + (selectedBoard?.columns || []).find((column) => column.id === columnId) || + (selectedBoard?.columns || []).find((column) => !column.is_archived) || + null + ); + const linkedTaskIds = useMemo( + () => new Set(linkedTasks.map((task) => task.id)), + [linkedTasks], + ); + const dialogMode = variant === "dialog"; + const resolvedModalOpen = dialogMode ? open : linkModalOpen; + + const refreshLinkedTasks = useCallback(async () => { + setLoading(true); + try { + setLinkedTasks(await loadLinkedTasks()); + } catch (loadError) { + console.error("Ошибка загрузки связанных задач:", loadError); + toast.error("Не удалось загрузить связанные задачи"); + } finally { + setLoading(false); + } + }, [loadLinkedTasks]); + + useEffect(() => { + void refreshLinkedTasks(); + }, [refreshLinkedTasks]); + + const resetForm = () => { + setBoardId(""); + setTaskId(""); + setTaskSearch(""); + setMode("existing"); + setTitle(""); + setDescription(""); + setDetailsOpen(false); + setColumnId(""); + setAssigneeId(""); + setPriority("medium"); + setDueDate(""); + setLabelIds([]); + setError(null); + }; + + const prepareLinkModal = useCallback(async () => { + setSaving(true); + setError(null); + setTitle(defaultTaskTitle); + setDescription(defaultTaskDescription); + try { + const [boardsResponse, employeesResponse] = await Promise.all([ + apiClient.getTaskBoards(), + apiClient.getEmployees({ limit: 200, is_active: true, ordering: "last_name" }), + ]); + const nextBoards = (boardsResponse.results || boardsResponse || []) as TaskBoard[]; + const nextEmployees = (employeesResponse.results || employeesResponse || []) as User[]; + const firstBoard = nextBoards.find((board) => (board.tasks || []).some((task) => !linkedTaskIds.has(task.id))) || nextBoards[0] || null; + const firstTask = (firstBoard?.tasks || []).find((task) => !linkedTaskIds.has(task.id)) || null; + const firstColumn = firstBoard?.columns?.find((column) => !column.is_archived) || null; + + setBoards(nextBoards); + setEmployees(nextEmployees); + setBoardId(firstBoard?.id || ""); + setTaskId(firstTask?.id || ""); + setTaskSearch(""); + setColumnId(firstColumn?.id || ""); + setMode(firstTask ? "existing" : "create"); + setDetailsOpen(false); + setAssigneeId(""); + setPriority("medium"); + setDueDate(""); + setLabelIds([]); + } catch (openError) { + setError(openError instanceof Error ? openError.message : "Не удалось загрузить задачи"); + } finally { + setSaving(false); + } + }, [defaultTaskDescription, defaultTaskTitle, linkedTaskIds]); + + const openLinkModal = async () => { + setLinkModalOpen(true); + await prepareLinkModal(); + }; + + const closeLinkModal = () => { + if (saving) return; + if (dialogMode) { + onClose?.(); + } else { + setLinkModalOpen(false); + } + resetForm(); + }; + + useEffect(() => { + if (!dialogMode) return; + if (open) { + void prepareLinkModal(); + } else { + resetForm(); + } + }, [dialogMode, open, prepareLinkModal]); + + const availableTasks = (selectedBoard?.tasks || []).filter((task) => !linkedTaskIds.has(task.id)); + const filteredAvailableTasks = availableTasks.filter((task) => taskMatchesSearch(task, taskSearch)); + + const handleBoardChange = (nextBoardId: number | "") => { + setBoardId(nextBoardId); + const nextBoard = boards.find((board) => board.id === nextBoardId) || null; + const nextTask = (nextBoard?.tasks || []).find((task) => !linkedTaskIds.has(task.id)) || null; + setTaskId(nextTask?.id || ""); + setTaskSearch(""); + setColumnId(nextBoard?.columns?.find((column) => !column.is_archived)?.id || ""); + setLabelIds([]); + if (!nextTask) setMode("create"); + }; + + const handleTaskSearchChange = (value: string) => { + setTaskSearch(value); + const nextTask = availableTasks.find((task) => taskMatchesSearch(task, value)); + setTaskId(nextTask?.id || ""); + }; + + const toggleLabel = (labelId: number) => { + setLabelIds((current) => ( + current.includes(labelId) + ? current.filter((id) => id !== labelId) + : [...current, labelId] + )); + }; + + const saveLink = async () => { + if (!selectedBoard) return; + if (mode === "existing" && !selectedTask) return; + if (mode === "create" && (!title.trim() || !selectedColumn)) return; + + setSaving(true); + setError(null); + try { + let taskToLink = selectedTask; + if (mode === "create") { + taskToLink = await apiClient.createTask({ + board: selectedBoard.id, + column: selectedColumn?.id, + title: title.trim(), + description: description.trim(), + assignee_id: assigneeId || null, + priority, + due_date: dueDate || null, + label_ids: labelIds, + }); + } + + if (!taskToLink) return; + await linkTask(taskToLink.id); + await refreshLinkedTasks(); + toast.success(successMessage); + onLinked?.(); + if (dialogMode) { + onClose?.(); + } else { + setLinkModalOpen(false); + } + resetForm(); + } catch (saveError) { + setError(saveError instanceof Error ? saveError.message : "Не удалось связать объект с задачей"); + } finally { + setSaving(false); + } + }; + + const modal = ( + + + +
+ )} + > +
+
+

{entityLabel}

+

+ {entityTitle} +

+ {entitySubtitle ? ( +

{entitySubtitle}

+ ) : null} +
+ + + +
+ + +
+ + {mode === "existing" ? ( +
+ + +
+ ) : ( +
+ +