Add task links for guests
This commit is contained in:
parent
b12f6ed854
commit
8f85ee7c1c
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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<link_id>\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<link_id>\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()
|
||||
|
||||
@ -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
|
||||
|
||||
47
backend/tasks/migrations/0009_add_guest_link_kinds.py
Normal file
47
backend/tasks/migrations/0009_add_guest_link_kinds.py
Normal file
@ -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="Тип связи",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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):
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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<Guest | null>(null);
|
||||
const [editingGuest, setEditingGuest] = useState<Guest | null>(null);
|
||||
const [guestEditForm, setGuestEditForm] = useState<GuestEditState | null>(null);
|
||||
const [taskLinkGuest, setTaskLinkGuest] = useState<Guest | null>(null);
|
||||
const [taskLinkVisit, setTaskLinkVisit] = useState<GuestVisit | null>(null);
|
||||
const [visitMenuOpenId, setVisitMenuOpenId] = useState<number | null>(null);
|
||||
const [visitCommentsOpenId, setVisitCommentsOpenId] = useState<number | null>(null);
|
||||
const [visitCommentDrafts, setVisitCommentDrafts] = useState<Record<number, string>>({});
|
||||
@ -411,6 +436,7 @@ function GuestsPageContent() {
|
||||
const [guestCommentDrafts, setGuestCommentDrafts] = useState<Record<number, string>>({});
|
||||
|
||||
const openedVisitParamRef = useRef<string | null>(null);
|
||||
const openedGuestParamRef = useRef<string | null>(null);
|
||||
const visitMenuRef = useRef<HTMLDivElement | null>(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<number, Document>();
|
||||
@ -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 ? (
|
||||
<RelatedTaskLinks
|
||||
key={`guest-task-link-${taskLinkGuest.id}`}
|
||||
entityLabel="Гость"
|
||||
entityTitle={guestName(taskLinkGuest)}
|
||||
entitySubtitle={[taskLinkGuest.organization, taskLinkGuest.email, taskLinkGuest.phone].filter(Boolean).join(" · ")}
|
||||
defaultTaskTitle={`Задача по гостю: ${guestName(taskLinkGuest)}`}
|
||||
defaultTaskDescription={[
|
||||
taskLinkGuest.organization ? `Организация: ${taskLinkGuest.organization}` : "",
|
||||
taskLinkGuest.position ? `Должность: ${taskLinkGuest.position}` : "",
|
||||
taskLinkGuest.phone ? `Телефон: ${taskLinkGuest.phone}` : "",
|
||||
taskLinkGuest.email ? `Email: ${taskLinkGuest.email}` : "",
|
||||
].filter(Boolean).join("\n")}
|
||||
successMessage="Гость связан с задачей"
|
||||
variant="dialog"
|
||||
open
|
||||
loadLinkedTasks={() => apiClient.getGuestLinkedTasks(taskLinkGuest.id) as Promise<TaskCard[]>}
|
||||
linkTask={(taskId) => apiClient.linkTaskGuest(taskId, taskLinkGuest.id)}
|
||||
onClose={() => setTaskLinkGuest(null)}
|
||||
onLinked={() => void handleGuestTaskLinked(taskLinkGuest.id)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{taskLinkVisit ? (
|
||||
<RelatedTaskLinks
|
||||
key={`guest-visit-task-link-${taskLinkVisit.id}`}
|
||||
entityLabel="Заявка на гостевой визит"
|
||||
entityTitle={`#${taskLinkVisit.id} · ${guestName(taskLinkVisit.guest)}`}
|
||||
entitySubtitle={taskLinkVisit.unlimited
|
||||
? "Бессрочно"
|
||||
: `${formatDateTime(taskLinkVisit.access_starts_at) || "—"} - ${formatDateTime(taskLinkVisit.access_expires_at) || "—"}`}
|
||||
defaultTaskTitle={`Задача по гостевому визиту #${taskLinkVisit.id}: ${guestName(taskLinkVisit.guest)}`}
|
||||
defaultTaskDescription={taskLinkVisit.purpose || ""}
|
||||
successMessage="Заявка на гостевой визит связана с задачей"
|
||||
variant="dialog"
|
||||
open
|
||||
loadLinkedTasks={() => apiClient.getGuestVisitLinkedTasks(taskLinkVisit.id) as Promise<TaskCard[]>}
|
||||
linkTask={(taskId) => apiClient.linkTaskGuestVisit(taskId, taskLinkVisit.id)}
|
||||
onClose={() => setTaskLinkVisit(null)}
|
||||
onLinked={() => void handleVisitTaskLinked(taskLinkVisit.id)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<ActionCommentModal
|
||||
action={actionDialog}
|
||||
busyKey={busyKey}
|
||||
@ -1427,11 +1538,21 @@ function GuestsPageContent() {
|
||||
<GuestDetailModal
|
||||
canEdit={canAdminGuests}
|
||||
guest={detailsGuest}
|
||||
onClose={() => 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);
|
||||
}}
|
||||
/>
|
||||
|
||||
<GuestPhotoModal
|
||||
@ -1678,6 +1799,34 @@ function GuestVisitControls({
|
||||
);
|
||||
}
|
||||
|
||||
function LinkedTaskPills({
|
||||
tasks,
|
||||
max = 3,
|
||||
className = "",
|
||||
}: {
|
||||
tasks?: Guest["linked_tasks"] | GuestVisit["linked_tasks"];
|
||||
max?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
if (!tasks || tasks.length === 0) return null;
|
||||
return (
|
||||
<div className={`flex flex-wrap gap-1.5 ${className}`}>
|
||||
{tasks.slice(0, max).map((task) => (
|
||||
<TaskLinkPill
|
||||
key={task.link_id || task.id}
|
||||
task={task}
|
||||
maxTitleClassName="max-w-44"
|
||||
/>
|
||||
))}
|
||||
{tasks.length > max ? (
|
||||
<span className="app-badge inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium">
|
||||
+{tasks.length - max}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<void>;
|
||||
onEdit: (visit: GuestVisit) => void;
|
||||
onLinkTask: (visit: GuestVisit) => void;
|
||||
onOpen: (visit: GuestVisit) => void | Promise<void>;
|
||||
onOpenGuestPhoto: (guest: Guest) => void;
|
||||
onRunAction: (visit: GuestVisit, action: "submit" | "sync") => void | Promise<void>;
|
||||
@ -1770,6 +1921,7 @@ function GuestVisitRow({
|
||||
visit={visit}
|
||||
onActionDialog={onActionDialog}
|
||||
onEdit={onEdit}
|
||||
onLinkTask={onLinkTask}
|
||||
onRunAction={onRunAction}
|
||||
onToggleMenu={onToggleMenu}
|
||||
/>
|
||||
@ -1784,6 +1936,7 @@ function GuestVisitRow({
|
||||
</div>
|
||||
<p className="app-text-muted mt-2 line-clamp-2 text-sm">{visit.purpose}</p>
|
||||
</button>
|
||||
<LinkedTaskPills tasks={visit.linked_tasks} className="mt-2" />
|
||||
{visit.documents?.length ? (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{visit.documents.map((document) => {
|
||||
@ -1913,9 +2066,11 @@ function VisitQuickActionButtons({
|
||||
onActionDialog: (kind: ActionKind) => void;
|
||||
onRunAction: (visit: GuestVisit, action: "submit" | "sync") => void | Promise<void>;
|
||||
}) {
|
||||
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 (
|
||||
<div className="mt-3 flex items-center justify-end gap-1.5">
|
||||
@ -1940,7 +2095,7 @@ function VisitQuickActionButtons({
|
||||
<MessageSquare size={16} />
|
||||
</button>
|
||||
) : null}
|
||||
{visit.can_approve ? (
|
||||
{canApprove ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onActionDialog("approve")}
|
||||
@ -1973,6 +2128,7 @@ function VisitActionButtons({
|
||||
visit,
|
||||
onActionDialog,
|
||||
onEdit,
|
||||
onLinkTask,
|
||||
onRunAction,
|
||||
onToggleMenu,
|
||||
}: {
|
||||
@ -1984,6 +2140,7 @@ function VisitActionButtons({
|
||||
visit: GuestVisit;
|
||||
onActionDialog: (kind: ActionKind) => void;
|
||||
onEdit: (visit: GuestVisit) => void;
|
||||
onLinkTask: (visit: GuestVisit) => void;
|
||||
onRunAction: (visit: GuestVisit, action: "submit" | "sync") => void | Promise<void>;
|
||||
onToggleMenu?: (visitId: number | null) => void;
|
||||
}) {
|
||||
@ -1997,7 +2154,7 @@ function VisitActionButtons({
|
||||
if (variant === "full") {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-1">
|
||||
{visit.can_approve ? (
|
||||
{canApproveGuestVisit(visit) ? (
|
||||
<button type="button" onClick={() => onActionDialog("approve")} className={buttonClass("app-action-approve")} title="Одобрить">
|
||||
<ThumbsUp size={approvalIconSize} className="text-emerald-500" />
|
||||
{showLabels ? <span className="text-emerald-500">Одобрить</span> : null}
|
||||
@ -2020,13 +2177,7 @@ function VisitActionButtons({
|
||||
}
|
||||
|
||||
const meta = statusMeta[visit.status] || { label: visit.status, className: "app-badge" };
|
||||
const hasSecondaryActions = visit.can_edit
|
||||
|| visit.can_request_info
|
||||
|| visit.can_revoke
|
||||
|| visit.can_cancel
|
||||
|| visit.can_return_to_work
|
||||
|| visit.can_delete
|
||||
|| visit.can_sync_ldap;
|
||||
const hasSecondaryActions = true;
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 flex-col items-start gap-2 lg:items-end">
|
||||
@ -2047,6 +2198,17 @@ function VisitActionButtons({
|
||||
</button>
|
||||
{isMenuOpen ? (
|
||||
<div className="app-menu absolute right-0 top-full z-20 mt-2 w-56 rounded-xl py-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onToggleMenu?.(null);
|
||||
onLinkTask(visit);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
|
||||
>
|
||||
<Link2 size={14} className="app-text-muted" />
|
||||
Связать с задачей
|
||||
</button>
|
||||
{visit.can_edit ? (
|
||||
<button
|
||||
type="button"
|
||||
@ -2099,7 +2261,7 @@ function VisitActionButtons({
|
||||
Отменить
|
||||
</button>
|
||||
) : null}
|
||||
{visit.can_return_to_work ? (
|
||||
{canReturnGuestVisitToWork(visit) ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@ -2154,24 +2316,19 @@ function VisitSecondaryActionsMenu({
|
||||
visit,
|
||||
onActionDialog,
|
||||
onEdit,
|
||||
onLinkTask,
|
||||
onRunAction,
|
||||
}: {
|
||||
busyKey: string | null;
|
||||
visit: GuestVisit;
|
||||
onActionDialog: (kind: ActionKind) => void;
|
||||
onEdit: (visit: GuestVisit) => void;
|
||||
onLinkTask: (visit: GuestVisit) => void;
|
||||
onRunAction: (visit: GuestVisit, action: "submit" | "sync") => void | Promise<void>;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
const hasActions = visit.can_submit
|
||||
|| visit.can_edit
|
||||
|| (visit.status === "needs_info" && visit.can_submit)
|
||||
|| visit.can_revoke
|
||||
|| visit.can_return_to_work
|
||||
|| visit.can_cancel
|
||||
|| visit.can_delete
|
||||
|| visit.can_sync_ldap;
|
||||
const hasActions = true;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@ -2207,7 +2364,18 @@ function VisitSecondaryActionsMenu({
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="app-menu absolute right-0 top-full z-30 mt-2 w-64 rounded-xl py-1.5">
|
||||
{visit.can_submit ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onLinkTask(visit);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
|
||||
>
|
||||
<Link2 size={14} className="app-text-muted" />
|
||||
Связать с задачей
|
||||
</button>
|
||||
{canSubmitGuestVisit(visit) ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@ -2234,7 +2402,7 @@ function VisitSecondaryActionsMenu({
|
||||
Редактировать
|
||||
</button>
|
||||
) : null}
|
||||
{visit.status === "needs_info" && visit.can_submit ? (
|
||||
{visit.status === "needs_info" && canSubmitGuestVisit(visit) ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@ -2260,7 +2428,7 @@ function VisitSecondaryActionsMenu({
|
||||
Отозвать доступ
|
||||
</button>
|
||||
) : null}
|
||||
{visit.can_return_to_work ? (
|
||||
{canReturnGuestVisitToWork(visit) ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@ -2919,6 +3087,7 @@ function GuestVisitDetailModal({
|
||||
onClose,
|
||||
onDeleteComment,
|
||||
onEdit,
|
||||
onLinkTask,
|
||||
onRunAction,
|
||||
}: {
|
||||
busyKey: string | null;
|
||||
@ -2929,6 +3098,7 @@ function GuestVisitDetailModal({
|
||||
onClose: () => void;
|
||||
onDeleteComment: (visit: GuestVisit, commentId: number) => void | Promise<void>;
|
||||
onEdit: (visit: GuestVisit) => void;
|
||||
onLinkTask: (visit: GuestVisit) => void;
|
||||
onRunAction: (visit: GuestVisit, action: "submit" | "sync") => void | Promise<void>;
|
||||
}) {
|
||||
const [commentDrafts, setCommentDrafts] = useState<Record<number, string>>({});
|
||||
@ -2939,7 +3109,7 @@ function GuestVisitDetailModal({
|
||||
setCommentDrafts((current) => ({ ...current, [visit.id]: value }));
|
||||
};
|
||||
const meta = statusMeta[visit.status] || { label: visit.status, className: "app-badge" };
|
||||
const hasDecisionActions = visit.can_approve
|
||||
const hasDecisionActions = canApproveGuestVisit(visit)
|
||||
|| visit.can_reject
|
||||
|| visit.can_request_info;
|
||||
|
||||
@ -2957,6 +3127,7 @@ function GuestVisitDetailModal({
|
||||
visit={visit}
|
||||
onActionDialog={(kind) => onActionDialog(kind, visit)}
|
||||
onEdit={onEdit}
|
||||
onLinkTask={onLinkTask}
|
||||
onRunAction={onRunAction}
|
||||
/>
|
||||
</div>
|
||||
@ -2990,6 +3161,7 @@ function GuestVisitDetailModal({
|
||||
visit={visit}
|
||||
onActionDialog={(kind) => onActionDialog(kind, visit)}
|
||||
onEdit={onEdit}
|
||||
onLinkTask={onLinkTask}
|
||||
onRunAction={onRunAction}
|
||||
/>
|
||||
</div>
|
||||
@ -3057,6 +3229,17 @@ function GuestVisitDetailModal({
|
||||
</div>
|
||||
|
||||
<aside className="space-y-4">
|
||||
<section className="app-surface-muted rounded-xl p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Link2 size={16} className="app-text-muted" />
|
||||
<h3 className="text-sm font-semibold text-[var(--foreground)]">Связанные задачи</h3>
|
||||
</div>
|
||||
<LinkedTaskPills tasks={visit.linked_tasks} max={4} />
|
||||
{visit.linked_tasks?.length ? null : (
|
||||
<p className="app-text-muted text-xs">Связанных задач нет.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="app-surface-muted rounded-xl p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
@ -3302,6 +3485,7 @@ function GuestRegistryPanel({
|
||||
onCommentDraftChange,
|
||||
onDeleteComment,
|
||||
onEditGuest,
|
||||
onLinkTask,
|
||||
onOpenGuest,
|
||||
onOpenGuestPhoto,
|
||||
onOpenGuestVisits,
|
||||
@ -3334,6 +3518,7 @@ function GuestRegistryPanel({
|
||||
onCommentDraftChange: (guestId: number, value: string) => void;
|
||||
onDeleteComment: (guest: Guest, commentId: number) => void | Promise<void>;
|
||||
onEditGuest: (guest: Guest) => void;
|
||||
onLinkTask: (guest: Guest) => void;
|
||||
onOpenGuest: (guest: Guest) => void;
|
||||
onOpenGuestPhoto: (guest: Guest) => void;
|
||||
onOpenGuestVisits: (guest: Guest) => void;
|
||||
@ -3457,7 +3642,7 @@ function GuestRegistryPanel({
|
||||
{ label: "Email", value: normalizeGuestMeta(guest.email), icon: Mail },
|
||||
{ label: "Телефон", value: normalizeGuestMeta(guest.phone), icon: Phone },
|
||||
].filter((item) => item.always || Boolean(item.value));
|
||||
const hasActions = canEditGuests || canManageLdap;
|
||||
const hasActions = true;
|
||||
const isGuestMenuOpen = guestMenuOpenId === guest.id;
|
||||
const commentsForGuest = commentsByGuest[guest.id] || [];
|
||||
const visibleCommentsCount = guest.comments_count || commentsForGuest.length;
|
||||
@ -3526,6 +3711,7 @@ function GuestRegistryPanel({
|
||||
<span className="min-w-0 truncate">Заявки {visitsCount}</span>
|
||||
</button>
|
||||
</div>
|
||||
<LinkedTaskPills tasks={guest.linked_tasks} className="mt-2" />
|
||||
{guest.ldap_last_error ? <p className="app-feedback-danger mt-2 rounded-lg p-2 text-xs">{guest.ldap_last_error}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
@ -3546,6 +3732,17 @@ function GuestRegistryPanel({
|
||||
</button>
|
||||
{isGuestMenuOpen ? (
|
||||
<div className="app-menu absolute right-0 top-full z-20 mt-2 w-56 rounded-xl py-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setGuestMenuOpenId(null);
|
||||
onLinkTask(guest);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
|
||||
>
|
||||
<Link2 size={14} className="app-text-muted" />
|
||||
Связать с задачей
|
||||
</button>
|
||||
{canEditGuests ? (
|
||||
<button
|
||||
type="button"
|
||||
@ -3766,11 +3963,13 @@ function GuestDetailModal({
|
||||
guest,
|
||||
onClose,
|
||||
onEdit,
|
||||
onLinkTask,
|
||||
}: {
|
||||
canEdit: boolean;
|
||||
guest: Guest | null;
|
||||
onClose: () => void;
|
||||
onEdit: (guest: Guest) => void;
|
||||
onLinkTask: (guest: Guest) => void;
|
||||
}) {
|
||||
if (!guest) return null;
|
||||
|
||||
@ -3797,6 +3996,14 @@ function GuestDetailModal({
|
||||
footer={
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" onClick={onClose} className="app-action-secondary rounded-lg px-4 py-2 text-sm font-medium">Закрыть</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onLinkTask(guest)}
|
||||
className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium"
|
||||
>
|
||||
<Link2 size={15} />
|
||||
Связать с задачей
|
||||
</button>
|
||||
{canEdit ? (
|
||||
<button
|
||||
type="button"
|
||||
@ -3870,6 +4077,17 @@ function GuestDetailModal({
|
||||
</InfoBlock>
|
||||
</section>
|
||||
|
||||
<section className="app-surface-muted rounded-xl p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Link2 size={16} className="app-text-muted" />
|
||||
<h3 className="text-sm font-semibold text-[var(--foreground)]">Связанные задачи</h3>
|
||||
</div>
|
||||
<LinkedTaskPills tasks={guest.linked_tasks} max={4} />
|
||||
{guest.linked_tasks?.length ? null : (
|
||||
<p className="app-text-muted text-sm">Связанных задач нет.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="app-surface-muted rounded-xl p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<FileText size={16} className="app-text-muted" />
|
||||
|
||||
@ -59,6 +59,8 @@ import type {
|
||||
TaskLinkedDocument,
|
||||
TaskLinkedEmployee,
|
||||
TaskLinkedCalendarEvent,
|
||||
TaskLinkedGuest,
|
||||
TaskLinkedGuestVisit,
|
||||
TaskLinkedMessage,
|
||||
TaskLinkedProcurementRequest,
|
||||
TaskLinkedRequest,
|
||||
@ -1164,6 +1166,193 @@ function LinkedEmployeeCard({
|
||||
);
|
||||
}
|
||||
|
||||
function getLinkedGuestName(guest: TaskLinkedGuest["guest"] | NonNullable<TaskLinkedGuestVisit["guest_visit"]>["guest"] | null | undefined) {
|
||||
return (
|
||||
guest?.full_name ||
|
||||
`${guest?.last_name || ""} ${guest?.first_name || ""} ${guest?.patronymic || ""}`.trim() ||
|
||||
(guest?.id ? `Гость #${guest.id}` : "Гость не найден")
|
||||
);
|
||||
}
|
||||
|
||||
function LinkedGuestCard({
|
||||
link,
|
||||
onUnlink,
|
||||
disabled,
|
||||
}: {
|
||||
link: TaskLinkedGuest;
|
||||
onUnlink: (linkId: number) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const guest = link.guest;
|
||||
const canOpen = Boolean(link.can_open && link.object_url);
|
||||
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">
|
||||
<UserRound size={12} />
|
||||
Гость
|
||||
</span>
|
||||
{guest?.is_blacklisted ? (
|
||||
<span className="app-feedback-danger rounded-full px-2 py-1 text-[11px]">
|
||||
Черный список
|
||||
</span>
|
||||
) : guest?.is_active ? (
|
||||
<span className="app-feedback-success rounded-full px-2 py-1 text-[11px]">
|
||||
Доступ активен
|
||||
</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)]">
|
||||
{getLinkedGuestName(guest)}
|
||||
</p>
|
||||
{guest?.organization ? (
|
||||
<p className="app-text-muted mt-0.5 text-xs">{guest.organization}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
{guest?.email ? (
|
||||
<span className="app-badge inline-flex max-w-full items-center gap-1 rounded-full px-2 py-1 text-[11px]">
|
||||
<span className="truncate">{guest.email}</span>
|
||||
</span>
|
||||
) : null}
|
||||
{guest?.phone ? (
|
||||
<span className="app-badge inline-flex max-w-full items-center gap-1 rounded-full px-2 py-1 text-[11px]">
|
||||
<span className="truncate">{guest.phone}</span>
|
||||
</span>
|
||||
) : null}
|
||||
<span className="app-text-muted text-[11px]">
|
||||
Связал: {link.created_by ? displayUserName(link.created_by) : "не указано"}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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 = (
|
||||
<>
|
||||
<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>
|
||||
{visit?.status_display ? (
|
||||
<span className="app-badge rounded-full px-2 py-1 text-[11px]">
|
||||
{visit.status_display}
|
||||
</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)]">
|
||||
{getLinkedGuestName(visit?.guest)}
|
||||
</p>
|
||||
{visit?.purpose ? (
|
||||
<p className="app-text-wrap app-text-muted mt-1 line-clamp-2 text-xs">
|
||||
{visit.purpose}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<span className="app-badge inline-flex max-w-full items-center gap-1 rounded-full px-2 py-1 text-[11px]">
|
||||
<CalendarDays size={11} />
|
||||
<span className="truncate">{period}</span>
|
||||
</span>
|
||||
<span className="app-text-muted text-[11px]">
|
||||
Связал: {link.created_by ? displayUserName(link.created_by) : "не указано"}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
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: "описание",
|
||||
@ -1268,6 +1457,8 @@ export default function TasksPage() {
|
||||
const [linkedRequests, setLinkedRequests] = useState<TaskLinkedRequest[]>([]);
|
||||
const [linkedProcurementRequests, setLinkedProcurementRequests] = useState<TaskLinkedProcurementRequest[]>([]);
|
||||
const [linkedEmployees, setLinkedEmployees] = useState<TaskLinkedEmployee[]>([]);
|
||||
const [linkedGuests, setLinkedGuests] = useState<TaskLinkedGuest[]>([]);
|
||||
const [linkedGuestVisits, setLinkedGuestVisits] = useState<TaskLinkedGuestVisit[]>([]);
|
||||
const [linkedObjectsLoading, setLinkedObjectsLoading] = useState(false);
|
||||
const [commentsOpen, setCommentsOpen] = useState(false);
|
||||
const [taskComments, setTaskComments] = useState<TaskComment[]>([]);
|
||||
@ -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) => (
|
||||
<LinkedGuestCard
|
||||
key={link.id}
|
||||
link={link}
|
||||
onUnlink={unlinkGuestFromViewedTask}
|
||||
disabled={saving}
|
||||
/>
|
||||
))}
|
||||
{linkedGuestVisits.map((link) => (
|
||||
<LinkedGuestVisitCard
|
||||
key={link.id}
|
||||
link={link}
|
||||
onUnlink={unlinkGuestVisitFromViewedTask}
|
||||
disabled={saving}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div className="app-surface-muted rounded-xl border border-dashed border-[var(--border-subtle)] px-3 py-4 text-center">
|
||||
|
||||
584
frontend/src/components/tasks/RelatedTaskLinks.tsx
Normal file
584
frontend/src/components/tasks/RelatedTaskLinks.tsx
Normal file
@ -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<TaskCard[]>;
|
||||
linkTask: (taskId: number) => Promise<unknown>;
|
||||
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<TaskCard[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [linkModalOpen, setLinkModalOpen] = useState(false);
|
||||
const [boards, setBoards] = useState<TaskBoard[]>([]);
|
||||
const [employees, setEmployees] = useState<User[]>([]);
|
||||
const [boardId, setBoardId] = useState<number | "">("");
|
||||
const [taskId, setTaskId] = useState<number | "">("");
|
||||
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<number | "">("");
|
||||
const [assigneeId, setAssigneeId] = useState<number | "">("");
|
||||
const [priority, setPriority] = useState<TaskPriority>("medium");
|
||||
const [dueDate, setDueDate] = useState("");
|
||||
const [labelIds, setLabelIds] = useState<number[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 = (
|
||||
<Modal
|
||||
isOpen={resolvedModalOpen}
|
||||
onClose={closeLinkModal}
|
||||
title="Связать с задачей"
|
||||
size="md"
|
||||
closeOnClickOutside
|
||||
footer={(
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeLinkModal}
|
||||
className="app-action-secondary rounded-xl px-4 py-2 text-sm font-medium"
|
||||
disabled={saving}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void saveLink()}
|
||||
className="app-action-primary inline-flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-medium disabled:opacity-60"
|
||||
disabled={
|
||||
saving ||
|
||||
(mode === "existing" ? !selectedTask : !title.trim() || !selectedColumn)
|
||||
}
|
||||
>
|
||||
{saving ? <Loader2 size={16} className="animate-spin" /> : <Link2 size={16} />}
|
||||
{mode === "create" ? "Создать и связать" : "Связать"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="app-surface-muted rounded-xl border border-[var(--border-subtle)] p-3">
|
||||
<p className="app-card-caption">{entityLabel}</p>
|
||||
<p className="app-text-wrap mt-1 line-clamp-3 text-sm text-[var(--foreground)]">
|
||||
{entityTitle}
|
||||
</p>
|
||||
{entitySubtitle ? (
|
||||
<p className="app-text-muted mt-1 text-xs">{entitySubtitle}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<label className="block">
|
||||
<span className="app-text-muted mb-1 block text-xs font-medium">Доска</span>
|
||||
<select
|
||||
value={boardId}
|
||||
onChange={(event) => handleBoardChange(Number(event.target.value) || "")}
|
||||
className="app-select w-full rounded-xl px-3 py-2 text-sm"
|
||||
disabled={saving}
|
||||
>
|
||||
<option value="">Выберите доску</option>
|
||||
{boards.map((board) => (
|
||||
<option key={board.id} value={board.id}>
|
||||
{board.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("existing")}
|
||||
className={`rounded-xl border px-3 py-2 text-sm font-medium transition ${
|
||||
mode === "existing"
|
||||
? "app-selected border-[var(--accent-primary)]"
|
||||
: "border-[var(--border-subtle)] text-[var(--muted-foreground)] hover:border-[var(--border-strong)]"
|
||||
}`}
|
||||
disabled={saving || !selectedBoard || availableTasks.length === 0}
|
||||
>
|
||||
Выбрать задачу
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("create")}
|
||||
className={`rounded-xl border px-3 py-2 text-sm font-medium transition ${
|
||||
mode === "create"
|
||||
? "app-selected border-[var(--accent-primary)]"
|
||||
: "border-[var(--border-subtle)] text-[var(--muted-foreground)] hover:border-[var(--border-strong)]"
|
||||
}`}
|
||||
disabled={saving || !selectedBoard}
|
||||
>
|
||||
Создать задачу
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mode === "existing" ? (
|
||||
<div className="space-y-2">
|
||||
<label className="block">
|
||||
<span className="app-text-muted mb-1 block text-xs font-medium">Поиск задачи</span>
|
||||
<input
|
||||
value={taskSearch}
|
||||
onChange={(event) => handleTaskSearchChange(event.target.value)}
|
||||
className="app-input w-full rounded-xl px-3 py-2 text-sm"
|
||||
disabled={saving || !selectedBoard}
|
||||
placeholder="ID, название, описание или исполнитель"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="app-text-muted mb-1 block text-xs font-medium">Задача</span>
|
||||
<select
|
||||
value={taskId}
|
||||
onChange={(event) => setTaskId(Number(event.target.value) || "")}
|
||||
className="app-select w-full rounded-xl px-3 py-2 text-sm"
|
||||
disabled={saving || !selectedBoard || filteredAvailableTasks.length === 0}
|
||||
>
|
||||
<option value="">
|
||||
{filteredAvailableTasks.length === 0 ? "Задачи не найдены" : "Выберите задачу"}
|
||||
</option>
|
||||
{filteredAvailableTasks.map((task) => (
|
||||
<option key={task.id} value={task.id}>
|
||||
{getTaskOptionLabel(task)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<label className="block">
|
||||
<span className="app-text-muted mb-1 block text-xs font-medium">Название задачи</span>
|
||||
<input
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
className="app-input w-full rounded-xl px-3 py-2 text-sm"
|
||||
disabled={saving}
|
||||
placeholder="Название новой задачи"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="app-text-muted mb-1 block text-xs font-medium">Описание</span>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
className="app-input w-full rounded-xl px-3 py-2 text-sm"
|
||||
disabled={saving}
|
||||
rows={3}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="rounded-xl border border-[var(--border-subtle)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDetailsOpen((current) => !current)}
|
||||
className="flex w-full items-center justify-between gap-3 px-3 py-2 text-left"
|
||||
disabled={saving}
|
||||
aria-expanded={detailsOpen}
|
||||
>
|
||||
<span>
|
||||
<span className="block text-sm font-medium text-[var(--foreground)]">
|
||||
Дополнительная информация
|
||||
</span>
|
||||
<span className="app-text-muted mt-0.5 block text-xs">
|
||||
Исполнитель, срочность, срок, колонка и метки
|
||||
</span>
|
||||
</span>
|
||||
<ChevronRight
|
||||
size={16}
|
||||
className={`app-text-muted shrink-0 transition-transform ${detailsOpen ? "rotate-90" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{detailsOpen ? (
|
||||
<div className="space-y-3 border-t border-[var(--border-subtle)] px-3 py-3">
|
||||
<label className="block">
|
||||
<span className="app-text-muted mb-1 block text-xs font-medium">Колонка</span>
|
||||
<select
|
||||
value={columnId}
|
||||
onChange={(event) => setColumnId(Number(event.target.value) || "")}
|
||||
className="app-select w-full rounded-xl px-3 py-2 text-sm"
|
||||
disabled={saving || !selectedBoard}
|
||||
>
|
||||
<option value="">Выберите колонку</option>
|
||||
{(selectedBoard?.columns || [])
|
||||
.filter((column) => !column.is_archived)
|
||||
.map((column) => (
|
||||
<option key={column.id} value={column.id}>
|
||||
{column.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="app-text-muted mb-1 block text-xs font-medium">Исполнитель</span>
|
||||
<select
|
||||
value={assigneeId}
|
||||
onChange={(event) => setAssigneeId(Number(event.target.value) || "")}
|
||||
className="app-select w-full rounded-xl px-3 py-2 text-sm"
|
||||
disabled={saving}
|
||||
>
|
||||
<option value="">Не назначен</option>
|
||||
{employees.map((employee) => (
|
||||
<option key={employee.id} value={employee.id}>
|
||||
{displayUserName(employee)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="app-text-muted mb-1 block text-xs font-medium">Срочность</span>
|
||||
<select
|
||||
value={priority}
|
||||
onChange={(event) => setPriority(event.target.value as TaskPriority)}
|
||||
className="app-select w-full rounded-xl px-3 py-2 text-sm"
|
||||
disabled={saving}
|
||||
>
|
||||
{taskPriorityOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="app-text-muted mb-1 block text-xs font-medium">Срок</span>
|
||||
<input
|
||||
type="date"
|
||||
value={dueDate}
|
||||
onChange={(event) => setDueDate(event.target.value)}
|
||||
className="app-input w-full rounded-xl px-3 py-2 text-sm"
|
||||
disabled={saving}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="app-text-muted mb-2 block text-xs font-medium">Метки</span>
|
||||
{(selectedBoard?.labels || []).length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(selectedBoard?.labels || []).map((label) => {
|
||||
const selected = labelIds.includes(label.id);
|
||||
return (
|
||||
<button
|
||||
key={label.id}
|
||||
type="button"
|
||||
onClick={() => toggleLabel(label.id)}
|
||||
className={`inline-flex max-w-full items-center rounded-full border px-2.5 py-1 text-xs font-medium transition ${
|
||||
selected ? "border-transparent text-white" : "border-[var(--border-subtle)] text-[var(--muted-foreground)]"
|
||||
}`}
|
||||
style={selected ? { backgroundColor: label.color || "#38bdf8" } : undefined}
|
||||
disabled={saving}
|
||||
>
|
||||
<span className="truncate">{label.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="app-text-muted text-xs">На этой доске меток нет</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error ? (
|
||||
<div className="app-feedback-danger rounded-xl px-3 py-2 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
if (dialogMode) {
|
||||
return modal;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Link2 size={18} className="app-text-muted shrink-0" />
|
||||
<h3 className="text-sm font-medium text-[var(--foreground)]">
|
||||
Связанные задачи ({linkedTasks.length})
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openLinkModal()}
|
||||
className="app-action-primary inline-flex shrink-0 items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Связать
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<Loader2 size={18} className="animate-spin text-sky-500" />
|
||||
</div>
|
||||
) : linkedTasks.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{linkedTasks.map((task) => (
|
||||
<TaskLinkPill
|
||||
key={task.id}
|
||||
task={{
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
board_id: task.board,
|
||||
board_name: task.board_name || "",
|
||||
column_color: task.column_color,
|
||||
priority: task.priority,
|
||||
priority_display: task.priority_display,
|
||||
}}
|
||||
maxTitleClassName="max-w-56"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="app-surface-muted rounded-xl border border-dashed border-[var(--border-subtle)] px-3 py-4 text-center">
|
||||
<p className="app-text-muted text-sm">Связанных задач нет</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{modal}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -9,6 +9,8 @@ import type {
|
||||
TaskLinkedCalendarEvent,
|
||||
TaskLinkedDocument,
|
||||
TaskLinkedEmployee,
|
||||
TaskLinkedGuest,
|
||||
TaskLinkedGuestVisit,
|
||||
TaskLinkedMessage,
|
||||
TaskLinkedProcurementRequest,
|
||||
TaskLinkedRequest,
|
||||
@ -86,6 +88,10 @@ export function createTasksApi(request: RequestFn) {
|
||||
request(`/api/v1/tasks/linked-procurement-request-tasks/?procurement_request_id=${requestId}`),
|
||||
getEmployeeLinkedTasks: (employeeId: number): Promise<TaskCard[]> =>
|
||||
request(`/api/v1/tasks/linked-employee-tasks/?employee_id=${employeeId}`),
|
||||
getGuestLinkedTasks: (guestId: number): Promise<TaskCard[]> =>
|
||||
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}`),
|
||||
getTaskLinkedMessages: (id: number): Promise<TaskLinkedMessage[]> =>
|
||||
request(`/api/v1/tasks/${id}/linked-messages/`),
|
||||
linkTaskMessage: (id: number, messageId: number): Promise<TaskLinkedMessage> =>
|
||||
@ -141,6 +147,28 @@ export function createTasksApi(request: RequestFn) {
|
||||
request(`/api/v1/tasks/${id}/linked-employees/${linkId}/`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
getTaskLinkedGuests: (id: number): Promise<TaskLinkedGuest[]> =>
|
||||
request(`/api/v1/tasks/${id}/linked-guests/`),
|
||||
linkTaskGuest: (id: number, guestId: number): Promise<TaskLinkedGuest> =>
|
||||
request(`/api/v1/tasks/${id}/linked-guests/`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ guest_id: guestId }),
|
||||
}),
|
||||
unlinkTaskGuest: (id: number, linkId: number): Promise<void> =>
|
||||
request(`/api/v1/tasks/${id}/linked-guests/${linkId}/`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
getTaskLinkedGuestVisits: (id: number): Promise<TaskLinkedGuestVisit[]> =>
|
||||
request(`/api/v1/tasks/${id}/linked-guest-visits/`),
|
||||
linkTaskGuestVisit: (id: number, guestVisitId: number): Promise<TaskLinkedGuestVisit> =>
|
||||
request(`/api/v1/tasks/${id}/linked-guest-visits/`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ guest_visit_id: guestVisitId }),
|
||||
}),
|
||||
unlinkTaskGuestVisit: (id: number, linkId: number): Promise<void> =>
|
||||
request(`/api/v1/tasks/${id}/linked-guest-visits/${linkId}/`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
getTaskLinkedEvents: (id: number): Promise<TaskLinkedCalendarEvent[]> =>
|
||||
request(`/api/v1/tasks/${id}/linked-events/`),
|
||||
linkTaskCalendarEvent: (id: number, eventId: number): Promise<TaskLinkedCalendarEvent> =>
|
||||
|
||||
@ -186,6 +186,8 @@ export interface TaskCard {
|
||||
linked_requests_count?: number;
|
||||
linked_procurement_requests_count?: number;
|
||||
linked_employees_count?: number;
|
||||
linked_guests_count?: number;
|
||||
linked_guest_visits_count?: number;
|
||||
linked_objects_count?: number;
|
||||
comments_count?: number;
|
||||
created_at: string;
|
||||
@ -324,12 +326,53 @@ export interface TaskLinkedEmployee {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface TaskLinkedGuest {
|
||||
id: number;
|
||||
kind: "guest";
|
||||
guest_id: number;
|
||||
guest: Guest | null;
|
||||
can_open?: boolean;
|
||||
object_url?: string | null;
|
||||
created_by?: User;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface TaskLinkedGuestVisit {
|
||||
id: number;
|
||||
kind: "guest_visit";
|
||||
guest_visit_id: number;
|
||||
guest_visit: {
|
||||
id: number;
|
||||
guest: Guest;
|
||||
inviter?: User | null;
|
||||
inviter_snapshot_name?: string;
|
||||
inviter_snapshot_email?: string;
|
||||
purpose?: string;
|
||||
visit_comment?: string;
|
||||
status?: GuestVisitStatus;
|
||||
status_display?: string;
|
||||
access_starts_at?: string | null;
|
||||
access_expires_at?: string | null;
|
||||
all_day?: boolean;
|
||||
unlimited?: boolean;
|
||||
submitted_at?: string | null;
|
||||
is_active_now?: boolean;
|
||||
is_expired?: boolean;
|
||||
created_at?: string;
|
||||
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" | "";
|
||||
object_kind?: "message" | "calendar_event" | "document" | "request" | "procurement_request" | "employee" | "guest" | "guest_visit" | "";
|
||||
object_id?: number | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
created_at: string;
|
||||
@ -561,6 +604,7 @@ export interface Guest {
|
||||
position?: string;
|
||||
comments_count?: number;
|
||||
visits_count?: number;
|
||||
linked_tasks?: MessageLinkedTask[];
|
||||
document_folder?: {
|
||||
id: number;
|
||||
name: string;
|
||||
@ -631,6 +675,7 @@ export interface GuestVisit {
|
||||
can_delete: boolean;
|
||||
comments_count: number;
|
||||
has_unread_info_response: boolean;
|
||||
linked_tasks?: MessageLinkedTask[];
|
||||
events: GuestVisitEvent[];
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user