feat: link procurement requests with tasks
This commit is contained in:
parent
042952b7a9
commit
e190affc7c
@ -168,6 +168,7 @@ class ProcurementRequestSummaryMixin(serializers.Serializer):
|
||||
total_ordered_quantity = serializers.SerializerMethodField()
|
||||
total_received_quantity = serializers.SerializerMethodField()
|
||||
quantity_unit_label = serializers.SerializerMethodField()
|
||||
linked_tasks = serializers.SerializerMethodField()
|
||||
|
||||
@extend_schema_field(serializers.BooleanField())
|
||||
def get_is_viewed(self, obj):
|
||||
@ -260,6 +261,60 @@ class ProcurementRequestSummaryMixin(serializers.Serializer):
|
||||
return _unit_display_label(next(iter(units)))
|
||||
return "единиц"
|
||||
|
||||
@extend_schema_field(serializers.ListField(child=serializers.DictField()))
|
||||
def get_linked_tasks(self, obj: ProcurementRequest) -> 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(ProcurementRequest)
|
||||
accessible_boards = TaskBoard.objects.filter(
|
||||
is_archived=False,
|
||||
).filter(task_board_access_q(user))
|
||||
|
||||
links = (
|
||||
TaskLinkedObject.objects.filter(
|
||||
kind=TaskLinkedObjectKind.PROCUREMENT_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 ProcurementItemSerializer(serializers.ModelSerializer):
|
||||
"""Сериализатор для позиций заявки."""
|
||||
@ -637,6 +692,7 @@ class ProcurementRequestListSerializer(
|
||||
"total_received_quantity",
|
||||
"quantity_unit_label",
|
||||
"comments_count",
|
||||
"linked_tasks",
|
||||
"can_current_user_approve",
|
||||
"can_current_user_submit_for_approval",
|
||||
"can_current_user_start_work",
|
||||
@ -675,6 +731,7 @@ class ProcurementRequestListSerializer(
|
||||
"total_received_quantity",
|
||||
"quantity_unit_label",
|
||||
"comments_count",
|
||||
"linked_tasks",
|
||||
"can_current_user_approve",
|
||||
"can_current_user_submit_for_approval",
|
||||
"can_current_user_start_work",
|
||||
@ -887,6 +944,7 @@ class ProcurementRequestDetailSerializer(
|
||||
"required_approval_priorities",
|
||||
"is_editable",
|
||||
"comments_count",
|
||||
"linked_tasks",
|
||||
"can_current_user_approve",
|
||||
"can_current_user_submit_for_approval",
|
||||
"can_current_user_start_work",
|
||||
@ -922,6 +980,7 @@ class ProcurementRequestDetailSerializer(
|
||||
"processing_department_name",
|
||||
"fulfillment_status_display",
|
||||
"comments_count",
|
||||
"linked_tasks",
|
||||
"can_current_user_approve",
|
||||
"can_current_user_submit_for_approval",
|
||||
"can_current_user_start_work",
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
"""ViewSets для API модуля закупок."""
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
@ -336,6 +337,75 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
|
||||
|
||||
return queryset.distinct()
|
||||
|
||||
def _attach_linked_task_payloads(
|
||||
self,
|
||||
requests: list[ProcurementRequest],
|
||||
) -> 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(ProcurementRequest)
|
||||
accessible_boards = TaskBoard.objects.filter(
|
||||
is_archived=False,
|
||||
).filter(task_board_access_q(user))
|
||||
|
||||
links = (
|
||||
TaskLinkedObject.objects.filter(
|
||||
kind=TaskLinkedObjectKind.PROCUREMENT_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)
|
||||
|
||||
@action(detail=False, methods=["get"], url_path="create-options")
|
||||
def create_options(self, request):
|
||||
"""Вернуть настройки выбора отделов для формы создания заявки."""
|
||||
|
||||
@ -13,6 +13,7 @@ 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 procurement.models import ProcurementRequest
|
||||
from requests_app.models import Request as EmployeeRequest
|
||||
from schedule.models import Event
|
||||
from api.v1.schedule.serializers import EventSerializer
|
||||
@ -20,6 +21,7 @@ from tasks.access import (
|
||||
user_can_access_calendar_event,
|
||||
user_can_access_document,
|
||||
user_can_access_employee_request,
|
||||
user_can_access_procurement_request,
|
||||
user_can_access_task_board,
|
||||
)
|
||||
from tasks.models import (
|
||||
@ -142,6 +144,7 @@ class TaskSerializer(serializers.ModelSerializer):
|
||||
linked_events_count = serializers.SerializerMethodField()
|
||||
linked_documents_count = serializers.SerializerMethodField()
|
||||
linked_requests_count = serializers.SerializerMethodField()
|
||||
linked_procurement_requests_count = serializers.SerializerMethodField()
|
||||
linked_objects_count = serializers.SerializerMethodField()
|
||||
comments_count = serializers.SerializerMethodField()
|
||||
|
||||
@ -170,6 +173,7 @@ class TaskSerializer(serializers.ModelSerializer):
|
||||
"linked_events_count",
|
||||
"linked_documents_count",
|
||||
"linked_requests_count",
|
||||
"linked_procurement_requests_count",
|
||||
"linked_objects_count",
|
||||
"comments_count",
|
||||
"created_at",
|
||||
@ -187,6 +191,7 @@ class TaskSerializer(serializers.ModelSerializer):
|
||||
"linked_events_count",
|
||||
"linked_documents_count",
|
||||
"linked_requests_count",
|
||||
"linked_procurement_requests_count",
|
||||
"linked_objects_count",
|
||||
"comments_count",
|
||||
"created_at",
|
||||
@ -225,6 +230,14 @@ class TaskSerializer(serializers.ModelSerializer):
|
||||
kind=TaskLinkedObjectKind.REQUEST,
|
||||
).count()
|
||||
|
||||
@extend_schema_field(serializers.IntegerField())
|
||||
def get_linked_procurement_requests_count(self, obj):
|
||||
if hasattr(obj, "linked_procurement_requests_count"):
|
||||
return obj.linked_procurement_requests_count
|
||||
return obj.linked_objects.filter(
|
||||
kind=TaskLinkedObjectKind.PROCUREMENT_REQUEST,
|
||||
).count()
|
||||
|
||||
@extend_schema_field(serializers.IntegerField())
|
||||
def get_linked_objects_count(self, obj):
|
||||
if hasattr(obj, "linked_objects_count"):
|
||||
@ -606,3 +619,92 @@ class TaskLinkedRequestSerializer(serializers.ModelSerializer):
|
||||
|
||||
def get_request_content_type():
|
||||
return ContentType.objects.get_for_model(EmployeeRequest)
|
||||
|
||||
|
||||
class TaskLinkedProcurementRequestSerializer(serializers.ModelSerializer):
|
||||
created_by = EmployeeBriefSerializer(read_only=True)
|
||||
procurement_request = serializers.SerializerMethodField()
|
||||
procurement_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",
|
||||
"procurement_request_id",
|
||||
"procurement_request",
|
||||
"can_open",
|
||||
"object_url",
|
||||
"created_by",
|
||||
"created_at",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
@extend_schema_field(serializers.JSONField(allow_null=True))
|
||||
def get_procurement_request(self, obj):
|
||||
if obj.kind != TaskLinkedObjectKind.PROCUREMENT_REQUEST:
|
||||
return None
|
||||
|
||||
procurement_request = getattr(obj, "content_object", None)
|
||||
if procurement_request is None:
|
||||
return None
|
||||
|
||||
return {
|
||||
"id": procurement_request.id,
|
||||
"title": procurement_request.title,
|
||||
"description": procurement_request.description,
|
||||
"status": procurement_request.status,
|
||||
"status_display": procurement_request.get_status_display(),
|
||||
"urgency": procurement_request.urgency,
|
||||
"urgency_display": procurement_request.get_urgency_display(),
|
||||
"fulfillment_status": procurement_request.fulfillment_status,
|
||||
"fulfillment_status_display": procurement_request.get_fulfillment_status_display(),
|
||||
"department_id": procurement_request.department_id,
|
||||
"department_name": (
|
||||
procurement_request.department.name
|
||||
if procurement_request.department_id
|
||||
else None
|
||||
),
|
||||
"processing_department_id": procurement_request.processing_department_id,
|
||||
"processing_department_name": (
|
||||
procurement_request.processing_department.name
|
||||
if procurement_request.processing_department_id
|
||||
else None
|
||||
),
|
||||
"requestor": EmployeeBriefSerializer(procurement_request.requestor).data,
|
||||
"executor": (
|
||||
EmployeeBriefSerializer(procurement_request.executor).data
|
||||
if procurement_request.executor_id
|
||||
else None
|
||||
),
|
||||
"total_cost": procurement_request.total_cost,
|
||||
"items_count": procurement_request.items_count,
|
||||
"created_at": procurement_request.created_at,
|
||||
"updated_at": procurement_request.updated_at,
|
||||
}
|
||||
|
||||
@extend_schema_field(serializers.BooleanField())
|
||||
def get_can_open(self, obj):
|
||||
procurement_request = getattr(obj, "content_object", None)
|
||||
request = self.context.get("request")
|
||||
user = getattr(request, "user", None)
|
||||
if (
|
||||
obj.kind != TaskLinkedObjectKind.PROCUREMENT_REQUEST
|
||||
or procurement_request is None
|
||||
or not user
|
||||
):
|
||||
return False
|
||||
return user_can_access_procurement_request(user, procurement_request)
|
||||
|
||||
@extend_schema_field(serializers.CharField(allow_null=True))
|
||||
def get_object_url(self, obj):
|
||||
procurement_request = getattr(obj, "content_object", None)
|
||||
if procurement_request is None or not self.get_can_open(obj):
|
||||
return None
|
||||
return f"/procurement?request={procurement_request.id}"
|
||||
|
||||
|
||||
def get_procurement_request_content_type():
|
||||
return ContentType.objects.get_for_model(ProcurementRequest)
|
||||
|
||||
@ -13,6 +13,7 @@ from communications import comments_helpers
|
||||
from communications.models import Message
|
||||
from communications.utils import user_can_access_chat
|
||||
from documents.models import Document
|
||||
from procurement.models import ProcurementRequest
|
||||
from requests_app.models import Request as EmployeeRequest
|
||||
from rest_framework import filters, status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
@ -25,6 +26,7 @@ from tasks.access import (
|
||||
user_can_access_calendar_event,
|
||||
user_can_access_document,
|
||||
user_can_access_employee_request,
|
||||
user_can_access_procurement_request,
|
||||
)
|
||||
from tasks.models import (
|
||||
Task,
|
||||
@ -50,12 +52,14 @@ from .serializers import (
|
||||
TaskLinkedCalendarEventSerializer,
|
||||
TaskLinkedDocumentSerializer,
|
||||
TaskLinkedMessageSerializer,
|
||||
TaskLinkedProcurementRequestSerializer,
|
||||
TaskLinkedRequestSerializer,
|
||||
TaskSerializer,
|
||||
create_default_columns,
|
||||
get_calendar_event_content_type,
|
||||
get_document_content_type,
|
||||
get_message_content_type,
|
||||
get_procurement_request_content_type,
|
||||
get_request_content_type,
|
||||
)
|
||||
|
||||
@ -286,6 +290,15 @@ class TaskViewSet(viewsets.ModelViewSet):
|
||||
),
|
||||
distinct=True,
|
||||
),
|
||||
linked_procurement_requests_count=Count(
|
||||
"linked_objects",
|
||||
filter=Q(
|
||||
linked_objects__kind=(
|
||||
TaskLinkedObjectKind.PROCUREMENT_REQUEST
|
||||
),
|
||||
),
|
||||
distinct=True,
|
||||
),
|
||||
linked_objects_count=Count("linked_objects", distinct=True),
|
||||
comments_count=Coalesce(
|
||||
Subquery(comments_subquery, output_field=IntegerField()),
|
||||
@ -656,6 +669,52 @@ class TaskViewSet(viewsets.ModelViewSet):
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=False, methods=["get"], url_path="linked-procurement-request-tasks")
|
||||
def linked_procurement_request_tasks(self, request):
|
||||
procurement_request_id = request.query_params.get("procurement_request_id")
|
||||
try:
|
||||
procurement_request_id = int(procurement_request_id)
|
||||
except (TypeError, ValueError):
|
||||
return Response(
|
||||
{"procurement_request_id": ["Укажите ID заявки на закупку."]},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
procurement_request = (
|
||||
ProcurementRequest.objects.select_related(
|
||||
"department",
|
||||
"processing_department",
|
||||
"requestor",
|
||||
"executor",
|
||||
)
|
||||
.prefetch_related("approvals", "items")
|
||||
.get(id=procurement_request_id)
|
||||
)
|
||||
except ProcurementRequest.DoesNotExist:
|
||||
return Response(
|
||||
{"procurement_request_id": ["Заявка на закупку не найдена."]},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
if not user_can_access_procurement_request(request.user, procurement_request):
|
||||
return Response(
|
||||
{"detail": "Нет доступа к этой заявке на закупку."},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
queryset = (
|
||||
self.get_queryset()
|
||||
.filter(
|
||||
linked_objects__kind=TaskLinkedObjectKind.PROCUREMENT_REQUEST,
|
||||
linked_objects__content_type=get_procurement_request_content_type(),
|
||||
linked_objects__object_id=procurement_request.id,
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
def _message_links_queryset(self, task):
|
||||
return (
|
||||
TaskLinkedObject.objects.filter(
|
||||
@ -696,6 +755,16 @@ class TaskViewSet(viewsets.ModelViewSet):
|
||||
.order_by("-created_at", "-id")
|
||||
)
|
||||
|
||||
def _procurement_request_links_queryset(self, task):
|
||||
return (
|
||||
TaskLinkedObject.objects.filter(
|
||||
task=task,
|
||||
kind=TaskLinkedObjectKind.PROCUREMENT_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()
|
||||
@ -1038,6 +1107,118 @@ class TaskViewSet(viewsets.ModelViewSet):
|
||||
)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@action(detail=True, methods=["get", "post"], url_path="linked-procurement-requests")
|
||||
def linked_procurement_requests(self, request, pk=None):
|
||||
task = self.get_object()
|
||||
if request.method == "GET":
|
||||
serializer = TaskLinkedProcurementRequestSerializer(
|
||||
self._procurement_request_links_queryset(task),
|
||||
many=True,
|
||||
context={"request": request},
|
||||
)
|
||||
return Response(serializer.data)
|
||||
|
||||
procurement_request_id = request.data.get("procurement_request_id")
|
||||
try:
|
||||
procurement_request_id = int(procurement_request_id)
|
||||
except (TypeError, ValueError):
|
||||
return Response(
|
||||
{"procurement_request_id": ["Укажите ID заявки на закупку."]},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
procurement_request = (
|
||||
ProcurementRequest.objects.select_related(
|
||||
"department",
|
||||
"processing_department",
|
||||
"requestor",
|
||||
"executor",
|
||||
)
|
||||
.prefetch_related("approvals", "items")
|
||||
.get(id=procurement_request_id)
|
||||
)
|
||||
except ProcurementRequest.DoesNotExist:
|
||||
return Response(
|
||||
{"procurement_request_id": ["Заявка на закупку не найдена."]},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
if not user_can_access_procurement_request(request.user, procurement_request):
|
||||
return Response(
|
||||
{"detail": "Нет доступа к этой заявке на закупку."},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
link, created = TaskLinkedObject.objects.get_or_create(
|
||||
task=task,
|
||||
kind=TaskLinkedObjectKind.PROCUREMENT_REQUEST,
|
||||
content_type=get_procurement_request_content_type(),
|
||||
object_id=procurement_request.id,
|
||||
defaults={"created_by": request.user},
|
||||
)
|
||||
serializer = TaskLinkedProcurementRequestSerializer(
|
||||
link,
|
||||
context={"request": request},
|
||||
)
|
||||
if created:
|
||||
create_task_activity(
|
||||
task,
|
||||
request.user,
|
||||
TaskActivityAction.LINKED,
|
||||
object_kind=TaskLinkedObjectKind.PROCUREMENT_REQUEST,
|
||||
object_id=procurement_request.id,
|
||||
metadata={
|
||||
"object_label": procurement_request.title,
|
||||
"object_type": "Заявка на закупку",
|
||||
},
|
||||
)
|
||||
send_task_board_update(
|
||||
task.board,
|
||||
"linked",
|
||||
"procurement_request",
|
||||
procurement_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-procurement-requests/(?P<link_id>\d+)",
|
||||
)
|
||||
def unlink_procurement_request(self, request, pk=None, link_id=None):
|
||||
task = self.get_object()
|
||||
try:
|
||||
link = self._procurement_request_links_queryset(task).get(id=link_id)
|
||||
except TaskLinkedObject.DoesNotExist:
|
||||
return Response(
|
||||
{"detail": "Связь не найдена."},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
procurement_request_id = link.object_id
|
||||
link.delete()
|
||||
create_task_activity(
|
||||
task,
|
||||
request.user,
|
||||
TaskActivityAction.UNLINKED,
|
||||
object_kind=TaskLinkedObjectKind.PROCUREMENT_REQUEST,
|
||||
object_id=procurement_request_id,
|
||||
metadata={"object_type": "Заявка на закупку"},
|
||||
)
|
||||
send_task_board_update(
|
||||
task.board,
|
||||
"unlinked",
|
||||
"procurement_request",
|
||||
procurement_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()
|
||||
|
||||
@ -107,3 +107,40 @@ def user_can_access_employee_request(
|
||||
if request_obj.recipients.filter(id=user.id).exists():
|
||||
return True
|
||||
return request_obj.cc_users.filter(id=user.id).exists()
|
||||
|
||||
|
||||
def user_can_access_procurement_request(user, procurement_request) -> bool:
|
||||
if not user or not user.is_authenticated or procurement_request is None:
|
||||
return False
|
||||
|
||||
from procurement.constants import ProcurementStatus
|
||||
from procurement.services import ProcurementApprovalResolver
|
||||
|
||||
if user.is_staff or user.is_superuser:
|
||||
return True
|
||||
if (
|
||||
user.has_perm("procurement.view_procurementrequest")
|
||||
or user.has_perm("procurement.change_procurementrequest")
|
||||
or user.has_perm("procurement.delete_procurementrequest")
|
||||
or user.has_perm("procurement.execute_procurement")
|
||||
):
|
||||
return True
|
||||
if procurement_request.requestor_id == user.id:
|
||||
return True
|
||||
if procurement_request.executor_id == user.id:
|
||||
return True
|
||||
if procurement_request.approvals.filter(approver=user).exists():
|
||||
return True
|
||||
|
||||
participant_department_ids = (
|
||||
ProcurementApprovalResolver.get_user_department_participant_ids(user)
|
||||
)
|
||||
if procurement_request.department_id in participant_department_ids:
|
||||
return True
|
||||
if procurement_request.processing_department_id in participant_department_ids:
|
||||
return True
|
||||
|
||||
return (
|
||||
procurement_request.status == ProcurementStatus.APPROVED
|
||||
and procurement_request.processing_department_id is None
|
||||
)
|
||||
|
||||
@ -0,0 +1,41 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("tasks", "0006_add_request_link_kind"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="taskactivity",
|
||||
name="object_kind",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("message", "Сообщение"),
|
||||
("calendar_event", "Календарное событие"),
|
||||
("document", "Документ"),
|
||||
("request", "Заявление"),
|
||||
("procurement_request", "Заявка на закупку"),
|
||||
],
|
||||
max_length=32,
|
||||
verbose_name="Тип объекта",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="tasklinkedobject",
|
||||
name="kind",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("message", "Сообщение"),
|
||||
("calendar_event", "Календарное событие"),
|
||||
("document", "Документ"),
|
||||
("request", "Заявление"),
|
||||
("procurement_request", "Заявка на закупку"),
|
||||
],
|
||||
max_length=32,
|
||||
verbose_name="Тип связи",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -178,6 +178,7 @@ class TaskLinkedObjectKind(models.TextChoices):
|
||||
CALENDAR_EVENT = "calendar_event", "Календарное событие"
|
||||
DOCUMENT = "document", "Документ"
|
||||
REQUEST = "request", "Заявление"
|
||||
PROCUREMENT_REQUEST = "procurement_request", "Заявка на закупку"
|
||||
|
||||
|
||||
class TaskActivityAction(models.TextChoices):
|
||||
|
||||
@ -42,6 +42,13 @@ from procurement.models import (
|
||||
ProcurementSettings,
|
||||
)
|
||||
from procurement.notifications.handlers import notify_new_request
|
||||
from tasks.models import (
|
||||
Task,
|
||||
TaskBoard,
|
||||
TaskColumn,
|
||||
TaskLinkedObject,
|
||||
TaskLinkedObjectKind,
|
||||
)
|
||||
from notifications.models import Notification, UserChannelPreferences
|
||||
|
||||
|
||||
@ -278,6 +285,97 @@ class TestProcurementRequestList:
|
||||
assert results[0]['id'] == procurement_request.id
|
||||
assert results[0]['title'] == "Закупка ноутбуков"
|
||||
|
||||
def test_list_includes_visible_linked_tasks_only(
|
||||
self,
|
||||
api_client,
|
||||
user,
|
||||
procurement_request,
|
||||
):
|
||||
"""Список заявок отдаёт бейджи только доступных пользователю задач."""
|
||||
hidden_member = Employee.objects.create_user(
|
||||
email="hidden-board-member@example.com",
|
||||
password="testpass123",
|
||||
phone_number="+79998880003",
|
||||
first_name="Скрытый",
|
||||
last_name="Участник",
|
||||
is_active=True,
|
||||
email_verified=True,
|
||||
send_activation_email=False,
|
||||
)
|
||||
visible_board = TaskBoard.objects.create(
|
||||
name="Видимая доска закупок",
|
||||
created_by=user,
|
||||
)
|
||||
visible_column = TaskColumn.objects.create(
|
||||
board=visible_board,
|
||||
name="Новые",
|
||||
position=1000,
|
||||
color="#38bdf8",
|
||||
)
|
||||
visible_task = Task.objects.create(
|
||||
board=visible_board,
|
||||
column=visible_column,
|
||||
title="Видимая задача закупки",
|
||||
created_by=user,
|
||||
priority="high",
|
||||
)
|
||||
hidden_board = TaskBoard.objects.create(
|
||||
name="Скрытая доска закупок",
|
||||
created_by=hidden_member,
|
||||
)
|
||||
hidden_board.members.add(hidden_member)
|
||||
hidden_column = TaskColumn.objects.create(
|
||||
board=hidden_board,
|
||||
name="Новые",
|
||||
position=1000,
|
||||
color="#ef4444",
|
||||
)
|
||||
hidden_task = Task.objects.create(
|
||||
board=hidden_board,
|
||||
column=hidden_column,
|
||||
title="Скрытая задача закупки",
|
||||
created_by=hidden_member,
|
||||
priority="critical",
|
||||
)
|
||||
request_ct = ContentType.objects.get_for_model(ProcurementRequest)
|
||||
TaskLinkedObject.objects.create(
|
||||
task=visible_task,
|
||||
kind=TaskLinkedObjectKind.PROCUREMENT_REQUEST,
|
||||
content_type=request_ct,
|
||||
object_id=procurement_request.id,
|
||||
created_by=user,
|
||||
)
|
||||
TaskLinkedObject.objects.create(
|
||||
task=hidden_task,
|
||||
kind=TaskLinkedObjectKind.PROCUREMENT_REQUEST,
|
||||
content_type=request_ct,
|
||||
object_id=procurement_request.id,
|
||||
created_by=hidden_member,
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=user)
|
||||
response = api_client.get(
|
||||
reverse('api:v1:procurement:procurementrequest-list')
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
result = response.data['results'][0]
|
||||
assert result['id'] == procurement_request.id
|
||||
assert result['linked_tasks'] == [
|
||||
{
|
||||
'link_id': visible_task.linked_objects.get().id,
|
||||
'id': visible_task.id,
|
||||
'title': 'Видимая задача закупки',
|
||||
'board_id': visible_board.id,
|
||||
'board_name': 'Видимая доска закупок',
|
||||
'column_id': visible_column.id,
|
||||
'column_name': 'Новые',
|
||||
'column_color': '#38bdf8',
|
||||
'priority': 'high',
|
||||
'priority_display': 'Высокий',
|
||||
}
|
||||
]
|
||||
|
||||
def test_list_department_requests(
|
||||
self, api_client, department_head, procurement_request
|
||||
):
|
||||
|
||||
@ -12,6 +12,8 @@ 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 procurement.constants import ProcurementStatus, UrgencyLevel
|
||||
from procurement.models import ProcurementRequest
|
||||
from requests_app.models import Request as EmployeeRequest
|
||||
from schedule.models import Calendar, CalendarRelation, Event
|
||||
from tasks.models import Task, TaskBoard, TaskColumn
|
||||
@ -588,6 +590,97 @@ def test_task_linked_request_respects_request_access(api_client):
|
||||
)
|
||||
|
||||
|
||||
def test_task_linked_procurement_request_respects_procurement_access(api_client):
|
||||
owner = make_user("task-proc-owner@example.com", "+79994440030")
|
||||
board_member = make_user("task-proc-member@example.com", "+79994440031")
|
||||
department = Department.objects.create(name="Закупочный отдел тест")
|
||||
board = TaskBoard.objects.create(
|
||||
name="Доска с закупками",
|
||||
created_by=owner,
|
||||
)
|
||||
board.members.add(board_member)
|
||||
column = TaskColumn.objects.create(
|
||||
board=board,
|
||||
name="Новые",
|
||||
position=1000,
|
||||
color="#38bdf8",
|
||||
)
|
||||
task = Task.objects.create(
|
||||
board=board,
|
||||
column=column,
|
||||
title="Проверить закупку",
|
||||
created_by=owner,
|
||||
)
|
||||
procurement_request = ProcurementRequest.objects.create(
|
||||
title="Закупка мониторов",
|
||||
description="Закупка к задаче",
|
||||
department=department,
|
||||
requestor=owner,
|
||||
status=ProcurementStatus.DRAFT,
|
||||
urgency=UrgencyLevel.HIGH,
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=board_member)
|
||||
denied_response = api_client.post(
|
||||
reverse(
|
||||
"api:v1:tasks:task-linked-procurement-requests",
|
||||
kwargs={"pk": task.id},
|
||||
),
|
||||
{"procurement_request_id": procurement_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-procurement-requests",
|
||||
kwargs={"pk": task.id},
|
||||
),
|
||||
{"procurement_request_id": procurement_request.id},
|
||||
format="json",
|
||||
)
|
||||
assert link_response.status_code == status.HTTP_201_CREATED
|
||||
assert link_response.data["procurement_request"]["title"] == "Закупка мониторов"
|
||||
assert link_response.data["procurement_request"]["status"] == ProcurementStatus.DRAFT
|
||||
assert link_response.data["procurement_request"]["urgency"] == UrgencyLevel.HIGH
|
||||
assert link_response.data["can_open"] is True
|
||||
assert link_response.data["object_url"] == (
|
||||
f"/procurement?request={procurement_request.id}"
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=board_member)
|
||||
linked_response = api_client.get(
|
||||
reverse(
|
||||
"api:v1:tasks:task-linked-procurement-requests",
|
||||
kwargs={"pk": task.id},
|
||||
)
|
||||
)
|
||||
assert linked_response.status_code == status.HTTP_200_OK
|
||||
assert linked_response.data[0]["procurement_request_id"] == procurement_request.id
|
||||
assert linked_response.data[0]["procurement_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-procurement-request-tasks"),
|
||||
{"procurement_request_id": procurement_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-procurement-request-tasks"),
|
||||
{"procurement_request_id": procurement_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_procurement_requests_count"] == 1
|
||||
|
||||
|
||||
def test_task_activity_records_core_actions(api_client, user):
|
||||
api_client.force_authenticate(user=user)
|
||||
board_response = api_client.get(
|
||||
|
||||
@ -6,8 +6,10 @@ import { AppShell } from "../../components/AppShell";
|
||||
import { useUser } from "@/contexts/UserContext";
|
||||
import Link from "next/link";
|
||||
import { ProcurementRequestDetailContent } from "@/components/procurement/ProcurementRequestDetailContent";
|
||||
import { ProcurementTaskLinks } from "@/components/procurement/ProcurementTaskLinks";
|
||||
import ProcurementStatsPanel from "@/components/procurement/ProcurementStatsPanel";
|
||||
import ProcurementSuppliersPanel from "@/components/procurement/ProcurementSuppliersPanel";
|
||||
import TaskLinkPill from "@/components/tasks/TaskLinkPill";
|
||||
import type {
|
||||
ProcurementApprovalOptions,
|
||||
ProcurementApprovalRouteOption,
|
||||
@ -28,6 +30,7 @@ import {
|
||||
Eye,
|
||||
EyeOff,
|
||||
Filter,
|
||||
Link2,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Package,
|
||||
@ -503,6 +506,7 @@ export default function ProcurementPage() {
|
||||
const statusFilterRef = useRef<HTMLDivElement | null>(null);
|
||||
const [procurementMenuOpenId, setProcurementMenuOpenId] = useState<number | null>(null);
|
||||
const [statusFilterOpen, setStatusFilterOpen] = useState(false);
|
||||
const [taskLinkRequest, setTaskLinkRequest] = useState<ProcurementRequest | null>(null);
|
||||
const [requestActionDialog, setRequestActionDialog] = useState<{ kind: RequestActionDialogKind; requestId: number } | null>(null);
|
||||
const [requestActionComment, setRequestActionComment] = useState("");
|
||||
const [submitApprovalRequestId, setSubmitApprovalRequestId] = useState<number | null>(null);
|
||||
@ -578,6 +582,13 @@ export default function ProcurementPage() {
|
||||
setManualApproverByPriority({});
|
||||
}, []);
|
||||
|
||||
const handleTaskLinked = useCallback(async (requestId: number) => {
|
||||
await Promise.all([
|
||||
loadPage1(),
|
||||
ensureRequestDetail(requestId),
|
||||
]);
|
||||
}, [ensureRequestDetail, loadPage1]);
|
||||
|
||||
const openSubmitApprovalDialog = useCallback(async (requestId: number) => {
|
||||
const requestForSubmit = detailsCache[requestId] || requests.find((request) => request.id === requestId);
|
||||
if (requestForSubmit && !canCurrentUserSubmitRequest(requestForSubmit)) {
|
||||
@ -1133,7 +1144,6 @@ export default function ProcurementPage() {
|
||||
(resolvedDetail.can_current_user_start_work ?? req.can_current_user_start_work) ||
|
||||
(st === "in_progress" && isExecutor)
|
||||
);
|
||||
const hasSecondaryActions = canEditThis || canDeleteThis || canCancelThis;
|
||||
const expanded = expandedIds.has(req.id);
|
||||
const comments = commentsMap[req.id] || [];
|
||||
const commentsOpen = Boolean(expandedComments[req.id]);
|
||||
@ -1143,8 +1153,8 @@ export default function ProcurementPage() {
|
||||
const rowExpanded = !isViewed && expanded;
|
||||
const rowCommentsOpen = !isViewed && commentsOpen;
|
||||
const showInlineRequestActions = !isViewed && hasInlineRequestActions;
|
||||
const showSecondaryActions = !isViewed && hasSecondaryActions;
|
||||
const requestMenuOpen = showSecondaryActions && procurementMenuOpenId === req.id;
|
||||
const showRequestMenu = true;
|
||||
const requestMenuOpen = showRequestMenu && procurementMenuOpenId === req.id;
|
||||
const requestorName = displayUserName(req.requestor, req.requestor_name, req.requestor_email);
|
||||
const executorName = req.executor || req.executor_name ? displayUserName(req.executor, req.executor_name || undefined) : "";
|
||||
const requestorLink = userLink(req.requestor);
|
||||
@ -1169,6 +1179,7 @@ export default function ProcurementPage() {
|
||||
const lastArrivalNoticeAt = resolvedDetail.last_arrival_notice_at ?? req.last_arrival_notice_at ?? null;
|
||||
const approvalsCount = resolvedDetail.approvals?.length ?? 0;
|
||||
const requestDateLabel = getRequestListDateLabel(resolvedDetail, st);
|
||||
const linkedTasks = resolvedDetail.linked_tasks ?? req.linked_tasks ?? [];
|
||||
|
||||
return (
|
||||
<article key={req.id} className={`app-surface-muted rounded-xl transition hover:border-[var(--border-strong)] ${requestMenuOpen ? "relative z-20 overflow-visible" : "overflow-hidden"}`}>
|
||||
@ -1238,6 +1249,22 @@ export default function ProcurementPage() {
|
||||
<span>{requestDateLabel}</span>
|
||||
{getRequestAmount(req) && <span className="font-medium text-[var(--foreground)]">{money(getRequestAmount(req))}</span>}
|
||||
</div>
|
||||
{linkedTasks.length > 0 ? (
|
||||
<div className="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-40"
|
||||
/>
|
||||
))}
|
||||
{linkedTasks.length > 3 ? (
|
||||
<span className="app-badge inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium">
|
||||
+{linkedTasks.length - 3}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
@ -1246,7 +1273,7 @@ export default function ProcurementPage() {
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`app-status-pill shrink-0 ${sMeta.cls}`}>{sMeta.label}</span>
|
||||
{showSecondaryActions ? (
|
||||
{showRequestMenu ? (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
@ -1263,7 +1290,18 @@ export default function ProcurementPage() {
|
||||
/>
|
||||
</button>
|
||||
{requestMenuOpen ? (
|
||||
<div className="app-menu absolute right-0 top-full z-20 mt-2 w-44 rounded-xl py-1.5">
|
||||
<div className="app-menu absolute right-0 top-full z-20 mt-2 w-52 rounded-xl py-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setProcurementMenuOpenId(null);
|
||||
setTaskLinkRequest(resolvedDetail);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
|
||||
>
|
||||
<Link2 size={14} className="app-text-muted" />
|
||||
Связать с задачей
|
||||
</button>
|
||||
{canCancelThis ? (
|
||||
<button
|
||||
type="button"
|
||||
@ -1606,6 +1644,28 @@ export default function ProcurementPage() {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
{(selectedRequest.linked_tasks || []).slice(0, 4).map((task) => (
|
||||
<TaskLinkPill
|
||||
key={task.link_id || task.id}
|
||||
task={task}
|
||||
maxTitleClassName="max-w-48"
|
||||
/>
|
||||
))}
|
||||
{(selectedRequest.linked_tasks || []).length > 4 ? (
|
||||
<span className="app-badge inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium">
|
||||
+{(selectedRequest.linked_tasks || []).length - 4}
|
||||
</span>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTaskLinkRequest(selectedRequest)}
|
||||
className="app-action-secondary inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium"
|
||||
>
|
||||
<Link2 size={12} />
|
||||
Связать с задачей
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1681,6 +1741,16 @@ export default function ProcurementPage() {
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{taskLinkRequest ? (
|
||||
<ProcurementTaskLinks
|
||||
request={taskLinkRequest}
|
||||
variant="dialog"
|
||||
open
|
||||
onClose={() => setTaskLinkRequest(null)}
|
||||
onLinked={() => void handleTaskLinked(taskLinkRequest.id)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Modal
|
||||
isOpen={submitApprovalRequestId !== null}
|
||||
onClose={closeSubmitApprovalDialog}
|
||||
|
||||
@ -32,6 +32,7 @@ import {
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
ShoppingCart,
|
||||
Tag,
|
||||
Trash2,
|
||||
UserRound,
|
||||
@ -44,7 +45,7 @@ import { CommentComposer, CommentDeleteButton } from "@/components/shared/Commen
|
||||
import { Modal } from "@/components/ui";
|
||||
import { useUser } from "@/contexts/UserContext";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { displayUserName, formatDate, formatDateTime } from "@/lib/shared";
|
||||
import { displayUserName, formatDate, formatDateTime, formatMoney } from "@/lib/shared";
|
||||
import { resolveMediaUrl } from "@/lib/url";
|
||||
import wsManager from "@/lib/websocketManager";
|
||||
import type {
|
||||
@ -58,6 +59,7 @@ import type {
|
||||
TaskLinkedDocument,
|
||||
TaskLinkedCalendarEvent,
|
||||
TaskLinkedMessage,
|
||||
TaskLinkedProcurementRequest,
|
||||
TaskLinkedRequest,
|
||||
TaskPriority,
|
||||
User,
|
||||
@ -928,6 +930,124 @@ function LinkedRequestCard({
|
||||
);
|
||||
}
|
||||
|
||||
function LinkedProcurementRequestCard({
|
||||
link,
|
||||
onUnlink,
|
||||
disabled,
|
||||
}: {
|
||||
link: TaskLinkedProcurementRequest;
|
||||
onUnlink: (linkId: number) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const request = link.procurement_request;
|
||||
const canOpen = Boolean(link.can_open && link.object_url);
|
||||
const amountLabel = request?.total_cost ? formatMoney(request.total_cost) : "";
|
||||
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">
|
||||
<ShoppingCart size={12} />
|
||||
Закупка
|
||||
</span>
|
||||
{request?.status_display ? (
|
||||
<span className="app-badge rounded-full px-2 py-1 text-[11px]">
|
||||
{request.status_display}
|
||||
</span>
|
||||
) : null}
|
||||
{request?.urgency_display ? (
|
||||
<span className="app-badge rounded-full px-2 py-1 text-[11px]">
|
||||
{request.urgency_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?.title || "Заявка на закупку не найдена"}
|
||||
</p>
|
||||
{request?.created_at ? (
|
||||
<p className="app-text-muted mt-0.5 text-xs">
|
||||
{formatDate(request.created_at)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{request?.description ? (
|
||||
<p className="app-text-wrap whitespace-pre-wrap text-sm leading-5 text-[var(--foreground)]">
|
||||
{request.description}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
{request?.department_name ? (
|
||||
<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.department_name}</span>
|
||||
</span>
|
||||
) : null}
|
||||
{request?.processing_department_name ? (
|
||||
<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.processing_department_name}</span>
|
||||
</span>
|
||||
) : null}
|
||||
{amountLabel ? (
|
||||
<span className="app-badge inline-flex max-w-full items-center gap-1 rounded-full px-2 py-1 text-[11px]">
|
||||
{amountLabel}
|
||||
</span>
|
||||
) : null}
|
||||
{request?.items_count ? (
|
||||
<span className="app-badge inline-flex max-w-full items-center gap-1 rounded-full px-2 py-1 text-[11px]">
|
||||
{request.items_count} поз.
|
||||
</span>
|
||||
) : null}
|
||||
{request?.requestor ? (
|
||||
<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.requestor)}</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: "описание",
|
||||
@ -1030,6 +1150,7 @@ export default function TasksPage() {
|
||||
const [linkedEvents, setLinkedEvents] = useState<TaskLinkedCalendarEvent[]>([]);
|
||||
const [linkedDocuments, setLinkedDocuments] = useState<TaskLinkedDocument[]>([]);
|
||||
const [linkedRequests, setLinkedRequests] = useState<TaskLinkedRequest[]>([]);
|
||||
const [linkedProcurementRequests, setLinkedProcurementRequests] = useState<TaskLinkedProcurementRequest[]>([]);
|
||||
const [linkedObjectsLoading, setLinkedObjectsLoading] = useState(false);
|
||||
const [commentsOpen, setCommentsOpen] = useState(false);
|
||||
const [taskComments, setTaskComments] = useState<TaskComment[]>([]);
|
||||
@ -1074,16 +1195,18 @@ export default function TasksPage() {
|
||||
const loadTaskLinkedObjects = useCallback(async (taskId: number) => {
|
||||
setLinkedObjectsLoading(true);
|
||||
try {
|
||||
const [messages, events, documents, requests] = await Promise.all([
|
||||
const [messages, events, documents, requests, procurementRequests] = await Promise.all([
|
||||
apiClient.getTaskLinkedMessages(taskId),
|
||||
apiClient.getTaskLinkedEvents(taskId),
|
||||
apiClient.getTaskLinkedDocuments(taskId),
|
||||
apiClient.getTaskLinkedRequests(taskId),
|
||||
apiClient.getTaskLinkedProcurementRequests(taskId),
|
||||
]);
|
||||
setLinkedMessages(messages);
|
||||
setLinkedEvents(events);
|
||||
setLinkedDocuments(documents);
|
||||
setLinkedRequests(requests);
|
||||
setLinkedProcurementRequests(procurementRequests);
|
||||
} catch (linksError) {
|
||||
setError(getTaskError(linksError, "Не удалось загрузить связанные объекты"));
|
||||
} finally {
|
||||
@ -1416,7 +1539,7 @@ export default function TasksPage() {
|
||||
() => activeColumns.find((column) => column.id === viewTask?.column) || null,
|
||||
[activeColumns, viewTask?.column],
|
||||
);
|
||||
const linkedObjectsCount = linkedMessages.length + linkedEvents.length + linkedDocuments.length + linkedRequests.length;
|
||||
const linkedObjectsCount = linkedMessages.length + linkedEvents.length + linkedDocuments.length + linkedRequests.length + linkedProcurementRequests.length;
|
||||
const linkedObjectsBadgeCount = viewTask?.linked_objects_count ?? linkedObjectsCount;
|
||||
const commentsBadgeCount = viewTask?.comments_count ?? taskComments.length;
|
||||
|
||||
@ -1435,6 +1558,7 @@ export default function TasksPage() {
|
||||
setLinkedEvents([]);
|
||||
setLinkedDocuments([]);
|
||||
setLinkedRequests([]);
|
||||
setLinkedProcurementRequests([]);
|
||||
setTaskActivities([]);
|
||||
setTaskComments([]);
|
||||
setCommentDraft("");
|
||||
@ -1481,6 +1605,7 @@ export default function TasksPage() {
|
||||
setLinkedEvents([]);
|
||||
setLinkedDocuments([]);
|
||||
setLinkedRequests([]);
|
||||
setLinkedProcurementRequests([]);
|
||||
setTaskComments([]);
|
||||
setTaskActivities([]);
|
||||
setCommentDraft("");
|
||||
@ -1500,6 +1625,7 @@ export default function TasksPage() {
|
||||
setLinkedEvents([]);
|
||||
setLinkedDocuments([]);
|
||||
setLinkedRequests([]);
|
||||
setLinkedProcurementRequests([]);
|
||||
setTaskComments([]);
|
||||
setTaskActivities([]);
|
||||
setLinkedObjectsOpen(false);
|
||||
@ -1898,6 +2024,23 @@ export default function TasksPage() {
|
||||
}
|
||||
}, [activityOpen, board, loadBoard, loadTaskActivity, loadTaskLinkedObjects, saving, viewTask]);
|
||||
|
||||
const unlinkProcurementRequestFromViewedTask = useCallback(async (linkId: number) => {
|
||||
if (!board || !viewTask || saving) return;
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await apiClient.unlinkTaskProcurementRequest(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);
|
||||
@ -2711,6 +2854,14 @@ export default function TasksPage() {
|
||||
disabled={saving}
|
||||
/>
|
||||
))}
|
||||
{linkedProcurementRequests.map((link) => (
|
||||
<LinkedProcurementRequestCard
|
||||
key={link.id}
|
||||
link={link}
|
||||
onUnlink={unlinkProcurementRequestFromViewedTask}
|
||||
disabled={saving}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div className="app-surface-muted rounded-xl border border-dashed border-[var(--border-subtle)] px-3 py-4 text-center">
|
||||
|
||||
585
frontend/src/components/procurement/ProcurementTaskLinks.tsx
Normal file
585
frontend/src/components/procurement/ProcurementTaskLinks.tsx
Normal file
@ -0,0 +1,585 @@
|
||||
"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, formatMoney } from "@/lib/shared";
|
||||
import type {
|
||||
ProcurementRequest,
|
||||
TaskBoard,
|
||||
TaskCard,
|
||||
TaskPriority,
|
||||
User,
|
||||
} from "@/types/api";
|
||||
|
||||
type ProcurementTaskLinksProps = {
|
||||
request: ProcurementRequest;
|
||||
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 requestTitle(request: ProcurementRequest) {
|
||||
return request.title || `Заявка на закупку #${request.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);
|
||||
}
|
||||
|
||||
export function ProcurementTaskLinks({
|
||||
request,
|
||||
variant = "section",
|
||||
open = false,
|
||||
onClose,
|
||||
onLinked,
|
||||
}: ProcurementTaskLinksProps) {
|
||||
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.getProcurementRequestLinkedTasks(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);
|
||||
const amount = request.total_cost ? `\nСумма: ${formatMoney(request.total_cost)}` : "";
|
||||
setTitle(`Задача по закупке: ${baseTitle}`);
|
||||
setDescription(`${request.description || ""}${amount}`.trim());
|
||||
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.linkTaskProcurementRequest(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>
|
||||
<p className="app-text-muted mt-1 text-xs">
|
||||
{[request.department_name, request.status_display, request.total_cost ? formatMoney(request.total_cost) : ""].filter(Boolean).join(" · ")}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@ -9,6 +9,7 @@ import type {
|
||||
TaskLinkedCalendarEvent,
|
||||
TaskLinkedDocument,
|
||||
TaskLinkedMessage,
|
||||
TaskLinkedProcurementRequest,
|
||||
TaskLinkedRequest,
|
||||
} from "@/types/api";
|
||||
import { buildQuery, type RequestFn } from "./utils";
|
||||
@ -80,6 +81,8 @@ export function createTasksApi(request: RequestFn) {
|
||||
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}`),
|
||||
getProcurementRequestLinkedTasks: (requestId: number): Promise<TaskCard[]> =>
|
||||
request(`/api/v1/tasks/linked-procurement-request-tasks/?procurement_request_id=${requestId}`),
|
||||
getTaskLinkedMessages: (id: number): Promise<TaskLinkedMessage[]> =>
|
||||
request(`/api/v1/tasks/${id}/linked-messages/`),
|
||||
linkTaskMessage: (id: number, messageId: number): Promise<TaskLinkedMessage> =>
|
||||
@ -113,6 +116,17 @@ export function createTasksApi(request: RequestFn) {
|
||||
request(`/api/v1/tasks/${id}/linked-requests/${linkId}/`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
getTaskLinkedProcurementRequests: (id: number): Promise<TaskLinkedProcurementRequest[]> =>
|
||||
request(`/api/v1/tasks/${id}/linked-procurement-requests/`),
|
||||
linkTaskProcurementRequest: (id: number, requestId: number): Promise<TaskLinkedProcurementRequest> =>
|
||||
request(`/api/v1/tasks/${id}/linked-procurement-requests/`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ procurement_request_id: requestId }),
|
||||
}),
|
||||
unlinkTaskProcurementRequest: (id: number, linkId: number): Promise<void> =>
|
||||
request(`/api/v1/tasks/${id}/linked-procurement-requests/${linkId}/`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
getTaskLinkedEvents: (id: number): Promise<TaskLinkedCalendarEvent[]> =>
|
||||
request(`/api/v1/tasks/${id}/linked-events/`),
|
||||
linkTaskCalendarEvent: (id: number, eventId: number): Promise<TaskLinkedCalendarEvent> =>
|
||||
|
||||
@ -183,6 +183,7 @@ export interface TaskCard {
|
||||
linked_events_count?: number;
|
||||
linked_documents_count?: number;
|
||||
linked_requests_count?: number;
|
||||
linked_procurement_requests_count?: number;
|
||||
linked_objects_count?: number;
|
||||
comments_count?: number;
|
||||
created_at: string;
|
||||
@ -265,12 +266,43 @@ export interface TaskLinkedRequest {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface TaskLinkedProcurementRequest {
|
||||
id: number;
|
||||
kind: "procurement_request";
|
||||
procurement_request_id: number;
|
||||
procurement_request: {
|
||||
id: number;
|
||||
title?: string;
|
||||
description?: string;
|
||||
status?: ProcurementStatus | string;
|
||||
status_display?: string;
|
||||
urgency?: UrgencyLevel | string;
|
||||
urgency_display?: string;
|
||||
fulfillment_status?: ProcurementFulfillmentStatus | string;
|
||||
fulfillment_status_display?: string;
|
||||
department_id?: number | null;
|
||||
department_name?: string | null;
|
||||
processing_department_id?: number | null;
|
||||
processing_department_name?: string | null;
|
||||
requestor?: User | null;
|
||||
executor?: User | null;
|
||||
total_cost?: string | number;
|
||||
items_count?: number;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
} | null;
|
||||
can_open?: boolean;
|
||||
object_url?: string | null;
|
||||
created_by?: User;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface TaskActivity {
|
||||
id: number;
|
||||
action: "created" | "updated" | "moved" | "linked" | "unlinked";
|
||||
action_display?: string;
|
||||
actor?: User | null;
|
||||
object_kind?: "message" | "calendar_event" | "document" | "request" | "";
|
||||
object_kind?: "message" | "calendar_event" | "document" | "request" | "procurement_request" | "";
|
||||
object_id?: number | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
created_at: string;
|
||||
@ -925,6 +957,7 @@ export interface ProcurementRequest {
|
||||
can_current_user_submit_for_approval?: boolean;
|
||||
can_current_user_start_work?: boolean;
|
||||
can_current_user_process_items?: boolean;
|
||||
linked_tasks?: MessageLinkedTask[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
submitted_at?: string | null;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user