Add task links for guests

This commit is contained in:
lizerk 2026-07-06 21:57:37 +03:00
parent b12f6ed854
commit 8f85ee7c1c
13 changed files with 2143 additions and 31 deletions

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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>
);
}

View File

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

View File

@ -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[];
}