feat: link tasks with workplace entities
This commit is contained in:
parent
d97b8e765a
commit
042952b7a9
@ -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):
|
||||
"""Сериализатор для записи/обновления документа.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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):
|
||||
"""Сериализатор записи заявки.
|
||||
|
||||
@ -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() ---
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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()
|
||||
|
||||
40
backend/tasks/migrations/0005_add_document_link_kind.py
Normal file
40
backend/tasks/migrations/0005_add_document_link_kind.py
Normal 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="Тип связи",
|
||||
),
|
||||
),
|
||||
]
|
||||
42
backend/tasks/migrations/0006_add_request_link_kind.py
Normal file
42
backend/tasks/migrations/0006_add_request_link_kind.py
Normal 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="Тип связи",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
):
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
571
frontend/src/components/documents/DocumentTaskLinks.tsx
Normal file
571
frontend/src/components/documents/DocumentTaskLinks.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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}
|
||||
|
||||
576
frontend/src/components/requests/RequestTaskLinks.tsx
Normal file
576
frontend/src/components/requests/RequestTaskLinks.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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> =>
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user