feat: link tasks with workplace entities

This commit is contained in:
lizerk 2026-07-06 15:24:37 +03:00
parent d97b8e765a
commit 042952b7a9
28 changed files with 3627 additions and 105 deletions

View File

@ -293,6 +293,7 @@ class DocumentReadSerializer(serializers.ModelSerializer):
source="file.size", read_only=True, allow_null=True
)
is_acknowledged = serializers.SerializerMethodField()
linked_tasks = serializers.SerializerMethodField()
class Meta:
model = Document
@ -319,6 +320,7 @@ class DocumentReadSerializer(serializers.ModelSerializer):
"file_name",
"file_size",
"is_acknowledged",
"linked_tasks",
)
def get_is_acknowledged(self, obj: Document) -> bool:
@ -341,6 +343,60 @@ class DocumentReadSerializer(serializers.ModelSerializer):
document=obj, user=request.user
).exists()
@extend_schema_field(serializers.ListField(child=serializers.DictField()))
def get_linked_tasks(self, obj: Document) -> list[dict]:
prefetched = getattr(obj, "_linked_task_payloads", None)
if prefetched is not None:
return prefetched
request = self.context.get("request")
user = getattr(request, "user", None)
if not user or not getattr(user, "is_authenticated", False):
return []
try:
from django.contrib.contenttypes.models import ContentType
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(Document)
accessible_boards = TaskBoard.objects.filter(
is_archived=False,
).filter(task_board_access_q(user))
links = (
TaskLinkedObject.objects.filter(
kind=TaskLinkedObjectKind.DOCUMENT,
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 DocumentWriteSerializer(serializers.ModelSerializer):
"""Сериализатор для записи/обновления документа.

View File

@ -6,6 +6,7 @@ import posixpath
import zipfile
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.db import transaction
from django.db.models import Count, Exists, OuterRef, QuerySet, Q
from django.http import FileResponse
@ -149,6 +150,72 @@ class DocumentViewSet(ModelViewSet):
return qs.order_by("-uploaded_at")
def _attach_linked_task_payloads(self, documents: list[Document]) -> None:
"""Предзагрузить компактные бейджи задач для списка документов."""
if not documents:
return
user = getattr(self.request, "user", None)
document_ids = [document.id for document in documents]
mapping = {document_id: [] for document_id in document_ids}
if user and user.is_authenticated:
from tasks.access import task_board_access_q
from tasks.models import (
TaskBoard,
TaskLinkedObject,
TaskLinkedObjectKind,
)
document_ct = ContentType.objects.get_for_model(Document)
accessible_boards = TaskBoard.objects.filter(
is_archived=False,
).filter(task_board_access_q(user))
links = (
TaskLinkedObject.objects.filter(
kind=TaskLinkedObjectKind.DOCUMENT,
content_type=document_ct,
object_id__in=document_ids,
task__board__in=accessible_boards,
)
.select_related("task", "task__board", "task__column")
.order_by("object_id", "task__title", "task_id")
)
for link in links:
mapping.setdefault(link.object_id, []).append(
{
"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 document in documents:
document._linked_task_payloads = mapping.get(document.id, [])
def list(self, request, *args, **kwargs):
queryset = self.filter_queryset(self.get_queryset())
page = self.paginate_queryset(queryset)
if page is not None:
self._attach_linked_task_payloads(list(page))
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
documents = list(queryset)
self._attach_linked_task_payloads(documents)
serializer = self.get_serializer(documents, many=True)
return Response(serializer.data)
@transaction.atomic
def create(self, request, *args, **kwargs):
"""Создаёт документ и возвращает read-схему + recipient_count.

View File

@ -96,6 +96,7 @@ class RequestReadSerializer(serializers.ModelSerializer):
can_decide = serializers.SerializerMethodField()
# Используем аннотированное поле из queryset
comments_count = serializers.IntegerField(read_only=True, allow_null=True)
linked_tasks = serializers.SerializerMethodField()
class Meta:
model = Request
@ -127,6 +128,7 @@ class RequestReadSerializer(serializers.ModelSerializer):
"is_recipient",
"can_decide",
"comments_count",
"linked_tasks",
)
read_only_fields = (
"employee",
@ -145,6 +147,7 @@ class RequestReadSerializer(serializers.ModelSerializer):
"is_recipient",
"can_decide",
"comments_count",
"linked_tasks",
)
@extend_schema_field(serializers.IntegerField())
@ -192,6 +195,60 @@ class RequestReadSerializer(serializers.ModelSerializer):
return False
return obj.recipients.filter(id=user.id).exists()
@extend_schema_field(serializers.ListField(child=serializers.DictField()))
def get_linked_tasks(self, obj: Request) -> list[dict]:
prefetched = getattr(obj, "_linked_task_payloads", None)
if prefetched is not None:
return prefetched
request = self.context.get("request")
user = getattr(request, "user", None)
if not user or not getattr(user, "is_authenticated", False):
return []
try:
from django.contrib.contenttypes.models import ContentType
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(Request)
accessible_boards = TaskBoard.objects.filter(
is_archived=False,
).filter(task_board_access_q(user))
links = (
TaskLinkedObject.objects.filter(
kind=TaskLinkedObjectKind.REQUEST,
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 RequestWriteSerializer(serializers.ModelSerializer):
"""Сериализатор записи заявки.

View File

@ -379,6 +379,75 @@ class RequestViewSet(viewsets.ModelViewSet):
return qs
def _attach_linked_task_payloads(
self,
requests: list[EmployeeRequest],
) -> None:
"""Предзагрузить компактные бейджи задач для списка заявлений."""
if not requests:
return
user = getattr(self.request, "user", None)
request_ids = [request_obj.id for request_obj in requests]
mapping = {request_id: [] for request_id in request_ids}
if user and user.is_authenticated:
from tasks.access import task_board_access_q
from tasks.models import (
TaskBoard,
TaskLinkedObject,
TaskLinkedObjectKind,
)
request_ct = ContentType.objects.get_for_model(EmployeeRequest)
accessible_boards = TaskBoard.objects.filter(
is_archived=False,
).filter(task_board_access_q(user))
links = (
TaskLinkedObject.objects.filter(
kind=TaskLinkedObjectKind.REQUEST,
content_type=request_ct,
object_id__in=request_ids,
task__board__in=accessible_boards,
)
.select_related("task", "task__board", "task__column")
.order_by("object_id", "task__title", "task_id")
)
for link in links:
mapping.setdefault(link.object_id, []).append(
{
"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 request_obj in requests:
request_obj._linked_task_payloads = mapping.get(request_obj.id, [])
def list(self, request, *args, **kwargs):
queryset = self.filter_queryset(self.get_queryset())
page = self.paginate_queryset(queryset)
if page is not None:
self._attach_linked_task_payloads(list(page))
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
requests = list(queryset)
self._attach_linked_task_payloads(requests)
serializer = self.get_serializer(requests, many=True)
return Response(serializer.data)
# --- ВАЖНО: не ловить 404 на detail-экшенах
# из-за урезанного get_queryset() ---

View File

@ -4,16 +4,22 @@ from django.contrib.contenttypes.models import ContentType
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from api.v1.documents.serializers import DocumentReadSerializer
from api.v1.employees.serializers import EmployeeBriefSerializer
from api.v1.employees.serializers.department import DepartmentBriefSerializer
from communications.models import Message
from communications.comments_helpers import get_comment_count
from communications.serialization import serialize_message
from communications.utils import user_can_access_chat
from documents.models import Document
from employees.models import Department
from requests_app.models import Request as EmployeeRequest
from schedule.models import Event
from api.v1.schedule.serializers import EventSerializer
from tasks.access import (
user_can_access_calendar_event,
user_can_access_document,
user_can_access_employee_request,
user_can_access_task_board,
)
from tasks.models import (
@ -134,7 +140,10 @@ class TaskSerializer(serializers.ModelSerializer):
column_color = serializers.CharField(source="column.color", read_only=True)
linked_messages_count = serializers.SerializerMethodField()
linked_events_count = serializers.SerializerMethodField()
linked_documents_count = serializers.SerializerMethodField()
linked_requests_count = serializers.SerializerMethodField()
linked_objects_count = serializers.SerializerMethodField()
comments_count = serializers.SerializerMethodField()
class Meta:
model = Task
@ -159,7 +168,10 @@ class TaskSerializer(serializers.ModelSerializer):
"completed_at",
"linked_messages_count",
"linked_events_count",
"linked_documents_count",
"linked_requests_count",
"linked_objects_count",
"comments_count",
"created_at",
"updated_at",
]
@ -173,7 +185,10 @@ class TaskSerializer(serializers.ModelSerializer):
"completed_at",
"linked_messages_count",
"linked_events_count",
"linked_documents_count",
"linked_requests_count",
"linked_objects_count",
"comments_count",
"created_at",
"updated_at",
]
@ -194,12 +209,34 @@ class TaskSerializer(serializers.ModelSerializer):
kind=TaskLinkedObjectKind.CALENDAR_EVENT,
).count()
@extend_schema_field(serializers.IntegerField())
def get_linked_documents_count(self, obj):
if hasattr(obj, "linked_documents_count"):
return obj.linked_documents_count
return obj.linked_objects.filter(
kind=TaskLinkedObjectKind.DOCUMENT,
).count()
@extend_schema_field(serializers.IntegerField())
def get_linked_requests_count(self, obj):
if hasattr(obj, "linked_requests_count"):
return obj.linked_requests_count
return obj.linked_objects.filter(
kind=TaskLinkedObjectKind.REQUEST,
).count()
@extend_schema_field(serializers.IntegerField())
def get_linked_objects_count(self, obj):
if hasattr(obj, "linked_objects_count"):
return obj.linked_objects_count
return obj.linked_objects.count()
@extend_schema_field(serializers.IntegerField())
def get_comments_count(self, obj):
if hasattr(obj, "comments_count"):
return obj.comments_count
return get_comment_count(obj)
def validate(self, attrs):
request = self.context.get("request")
user = getattr(request, "user", None)
@ -427,3 +464,145 @@ class TaskLinkedCalendarEventSerializer(serializers.ModelSerializer):
def get_calendar_event_content_type():
return ContentType.objects.get_for_model(Event)
class TaskLinkedDocumentSerializer(serializers.ModelSerializer):
created_by = EmployeeBriefSerializer(read_only=True)
document = serializers.SerializerMethodField()
document_id = serializers.IntegerField(source="object_id", read_only=True)
can_open = serializers.SerializerMethodField()
object_url = serializers.SerializerMethodField()
class Meta:
model = TaskLinkedObject
fields = [
"id",
"kind",
"document_id",
"document",
"can_open",
"object_url",
"created_by",
"created_at",
]
read_only_fields = fields
@extend_schema_field(serializers.JSONField(allow_null=True))
def get_document(self, obj):
if obj.kind != TaskLinkedObjectKind.DOCUMENT:
return None
document = getattr(obj, "content_object", None)
if document is None:
return None
if not self.get_can_open(obj):
return {
"id": document.id,
"title": document.title,
"description": document.description,
"is_regulation": document.is_regulation,
"folder_path": document.folder_path,
"file_name": (
getattr(document.file, "name", None)
if document.file
else None
),
}
return DocumentReadSerializer(document, context=self.context).data
@extend_schema_field(serializers.BooleanField())
def get_can_open(self, obj):
document = getattr(obj, "content_object", None)
request = self.context.get("request")
user = getattr(request, "user", None)
if (
obj.kind != TaskLinkedObjectKind.DOCUMENT
or document is None
or not user
):
return False
return user_can_access_document(user, document)
@extend_schema_field(serializers.CharField(allow_null=True))
def get_object_url(self, obj):
document = getattr(obj, "content_object", None)
if document is None or not self.get_can_open(obj):
return None
return f"/documents?document={document.id}"
def get_document_content_type():
return ContentType.objects.get_for_model(Document)
class TaskLinkedRequestSerializer(serializers.ModelSerializer):
created_by = EmployeeBriefSerializer(read_only=True)
request = serializers.SerializerMethodField()
request_id = serializers.IntegerField(source="object_id", read_only=True)
can_open = serializers.SerializerMethodField()
object_url = serializers.SerializerMethodField()
class Meta:
model = TaskLinkedObject
fields = [
"id",
"kind",
"request_id",
"request",
"can_open",
"object_url",
"created_by",
"created_at",
]
read_only_fields = fields
@extend_schema_field(serializers.JSONField(allow_null=True))
def get_request(self, obj):
if obj.kind != TaskLinkedObjectKind.REQUEST:
return None
request_obj = getattr(obj, "content_object", None)
if request_obj is None:
return None
return {
"id": request_obj.id,
"title": request_obj.title,
"display_title": request_obj.display_title,
"type": request_obj.type,
"type_display": request_obj.get_type_display(),
"status": request_obj.status,
"status_display": request_obj.get_status_display(),
"comment": request_obj.comment,
"date_from": request_obj.date_from,
"date_to": request_obj.date_to,
"employee": EmployeeBriefSerializer(request_obj.employee).data,
"created_at": request_obj.created_at,
"updated_at": request_obj.updated_at,
}
@extend_schema_field(serializers.BooleanField())
def get_can_open(self, obj):
request_obj = getattr(obj, "content_object", None)
request = self.context.get("request")
user = getattr(request, "user", None)
if (
obj.kind != TaskLinkedObjectKind.REQUEST
or request_obj is None
or not user
):
return False
return user_can_access_employee_request(user, request_obj)
@extend_schema_field(serializers.CharField(allow_null=True))
def get_object_url(self, obj):
request_obj = getattr(obj, "content_object", None)
if request_obj is None or not self.get_can_open(obj):
return None
return f"/requests?request={request_obj.id}"
def get_request_content_type():
return ContentType.objects.get_for_model(EmployeeRequest)

View File

@ -1,13 +1,31 @@
from django.db.models import Count, Max, Q
from django.contrib.contenttypes.models import ContentType
from django.db.models import (
Count,
IntegerField,
Max,
OuterRef,
Q,
Subquery,
Value,
)
from django.db.models.functions import Coalesce
from communications import comments_helpers
from communications.models import Message
from communications.utils import user_can_access_chat
from documents.models import Document
from requests_app.models import Request as EmployeeRequest
from rest_framework import filters, status, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from schedule.models import Event
from tasks.access import task_board_access_q, user_can_access_calendar_event
from tasks.access import (
task_board_access_q,
user_can_access_calendar_event,
user_can_access_document,
user_can_access_employee_request,
)
from tasks.models import (
Task,
TaskActivity,
@ -24,16 +42,21 @@ from tasks.realtime import (
)
from .serializers import (
EmployeeBriefSerializer,
TaskActivitySerializer,
TaskBoardSerializer,
TaskColumnSerializer,
TaskLabelSerializer,
TaskLinkedCalendarEventSerializer,
TaskLinkedDocumentSerializer,
TaskLinkedMessageSerializer,
TaskLinkedRequestSerializer,
TaskSerializer,
create_default_columns,
get_calendar_event_content_type,
get_document_content_type,
get_message_content_type,
get_request_content_type,
)
@ -215,6 +238,18 @@ class TaskViewSet(viewsets.ModelViewSet):
ordering = ["position", "-created_at"]
def get_queryset(self):
task_content_type = ContentType.objects.get_for_model(Task)
comments_subquery = (
Message.objects.filter(
chat__type="comments",
chat__context_content_type=task_content_type,
chat__context_object_id=OuterRef("pk"),
is_deleted=False,
)
.values("chat")
.annotate(count=Count("id"))
.values("count")
)
queryset = (
Task.objects.filter(board__is_archived=False)
.filter(
@ -237,7 +272,25 @@ class TaskViewSet(viewsets.ModelViewSet):
),
distinct=True,
),
linked_documents_count=Count(
"linked_objects",
filter=Q(
linked_objects__kind=TaskLinkedObjectKind.DOCUMENT,
),
distinct=True,
),
linked_requests_count=Count(
"linked_objects",
filter=Q(
linked_objects__kind=TaskLinkedObjectKind.REQUEST,
),
distinct=True,
),
linked_objects_count=Count("linked_objects", distinct=True),
comments_count=Coalesce(
Subquery(comments_subquery, output_field=IntegerField()),
Value(0),
),
)
)
params = self.request.query_params
@ -374,10 +427,106 @@ class TaskViewSet(viewsets.ModelViewSet):
@action(detail=True, methods=["get"], url_path="activity")
def activity(self, request, pk=None):
task = self.get_object()
queryset = task.activities.select_related("actor").order_by("-created_at", "-id")
queryset = task.activities.select_related("actor").order_by(
"-created_at",
"-id",
)
serializer = TaskActivitySerializer(queryset, many=True)
return Response(serializer.data)
def _comment_payload(self, task, message):
return {
"id": message.id,
"task": task.id,
"author": EmployeeBriefSerializer(message.author).data,
"text": message.content,
"created_at": message.created_at,
}
@action(detail=True, methods=["get", "post"], url_path="comments")
def comments(self, request, pk=None):
task = self.get_object()
if request.method in ("GET", "HEAD"):
messages = comments_helpers.get_comments(task)
return Response(
[self._comment_payload(task, message) for message in messages]
)
text = request.data.get("text", "").strip()
if not text:
return Response(
{"text": ["Это поле не может быть пустым."]},
status=status.HTTP_400_BAD_REQUEST,
)
message = comments_helpers.create_comment(
obj=task,
author=request.user,
content=text,
)
send_task_board_update(
task.board,
"commented",
"task",
task.id,
extra={"comment_id": message.id},
)
return Response(
self._comment_payload(task, message),
status=status.HTTP_201_CREATED,
)
@action(
detail=True,
methods=["delete"],
url_path=r"comments/(?P<comment_id>\d+)",
)
def delete_comment(self, request, pk=None, comment_id=None):
task = self.get_object()
chat = comments_helpers.get_comments_chat_if_exists(task)
if chat is None:
return Response(
{"detail": "Комментарий не найден."},
status=status.HTTP_404_NOT_FOUND,
)
try:
message = Message.objects.get(
id=comment_id,
chat=chat,
is_deleted=False,
)
except Message.DoesNotExist:
return Response(
{"detail": "Комментарий не найден."},
status=status.HTTP_404_NOT_FOUND,
)
if not (
request.user.is_staff
or request.user.is_superuser
or message.author_id == request.user.id
):
return Response(
{"detail": "Нет прав на удаление комментария."},
status=status.HTTP_403_FORBIDDEN,
)
comments_helpers.delete_comment(
message=message,
deleted_by=request.user,
soft_delete=True,
)
send_task_board_update(
task.board,
"comment_deleted",
"task",
task.id,
extra={"comment_id": int(comment_id)},
)
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=False, methods=["get"], url_path="linked-event-tasks")
def linked_event_tasks(self, request):
event_id = request.query_params.get("event_id")
@ -421,6 +570,92 @@ class TaskViewSet(viewsets.ModelViewSet):
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
@action(detail=False, methods=["get"], url_path="linked-document-tasks")
def linked_document_tasks(self, request):
document_id = request.query_params.get("document_id")
try:
document_id = int(document_id)
except (TypeError, ValueError):
return Response(
{"document_id": ["Укажите ID документа."]},
status=status.HTTP_400_BAD_REQUEST,
)
try:
document = (
Document.objects.select_related("uploaded_by", "file", "folder")
.prefetch_related("recipients", "departments", "tags")
.get(id=document_id)
)
except Document.DoesNotExist:
return Response(
{"document_id": ["Документ не найден."]},
status=status.HTTP_404_NOT_FOUND,
)
if not user_can_access_document(request.user, document):
return Response(
{"detail": "Нет доступа к этому документу."},
status=status.HTTP_403_FORBIDDEN,
)
queryset = (
self.get_queryset()
.filter(
linked_objects__kind=TaskLinkedObjectKind.DOCUMENT,
linked_objects__content_type=get_document_content_type(),
linked_objects__object_id=document.id,
)
.distinct()
)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
@action(detail=False, methods=["get"], url_path="linked-request-tasks")
def linked_request_tasks(self, request):
request_id = request.query_params.get("request_id")
try:
request_id = int(request_id)
except (TypeError, ValueError):
return Response(
{"request_id": ["Укажите ID заявления."]},
status=status.HTTP_400_BAD_REQUEST,
)
try:
employee_request = (
EmployeeRequest.objects.select_related(
"employee",
"approver",
"department",
)
.prefetch_related("recipients", "cc_users")
.get(id=request_id)
)
except EmployeeRequest.DoesNotExist:
return Response(
{"request_id": ["Заявление не найдено."]},
status=status.HTTP_404_NOT_FOUND,
)
if not user_can_access_employee_request(request.user, employee_request):
return Response(
{"detail": "Нет доступа к этому заявлению."},
status=status.HTTP_403_FORBIDDEN,
)
queryset = (
self.get_queryset()
.filter(
linked_objects__kind=TaskLinkedObjectKind.REQUEST,
linked_objects__content_type=get_request_content_type(),
linked_objects__object_id=employee_request.id,
)
.distinct()
)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
def _message_links_queryset(self, task):
return (
TaskLinkedObject.objects.filter(
@ -441,6 +676,26 @@ class TaskViewSet(viewsets.ModelViewSet):
.order_by("-created_at", "-id")
)
def _document_links_queryset(self, task):
return (
TaskLinkedObject.objects.filter(
task=task,
kind=TaskLinkedObjectKind.DOCUMENT,
)
.select_related("created_by", "content_type")
.order_by("-created_at", "-id")
)
def _request_links_queryset(self, task):
return (
TaskLinkedObject.objects.filter(
task=task,
kind=TaskLinkedObjectKind.REQUEST,
)
.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()
@ -565,6 +820,224 @@ class TaskViewSet(viewsets.ModelViewSet):
)
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=True, methods=["get", "post"], url_path="linked-documents")
def linked_documents(self, request, pk=None):
task = self.get_object()
if request.method == "GET":
serializer = TaskLinkedDocumentSerializer(
self._document_links_queryset(task),
many=True,
context={"request": request},
)
return Response(serializer.data)
document_id = request.data.get("document_id")
try:
document_id = int(document_id)
except (TypeError, ValueError):
return Response(
{"document_id": ["Укажите ID документа."]},
status=status.HTTP_400_BAD_REQUEST,
)
try:
document = (
Document.objects.select_related("uploaded_by", "file", "folder")
.prefetch_related("recipients", "departments", "tags")
.get(id=document_id)
)
except Document.DoesNotExist:
return Response(
{"document_id": ["Документ не найден."]},
status=status.HTTP_404_NOT_FOUND,
)
if not user_can_access_document(request.user, document):
return Response(
{"detail": "Нет доступа к этому документу."},
status=status.HTTP_403_FORBIDDEN,
)
link, created = TaskLinkedObject.objects.get_or_create(
task=task,
kind=TaskLinkedObjectKind.DOCUMENT,
content_type=get_document_content_type(),
object_id=document.id,
defaults={"created_by": request.user},
)
serializer = TaskLinkedDocumentSerializer(
link,
context={"request": request},
)
if created:
create_task_activity(
task,
request.user,
TaskActivityAction.LINKED,
object_kind=TaskLinkedObjectKind.DOCUMENT,
object_id=document.id,
metadata={
"object_label": document.title,
"object_type": "Документ",
},
)
send_task_board_update(
task.board,
"linked",
"document",
document.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-documents/(?P<link_id>\d+)",
)
def unlink_document(self, request, pk=None, link_id=None):
task = self.get_object()
try:
link = self._document_links_queryset(task).get(id=link_id)
except TaskLinkedObject.DoesNotExist:
return Response(
{"detail": "Связь не найдена."},
status=status.HTTP_404_NOT_FOUND,
)
document_id = link.object_id
link.delete()
create_task_activity(
task,
request.user,
TaskActivityAction.UNLINKED,
object_kind=TaskLinkedObjectKind.DOCUMENT,
object_id=document_id,
metadata={"object_type": "Документ"},
)
send_task_board_update(
task.board,
"unlinked",
"document",
document_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-requests")
def linked_requests(self, request, pk=None):
task = self.get_object()
if request.method == "GET":
serializer = TaskLinkedRequestSerializer(
self._request_links_queryset(task),
many=True,
context={"request": request},
)
return Response(serializer.data)
request_id = request.data.get("request_id")
try:
request_id = int(request_id)
except (TypeError, ValueError):
return Response(
{"request_id": ["Укажите ID заявления."]},
status=status.HTTP_400_BAD_REQUEST,
)
try:
employee_request = (
EmployeeRequest.objects.select_related(
"employee",
"approver",
"department",
)
.prefetch_related("recipients", "cc_users")
.get(id=request_id)
)
except EmployeeRequest.DoesNotExist:
return Response(
{"request_id": ["Заявление не найдено."]},
status=status.HTTP_404_NOT_FOUND,
)
if not user_can_access_employee_request(request.user, employee_request):
return Response(
{"detail": "Нет доступа к этому заявлению."},
status=status.HTTP_403_FORBIDDEN,
)
link, created = TaskLinkedObject.objects.get_or_create(
task=task,
kind=TaskLinkedObjectKind.REQUEST,
content_type=get_request_content_type(),
object_id=employee_request.id,
defaults={"created_by": request.user},
)
serializer = TaskLinkedRequestSerializer(
link,
context={"request": request},
)
if created:
create_task_activity(
task,
request.user,
TaskActivityAction.LINKED,
object_kind=TaskLinkedObjectKind.REQUEST,
object_id=employee_request.id,
metadata={
"object_label": employee_request.display_title,
"object_type": "Заявление",
},
)
send_task_board_update(
task.board,
"linked",
"request",
employee_request.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-requests/(?P<link_id>\d+)",
)
def unlink_request(self, request, pk=None, link_id=None):
task = self.get_object()
try:
link = self._request_links_queryset(task).get(id=link_id)
except TaskLinkedObject.DoesNotExist:
return Response(
{"detail": "Связь не найдена."},
status=status.HTTP_404_NOT_FOUND,
)
request_id = link.object_id
link.delete()
create_task_activity(
task,
request.user,
TaskActivityAction.UNLINKED,
object_kind=TaskLinkedObjectKind.REQUEST,
object_id=request_id,
metadata={"object_type": "Заявление"},
)
send_task_board_update(
task.board,
"unlinked",
"request",
request_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

@ -425,6 +425,50 @@ def resolve_chat_participants_for_procurement_item(context_object, **kwargs):
)
def resolve_chat_participants_for_task(context_object, **kwargs):
"""
EUSRR callback для участников комментариев задачи.
Доступ соответствует видимости доски задачи.
"""
from django.db.models import Q
from tasks.models import Task
if not isinstance(context_object, Task):
return Employee.objects.none()
board = context_object.board
if not board.members.exists() and not board.departments.exists():
return Employee.objects.filter(is_active=True)
department_ids = list(board.departments.values_list("id", flat=True))
member_ids = board.members.values_list("id", flat=True)
department_member_ids = EmployeeDepartment.objects.filter(
department_id__in=department_ids,
is_active=True,
).values_list("employee_id", flat=True)
role_member_ids = RoleAssignment.objects.filter(
role__department_id__in=department_ids,
is_active=True,
).values_list("employee_id", flat=True)
department_head_ids = Department.objects.filter(
id__in=department_ids,
head_id__isnull=False,
).values_list("head_id", flat=True)
query = (
Q(id=board.created_by_id)
| Q(id__in=member_ids)
| Q(id__in=department_member_ids)
| Q(id__in=role_member_ids)
| Q(id__in=department_head_ids)
| Q(is_staff=True)
| Q(is_superuser=True)
)
return Employee.objects.filter(query, is_active=True).distinct()
def resolve_chat_participants(chat):
"""
Главная функция для разрешения участников чата в EUSRR.
@ -471,6 +515,13 @@ def resolve_chat_participants(chat):
chat.context_object, chat=chat, type=chat.type
)
from tasks.models import Task
if isinstance(chat.context_object, Task):
return resolve_chat_participants_for_task(
chat.context_object, chat=chat, type=chat.type
)
# 5. Legacy: department field (DEPRECATED, для обратной совместимости)
if (
chat.type == "department"

View File

@ -1,5 +1,8 @@
from django.db.models import Q
from documents.models import Document
from employees.models import Department, EmployeeDepartment, RoleAssignment
from requests_app.enums import RequestStatus
from requests_app.models import Request as EmployeeRequest
from schedule.models import Event
from scheduling.services import user_can_view_calendar
@ -63,3 +66,44 @@ def user_can_access_calendar_event(user, event: Event | None) -> bool:
if not user or not user.is_authenticated or event is None:
return False
return user_can_view_calendar(user, event.calendar)
def user_can_access_document(user, document: Document | None) -> bool:
if not user or not user.is_authenticated or document is None:
return False
if user.is_staff or user.is_superuser:
return True
if (
user.has_perm("documents.view_document")
or user.has_perm("documents.add_document")
or user.has_perm("documents.change_document")
or user.has_perm("documents.delete_document")
):
return True
if document.sent_to_all and user.is_active:
return True
if document.uploaded_by_id == user.id:
return True
if document.recipients.filter(id=user.id, is_active=True).exists():
return True
return document.departments.filter(
employeedepartment__employee=user,
employeedepartment__is_active=True,
).exists()
def user_can_access_employee_request(
user,
request_obj: EmployeeRequest | None,
) -> bool:
if not user or not user.is_authenticated or request_obj is None:
return False
if request_obj.status == RequestStatus.DRAFT:
return request_obj.employee_id == user.id
if request_obj.employee_id == user.id:
return True
if request_obj.recipients.filter(id=user.id).exists():
return True
return request_obj.cc_users.filter(id=user.id).exists()

View File

@ -0,0 +1,40 @@
# Generated by Codex on 2026-07-06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("tasks", "0004_taskactivity"),
]
operations = [
migrations.AlterField(
model_name="taskactivity",
name="object_kind",
field=models.CharField(
blank=True,
choices=[
("message", "Сообщение"),
("calendar_event", "Календарное событие"),
("document", "Документ"),
],
max_length=32,
verbose_name="Тип объекта",
),
),
migrations.AlterField(
model_name="tasklinkedobject",
name="kind",
field=models.CharField(
choices=[
("message", "Сообщение"),
("calendar_event", "Календарное событие"),
("document", "Документ"),
],
max_length=32,
verbose_name="Тип связи",
),
),
]

View File

@ -0,0 +1,42 @@
# Generated by Codex on 2026-07-06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("tasks", "0005_add_document_link_kind"),
]
operations = [
migrations.AlterField(
model_name="taskactivity",
name="object_kind",
field=models.CharField(
blank=True,
choices=[
("message", "Сообщение"),
("calendar_event", "Календарное событие"),
("document", "Документ"),
("request", "Заявление"),
],
max_length=32,
verbose_name="Тип объекта",
),
),
migrations.AlterField(
model_name="tasklinkedobject",
name="kind",
field=models.CharField(
choices=[
("message", "Сообщение"),
("calendar_event", "Календарное событие"),
("document", "Документ"),
("request", "Заявление"),
],
max_length=32,
verbose_name="Тип связи",
),
),
]

View File

@ -176,6 +176,8 @@ class Task(models.Model):
class TaskLinkedObjectKind(models.TextChoices):
MESSAGE = "message", "Сообщение"
CALENDAR_EVENT = "calendar_event", "Календарное событие"
DOCUMENT = "document", "Документ"
REQUEST = "request", "Заявление"
class TaskActivityAction(models.TextChoices):

View File

@ -33,6 +33,13 @@ from employees.models import Department, EmployeeDepartment
from filer.models import File as FilerFile, Folder
from rest_framework import status
from rest_framework.test import APIClient
from tasks.models import (
Task,
TaskBoard,
TaskColumn,
TaskLinkedObject,
TaskLinkedObjectKind,
)
from tests.conftest import _unique_phone
pytestmark = pytest.mark.django_db
@ -591,6 +598,90 @@ class TestRead:
}
assert expected <= set(sample.keys())
def test_list_includes_accessible_linked_tasks(
self, auth_client, make_user, api_urls
):
"""GET list отдаёт бейджи только задач с доступных досок."""
from django.contrib.contenttypes.models import ContentType
author = make_user("doc-task-author@example.com")
viewer = make_user("doc-task-viewer@example.com")
hidden_user = make_user("doc-task-hidden@example.com")
grant_perms(viewer, "view_document")
doc = make_document(uploaded_by=author, title="Документ с задачей")
visible_board = TaskBoard.objects.create(
name="Видимая доска",
created_by=author,
)
visible_board.members.add(viewer)
visible_column = TaskColumn.objects.create(
board=visible_board,
name="В работе",
position=1000,
color="#f59e0b",
)
visible_task = Task.objects.create(
board=visible_board,
column=visible_column,
title="Доступная задача",
created_by=author,
priority="critical",
)
hidden_board = TaskBoard.objects.create(
name="Закрытая доска",
created_by=hidden_user,
)
hidden_board.members.add(hidden_user)
hidden_column = TaskColumn.objects.create(
board=hidden_board,
name="Новые",
position=1000,
color="#38bdf8",
)
hidden_task = Task.objects.create(
board=hidden_board,
column=hidden_column,
title="Недоступная задача",
created_by=hidden_user,
)
document_ct = ContentType.objects.get_for_model(Document)
visible_link = TaskLinkedObject.objects.create(
task=visible_task,
kind=TaskLinkedObjectKind.DOCUMENT,
content_type=document_ct,
object_id=doc.id,
created_by=author,
)
TaskLinkedObject.objects.create(
task=hidden_task,
kind=TaskLinkedObjectKind.DOCUMENT,
content_type=document_ct,
object_id=doc.id,
created_by=hidden_user,
)
response = auth_client(viewer).get(api_urls["list"])
assert response.status_code == 200
result = response.json()["results"][0]
assert result["linked_tasks"] == [
{
"link_id": visible_link.id,
"id": visible_task.id,
"title": "Доступная задача",
"board_id": visible_board.id,
"board_name": "Видимая доска",
"column_id": visible_column.id,
"column_name": "В работе",
"column_color": "#f59e0b",
"priority": "critical",
"priority_display": "Критический",
}
]
def test_get_detail_fields_and_file_url(
self, auth_client, make_user, api_urls, settings
):

View File

@ -4,12 +4,21 @@ from datetime import datetime
from typing import Any
import pytest
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.utils import timezone
from notifications.models import Notification
from rest_framework.test import APIClient
from requests_app.enums import RequestStatus, RequestType
from requests_app.models import Request as EmployeeRequest
from tasks.models import (
Task,
TaskBoard,
TaskColumn,
TaskLinkedObject,
TaskLinkedObjectKind,
)
from tests.test_config import API_REQUESTS_URL
@ -213,6 +222,94 @@ def test_list_addressed_to_me_shows_only_recipient_and_cc_requests(
assert cc_request.id in ids
def test_list_includes_accessible_linked_tasks(
auth_client, regular_user: models.Model, make_user, make_request
) -> None:
"""Список заявлений отдаёт task-пилюли только с доступных досок."""
author = make_user(email="request-task-author@example.com")
hidden_user = make_user(email="request-task-hidden@example.com")
employee_request = make_request(
employee=author,
type_=RequestType.DAY_OFF,
comment="Заявление со связанной задачей",
)
employee_request.recipients.add(regular_user)
visible_board = TaskBoard.objects.create(
name="Видимая доска заявлений",
created_by=author,
)
visible_board.members.add(regular_user)
visible_column = TaskColumn.objects.create(
board=visible_board,
name="В работе",
position=1000,
color="#f59e0b",
)
visible_task = Task.objects.create(
board=visible_board,
column=visible_column,
title="Доступная задача по заявлению",
created_by=author,
priority="high",
)
hidden_board = TaskBoard.objects.create(
name="Скрытая доска заявлений",
created_by=hidden_user,
)
hidden_board.members.add(hidden_user)
hidden_column = TaskColumn.objects.create(
board=hidden_board,
name="Новые",
position=1000,
color="#38bdf8",
)
hidden_task = Task.objects.create(
board=hidden_board,
column=hidden_column,
title="Недоступная задача по заявлению",
created_by=hidden_user,
)
request_ct = ContentType.objects.get_for_model(EmployeeRequest)
visible_link = TaskLinkedObject.objects.create(
task=visible_task,
kind=TaskLinkedObjectKind.REQUEST,
content_type=request_ct,
object_id=employee_request.id,
created_by=author,
)
TaskLinkedObject.objects.create(
task=hidden_task,
kind=TaskLinkedObjectKind.REQUEST,
content_type=request_ct,
object_id=employee_request.id,
created_by=hidden_user,
)
resp = auth_client(regular_user).get(API_BASE)
assert resp.status_code == 200
result = next(
item for item in _results(resp.json()) if item["id"] == employee_request.id
)
assert result["linked_tasks"] == [
{
"link_id": visible_link.id,
"id": visible_task.id,
"title": "Доступная задача по заявлению",
"board_id": visible_board.id,
"board_name": "Видимая доска заявлений",
"column_id": visible_column.id,
"column_name": "В работе",
"column_color": "#f59e0b",
"priority": "high",
"priority_display": "Высокий",
}
]
def test_list_admin_without_participation_sees_nothing(
auth_client, admin_user: models.Model, make_user, make_request
) -> None:

View File

@ -10,7 +10,9 @@ from rest_framework import status
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 requests_app.models import Request as EmployeeRequest
from schedule.models import Calendar, CalendarRelation, Event
from tasks.models import Task, TaskBoard, TaskColumn
from tasks.realtime import get_task_board_recipient_ids
@ -431,6 +433,161 @@ def test_task_linked_calendar_event_respects_event_access(api_client):
assert reverse_linked_response.data[0]["board_name"] == "Доска с событиями"
def test_task_linked_document_respects_document_access(api_client):
owner = make_user("task-doc-owner@example.com", "+79994440026")
board_member = make_user("task-doc-member@example.com", "+79994440027")
board = TaskBoard.objects.create(
name="Доска с документами",
created_by=owner,
)
board.members.add(board_member)
column = TaskColumn.objects.create(
board=board,
name="Новые",
position=1000,
)
task = Task.objects.create(
board=board,
column=column,
title="Проверить регламент",
created_by=owner,
)
document = Document.objects.create(
title="Регламент доступа",
description="Закрытый документ",
uploaded_by=owner,
sent_to_all=False,
is_regulation=True,
)
api_client.force_authenticate(user=board_member)
denied_response = api_client.post(
reverse("api:v1:tasks:task-linked-documents", kwargs={"pk": task.id}),
{"document_id": document.id},
format="json",
)
assert denied_response.status_code == status.HTTP_403_FORBIDDEN
api_client.force_authenticate(user=owner)
link_response = api_client.post(
reverse("api:v1:tasks:task-linked-documents", kwargs={"pk": task.id}),
{"document_id": document.id},
format="json",
)
assert link_response.status_code == status.HTTP_201_CREATED
assert link_response.data["document"]["title"] == "Регламент доступа"
assert link_response.data["document"]["is_regulation"] is True
assert link_response.data["can_open"] is True
assert link_response.data["object_url"] == (
f"/documents?document={document.id}"
)
api_client.force_authenticate(user=board_member)
linked_response = api_client.get(
reverse("api:v1:tasks:task-linked-documents", kwargs={"pk": task.id})
)
assert linked_response.status_code == status.HTTP_200_OK
assert linked_response.data[0]["document_id"] == document.id
assert linked_response.data[0]["document"]["title"] == "Регламент доступа"
assert linked_response.data[0]["can_open"] is False
assert linked_response.data[0]["object_url"] is None
reverse_linked_response = api_client.get(
reverse("api:v1:tasks:task-linked-document-tasks"),
{"document_id": document.id},
)
assert reverse_linked_response.status_code == status.HTTP_403_FORBIDDEN
api_client.force_authenticate(user=owner)
reverse_linked_response = api_client.get(
reverse("api:v1:tasks:task-linked-document-tasks"),
{"document_id": document.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]["title"] == "Проверить регламент"
assert (
reverse_linked_response.data[0]["linked_documents_count"] == 1
)
def test_task_linked_request_respects_request_access(api_client):
owner = make_user("task-request-owner@example.com", "+79994440028")
board_member = make_user("task-request-member@example.com", "+79994440029")
board = TaskBoard.objects.create(
name="Доска с заявлениями",
created_by=owner,
)
board.members.add(board_member)
column = TaskColumn.objects.create(
board=board,
name="Новые",
position=1000,
)
task = Task.objects.create(
board=board,
column=column,
title="Проверить заявление",
created_by=owner,
)
employee_request = EmployeeRequest.objects.create(
employee=owner,
type="vacation",
title="Отпуск",
comment="Заявление к задаче",
)
api_client.force_authenticate(user=board_member)
denied_response = api_client.post(
reverse("api:v1:tasks:task-linked-requests", kwargs={"pk": task.id}),
{"request_id": employee_request.id},
format="json",
)
assert denied_response.status_code == status.HTTP_403_FORBIDDEN
api_client.force_authenticate(user=owner)
link_response = api_client.post(
reverse("api:v1:tasks:task-linked-requests", kwargs={"pk": task.id}),
{"request_id": employee_request.id},
format="json",
)
assert link_response.status_code == status.HTTP_201_CREATED
assert link_response.data["request"]["title"] == "Отпуск"
assert link_response.data["request"]["comment"] == "Заявление к задаче"
assert link_response.data["can_open"] is True
assert link_response.data["object_url"] == (
f"/requests?request={employee_request.id}"
)
api_client.force_authenticate(user=board_member)
linked_response = api_client.get(
reverse("api:v1:tasks:task-linked-requests", kwargs={"pk": task.id})
)
assert linked_response.status_code == status.HTTP_200_OK
assert linked_response.data[0]["request_id"] == employee_request.id
assert linked_response.data[0]["request"]["title"] == "Отпуск"
assert linked_response.data[0]["can_open"] is False
assert linked_response.data[0]["object_url"] is None
reverse_linked_response = api_client.get(
reverse("api:v1:tasks:task-linked-request-tasks"),
{"request_id": employee_request.id},
)
assert reverse_linked_response.status_code == status.HTTP_403_FORBIDDEN
api_client.force_authenticate(user=owner)
reverse_linked_response = api_client.get(
reverse("api:v1:tasks:task-linked-request-tasks"),
{"request_id": employee_request.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]["title"] == "Проверить заявление"
assert (
reverse_linked_response.data[0]["linked_requests_count"] == 1
)
def test_task_activity_records_core_actions(api_client, user):
api_client.force_authenticate(user=user)
board_response = api_client.get(
@ -535,3 +692,125 @@ def test_chat_message_serialization_shows_only_accessible_linked_tasks(api_clien
)
assert hidden_response.status_code == status.HTTP_200_OK
assert hidden_response.data["messages"][0]["linked_tasks"] == []
def test_task_comments_use_communications_and_update_count(api_client, user):
api_client.force_authenticate(user=user)
board = TaskBoard.objects.create(
name="Доска с комментариями",
created_by=user,
)
column = TaskColumn.objects.create(board=board, name="Новые", position=1000)
task = Task.objects.create(
board=board,
column=column,
title="Задача с обсуждением",
created_by=user,
)
detail_response = api_client.get(
reverse("api:v1:tasks:task-detail", kwargs={"pk": task.id})
)
assert detail_response.status_code == status.HTTP_200_OK
assert detail_response.data["comments_count"] == 0
empty_response = api_client.post(
reverse("api:v1:tasks:task-comments", kwargs={"pk": task.id}),
{"text": ""},
format="json",
)
assert empty_response.status_code == status.HTTP_400_BAD_REQUEST
create_response = api_client.post(
reverse("api:v1:tasks:task-comments", kwargs={"pk": task.id}),
{"text": "Нужно уточнить детали"},
format="json",
)
assert create_response.status_code == status.HTTP_201_CREATED
assert create_response.data["task"] == task.id
assert create_response.data["text"] == "Нужно уточнить детали"
assert create_response.data["author"]["id"] == user.id
task_ct = ContentType.objects.get_for_model(Task)
chat = Chat.objects.get(
type="comments",
context_content_type=task_ct,
context_object_id=task.id,
)
message = Message.objects.get(chat=chat)
assert message.content == "Нужно уточнить детали"
assert message.author == user
list_response = api_client.get(
reverse("api:v1:tasks:task-comments", kwargs={"pk": task.id})
)
assert list_response.status_code == status.HTTP_200_OK
assert [comment["text"] for comment in list_response.data] == [
"Нужно уточнить детали"
]
detail_response = api_client.get(
reverse("api:v1:tasks:task-detail", kwargs={"pk": task.id})
)
assert detail_response.data["comments_count"] == 1
delete_response = api_client.delete(
reverse(
"api:v1:tasks:task-delete-comment",
kwargs={"pk": task.id, "comment_id": message.id},
)
)
assert delete_response.status_code == status.HTTP_204_NO_CONTENT
message.refresh_from_db()
assert message.is_deleted is True
assert api_client.get(
reverse("api:v1:tasks:task-comments", kwargs={"pk": task.id})
).data == []
assert api_client.get(
reverse("api:v1:tasks:task-detail", kwargs={"pk": task.id})
).data["comments_count"] == 0
def test_task_comments_chat_access_follows_board_access(api_client):
owner = make_user("task-comments-owner@example.com", "+79994440024")
member = make_user("task-comments-member@example.com", "+79994440025")
outsider = make_user("task-comments-outsider@example.com", "+79994440026")
board = TaskBoard.objects.create(name="Закрытая доска", created_by=owner)
board.members.add(member)
column = TaskColumn.objects.create(board=board, name="Новые", position=1000)
task = Task.objects.create(
board=board,
column=column,
title="Закрытая задача",
created_by=owner,
)
api_client.force_authenticate(user=owner)
create_response = api_client.post(
reverse("api:v1:tasks:task-comments", kwargs={"pk": task.id}),
{"text": "Комментарий закрытой доски"},
format="json",
)
assert create_response.status_code == status.HTTP_201_CREATED
chat = Chat.objects.get(
type="comments",
context_content_type=ContentType.objects.get_for_model(Task),
context_object_id=task.id,
)
api_client.force_authenticate(user=member)
member_response = api_client.get(
reverse("api:v1:chats-messages", kwargs={"pk": chat.id})
)
assert member_response.status_code == status.HTTP_200_OK
assert member_response.data["messages"][0]["content"] == (
"Комментарий закрытой доски"
)
api_client.force_authenticate(user=outsider)
outsider_response = api_client.get(
reverse("api:v1:chats-messages", kwargs={"pk": chat.id})
)
assert outsider_response.status_code == status.HTTP_403_FORBIDDEN

View File

@ -29,13 +29,16 @@ import {
Trash2,
ScrollText,
Loader2,
Link2,
} from "lucide-react";
import { DocumentAcknowledgementsReport } from "@/components/documents/DocumentAcknowledgementsReport";
import { DocumentMetadataEditor } from "@/components/documents/DocumentMetadataEditor";
import { DocumentDetailModal } from "@/components/documents/DocumentDetailModal";
import { DocumentTaskLinks } from "@/components/documents/DocumentTaskLinks";
import { FolderTree, type FolderNode } from "@/components/documents/folders";
import { BulkActionsToolbar, useDocumentSelection } from "@/components/documents/batch";
import { TagManagementModal } from "@/components/documents/tags";
import TaskLinkPill from "@/components/tasks/TaskLinkPill";
import { Modal } from "@/components/ui";
// Динамический импорт компонентов с PDF обработкой (избегаем SSR ошибок с DOMMatrix)
@ -252,6 +255,7 @@ function DocumentsPageContent() {
const [showMetadataEditor, setShowMetadataEditor] = useState(false);
const [selectedDocument, setSelectedDocument] = useState<Document | null>(null);
const [metadataDocument, setMetadataDocument] = useState<Document | null>(null);
const [taskLinkDocument, setTaskLinkDocument] = useState<Document | null>(null);
const [previewFile, setPreviewFile] = useState<{ url: string; name: string } | null>(null);
const [pdfViewerFile, setPdfViewerFile] = useState<{ url: string; name: string } | null>(null);
@ -666,6 +670,11 @@ function DocumentsPageContent() {
setDocumentMenuOpenId(null);
};
const openDocumentTaskLinkFromMenu = (documentItem: Document) => {
setTaskLinkDocument(documentItem);
setDocumentMenuOpenId(null);
};
const deleteDocumentFromMenu = async (documentItem: Document) => {
setDocumentMenuOpenId(null);
@ -1541,6 +1550,15 @@ function DocumentsPageContent() {
</button>
)}
<button
type="button"
onClick={() => openDocumentTaskLinkFromMenu(doc)}
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} />
Связать с задачей
</button>
<div className="my-1 border-t border-[var(--border-subtle)]" />
{isDocumentSelected ? (
@ -1605,6 +1623,23 @@ function DocumentsPageContent() {
</p>
)}
{doc.linked_tasks && doc.linked_tasks.length > 0 && (
<div className="mt-3 flex flex-wrap items-center gap-1.5">
{doc.linked_tasks.slice(0, 3).map((task) => (
<TaskLinkPill
key={task.link_id || task.id}
task={task}
maxTitleClassName="max-w-44"
/>
))}
{doc.linked_tasks.length > 3 && (
<span className="app-badge rounded-full px-2 py-0.5 text-[11px] font-medium">
+{doc.linked_tasks.length - 3}
</span>
)}
</div>
)}
<div className="mt-3 flex flex-wrap items-center justify-end gap-1.5">
{doc.acknowledgement_required && !doc.is_acknowledged ? (
<button
@ -1742,6 +1777,18 @@ function DocumentsPageContent() {
}}
/>
{taskLinkDocument && (
<DocumentTaskLinks
document={taskLinkDocument}
variant="dialog"
open={!!taskLinkDocument}
onClose={() => setTaskLinkDocument(null)}
onLinked={() => {
loadDocuments();
}}
/>
)}
{/* File Preview Modal */}
{previewFile && (
<DocumentPreview

View File

@ -99,6 +99,24 @@ function getDefaultTaskTitleFromMessage(message: Message): string {
return `Задача по сообщению #${message.id}`;
}
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);
}
/* ─── component ─── */
export default function MessageDialogPage() {
@ -245,6 +263,7 @@ function MessageDialogPageContent() {
const [taskLinkEmployees, setTaskLinkEmployees] = useState<User[]>([]);
const [taskLinkBoardId, setTaskLinkBoardId] = useState<number | "">("");
const [taskLinkTaskId, setTaskLinkTaskId] = useState<number | "">("");
const [taskLinkSearch, setTaskLinkSearch] = useState("");
const [taskLinkMode, setTaskLinkMode] = useState<TaskLinkMode>("existing");
const [taskLinkNewTitle, setTaskLinkNewTitle] = useState("");
const [taskLinkNewDescription, setTaskLinkNewDescription] = useState("");
@ -816,6 +835,14 @@ function MessageDialogPageContent() {
() => (selectedTaskLinkBoard?.tasks || []).find((task) => task.id === taskLinkTaskId) || null,
[selectedTaskLinkBoard?.tasks, taskLinkTaskId],
);
const availableTaskLinkTasks = useMemo(
() => selectedTaskLinkBoard?.tasks || [],
[selectedTaskLinkBoard?.tasks],
);
const filteredTaskLinkTasks = useMemo(
() => availableTaskLinkTasks.filter((task) => taskMatchesSearch(task, taskLinkSearch)),
[availableTaskLinkTasks, taskLinkSearch],
);
const selectedTaskLinkColumn = useMemo(
() => (
(selectedTaskLinkBoard?.columns || []).find((column) => column.id === taskLinkColumnId) ||
@ -845,6 +872,7 @@ function MessageDialogPageContent() {
const firstColumn = firstBoardWithTasks?.columns?.find((column) => !column.is_archived) || null;
setTaskLinkBoardId(firstBoardWithTasks?.id || "");
setTaskLinkTaskId(firstBoardWithTasks?.tasks?.[0]?.id || "");
setTaskLinkSearch("");
setTaskLinkColumnId(firstColumn?.id || "");
setTaskLinkMode(firstBoardWithTasks?.tasks?.length ? "existing" : "create");
setTaskLinkDetailsOpen(false);
@ -864,6 +892,7 @@ function MessageDialogPageContent() {
setTaskLinkBoardId(boardId);
const nextBoard = taskLinkBoards.find((item) => item.id === boardId) || null;
setTaskLinkTaskId(nextBoard?.tasks?.[0]?.id || "");
setTaskLinkSearch("");
setTaskLinkColumnId(nextBoard?.columns?.find((column) => !column.is_archived)?.id || "");
setTaskLinkLabelIds([]);
if (!nextBoard?.tasks?.length) {
@ -871,12 +900,19 @@ function MessageDialogPageContent() {
}
}, [taskLinkBoards]);
const handleTaskLinkSearchChange = useCallback((value: string) => {
setTaskLinkSearch(value);
const nextTask = availableTaskLinkTasks.find((task) => taskMatchesSearch(task, value));
setTaskLinkTaskId(nextTask?.id || "");
}, [availableTaskLinkTasks]);
const handleCloseTaskLinkModal = useCallback(() => {
if (taskLinkLoading) return;
setTaskLinkMessage(null);
setTaskLinkError(null);
setTaskLinkBoardId("");
setTaskLinkTaskId("");
setTaskLinkSearch("");
setTaskLinkMode("existing");
setTaskLinkNewTitle("");
setTaskLinkNewDescription("");
@ -953,6 +989,7 @@ function MessageDialogPageContent() {
setTaskLinkError(null);
setTaskLinkBoardId("");
setTaskLinkTaskId("");
setTaskLinkSearch("");
setTaskLinkMode("existing");
setTaskLinkNewTitle("");
setTaskLinkNewDescription("");
@ -1648,22 +1685,36 @@ function MessageDialogPageContent() {
</div>
{taskLinkMode === "existing" ? (
<label className="block">
<span className="app-text-muted mb-1 block text-xs font-medium">Задача</span>
<select
value={taskLinkTaskId}
onChange={(event) => setTaskLinkTaskId(Number(event.target.value) || "")}
className="app-select w-full rounded-xl px-3 py-2 text-sm"
disabled={taskLinkLoading || !selectedTaskLinkBoard}
>
<option value="">Выберите задачу</option>
{(selectedTaskLinkBoard?.tasks || []).map((task: TaskCard) => (
<option key={task.id} value={task.id}>
{task.title}
<div className="space-y-2">
<label className="block">
<span className="app-text-muted mb-1 block text-xs font-medium">Поиск задачи</span>
<input
value={taskLinkSearch}
onChange={(event) => handleTaskLinkSearchChange(event.target.value)}
className="app-input w-full rounded-xl px-3 py-2 text-sm"
disabled={taskLinkLoading || !selectedTaskLinkBoard}
placeholder="ID, название, описание или исполнитель"
/>
</label>
<label className="block">
<span className="app-text-muted mb-1 block text-xs font-medium">Задача</span>
<select
value={taskLinkTaskId}
onChange={(event) => setTaskLinkTaskId(Number(event.target.value) || "")}
className="app-select w-full rounded-xl px-3 py-2 text-sm"
disabled={taskLinkLoading || !selectedTaskLinkBoard || filteredTaskLinkTasks.length === 0}
>
<option value="">
{filteredTaskLinkTasks.length === 0 ? "Задачи не найдены" : "Выберите задачу"}
</option>
))}
</select>
</label>
{filteredTaskLinkTasks.map((task: TaskCard) => (
<option key={task.id} value={task.id}>
{getTaskOptionLabel(task)}
</option>
))}
</select>
</label>
</div>
) : (
<div className="space-y-3">
<label className="block">

View File

@ -8,11 +8,13 @@ import { RequestListControls } from "@/components/requests/RequestListControls";
import { RequestListSection } from "@/components/requests/RequestListSection";
import { RequestStatisticsPanel } from "@/components/requests/RequestStatisticsPanel";
import { RequestSwipeModePanel } from "@/components/requests/RequestSwipeModePanel";
import { RequestTaskLinks } from "@/components/requests/RequestTaskLinks";
import { useUser } from "@/contexts/UserContext";
import { Suspense } from "react";
import { Suspense, useState } from "react";
import { useRequestsPage } from "@/hooks/useRequestsPage";
import { useRequestsPageScreen } from "@/hooks/useRequestsPageScreen";
import { canViewRequestStatistics } from "@/lib/permissions";
import type { Request } from "@/types/api";
export default function RequestsPage() {
return (
@ -36,6 +38,7 @@ function RequestsPageFallback() {
function RequestsPageContent() {
const { user } = useUser();
const canViewStats = canViewRequestStatistics(user);
const [taskLinkRequest, setTaskLinkRequest] = useState<Request | null>(null);
const h = useRequestsPage(user?.id);
const screen = useRequestsPageScreen({
detailsRequestId: h.detailsRequest?.id,
@ -129,6 +132,7 @@ function RequestsPageContent() {
loadMoreRef={h.loadMoreRef}
loadingMore={h.loadingMore}
nextPage={h.nextPage}
onLinkTask={setTaskLinkRequest}
openEdit={h.openEdit}
requestMenuOpenId={screen.requestMenuOpenId}
requestMenuRef={screen.requestMenuRef}
@ -165,6 +169,7 @@ function RequestsPageContent() {
screen.closeDetailsRequest();
h.openEdit(request);
}}
onLinkTask={setTaskLinkRequest}
onPreviewAttachment={h.setAttachmentPreview}
onReject={h.handleReject}
onSetCommentDraft={h.setCommentDraft}
@ -187,6 +192,16 @@ function RequestsPageContent() {
/>
)}
{taskLinkRequest ? (
<RequestTaskLinks
request={taskLinkRequest}
variant="dialog"
open={Boolean(taskLinkRequest)}
onClose={() => setTaskLinkRequest(null)}
onLinked={() => void h.refreshRequests()}
/>
) : null}
<RequestAttachmentPreviewModal
preview={h.attachmentPreview}
onClose={() => h.setAttachmentPreview(null)}

View File

@ -19,6 +19,8 @@ import {
CalendarDays,
ChevronDown,
ChevronRight,
FileSignature,
FileText,
GripVertical,
History,
Kanban,
@ -38,6 +40,7 @@ import {
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import { AppShell } from "@/components/AppShell";
import { CommentComposer, CommentDeleteButton } from "@/components/shared/CommentControls";
import { Modal } from "@/components/ui";
import { useUser } from "@/contexts/UserContext";
import { apiClient } from "@/lib/api";
@ -51,8 +54,11 @@ import type {
TaskBoard,
TaskCard,
TaskColumn,
TaskComment,
TaskLinkedDocument,
TaskLinkedCalendarEvent,
TaskLinkedMessage,
TaskLinkedRequest,
TaskPriority,
User,
} from "@/types/api";
@ -119,11 +125,41 @@ const emptyColumnForm: ColumnFormState = {
is_done: false,
};
const priorityOptions: { value: TaskPriority; label: string; className: string }[] = [
{ value: "low", label: "Низкий", className: "app-badge" },
{ value: "medium", label: "Средний", className: "app-selected" },
{ value: "high", label: "Высокий", className: "app-feedback-warning" },
{ value: "critical", label: "Критический", className: "app-feedback-danger" },
const priorityOptions: {
value: TaskPriority;
label: string;
urgencyLabel: string;
className: string;
textClassName: string;
}[] = [
{
value: "low",
label: "Низкий",
urgencyLabel: "Низкая",
className: "app-badge",
textClassName: "app-text-muted",
},
{
value: "medium",
label: "Средний",
urgencyLabel: "Средняя",
className: "app-selected",
textClassName: "text-[var(--accent-primary-strong)]",
},
{
value: "high",
label: "Высокий",
urgencyLabel: "Высокая",
className: "app-feedback-warning",
textClassName: "text-[var(--warning-foreground)]",
},
{
value: "critical",
label: "Критический",
urgencyLabel: "Критическая",
className: "app-feedback-danger",
textClassName: "text-[var(--danger-foreground)]",
},
];
const priorityMeta = Object.fromEntries(priorityOptions.map((item) => [item.value, item])) as Record<TaskPriority, typeof priorityOptions[number]>;
@ -144,10 +180,36 @@ function getTaskError(error: unknown, fallback: string) {
return raw;
}
function isOverdue(task: TaskCard) {
if (!task.due_date || task.completed_at) return false;
const today = new Date().toISOString().slice(0, 10);
return task.due_date < today;
function toDateOnlyTime(value?: string | null) {
if (!value) return null;
const match = String(value).match(/^(\d{4})-(\d{2})-(\d{2})/);
if (!match) return null;
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) {
return null;
}
const date = new Date(year, month - 1, day);
date.setHours(0, 0, 0, 0);
return date.getTime();
}
function getTaskDueDateBadgeClass(task: TaskCard, defaultClass = "app-badge") {
if (!task.due_date || task.completed_at) return defaultClass;
const dueTime = toDateOnlyTime(task.due_date);
if (dueTime === null) return defaultClass;
const today = new Date();
today.setHours(0, 0, 0, 0);
const todayTime = today.getTime();
if (dueTime < todayTime) return "app-feedback-danger";
if (dueTime === todayTime) return "app-feedback-warning";
return defaultClass;
}
function getMessageCreatedLabel(message?: Message | null) {
@ -178,7 +240,7 @@ function TaskCardView({
});
const style = transform && !isDragging ? { transform: CSS.Translate.toString(transform) } : undefined;
const priority = priorityMeta[task.priority] ?? priorityMeta.medium;
const overdue = isOverdue(task);
const dueDateClass = getTaskDueDateBadgeClass(task);
return (
<article
@ -213,41 +275,46 @@ function TaskCardView({
</p>
) : null}
</button>
<div ref={menuOpen ? menuRef : null} className="relative shrink-0">
<button
type="button"
onClick={onToggleMenu}
className="app-icon-button flex h-7 w-7 items-center justify-center rounded-md"
title="Действия"
aria-label="Действия с задачей"
aria-expanded={menuOpen}
aria-haspopup="menu"
>
<ChevronDown
size={14}
className={`transition-transform ${menuOpen ? "" : "-rotate-90"}`}
/>
</button>
{menuOpen ? (
<div className="app-menu absolute right-0 top-full z-30 mt-2 w-44 rounded-lg p-1.5">
<button
type="button"
onClick={() => onEdit(task)}
className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition hover:bg-[var(--surface-secondary)]"
>
<Pencil size={14} className="app-text-muted" />
Редактировать
</button>
<button
type="button"
onClick={() => onDelete(task)}
className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm text-[var(--danger-foreground)] transition hover:bg-[var(--danger-soft)]"
>
<Trash2 size={14} />
Удалить
</button>
</div>
) : null}
<div className="flex shrink-0 flex-col items-end gap-1">
<div ref={menuOpen ? menuRef : null} className="relative">
<button
type="button"
onClick={onToggleMenu}
className="app-icon-button flex h-7 w-7 items-center justify-center rounded-md"
title="Действия"
aria-label="Действия с задачей"
aria-expanded={menuOpen}
aria-haspopup="menu"
>
<ChevronDown
size={14}
className={`transition-transform ${menuOpen ? "" : "-rotate-90"}`}
/>
</button>
{menuOpen ? (
<div className="app-menu absolute right-0 top-full z-30 mt-2 w-44 rounded-lg p-1.5">
<button
type="button"
onClick={() => onEdit(task)}
className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition hover:bg-[var(--surface-secondary)]"
>
<Pencil size={14} className="app-text-muted" />
Редактировать
</button>
<button
type="button"
onClick={() => onDelete(task)}
className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm text-[var(--danger-foreground)] transition hover:bg-[var(--danger-soft)]"
>
<Trash2 size={14} />
Удалить
</button>
</div>
) : null}
</div>
<span className={`whitespace-nowrap text-right text-[11px] font-medium ${priority.textClassName}`}>
{priority.urgencyLabel} срочность
</span>
</div>
</div>
@ -266,9 +333,6 @@ function TaskCardView({
) : null}
<div className="flex flex-wrap items-center gap-1.5">
<span className={`app-status-pill min-h-0 px-2 py-1 text-[11px] ${priority.className}`}>
{task.priority_display || priority.label}
</span>
{task.assignee ? (
<span className="app-badge inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px]">
<UserRound size={11} />
@ -276,7 +340,7 @@ function TaskCardView({
</span>
) : null}
{task.due_date ? (
<span className={`${overdue ? "app-feedback-danger" : "app-badge"} inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px]`}>
<span className={`${dueDateClass} inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px]`}>
<CalendarDays size={11} />
{formatDate(task.due_date)}
</span>
@ -287,6 +351,12 @@ function TaskCardView({
{task.linked_objects_count || task.linked_messages_count}
</span>
) : null}
{(task.comments_count || 0) > 0 ? (
<span className="app-badge inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px]">
<MessageSquare size={11} />
{task.comments_count}
</span>
) : null}
</div>
</article>
);
@ -294,7 +364,7 @@ function TaskCardView({
function TaskDragOverlayCard({ task }: { task: TaskCard }) {
const priority = priorityMeta[task.priority] ?? priorityMeta.medium;
const overdue = isOverdue(task);
const dueDateClass = getTaskDueDateBadgeClass(task);
return (
<article className="app-surface-elevated w-[17rem] cursor-grabbing rounded-xl border border-[var(--accent-primary)] p-3 shadow-2xl ring-1 ring-[color:var(--accent-primary)]/20">
@ -312,6 +382,9 @@ function TaskDragOverlayCard({ task }: { task: TaskCard }) {
</p>
) : null}
</div>
<span className={`shrink-0 whitespace-nowrap text-right text-[11px] font-medium ${priority.textClassName}`}>
{priority.urgencyLabel} срочность
</span>
</div>
{task.labels && task.labels.length > 0 ? (
@ -329,9 +402,6 @@ function TaskDragOverlayCard({ task }: { task: TaskCard }) {
) : null}
<div className="flex flex-wrap items-center gap-1.5">
<span className={`app-status-pill min-h-0 px-2 py-1 text-[11px] ${priority.className}`}>
{task.priority_display || priority.label}
</span>
{task.assignee ? (
<span className="app-badge inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px]">
<UserRound size={11} />
@ -339,7 +409,7 @@ function TaskDragOverlayCard({ task }: { task: TaskCard }) {
</span>
) : null}
{task.due_date ? (
<span className={`${overdue ? "app-feedback-danger" : "app-badge"} inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px]`}>
<span className={`${dueDateClass} inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px]`}>
<CalendarDays size={11} />
{formatDate(task.due_date)}
</span>
@ -350,6 +420,12 @@ function TaskDragOverlayCard({ task }: { task: TaskCard }) {
{task.linked_objects_count || task.linked_messages_count}
</span>
) : null}
{(task.comments_count || 0) > 0 ? (
<span className="app-badge inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px]">
<MessageSquare size={11} />
{task.comments_count}
</span>
) : null}
</div>
</article>
);
@ -655,6 +731,203 @@ function LinkedCalendarEventCard({
);
}
function LinkedDocumentCard({
link,
onUnlink,
disabled,
}: {
link: TaskLinkedDocument;
onUnlink: (linkId: number) => void;
disabled?: boolean;
}) {
const document = link.document;
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">
<FileText size={12} />
Документ
</span>
{document?.is_regulation ? (
<span className="app-selected app-accent-text inline-flex items-center gap-1.5 rounded-full px-2 py-1 text-[11px] font-medium">
Регламент
</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)]">
{document?.title || "Документ не найден"}
</p>
{document?.uploaded_at || document?.created_at ? (
<p className="app-text-muted mt-0.5 text-xs">
{formatDate(document.uploaded_at || document.created_at)}
</p>
) : null}
</div>
{document?.description ? (
<p className="app-text-wrap whitespace-pre-wrap text-sm leading-5 text-[var(--foreground)]">
{document.description}
</p>
) : null}
<div className="mt-2 flex flex-wrap items-center gap-2">
{document?.folder_path ? (
<span className="app-badge inline-flex max-w-full items-center gap-1 rounded-full px-2 py-1 text-[11px]">
<span className="truncate">{document.folder_path}</span>
</span>
) : null}
{document?.file_name ? (
<span className="app-badge inline-flex max-w-full items-center gap-1 rounded-full px-2 py-1 text-[11px]">
<Paperclip size={11} />
<span className="truncate">{document.file_name}</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 LinkedRequestCard({
link,
onUnlink,
disabled,
}: {
link: TaskLinkedRequest;
onUnlink: (linkId: number) => void;
disabled?: boolean;
}) {
const request = link.request;
const canOpen = Boolean(link.can_open && link.object_url);
const periodLabel = request?.date_from
? `${formatDate(request.date_from)}${request.date_to ? ` - ${formatDate(request.date_to)}` : ""}`
: "";
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">
<FileSignature size={12} />
Заявление
</span>
{request?.status_display ? (
<span className="app-badge rounded-full px-2 py-1 text-[11px]">
{request.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)]">
{request?.display_title || request?.title || "Заявление не найдено"}
</p>
{periodLabel ? (
<p className="app-text-muted mt-0.5 text-xs">
{periodLabel}
</p>
) : null}
</div>
{request?.comment ? (
<p className="app-text-wrap whitespace-pre-wrap text-sm leading-5 text-[var(--foreground)]">
{request.comment}
</p>
) : null}
<div className="mt-2 flex flex-wrap items-center gap-2">
{request?.type_display ? (
<span className="app-badge inline-flex max-w-full items-center gap-1 rounded-full px-2 py-1 text-[11px]">
<span className="truncate">{request.type_display}</span>
</span>
) : null}
{request?.employee ? (
<span className="app-badge inline-flex max-w-full items-center gap-1 rounded-full px-2 py-1 text-[11px]">
<UserRound size={11} />
<span className="truncate">{displayUserName(request.employee)}</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>
);
}
const activityFieldLabels: Record<string, string> = {
title: "название",
description: "описание",
@ -755,7 +1028,13 @@ export default function TasksPage() {
const [linkedObjectsOpen, setLinkedObjectsOpen] = useState(false);
const [linkedMessages, setLinkedMessages] = useState<TaskLinkedMessage[]>([]);
const [linkedEvents, setLinkedEvents] = useState<TaskLinkedCalendarEvent[]>([]);
const [linkedDocuments, setLinkedDocuments] = useState<TaskLinkedDocument[]>([]);
const [linkedRequests, setLinkedRequests] = useState<TaskLinkedRequest[]>([]);
const [linkedObjectsLoading, setLinkedObjectsLoading] = useState(false);
const [commentsOpen, setCommentsOpen] = useState(false);
const [taskComments, setTaskComments] = useState<TaskComment[]>([]);
const [taskCommentsLoading, setTaskCommentsLoading] = useState(false);
const [commentDraft, setCommentDraft] = useState("");
const [activityOpen, setActivityOpen] = useState(false);
const [taskActivities, setTaskActivities] = useState<TaskActivity[]>([]);
const [taskActivityLoading, setTaskActivityLoading] = useState(false);
@ -795,12 +1074,16 @@ export default function TasksPage() {
const loadTaskLinkedObjects = useCallback(async (taskId: number) => {
setLinkedObjectsLoading(true);
try {
const [messages, events] = await Promise.all([
const [messages, events, documents, requests] = await Promise.all([
apiClient.getTaskLinkedMessages(taskId),
apiClient.getTaskLinkedEvents(taskId),
apiClient.getTaskLinkedDocuments(taskId),
apiClient.getTaskLinkedRequests(taskId),
]);
setLinkedMessages(messages);
setLinkedEvents(events);
setLinkedDocuments(documents);
setLinkedRequests(requests);
} catch (linksError) {
setError(getTaskError(linksError, "Не удалось загрузить связанные объекты"));
} finally {
@ -820,6 +1103,18 @@ export default function TasksPage() {
}
}, []);
const loadTaskComments = useCallback(async (taskId: number) => {
setTaskCommentsLoading(true);
try {
const data = await apiClient.getTaskComments(taskId);
setTaskComments(data);
} catch (commentsError) {
setError(getTaskError(commentsError, "Не удалось загрузить комментарии"));
} finally {
setTaskCommentsLoading(false);
}
}, []);
useEffect(() => {
selectedBoardIdRef.current = selectedBoardId;
}, [selectedBoardId]);
@ -1121,8 +1416,9 @@ export default function TasksPage() {
() => activeColumns.find((column) => column.id === viewTask?.column) || null,
[activeColumns, viewTask?.column],
);
const linkedObjectsCount = linkedMessages.length + linkedEvents.length;
const linkedObjectsCount = linkedMessages.length + linkedEvents.length + linkedDocuments.length + linkedRequests.length;
const linkedObjectsBadgeCount = viewTask?.linked_objects_count ?? linkedObjectsCount;
const commentsBadgeCount = viewTask?.comments_count ?? taskComments.length;
useEffect(() => {
if (
@ -1137,8 +1433,13 @@ export default function TasksPage() {
if (!viewTaskId) {
setLinkedMessages([]);
setLinkedEvents([]);
setLinkedDocuments([]);
setLinkedRequests([]);
setTaskActivities([]);
setTaskComments([]);
setCommentDraft("");
setLinkedObjectsOpen(false);
setCommentsOpen(false);
setActivityOpen(false);
return;
}
@ -1148,6 +1449,11 @@ export default function TasksPage() {
}
}, [linkedObjectsOpen, loadTaskLinkedObjects, viewTask?.linked_objects_count, viewTaskId]);
useEffect(() => {
if (!viewTaskId || !commentsOpen) return;
void loadTaskComments(viewTaskId);
}, [commentsOpen, loadTaskComments, viewTask?.comments_count, viewTaskId]);
useEffect(() => {
if (!viewTaskId || !activityOpen) return;
void loadTaskActivity(viewTaskId);
@ -1169,10 +1475,15 @@ export default function TasksPage() {
setTaskMenuOpen(false);
setViewColumnMenuOpen(false);
setLinkedObjectsOpen(false);
setCommentsOpen(false);
setActivityOpen(false);
setLinkedMessages([]);
setLinkedEvents([]);
setLinkedDocuments([]);
setLinkedRequests([]);
setTaskComments([]);
setTaskActivities([]);
setCommentDraft("");
setCardMenuTaskId(null);
}, []);
@ -1187,9 +1498,14 @@ export default function TasksPage() {
setViewColumnMenuOpen(false);
setLinkedMessages([]);
setLinkedEvents([]);
setLinkedDocuments([]);
setLinkedRequests([]);
setTaskComments([]);
setTaskActivities([]);
setLinkedObjectsOpen(false);
setCommentsOpen(false);
setActivityOpen(false);
setCommentDraft("");
}, [saving]);
const openEditTask = useCallback((task: TaskCard) => {
@ -1481,6 +1797,39 @@ export default function TasksPage() {
await deleteTask(viewTask);
}, [deleteTask, viewTask]);
const addViewedTaskComment = useCallback(async () => {
if (!board || !viewTask || saving || !commentDraft.trim()) return;
setSaving(true);
setError(null);
try {
const created = await apiClient.addTaskComment(viewTask.id, commentDraft.trim());
setTaskComments((current) => [...current, created]);
setCommentDraft("");
await loadBoard(board.id);
} catch (commentError) {
setError(getTaskError(commentError, "Не удалось добавить комментарий"));
} finally {
setSaving(false);
}
}, [board, commentDraft, loadBoard, saving, viewTask]);
const deleteViewedTaskComment = useCallback(async (commentId: number) => {
if (!board || !viewTask || saving) return;
setSaving(true);
setError(null);
try {
await apiClient.deleteTaskComment(viewTask.id, commentId);
setTaskComments((current) => current.filter((comment) => comment.id !== commentId));
await loadBoard(board.id);
} catch (commentError) {
setError(getTaskError(commentError, "Не удалось удалить комментарий"));
} finally {
setSaving(false);
}
}, [board, loadBoard, saving, viewTask]);
const unlinkMessageFromViewedTask = useCallback(async (linkId: number) => {
if (!board || !viewTask || saving) return;
@ -1515,6 +1864,40 @@ export default function TasksPage() {
}
}, [activityOpen, board, loadBoard, loadTaskActivity, loadTaskLinkedObjects, saving, viewTask]);
const unlinkDocumentFromViewedTask = useCallback(async (linkId: number) => {
if (!board || !viewTask || saving) return;
setSaving(true);
setError(null);
try {
await apiClient.unlinkTaskDocument(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 unlinkRequestFromViewedTask = useCallback(async (linkId: number) => {
if (!board || !viewTask || saving) return;
setSaving(true);
setError(null);
try {
await apiClient.unlinkTaskRequest(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);
@ -2108,7 +2491,7 @@ export default function TasksPage() {
</span>
) : null}
{viewTask.due_date ? (
<span className={`${isOverdue(viewTask) ? "app-feedback-danger" : "app-pill"} inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium`}>
<span className={`${getTaskDueDateBadgeClass(viewTask, "app-pill")} inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium`}>
<CalendarDays size={12} />
{formatDate(viewTask.due_date)}
</span>
@ -2183,6 +2566,89 @@ export default function TasksPage() {
</div>
) : null}
<div className="rounded-xl border border-[var(--border-subtle)]">
<button
type="button"
onClick={() => setCommentsOpen((current) => !current)}
className="flex w-full items-center justify-between gap-3 px-3 py-2.5 text-left"
aria-expanded={commentsOpen}
>
<span className="flex min-w-0 items-center gap-2">
<MessageSquare size={15} className="app-text-muted shrink-0" />
<span className="text-sm font-medium text-[var(--foreground)]">
Комментарии
</span>
<span className="app-badge rounded-full px-2 py-0.5 text-[11px]">
{commentsBadgeCount}
</span>
</span>
<ChevronRight
size={16}
className={`app-text-muted shrink-0 transition-transform ${commentsOpen ? "rotate-90" : ""}`}
/>
</button>
{commentsOpen ? (
<div className="space-y-3 border-t border-[var(--border-subtle)] p-3">
{taskCommentsLoading ? (
<div className="app-surface-muted rounded-xl border border-[var(--border-subtle)] p-4 text-center">
<Loader2 size={18} className="mx-auto animate-spin text-sky-500" />
</div>
) : taskComments.length > 0 ? (
<div className="space-y-2">
{taskComments.map((comment) => {
const canDeleteComment = Boolean(
user?.auth?.is_staff ||
user?.auth?.is_superuser ||
comment.author?.id === user?.id,
);
return (
<div
key={comment.id}
className="app-surface-muted rounded-xl border border-[var(--border-subtle)] px-3 py-2 text-sm"
>
<div className="mb-1 flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate text-xs font-semibold text-[var(--foreground)]">
{comment.author ? displayUserName(comment.author) : "Сотрудник"}
</p>
<p className="app-text-muted mt-0.5 text-[11px]">
{formatDateTime(comment.created_at)}
</p>
</div>
{canDeleteComment ? (
<CommentDeleteButton
disabled={saving}
onClick={() => deleteViewedTaskComment(comment.id)}
/>
) : null}
</div>
<p className="app-text-wrap whitespace-pre-wrap text-sm leading-5 text-[var(--foreground)]">
{comment.text}
</p>
</div>
);
})}
</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-xs">Комментариев пока нет</p>
</div>
)}
<CommentComposer
value={commentDraft}
onChange={setCommentDraft}
onSubmit={addViewedTaskComment}
disabled={saving}
multiline
rows={2}
placeholder="Комментарий к задаче"
/>
</div>
) : null}
</div>
<div className="rounded-xl border border-[var(--border-subtle)]">
<button
type="button"
@ -2229,6 +2695,22 @@ export default function TasksPage() {
disabled={saving}
/>
))}
{linkedDocuments.map((link) => (
<LinkedDocumentCard
key={link.id}
link={link}
onUnlink={unlinkDocumentFromViewedTask}
disabled={saving}
/>
))}
{linkedRequests.map((link) => (
<LinkedRequestCard
key={link.id}
link={link}
onUnlink={unlinkRequestFromViewedTask}
disabled={saving}
/>
))}
</>
) : (
<div className="app-surface-muted rounded-xl border border-dashed border-[var(--border-subtle)] px-3 py-4 text-center">

View File

@ -57,6 +57,24 @@ const taskPriorityOptions: { value: TaskPriority; label: string }[] = [
{ 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);
}
interface ViewEventDetailsModalProps {
isOpen: boolean;
onClose: () => void;
@ -121,6 +139,7 @@ export function ViewEventDetailsModal({
const [taskLinkEmployees, setTaskLinkEmployees] = useState<User[]>([]);
const [taskLinkBoardId, setTaskLinkBoardId] = useState<number | "">("");
const [taskLinkTaskId, setTaskLinkTaskId] = useState<number | "">("");
const [taskLinkSearch, setTaskLinkSearch] = useState("");
const [taskLinkMode, setTaskLinkMode] = useState<"existing" | "create">("existing");
const [taskLinkTitle, setTaskLinkTitle] = useState("");
const [taskLinkDescription, setTaskLinkDescription] = useState("");
@ -214,6 +233,8 @@ export function ViewEventDetailsModal({
(selectedTaskLinkBoard?.columns || []).find((column) => !column.is_archived) ||
null
);
const availableTaskLinkTasks = selectedTaskLinkBoard?.tasks || [];
const filteredTaskLinkTasks = availableTaskLinkTasks.filter((task: TaskCard) => taskMatchesSearch(task, taskLinkSearch));
const openTaskLinkModal = async () => {
if (!event?.id) return;
@ -235,6 +256,7 @@ export function ViewEventDetailsModal({
const firstColumn = firstBoardWithTasks?.columns?.find((column) => !column.is_archived) || null;
setTaskLinkBoardId(firstBoardWithTasks?.id || "");
setTaskLinkTaskId(firstBoardWithTasks?.tasks?.[0]?.id || "");
setTaskLinkSearch("");
setTaskLinkColumnId(firstColumn?.id || "");
setTaskLinkMode(firstBoardWithTasks?.tasks?.length ? "existing" : "create");
setTaskLinkDetailsOpen(false);
@ -255,6 +277,7 @@ export function ViewEventDetailsModal({
setTaskLinkError(null);
setTaskLinkBoardId("");
setTaskLinkTaskId("");
setTaskLinkSearch("");
setTaskLinkMode("existing");
setTaskLinkTitle("");
setTaskLinkDescription("");
@ -270,6 +293,7 @@ export function ViewEventDetailsModal({
setTaskLinkBoardId(boardId);
const nextBoard = taskLinkBoards.find((board) => board.id === boardId) || null;
setTaskLinkTaskId(nextBoard?.tasks?.[0]?.id || "");
setTaskLinkSearch("");
setTaskLinkColumnId(nextBoard?.columns?.find((column) => !column.is_archived)?.id || "");
setTaskLinkLabelIds([]);
if (!nextBoard?.tasks?.length) {
@ -277,6 +301,12 @@ export function ViewEventDetailsModal({
}
};
const handleTaskLinkSearchChange = (value: string) => {
setTaskLinkSearch(value);
const nextTask = availableTaskLinkTasks.find((task: TaskCard) => taskMatchesSearch(task, value));
setTaskLinkTaskId(nextTask?.id || "");
};
const toggleTaskLinkLabel = (labelId: number) => {
setTaskLinkLabelIds((current) => (
current.includes(labelId)
@ -639,22 +669,36 @@ export function ViewEventDetailsModal({
</div>
{taskLinkMode === "existing" ? (
<label className="block">
<span className="app-text-muted mb-1 block text-xs font-medium">Задача</span>
<select
value={taskLinkTaskId}
onChange={(selectEvent) => setTaskLinkTaskId(Number(selectEvent.target.value) || "")}
className="app-select w-full rounded-xl px-3 py-2 text-sm"
disabled={taskLinkLoading || !selectedTaskLinkBoard}
>
<option value="">Выберите задачу</option>
{(selectedTaskLinkBoard?.tasks || []).map((task: TaskCard) => (
<option key={task.id} value={task.id}>
{task.title}
<div className="space-y-2">
<label className="block">
<span className="app-text-muted mb-1 block text-xs font-medium">Поиск задачи</span>
<input
value={taskLinkSearch}
onChange={(inputEvent) => handleTaskLinkSearchChange(inputEvent.target.value)}
className="app-input w-full rounded-xl px-3 py-2 text-sm"
disabled={taskLinkLoading || !selectedTaskLinkBoard}
placeholder="ID, название, описание или исполнитель"
/>
</label>
<label className="block">
<span className="app-text-muted mb-1 block text-xs font-medium">Задача</span>
<select
value={taskLinkTaskId}
onChange={(selectEvent) => setTaskLinkTaskId(Number(selectEvent.target.value) || "")}
className="app-select w-full rounded-xl px-3 py-2 text-sm"
disabled={taskLinkLoading || !selectedTaskLinkBoard || filteredTaskLinkTasks.length === 0}
>
<option value="">
{filteredTaskLinkTasks.length === 0 ? "Задачи не найдены" : "Выберите задачу"}
</option>
))}
</select>
</label>
{filteredTaskLinkTasks.map((task: TaskCard) => (
<option key={task.id} value={task.id}>
{getTaskOptionLabel(task)}
</option>
))}
</select>
</label>
</div>
) : (
<div className="space-y-3">
<label className="block">

View File

@ -30,6 +30,7 @@ import {
import { DocumentAcknowledgement } from "./DocumentAcknowledgement";
import { DocumentComments } from "./DocumentComments";
import { DocumentRelated } from "./DocumentRelated";
import { DocumentTaskLinks } from "./DocumentTaskLinks";
const DocumentPreviewPane = dynamic(
() => import("./DocumentPreview").then((mod) => mod.DocumentPreviewPane),
@ -528,11 +529,16 @@ export function DocumentDetailModal({
{currentActiveTab === "related" && (
<div className="p-6">
<div className="app-surface rounded-xl p-6">
<DocumentRelated
documentId={document.id}
onNavigate={(docId) => onNavigateToRelated?.(docId)}
/>
<div className="space-y-4">
<div className="app-surface rounded-xl p-6">
<DocumentTaskLinks document={document} />
</div>
<div className="app-surface rounded-xl p-6">
<DocumentRelated
documentId={document.id}
onNavigate={(docId) => onNavigateToRelated?.(docId)}
/>
</div>
</div>
</div>
)}
@ -791,11 +797,16 @@ export function DocumentDetailModal({
{currentActiveTab === "related" && (
<div className="p-4 sm:p-6">
<div className="app-surface rounded-xl p-4 sm:p-6">
<DocumentRelated
documentId={document.id}
onNavigate={(docId) => onNavigateToRelated?.(docId)}
/>
<div className="space-y-4">
<div className="app-surface rounded-xl p-4 sm:p-6">
<DocumentTaskLinks document={document} />
</div>
<div className="app-surface rounded-xl p-4 sm:p-6">
<DocumentRelated
documentId={document.id}
onNavigate={(docId) => onNavigateToRelated?.(docId)}
/>
</div>
</div>
</div>
)}

View File

@ -0,0 +1,571 @@
"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 { Document, TaskBoard, TaskCard, TaskPriority, User } from "@/types/api";
type DocumentTaskLinksProps = {
document: Document;
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 DocumentTaskLinks({
document,
variant = "section",
open = false,
onClose,
onLinked,
}: DocumentTaskLinksProps) {
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 loadLinkedTasks = useCallback(async () => {
setLoading(true);
try {
const tasks = await apiClient.getDocumentLinkedTasks(document.id);
setLinkedTasks(tasks);
} catch (loadError) {
console.error("Ошибка загрузки связанных задач:", loadError);
toast.error("Не удалось загрузить связанные задачи");
} finally {
setLoading(false);
}
}, [document.id]);
useEffect(() => {
void loadLinkedTasks();
}, [loadLinkedTasks]);
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(document.title ? `Задача по документу: ${document.title}` : "Задача по документу");
setDescription(document.description || "");
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);
}
}, [document.description, document.title, 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 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 toggleLabel = (labelId: number) => {
setLabelIds((current) => (
current.includes(labelId)
? current.filter((id) => id !== labelId)
: [...current, labelId]
));
};
const handleTaskSearchChange = (value: string) => {
setTaskSearch(value);
const nextTask = availableTasks.find((task) => taskMatchesSearch(task, value));
setTaskId(nextTask?.id || "");
};
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 apiClient.linkTaskDocument(taskToLink.id, document.id);
await loadLinkedTasks();
toast.success("Документ связан с задачей");
onLinked?.();
if (dialogMode) {
onClose?.();
} else {
setLinkModalOpen(false);
}
resetForm();
} catch (saveError) {
setError(saveError instanceof Error ? saveError.message : "Не удалось связать документ с задачей");
} finally {
setSaving(false);
}
};
const availableTasks = (selectedBoard?.tasks || []).filter((task) => !linkedTaskIds.has(task.id));
const filteredAvailableTasks = availableTasks.filter((task) => taskMatchesSearch(task, taskSearch));
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">Документ</p>
<p className="app-text-wrap mt-1 line-clamp-3 text-sm text-[var(--foreground)]">
{document.title}
</p>
</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

@ -8,9 +8,10 @@ import {
type RequestAttachmentPreview,
} from "@/hooks/useRequestsPage";
import { CommentComposer, CommentDeleteButton } from "@/components/shared/CommentControls";
import TaskLinkPill from "@/components/tasks/TaskLinkPill";
import { displayUserName, formatDate, formatDateTime } from "@/lib/shared";
import type { Request, RequestComment } from "@/types/api";
import { Ban, Paperclip, Pencil, ThumbsDown, ThumbsUp, Trash2 } from "lucide-react";
import { Ban, Link2, Paperclip, Pencil, ThumbsDown, ThumbsUp, Trash2 } from "lucide-react";
import { getRequestActionState } from "./requestActions";
import { RequestUserBadge } from "./RequestUserBadge";
@ -30,6 +31,7 @@ type RequestDetailModalProps = {
onDelete: (requestId: number) => void | Promise<void>;
onDeleteComment: (requestId: number, commentId: number) => void | Promise<void>;
onEdit: (request: Request) => void;
onLinkTask: (request: Request) => void;
onPreviewAttachment: (preview: RequestAttachmentPreview) => void;
onReject: (requestId: number) => void | Promise<void>;
onSetCommentDraft: (requestId: number, value: string) => void;
@ -52,6 +54,7 @@ export function RequestDetailModal({
onDelete,
onDeleteComment,
onEdit,
onLinkTask,
onPreviewAttachment,
onReject,
onSetCommentDraft,
@ -71,6 +74,7 @@ export function RequestDetailModal({
? decodeURIComponent(attachmentUrl.split("/").pop() || "Вложение")
: "";
const commentCount = request.comments_count ?? comments.length;
const linkedTasks = request.linked_tasks || [];
return (
<Modal
@ -168,6 +172,40 @@ export function RequestDetailModal({
) : (
<p className="app-text-muted mt-3 text-sm">Описание отсутствует</p>
)}
<div className="mt-4 rounded-xl border border-[var(--border-subtle)] p-3">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<Link2 size={15} className="app-text-muted shrink-0" />
<span className="text-sm font-medium text-[var(--foreground)]">
Связанные задачи
</span>
<span className="app-badge rounded-full px-2 py-0.5 text-[11px]">
{linkedTasks.length}
</span>
</div>
<button
type="button"
onClick={() => onLinkTask(request)}
className="app-action-secondary inline-flex shrink-0 items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium"
>
<Link2 size={13} />
Связать
</button>
</div>
{linkedTasks.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{linkedTasks.map((task) => (
<TaskLinkPill
key={task.link_id || task.id}
task={task}
maxTitleClassName="max-w-56"
/>
))}
</div>
) : (
<p className="app-text-muted text-xs">Связанных задач нет</p>
)}
</div>
</div>
</div>

View File

@ -11,6 +11,7 @@ import {
Ban,
ChevronDown,
ChevronRight,
Link2,
MessageSquare,
Paperclip,
Pencil,
@ -20,6 +21,7 @@ import {
} from "lucide-react";
import type { Ref } from "react";
import { CommentComposer, CommentDeleteButton } from "@/components/shared/CommentControls";
import TaskLinkPill from "@/components/tasks/TaskLinkPill";
import { RequestAvatar } from "./RequestAvatar";
import { getRequestActionState } from "./requestActions";
import { RequestUserBadge } from "./RequestUserBadge";
@ -40,6 +42,7 @@ type RequestListItemProps = {
onDelete: (requestId: number) => void | Promise<void>;
onDeleteComment: (requestId: number, commentId: number) => void | Promise<void>;
onEdit: (request: Request) => void;
onLinkTask: (request: Request) => void;
onOpenDetails: (request: Request) => void;
onPreviewAttachment: (preview: RequestAttachmentPreview) => void;
onReject: (requestId: number) => void | Promise<void>;
@ -73,6 +76,7 @@ export function RequestListItem({
onDelete,
onDeleteComment,
onEdit,
onLinkTask,
onOpenDetails,
onPreviewAttachment,
onReject,
@ -105,6 +109,8 @@ export function RequestListItem({
: "";
const commentCount = request.comments_count ?? comments.length;
const decisionMaker = request.approver || request.assigned_to;
const linkedTasks = request.linked_tasks || [];
const hasMenuActions = hasSecondaryActions || Boolean(onLinkTask);
return (
<article
@ -186,7 +192,7 @@ export function RequestListItem({
<span className={`app-status-pill ${status.className}`}>
{status.label}
</span>
{hasSecondaryActions ? (
{hasMenuActions ? (
<div className="relative">
<button
type="button"
@ -204,6 +210,17 @@ export function RequestListItem({
</button>
{isMenuOpen ? (
<div className="app-menu absolute right-0 top-full z-20 mt-2 w-44 rounded-xl py-1.5">
<button
type="button"
onClick={() => {
onToggleMenu(null);
onLinkTask(request);
}}
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} />
Связать с задачей
</button>
{canCancel ? (
<button
type="button"
@ -261,8 +278,25 @@ export function RequestListItem({
</p>
) : null}
{linkedTasks.length > 0 ? (
<div className={`${summary ? "mt-3" : "mt-2"} flex flex-wrap gap-1.5`}>
{linkedTasks.slice(0, 3).map((task) => (
<TaskLinkPill
key={task.link_id || task.id}
task={task}
maxTitleClassName="max-w-44"
/>
))}
{linkedTasks.length > 3 ? (
<span className="app-badge rounded-full px-2 py-1 text-[11px] font-medium">
+{linkedTasks.length - 3}
</span>
) : null}
</div>
) : null}
{canProcess ? (
<div className={`${summary ? "mt-3" : "mt-2"} flex flex-wrap items-center gap-1.5`}>
<div className={`${summary || linkedTasks.length > 0 ? "mt-3" : "mt-2"} flex flex-wrap items-center gap-1.5`}>
<span className="ml-auto inline-flex items-center gap-2">
<button
type="button"

View File

@ -22,6 +22,7 @@ type RequestListSectionProps = {
loadMoreRef: RefObject<HTMLDivElement | null>;
loadingMore: boolean;
nextPage: number | null;
onLinkTask: (request: Request) => void;
openEdit: (request: Request) => void;
requestMenuOpenId: number | null;
requestMenuRef: RefObject<HTMLDivElement | null>;
@ -52,6 +53,7 @@ export function RequestListSection({
loadMoreRef,
loadingMore,
nextPage,
onLinkTask,
openEdit,
requestMenuOpenId,
requestMenuRef,
@ -89,6 +91,7 @@ export function RequestListSection({
onDelete={handleDelete}
onDeleteComment={handleDeleteComment}
onEdit={openEdit}
onLinkTask={onLinkTask}
onOpenDetails={setDetailsRequest}
onPreviewAttachment={setAttachmentPreview}
onReject={handleReject}

View File

@ -0,0 +1,576 @@
"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 { Request, TaskBoard, TaskCard, TaskPriority, User } from "@/types/api";
type RequestTaskLinksProps = {
request: Request;
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);
}
function requestTitle(request: Request) {
return request.display_title || request.title || `Заявление #${request.id}`;
}
export function RequestTaskLinks({
request,
variant = "section",
open = false,
onClose,
onLinked,
}: RequestTaskLinksProps) {
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 loadLinkedTasks = useCallback(async () => {
setLoading(true);
try {
const tasks = await apiClient.getRequestLinkedTasks(request.id);
setLinkedTasks(tasks);
} catch (loadError) {
console.error("Ошибка загрузки связанных задач:", loadError);
toast.error("Не удалось загрузить связанные задачи");
} finally {
setLoading(false);
}
}, [request.id]);
useEffect(() => {
void loadLinkedTasks();
}, [loadLinkedTasks]);
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);
const baseTitle = requestTitle(request);
setTitle(`Задача по заявлению: ${baseTitle}`);
setDescription(request.comment || request.description || "");
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);
}
}, [linkedTaskIds, request]);
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 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 toggleLabel = (labelId: number) => {
setLabelIds((current) => (
current.includes(labelId)
? current.filter((id) => id !== labelId)
: [...current, labelId]
));
};
const handleTaskSearchChange = (value: string) => {
setTaskSearch(value);
const nextTask = availableTasks.find((task) => taskMatchesSearch(task, value));
setTaskId(nextTask?.id || "");
};
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 apiClient.linkTaskRequest(taskToLink.id, request.id);
await loadLinkedTasks();
toast.success("Заявление связано с задачей");
onLinked?.();
if (dialogMode) {
onClose?.();
} else {
setLinkModalOpen(false);
}
resetForm();
} catch (saveError) {
setError(saveError instanceof Error ? saveError.message : "Не удалось связать заявление с задачей");
} finally {
setSaving(false);
}
};
const availableTasks = (selectedBoard?.tasks || []).filter((task) => !linkedTaskIds.has(task.id));
const filteredAvailableTasks = availableTasks.filter((task) => taskMatchesSearch(task, taskSearch));
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">Заявление</p>
<p className="app-text-wrap mt-1 line-clamp-3 text-sm text-[var(--foreground)]">
{requestTitle(request)}
</p>
</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

@ -170,6 +170,19 @@ export function useRequestsPage(_userId: number | null | undefined) {
[view, typeFilter, statusFilter, employeeFilter, createdFromFilter, createdToFilter, periodFromFilter, periodToFilter],
);
const refreshRequests = useCallback(async () => {
const response = await apiClient.getRequests(buildRequestParams(1));
const nextRequests = response.results || [];
setRequests(nextRequests);
setNextPage(extractNextPage(response.next));
setDetailsRequest((prev) => {
if (!prev) return prev;
const updated = nextRequests.find((request: Request) => request.id === prev.id);
return updated ? { ...prev, ...updated } : prev;
});
return nextRequests;
}, [buildRequestParams]);
/* ── load data ── */
useEffect(() => {
let cancelled = false;
@ -559,6 +572,7 @@ export function useRequestsPage(_userId: number | null | undefined) {
/* actions */
handleApprove, handleReject, handleCancel, handleDelete,
toggleRow, toggleComments, handleAddComment, handleDeleteComment,
refreshRequests,
isFinal,
};
}

View File

@ -4,9 +4,12 @@ import type {
TaskCard,
TaskColumn,
TaskActivity,
TaskComment,
TaskLabel,
TaskLinkedCalendarEvent,
TaskLinkedDocument,
TaskLinkedMessage,
TaskLinkedRequest,
} from "@/types/api";
import { buildQuery, type RequestFn } from "./utils";
@ -60,8 +63,23 @@ export function createTasksApi(request: RequestFn) {
}),
getTaskActivity: (id: number): Promise<TaskActivity[]> =>
request(`/api/v1/tasks/${id}/activity/`),
getTaskComments: (id: number): Promise<TaskComment[]> =>
request(`/api/v1/tasks/${id}/comments/`),
addTaskComment: (id: number, text: string): Promise<TaskComment> =>
request(`/api/v1/tasks/${id}/comments/`, {
method: "POST",
body: JSON.stringify({ text }),
}),
deleteTaskComment: (id: number, commentId: number): Promise<void> =>
request(`/api/v1/tasks/${id}/comments/${commentId}/`, {
method: "DELETE",
}),
getCalendarEventLinkedTasks: (eventId: number): Promise<TaskCard[]> =>
request(`/api/v1/tasks/linked-event-tasks/?event_id=${eventId}`),
getDocumentLinkedTasks: (documentId: number): Promise<TaskCard[]> =>
request(`/api/v1/tasks/linked-document-tasks/?document_id=${documentId}`),
getRequestLinkedTasks: (requestId: number): Promise<TaskCard[]> =>
request(`/api/v1/tasks/linked-request-tasks/?request_id=${requestId}`),
getTaskLinkedMessages: (id: number): Promise<TaskLinkedMessage[]> =>
request(`/api/v1/tasks/${id}/linked-messages/`),
linkTaskMessage: (id: number, messageId: number): Promise<TaskLinkedMessage> =>
@ -73,6 +91,28 @@ export function createTasksApi(request: RequestFn) {
request(`/api/v1/tasks/${id}/linked-messages/${linkId}/`, {
method: "DELETE",
}),
getTaskLinkedDocuments: (id: number): Promise<TaskLinkedDocument[]> =>
request(`/api/v1/tasks/${id}/linked-documents/`),
linkTaskDocument: (id: number, documentId: number): Promise<TaskLinkedDocument> =>
request(`/api/v1/tasks/${id}/linked-documents/`, {
method: "POST",
body: JSON.stringify({ document_id: documentId }),
}),
unlinkTaskDocument: (id: number, linkId: number): Promise<void> =>
request(`/api/v1/tasks/${id}/linked-documents/${linkId}/`, {
method: "DELETE",
}),
getTaskLinkedRequests: (id: number): Promise<TaskLinkedRequest[]> =>
request(`/api/v1/tasks/${id}/linked-requests/`),
linkTaskRequest: (id: number, requestId: number): Promise<TaskLinkedRequest> =>
request(`/api/v1/tasks/${id}/linked-requests/`, {
method: "POST",
body: JSON.stringify({ request_id: requestId }),
}),
unlinkTaskRequest: (id: number, linkId: number): Promise<void> =>
request(`/api/v1/tasks/${id}/linked-requests/${linkId}/`, {
method: "DELETE",
}),
getTaskLinkedEvents: (id: number): Promise<TaskLinkedCalendarEvent[]> =>
request(`/api/v1/tasks/${id}/linked-events/`),
linkTaskCalendarEvent: (id: number, eventId: number): Promise<TaskLinkedCalendarEvent> =>

View File

@ -181,7 +181,10 @@ export interface TaskCard {
completed_at?: string | null;
linked_messages_count?: number;
linked_events_count?: number;
linked_documents_count?: number;
linked_requests_count?: number;
linked_objects_count?: number;
comments_count?: number;
created_at: string;
updated_at: string;
}
@ -226,17 +229,61 @@ export interface TaskLinkedCalendarEvent {
created_at: string;
}
export interface TaskLinkedDocument {
id: number;
kind: "document";
document_id: number;
document: Document | null;
can_open?: boolean;
object_url?: string | null;
created_by?: User;
created_at: string;
}
export interface TaskLinkedRequest {
id: number;
kind: "request";
request_id: number;
request: {
id: number;
title?: string;
display_title?: string;
type?: string;
type_display?: string;
status?: string;
status_display?: string;
comment?: string;
date_from?: string | null;
date_to?: string | null;
employee?: User;
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" | "";
object_kind?: "message" | "calendar_event" | "document" | "request" | "";
object_id?: number | null;
metadata?: Record<string, unknown>;
created_at: string;
}
export interface TaskComment {
id: number;
task: number;
author: User;
text: string;
created_at: string;
}
export interface MessageLinkedTask {
link_id: number;
id: number;
@ -427,6 +474,7 @@ export interface Document {
acknowledgements?: DocumentAcknowledgement[];
acknowledgement_required?: boolean;
is_acknowledged?: boolean;
linked_tasks?: MessageLinkedTask[];
}
// Guests
@ -646,6 +694,7 @@ export interface Request {
is_recipient?: boolean;
can_decide?: boolean;
comments_count?: number;
linked_tasks?: MessageLinkedTask[];
is_final?: boolean;
attachment?: string | null;
attachment_url?: string | null;