feat: add employee task links

This commit is contained in:
lizerk 2026-07-06 21:26:52 +03:00
parent e190affc7c
commit b12f6ed854
16 changed files with 1443 additions and 38 deletions

View File

@ -6,6 +6,7 @@ from typing import Any, Dict
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.utils import timezone
from employees.models import EmployeeDepartment, Position, Skill
from employees.services.personnel_state import resolve_employee_personnel_state
@ -23,6 +24,53 @@ from .shared import (
Employee = get_user_model()
def _linked_task_payloads_for_employee(obj, user) -> list[dict]:
if not user or not getattr(user, "is_authenticated", False):
return []
try:
from tasks.access import task_board_access_q
from tasks.models import (
TaskBoard,
TaskLinkedObject,
TaskLinkedObjectKind,
)
except Exception:
return []
content_type = ContentType.objects.get_for_model(Employee)
accessible_boards = TaskBoard.objects.filter(
is_archived=False,
).filter(task_board_access_q(user))
links = (
TaskLinkedObject.objects.filter(
kind=TaskLinkedObjectKind.EMPLOYEE,
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
]
def _employee_personnel_state_payload(obj):
prefetched = getattr(obj, "_prefetched_objects_cache", {})
actions = prefetched.get("actions") if prefetched else None
@ -96,6 +144,7 @@ class EmployeeSerializer(serializers.ModelSerializer):
departments = serializers.SerializerMethodField()
auth = serializers.SerializerMethodField(read_only=True)
personnel_state = serializers.SerializerMethodField(read_only=True)
linked_tasks = serializers.SerializerMethodField()
class Meta:
model = Employee
@ -130,6 +179,7 @@ class EmployeeSerializer(serializers.ModelSerializer):
"date_joined",
"auth",
"personnel_state",
"linked_tasks",
)
read_only_fields = (
"is_ldap_managed",
@ -142,6 +192,7 @@ class EmployeeSerializer(serializers.ModelSerializer):
"date_joined",
"auth",
"personnel_state",
"linked_tasks",
)
extra_kwargs = {
"password": {"write_only": True, "required": False},
@ -295,6 +346,16 @@ class EmployeeSerializer(serializers.ModelSerializer):
def get_personnel_state(self, obj):
return _employee_personnel_state_payload(obj)
@extend_schema_field(serializers.ListField(child=serializers.DictField()))
def get_linked_tasks(self, obj):
prefetched = getattr(obj, "_linked_task_payloads", None)
if prefetched is not None:
return prefetched
request = self.context.get("request")
user = getattr(request, "user", None)
return _linked_task_payloads_for_employee(obj, user)
class EmployeeBriefSerializer(serializers.ModelSerializer):
display_name = serializers.SerializerMethodField()
@ -354,6 +415,7 @@ class EmployeeListSerializer(serializers.ModelSerializer):
skills = SkillSerializer(many=True, read_only=True)
department_relation = serializers.SerializerMethodField()
personnel_state = serializers.SerializerMethodField(read_only=True)
linked_tasks = serializers.SerializerMethodField()
class Meta:
model = Employee
@ -373,12 +435,23 @@ class EmployeeListSerializer(serializers.ModelSerializer):
"display_name",
"department_relation",
"personnel_state",
"linked_tasks",
)
@extend_schema_field(EmployeePersonnelStateSerializer())
def get_personnel_state(self, obj):
return _employee_personnel_state_payload(obj)
@extend_schema_field(serializers.ListField(child=serializers.DictField()))
def get_linked_tasks(self, obj):
prefetched = getattr(obj, "_linked_task_payloads", None)
if prefetched is not None:
return prefetched
request = self.context.get("request")
user = getattr(request, "user", None)
return _linked_task_payloads_for_employee(obj, user)
@extend_schema_field(EmployeeDepartmentRelationSerializer(allow_null=True))
def get_department_relation(self, obj):
is_member = getattr(obj, "_is_dept_member", None)

View File

@ -8,6 +8,7 @@ from __future__ import annotations
import logging
from common.emails import send_templated_mail
from django.contrib.contenttypes.models import ContentType
from django.db.models import Exists, Max, OuterRef, Prefetch, Q, Subquery
from django.utils.crypto import get_random_string
from employees.constants import (
@ -211,6 +212,72 @@ class EmployeeViewSet(viewsets.ModelViewSet):
return qs
def _attach_linked_task_payloads(self, employees: list[Employee]) -> None:
"""Предзагрузить компактные бейджи задач для списка сотрудников."""
if not employees:
return
user = getattr(self.request, "user", None)
employee_ids = [employee.id for employee in employees]
mapping = {employee_id: [] for employee_id in employee_ids}
if user and user.is_authenticated:
from tasks.access import task_board_access_q
from tasks.models import (
TaskBoard,
TaskLinkedObject,
TaskLinkedObjectKind,
)
employee_ct = ContentType.objects.get_for_model(Employee)
accessible_boards = TaskBoard.objects.filter(
is_archived=False,
).filter(task_board_access_q(user))
links = (
TaskLinkedObject.objects.filter(
kind=TaskLinkedObjectKind.EMPLOYEE,
content_type=employee_ct,
object_id__in=employee_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 employee in employees:
employee._linked_task_payloads = mapping.get(employee.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)
employees = list(queryset)
self._attach_linked_task_payloads(employees)
serializer = self.get_serializer(employees, many=True)
return Response(serializer.data)
def get_serializer_class(self):
if self.action == "list":
return EmployeeListSerializer

View File

@ -1,6 +1,7 @@
from django.db.models import Max
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.utils import timezone
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
@ -12,7 +13,8 @@ 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 employees.models import Department, EmployeeDepartment
from employees.services.personnel_state import resolve_employee_personnel_state
from procurement.models import ProcurementRequest
from requests_app.models import Request as EmployeeRequest
from schedule.models import Event
@ -20,6 +22,7 @@ from api.v1.schedule.serializers import EventSerializer
from tasks.access import (
user_can_access_calendar_event,
user_can_access_document,
user_can_access_employee,
user_can_access_employee_request,
user_can_access_procurement_request,
user_can_access_task_board,
@ -145,6 +148,7 @@ class TaskSerializer(serializers.ModelSerializer):
linked_documents_count = serializers.SerializerMethodField()
linked_requests_count = serializers.SerializerMethodField()
linked_procurement_requests_count = serializers.SerializerMethodField()
linked_employees_count = serializers.SerializerMethodField()
linked_objects_count = serializers.SerializerMethodField()
comments_count = serializers.SerializerMethodField()
@ -174,6 +178,7 @@ class TaskSerializer(serializers.ModelSerializer):
"linked_documents_count",
"linked_requests_count",
"linked_procurement_requests_count",
"linked_employees_count",
"linked_objects_count",
"comments_count",
"created_at",
@ -192,6 +197,7 @@ class TaskSerializer(serializers.ModelSerializer):
"linked_documents_count",
"linked_requests_count",
"linked_procurement_requests_count",
"linked_employees_count",
"linked_objects_count",
"comments_count",
"created_at",
@ -238,6 +244,14 @@ class TaskSerializer(serializers.ModelSerializer):
kind=TaskLinkedObjectKind.PROCUREMENT_REQUEST,
).count()
@extend_schema_field(serializers.IntegerField())
def get_linked_employees_count(self, obj):
if hasattr(obj, "linked_employees_count"):
return obj.linked_employees_count
return obj.linked_objects.filter(
kind=TaskLinkedObjectKind.EMPLOYEE,
).count()
@extend_schema_field(serializers.IntegerField())
def get_linked_objects_count(self, obj):
if hasattr(obj, "linked_objects_count"):
@ -708,3 +722,102 @@ class TaskLinkedProcurementRequestSerializer(serializers.ModelSerializer):
def get_procurement_request_content_type():
return ContentType.objects.get_for_model(ProcurementRequest)
class TaskLinkedEmployeeSerializer(serializers.ModelSerializer):
created_by = EmployeeBriefSerializer(read_only=True)
employee = serializers.SerializerMethodField()
employee_id = serializers.IntegerField(source="object_id", read_only=True)
can_open = serializers.SerializerMethodField()
object_url = serializers.SerializerMethodField()
class Meta:
model = TaskLinkedObject
fields = [
"id",
"kind",
"employee_id",
"employee",
"can_open",
"object_url",
"created_by",
"created_at",
]
read_only_fields = fields
@extend_schema_field(serializers.JSONField(allow_null=True))
def get_employee(self, obj):
if obj.kind != TaskLinkedObjectKind.EMPLOYEE:
return None
employee = getattr(obj, "content_object", None)
if employee is None:
return None
departments = []
links = getattr(employee, "dept_links", None)
if links is None:
links = EmployeeDepartment.objects.select_related(
"department",
"role",
).filter(employee=employee, is_active=True)
departments = [
{
"id": link.department_id,
"name": link.department.name if link.department_id else None,
"role_id": link.role_id,
"role_name": link.role.name if link.role_id else None,
"is_head": (
link.department.head_id == employee.id
if link.department_id
else False
),
}
for link in links
]
personnel_state = resolve_employee_personnel_state(
employee,
timezone.localdate(),
)
return {
**EmployeeBriefSerializer(employee).data,
"position": (
{
"id": employee.position_id,
"name": employee.position.name,
}
if employee.position_id
else None
),
"departments": departments,
"is_active": employee.is_active,
"personnel_state": {
"status": personnel_state.status,
"label": personnel_state.label or "Работает",
"action_id": personnel_state.action_id,
"date_from": personnel_state.date_from,
"date_to": personnel_state.date_to,
"expects_attendance": personnel_state.expects_attendance,
},
}
@extend_schema_field(serializers.BooleanField())
def get_can_open(self, obj):
employee = getattr(obj, "content_object", None)
request = self.context.get("request")
user = getattr(request, "user", None)
if obj.kind != TaskLinkedObjectKind.EMPLOYEE or employee is None or not user:
return False
return user_can_access_employee(user, employee)
@extend_schema_field(serializers.CharField(allow_null=True))
def get_object_url(self, obj):
employee = getattr(obj, "content_object", None)
if employee is None or not self.get_can_open(obj):
return None
return f"/users/{employee.id}"
def get_employee_content_type():
return ContentType.objects.get_for_model(User)

View File

@ -1,4 +1,5 @@
from django.contrib.contenttypes.models import ContentType
from django.contrib.auth import get_user_model
from django.db.models import (
Count,
IntegerField,
@ -25,6 +26,7 @@ from tasks.access import (
task_board_access_q,
user_can_access_calendar_event,
user_can_access_document,
user_can_access_employee,
user_can_access_employee_request,
user_can_access_procurement_request,
)
@ -51,6 +53,7 @@ from .serializers import (
TaskLabelSerializer,
TaskLinkedCalendarEventSerializer,
TaskLinkedDocumentSerializer,
TaskLinkedEmployeeSerializer,
TaskLinkedMessageSerializer,
TaskLinkedProcurementRequestSerializer,
TaskLinkedRequestSerializer,
@ -58,11 +61,14 @@ from .serializers import (
create_default_columns,
get_calendar_event_content_type,
get_document_content_type,
get_employee_content_type,
get_message_content_type,
get_procurement_request_content_type,
get_request_content_type,
)
Employee = get_user_model()
def create_task_activity(
task,
@ -299,6 +305,13 @@ class TaskViewSet(viewsets.ModelViewSet):
),
distinct=True,
),
linked_employees_count=Count(
"linked_objects",
filter=Q(
linked_objects__kind=TaskLinkedObjectKind.EMPLOYEE,
),
distinct=True,
),
linked_objects_count=Count("linked_objects", distinct=True),
comments_count=Coalesce(
Subquery(comments_subquery, output_field=IntegerField()),
@ -715,6 +728,43 @@ class TaskViewSet(viewsets.ModelViewSet):
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
@action(detail=False, methods=["get"], url_path="linked-employee-tasks")
def linked_employee_tasks(self, request):
employee_id = request.query_params.get("employee_id")
try:
employee_id = int(employee_id)
except (TypeError, ValueError):
return Response(
{"employee_id": ["Укажите ID сотрудника."]},
status=status.HTTP_400_BAD_REQUEST,
)
try:
employee = Employee.objects.select_related("position").get(id=employee_id)
except Employee.DoesNotExist:
return Response(
{"employee_id": ["Сотрудник не найден."]},
status=status.HTTP_404_NOT_FOUND,
)
if not user_can_access_employee(request.user, employee):
return Response(
{"detail": "Нет доступа к этому сотруднику."},
status=status.HTTP_403_FORBIDDEN,
)
queryset = (
self.get_queryset()
.filter(
linked_objects__kind=TaskLinkedObjectKind.EMPLOYEE,
linked_objects__content_type=get_employee_content_type(),
linked_objects__object_id=employee.id,
)
.distinct()
)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
def _message_links_queryset(self, task):
return (
TaskLinkedObject.objects.filter(
@ -765,6 +815,16 @@ class TaskViewSet(viewsets.ModelViewSet):
.order_by("-created_at", "-id")
)
def _employee_links_queryset(self, task):
return (
TaskLinkedObject.objects.filter(
task=task,
kind=TaskLinkedObjectKind.EMPLOYEE,
)
.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()
@ -1219,6 +1279,109 @@ class TaskViewSet(viewsets.ModelViewSet):
)
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=True, methods=["get", "post"], url_path="linked-employees")
def linked_employees(self, request, pk=None):
task = self.get_object()
if request.method == "GET":
serializer = TaskLinkedEmployeeSerializer(
self._employee_links_queryset(task),
many=True,
context={"request": request},
)
return Response(serializer.data)
employee_id = request.data.get("employee_id")
try:
employee_id = int(employee_id)
except (TypeError, ValueError):
return Response(
{"employee_id": ["Укажите ID сотрудника."]},
status=status.HTTP_400_BAD_REQUEST,
)
try:
employee = Employee.objects.select_related("position").get(id=employee_id)
except Employee.DoesNotExist:
return Response(
{"employee_id": ["Сотрудник не найден."]},
status=status.HTTP_404_NOT_FOUND,
)
if not user_can_access_employee(request.user, employee):
return Response(
{"detail": "Нет доступа к этому сотруднику."},
status=status.HTTP_403_FORBIDDEN,
)
link, created = TaskLinkedObject.objects.get_or_create(
task=task,
kind=TaskLinkedObjectKind.EMPLOYEE,
content_type=get_employee_content_type(),
object_id=employee.id,
defaults={"created_by": request.user},
)
serializer = TaskLinkedEmployeeSerializer(
link,
context={"request": request},
)
if created:
create_task_activity(
task,
request.user,
TaskActivityAction.LINKED,
object_kind=TaskLinkedObjectKind.EMPLOYEE,
object_id=employee.id,
metadata={
"object_label": employee.get_full_name() or employee.email,
"object_type": "Сотрудник",
},
)
send_task_board_update(
task.board,
"linked",
"employee",
employee.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-employees/(?P<link_id>\d+)",
)
def unlink_employee(self, request, pk=None, link_id=None):
task = self.get_object()
try:
link = self._employee_links_queryset(task).get(id=link_id)
except TaskLinkedObject.DoesNotExist:
return Response(
{"detail": "Связь не найдена."},
status=status.HTTP_404_NOT_FOUND,
)
employee_id = link.object_id
link.delete()
create_task_activity(
task,
request.user,
TaskActivityAction.UNLINKED,
object_kind=TaskLinkedObjectKind.EMPLOYEE,
object_id=employee_id,
metadata={"object_type": "Сотрудник"},
)
send_task_board_update(
task.board,
"unlinked",
"employee",
employee_id,
extra={"task_id": task.id, "link_id": int(link_id)},
)
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=True, methods=["get", "post"], url_path="linked-events")
def linked_events(self, request, pk=None):
task = self.get_object()

View File

@ -144,3 +144,7 @@ def user_can_access_procurement_request(user, procurement_request) -> bool:
procurement_request.status == ProcurementStatus.APPROVED
and procurement_request.processing_department_id is None
)
def user_can_access_employee(user, employee) -> bool:
return bool(user and user.is_authenticated and employee is not None)

View File

@ -0,0 +1,43 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("tasks", "0007_add_procurement_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", "Заявка на закупку"),
("employee", "Сотрудник"),
],
max_length=32,
verbose_name="Тип объекта",
),
),
migrations.AlterField(
model_name="tasklinkedobject",
name="kind",
field=models.CharField(
choices=[
("message", "Сообщение"),
("calendar_event", "Календарное событие"),
("document", "Документ"),
("request", "Заявление"),
("procurement_request", "Заявка на закупку"),
("employee", "Сотрудник"),
],
max_length=32,
verbose_name="Тип связи",
),
),
]

View File

@ -179,6 +179,7 @@ class TaskLinkedObjectKind(models.TextChoices):
DOCUMENT = "document", "Документ"
REQUEST = "request", "Заявление"
PROCUREMENT_REQUEST = "procurement_request", "Заявка на закупку"
EMPLOYEE = "employee", "Сотрудник"
class TaskActivityAction(models.TextChoices):

View File

@ -4,6 +4,7 @@ import datetime as dt
import pytest
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient
@ -17,6 +18,13 @@ from employees.models import (
Skill,
)
from employees.constants import ACTION_DISMISSED # для фильтра actually_active
from tasks.models import (
Task,
TaskBoard,
TaskColumn,
TaskLinkedObject,
TaskLinkedObjectKind,
)
from tests.conftest import _unique_phone
pytestmark = pytest.mark.django_db
@ -85,6 +93,84 @@ def test_list_ok_for_authenticated(api_client: APIClient):
ids = {it["id"] for it in items}
assert e1.id in ids and e2.id in ids
def test_list_includes_visible_linked_tasks_only(api_client: APIClient):
user = make_user("employee-link-user@example.com")
target = make_user("employee-link-target@example.com")
hidden_member = make_user("employee-link-hidden@example.com")
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",
)
employee_ct = ContentType.objects.get_for_model(User)
visible_link = TaskLinkedObject.objects.create(
task=visible_task,
kind=TaskLinkedObjectKind.EMPLOYEE,
content_type=employee_ct,
object_id=target.id,
created_by=user,
)
TaskLinkedObject.objects.create(
task=hidden_task,
kind=TaskLinkedObjectKind.EMPLOYEE,
content_type=employee_ct,
object_id=target.id,
created_by=hidden_member,
)
api_client.force_authenticate(user=user)
response = api_client.get(reverse("api:v1:employees-list"))
assert response.status_code == status.HTTP_200_OK
items = extract_results(response.json())
target_payload = next(item for item in items if item["id"] == target.id)
assert target_payload["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": "#38bdf8",
"priority": "high",
"priority_display": "Высокий",
}
]
def test_retrieve_requires_auth(api_client: APIClient):
e = make_user("x@example.com")
url = reverse("api:v1:employees-detail", args=[e.pk])

View File

@ -681,6 +681,74 @@ def test_task_linked_procurement_request_respects_procurement_access(api_client)
assert reverse_linked_response.data[0]["linked_procurement_requests_count"] == 1
def test_task_linked_employee_respects_task_board_access(api_client):
owner = make_user("task-employee-owner@example.com", "+79994440032")
board_member = make_user("task-employee-member@example.com", "+79994440033")
outsider = make_user("task-employee-outsider@example.com", "+79994440034")
linked_employee = make_user(
"task-employee-linked@example.com",
"+79994440035",
first_name="Гарик",
last_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,
)
api_client.force_authenticate(user=board_member)
link_response = api_client.post(
reverse("api:v1:tasks:task-linked-employees", kwargs={"pk": task.id}),
{"employee_id": linked_employee.id},
format="json",
)
assert link_response.status_code == status.HTTP_201_CREATED
assert link_response.data["employee_id"] == linked_employee.id
assert link_response.data["employee"]["full_name"] == "Мусик Гарик"
assert link_response.data["can_open"] is True
assert link_response.data["object_url"] == f"/users/{linked_employee.id}"
linked_response = api_client.get(
reverse("api:v1:tasks:task-linked-employees", kwargs={"pk": task.id})
)
assert linked_response.status_code == status.HTTP_200_OK
assert linked_response.data[0]["employee"]["email"] == linked_employee.email
reverse_linked_response = api_client.get(
reverse("api:v1:tasks:task-linked-employee-tasks"),
{"employee_id": linked_employee.id},
)
assert reverse_linked_response.status_code == status.HTTP_200_OK
assert reverse_linked_response.data[0]["id"] == task.id
assert reverse_linked_response.data[0]["linked_employees_count"] == 1
api_client.force_authenticate(user=outsider)
task_detail_response = api_client.get(
reverse("api:v1:tasks:task-linked-employees", kwargs={"pk": task.id})
)
assert task_detail_response.status_code == status.HTTP_404_NOT_FOUND
reverse_linked_response = api_client.get(
reverse("api:v1:tasks:task-linked-employee-tasks"),
{"employee_id": linked_employee.id},
)
assert reverse_linked_response.status_code == status.HTTP_200_OK
assert reverse_linked_response.data == []
def test_task_activity_records_core_actions(api_client, user):
api_client.force_authenticate(user=user)
board_response = api_client.get(

View File

@ -246,7 +246,8 @@ export default function EmployeesPage() {
</div>
</Link>
{!isCurrentUser ? (
<div className="flex shrink-0 items-center gap-2">
{!isCurrentUser ? (
<button
type="button"
onClick={(event) => void handleStartChat(employee, event)}
@ -260,7 +261,8 @@ export default function EmployeesPage() {
<MessageCircle size={18} />
)}
</button>
) : null}
) : null}
</div>
</div>
);
})}

View File

@ -57,6 +57,7 @@ import type {
TaskColumn,
TaskComment,
TaskLinkedDocument,
TaskLinkedEmployee,
TaskLinkedCalendarEvent,
TaskLinkedMessage,
TaskLinkedProcurementRequest,
@ -268,9 +269,14 @@ function TaskCardView({
onClick={() => onOpen(task)}
className="min-w-0 flex-1 text-left"
>
<h3 className="app-text-wrap text-sm font-semibold text-[var(--foreground)]">
{task.title}
</h3>
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<span className="app-badge shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-semibold">
#{task.id}
</span>
<h3 className="app-text-wrap min-w-0 text-sm font-semibold text-[var(--foreground)]">
{task.title}
</h3>
</div>
{task.description ? (
<p className="app-text-muted mt-1 line-clamp-2 text-xs">
{task.description}
@ -375,9 +381,14 @@ function TaskDragOverlayCard({ task }: { task: TaskCard }) {
<GripVertical size={14} />
</div>
<div className="min-w-0 flex-1 text-left">
<h3 className="app-text-wrap text-sm font-semibold text-[var(--foreground)]">
{task.title}
</h3>
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<span className="app-badge shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-semibold">
#{task.id}
</span>
<h3 className="app-text-wrap min-w-0 text-sm font-semibold text-[var(--foreground)]">
{task.title}
</h3>
</div>
{task.description ? (
<p className="app-text-muted mt-1 line-clamp-2 text-xs">
{task.description}
@ -1048,6 +1059,111 @@ function LinkedProcurementRequestCard({
);
}
function LinkedEmployeeCard({
link,
onUnlink,
disabled,
}: {
link: TaskLinkedEmployee;
onUnlink: (linkId: number) => void;
disabled?: boolean;
}) {
const employee = link.employee;
const canOpen = Boolean(link.can_open && link.object_url);
const fullName = employee
? (
employee.display_name ||
employee.full_name ||
`${employee.last_name || ""} ${employee.first_name || ""} ${employee.patronymic || ""}`.trim() ||
employee.email ||
`Сотрудник #${employee.id}`
)
: "Сотрудник не найден";
const departmentNames = (employee?.departments || [])
.map((department) => department.name)
.filter(Boolean);
const mainContent = (
<>
<div className="mb-2 min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="app-pill inline-flex items-center gap-1.5 rounded-full px-2 py-1 text-xs font-medium">
<UserRound size={12} />
Сотрудник
</span>
{employee?.personnel_state ? (
<span className="app-badge rounded-full px-2 py-1 text-[11px]">
{employee.personnel_state.label || employee.personnel_state.status}
</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)]">
{fullName}
</p>
{employee?.position?.name ? (
<p className="app-text-muted mt-0.5 text-xs">
{employee.position.name}
</p>
) : null}
</div>
<div className="mt-2 flex flex-wrap items-center gap-2">
{departmentNames.slice(0, 2).map((department) => (
<span
key={department}
className="app-badge inline-flex max-w-full items-center gap-1 rounded-full px-2 py-1 text-[11px]"
>
<span className="truncate">{department}</span>
</span>
))}
{employee?.email ? (
<span className="app-badge inline-flex max-w-full items-center gap-1 rounded-full px-2 py-1 text-[11px]">
<span className="truncate">{employee.email}</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: "описание",
@ -1151,6 +1267,7 @@ export default function TasksPage() {
const [linkedDocuments, setLinkedDocuments] = useState<TaskLinkedDocument[]>([]);
const [linkedRequests, setLinkedRequests] = useState<TaskLinkedRequest[]>([]);
const [linkedProcurementRequests, setLinkedProcurementRequests] = useState<TaskLinkedProcurementRequest[]>([]);
const [linkedEmployees, setLinkedEmployees] = useState<TaskLinkedEmployee[]>([]);
const [linkedObjectsLoading, setLinkedObjectsLoading] = useState(false);
const [commentsOpen, setCommentsOpen] = useState(false);
const [taskComments, setTaskComments] = useState<TaskComment[]>([]);
@ -1195,18 +1312,20 @@ export default function TasksPage() {
const loadTaskLinkedObjects = useCallback(async (taskId: number) => {
setLinkedObjectsLoading(true);
try {
const [messages, events, documents, requests, procurementRequests] = await Promise.all([
const [messages, events, documents, requests, procurementRequests, employees] = await Promise.all([
apiClient.getTaskLinkedMessages(taskId),
apiClient.getTaskLinkedEvents(taskId),
apiClient.getTaskLinkedDocuments(taskId),
apiClient.getTaskLinkedRequests(taskId),
apiClient.getTaskLinkedProcurementRequests(taskId),
apiClient.getTaskLinkedEmployees(taskId),
]);
setLinkedMessages(messages);
setLinkedEvents(events);
setLinkedDocuments(documents);
setLinkedRequests(requests);
setLinkedProcurementRequests(procurementRequests);
setLinkedEmployees(employees);
} catch (linksError) {
setError(getTaskError(linksError, "Не удалось загрузить связанные объекты"));
} finally {
@ -1539,7 +1658,7 @@ export default function TasksPage() {
() => activeColumns.find((column) => column.id === viewTask?.column) || null,
[activeColumns, viewTask?.column],
);
const linkedObjectsCount = linkedMessages.length + linkedEvents.length + linkedDocuments.length + linkedRequests.length + linkedProcurementRequests.length;
const linkedObjectsCount = linkedMessages.length + linkedEvents.length + linkedDocuments.length + linkedRequests.length + linkedProcurementRequests.length + linkedEmployees.length;
const linkedObjectsBadgeCount = viewTask?.linked_objects_count ?? linkedObjectsCount;
const commentsBadgeCount = viewTask?.comments_count ?? taskComments.length;
@ -1559,6 +1678,7 @@ export default function TasksPage() {
setLinkedDocuments([]);
setLinkedRequests([]);
setLinkedProcurementRequests([]);
setLinkedEmployees([]);
setTaskActivities([]);
setTaskComments([]);
setCommentDraft("");
@ -1606,6 +1726,7 @@ export default function TasksPage() {
setLinkedDocuments([]);
setLinkedRequests([]);
setLinkedProcurementRequests([]);
setLinkedEmployees([]);
setTaskComments([]);
setTaskActivities([]);
setCommentDraft("");
@ -1626,6 +1747,7 @@ export default function TasksPage() {
setLinkedDocuments([]);
setLinkedRequests([]);
setLinkedProcurementRequests([]);
setLinkedEmployees([]);
setTaskComments([]);
setTaskActivities([]);
setLinkedObjectsOpen(false);
@ -2041,6 +2163,23 @@ export default function TasksPage() {
}
}, [activityOpen, board, loadBoard, loadTaskActivity, loadTaskLinkedObjects, saving, viewTask]);
const unlinkEmployeeFromViewedTask = useCallback(async (linkId: number) => {
if (!board || !viewTask || saving) return;
setSaving(true);
setError(null);
try {
await apiClient.unlinkTaskEmployee(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);
@ -2566,9 +2705,14 @@ export default function TasksPage() {
<div className="space-y-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<h2 className="app-text-wrap text-lg font-semibold text-[var(--foreground)]">
{viewTask.title}
</h2>
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<span className="app-badge shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-semibold">
#{viewTask.id}
</span>
<h2 className="app-text-wrap min-w-0 text-lg font-semibold text-[var(--foreground)]">
{viewTask.title}
</h2>
</div>
<div className="mt-2 flex flex-wrap gap-1.5">
{viewTaskColumn ? (
<div ref={viewColumnMenuRef} className="relative">
@ -2862,6 +3006,14 @@ export default function TasksPage() {
disabled={saving}
/>
))}
{linkedEmployees.map((link) => (
<LinkedEmployeeCard
key={link.id}
link={link}
onUnlink={unlinkEmployeeFromViewedTask}
disabled={saving}
/>
))}
</>
) : (
<div className="app-surface-muted rounded-xl border border-dashed border-[var(--border-subtle)] px-3 py-4 text-center">

View File

@ -8,6 +8,7 @@ import {
BriefcaseBusiness,
ChevronRight,
History,
Link2,
Mail,
MessageCircle,
Pencil,
@ -16,6 +17,8 @@ import {
import { AppShell } from "../../../components/AppShell";
import EmployeeAttendanceCard from "@/components/attendance/EmployeeAttendanceCard";
import { EmployeeTaskLinks } from "@/components/employees/EmployeeTaskLinks";
import TaskLinkPill from "@/components/tasks/TaskLinkPill";
import ChangeUserPositionModal from "@/components/users/ChangeUserPositionModal";
import EditUserProfileModal from "@/components/users/EditUserProfileModal";
import EmployeeActionModal from "@/components/users/EmployeeActionModal";
@ -91,6 +94,7 @@ export default function UserDetailPage() {
positions,
positionsError,
positionsLoading,
refreshPerson,
setActionField,
setAvatarFailed,
setEditField,
@ -128,6 +132,7 @@ export default function UserDetailPage() {
const [skillsSaving, setSkillsSaving] = useState(false);
const [skillsError, setSkillsError] = useState<string | null>(null);
const [profileMenuOpen, setProfileMenuOpen] = useState(false);
const [taskLinksOpen, setTaskLinksOpen] = useState(false);
const profileMenuRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
@ -362,7 +367,7 @@ export default function UserDetailPage() {
<ProfileHeroCard
caption="Профиль сотрудника"
statusBadge={
(person.personnel_state || latestAction || canEdit || canManageActions) ? (
(person.personnel_state || latestAction || currentUser) ? (
<div
ref={profileMenuOpen ? profileMenuRef : null}
className="relative flex items-center gap-2"
@ -374,7 +379,7 @@ export default function UserDetailPage() {
{person.personnel_state.label || person.personnel_state.status}
</span>
) : null}
{(canEdit || canManageActions) ? (
{currentUser ? (
<>
<button
type="button"
@ -392,6 +397,17 @@ export default function UserDetailPage() {
</button>
{profileMenuOpen ? (
<div className="app-menu absolute right-0 top-full z-20 mt-2 w-64 rounded-xl py-1.5">
<button
type="button"
onClick={() => {
setProfileMenuOpen(false);
setTaskLinksOpen(true);
}}
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>
{canEdit ? (
<button
type="button"
@ -469,28 +485,24 @@ export default function UserDetailPage() {
) : undefined
}
actionRow={
currentUser && (currentUser.id !== person.id || canEdit) ? (
currentUser && currentUser.id !== person.id ? (
<>
{currentUser.id !== person.id ? (
<>
<button
onClick={handleStartChat}
disabled={creatingChat}
className="app-action-primary inline-flex items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium disabled:opacity-50"
>
<MessageCircle size={16} />
{creatingChat ? "Загрузка..." : "Написать"}
</button>
{person.phone_number ? (
<a
href={`tel:${formatPhoneForLink(person.phone_number)}`}
className="app-action-secondary inline-flex items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium"
>
<Phone size={16} />
Позвонить
</a>
) : null}
</>
<button
onClick={handleStartChat}
disabled={creatingChat}
className="app-action-primary inline-flex items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium disabled:opacity-50"
>
<MessageCircle size={16} />
{creatingChat ? "Загрузка..." : "Написать"}
</button>
{person.phone_number ? (
<a
href={`tel:${formatPhoneForLink(person.phone_number)}`}
className="app-action-secondary inline-flex items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium"
>
<Phone size={16} />
Позвонить
</a>
) : null}
</>
) : undefined
@ -503,6 +515,40 @@ export default function UserDetailPage() {
}
/>
<section className="app-surface rounded-2xl p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="app-card-caption">Связанные задачи</p>
<p className="app-text-muted mt-1 text-xs">
Задачи, привязанные к этому сотруднику
</p>
</div>
<button
type="button"
onClick={() => setTaskLinksOpen(true)}
className="app-action-primary inline-flex shrink-0 items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium"
>
<Link2 size={14} />
Связать
</button>
</div>
{person.linked_tasks && person.linked_tasks.length > 0 ? (
<div className="flex flex-wrap gap-2">
{person.linked_tasks.map((task) => (
<TaskLinkPill
key={task.link_id || task.id}
task={task}
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>
)}
</section>
<ProfileSkillsCard
inputValue={skillName}
onInputChange={setSkillName}
@ -591,6 +637,16 @@ export default function UserDetailPage() {
onFieldChange={(field, value) => setActionField(field, value)}
onSave={handleSaveAction}
/>
{person && taskLinksOpen ? (
<EmployeeTaskLinks
employee={person}
variant="dialog"
open={taskLinksOpen}
onClose={() => setTaskLinksOpen(false)}
onLinked={() => void refreshPerson()}
/>
) : null}
</AppShell>
);
}

View File

@ -0,0 +1,535 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ChevronRight, Link2, Loader2, Plus } from "lucide-react";
import { toast } from "sonner";
import TaskLinkPill from "@/components/tasks/TaskLinkPill";
import { Modal } from "@/components/ui";
import { apiClient } from "@/lib/api";
import { displayUserName } from "@/lib/shared";
import type { TaskBoard, TaskCard, TaskPriority, User } from "@/types/api";
type EmployeeTaskLinksProps = {
employee: User;
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 EmployeeTaskLinks({
employee,
variant = "section",
open = false,
onClose,
onLinked,
}: EmployeeTaskLinksProps) {
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 [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 employeeName = displayUserName(employee);
const loadLinkedTasks = useCallback(async () => {
setLoading(true);
try {
const tasks = await apiClient.getEmployeeLinkedTasks(employee.id);
setLinkedTasks(tasks);
} catch (loadError) {
console.error("Ошибка загрузки связанных задач:", loadError);
toast.error("Не удалось загрузить связанные задачи");
} finally {
setLoading(false);
}
}, [employee.id]);
useEffect(() => {
void loadLinkedTasks();
}, [loadLinkedTasks]);
const resetForm = () => {
setBoardId("");
setTaskId("");
setTaskSearch("");
setMode("existing");
setTitle("");
setDescription("");
setDetailsOpen(false);
setColumnId("");
setAssigneeId("");
setPriority("medium");
setDueDate("");
setError(null);
};
const prepareLinkModal = useCallback(async () => {
setSaving(true);
setError(null);
setTitle(`Задача по сотруднику: ${employeeName}`);
setDescription(employee.position?.name ? `Должность: ${employee.position.name}` : "");
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(employee.id);
setPriority("medium");
setDueDate("");
} catch (openError) {
setError(openError instanceof Error ? openError.message : "Не удалось загрузить задачи");
} finally {
setSaving(false);
}
}, [employee.id, employee.position?.name, employeeName, 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 || "");
if (!nextTask) {
setMode("create");
}
};
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: [],
});
}
if (!taskToLink) return;
await apiClient.linkTaskEmployee(taskToLink.id, employee.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)]">
{employeeName}
</p>
<p className="app-text-muted mt-1 text-xs">
{[employee.position?.name, employee.email].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((item) => (
<option key={item.id} value={item.id}>
{displayUserName(item)}
</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>
) : null}
</div>
</div>
)}
{error ? (
<div className="app-feedback-danger rounded-xl px-3 py-2 text-sm">
{error}
</div>
) : null}
</div>
</Modal>
);
if (dialogMode) {
return modal;
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<Link2 size={18} className="app-text-muted shrink-0" />
<h3 className="text-sm font-medium text-[var(--foreground)]">
Связанные задачи ({linkedTasks.length})
</h3>
</div>
<button
type="button"
onClick={() => void openLinkModal()}
className="app-action-primary inline-flex shrink-0 items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium"
>
<Plus size={14} />
Связать
</button>
</div>
{loading ? (
<div className="flex items-center justify-center py-6">
<Loader2 size={18} className="animate-spin text-sky-500" />
</div>
) : linkedTasks.length > 0 ? (
<div className="flex flex-wrap gap-2">
{linkedTasks.map((task) => (
<TaskLinkPill
key={task.id}
task={{
id: task.id,
title: task.title,
board_id: task.board,
board_name: task.board_name || "",
column_color: task.column_color,
priority: task.priority,
priority_display: task.priority_display,
}}
maxTitleClassName="max-w-56"
/>
))}
</div>
) : (
<div className="app-surface-muted rounded-xl border border-dashed border-[var(--border-subtle)] px-3 py-4 text-center">
<p className="app-text-muted text-sm">Связанных задач нет</p>
</div>
)}
{modal}
</div>
);
}

View File

@ -398,6 +398,7 @@ export function useUserDetailPage(userId: number, currentUser: User | null) {
positions,
positionsError,
positionsLoading,
refreshPerson,
setActionField,
setAvatarFailed,
setEditField,

View File

@ -8,6 +8,7 @@ import type {
TaskLabel,
TaskLinkedCalendarEvent,
TaskLinkedDocument,
TaskLinkedEmployee,
TaskLinkedMessage,
TaskLinkedProcurementRequest,
TaskLinkedRequest,
@ -83,6 +84,8 @@ export function createTasksApi(request: RequestFn) {
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}`),
getEmployeeLinkedTasks: (employeeId: number): Promise<TaskCard[]> =>
request(`/api/v1/tasks/linked-employee-tasks/?employee_id=${employeeId}`),
getTaskLinkedMessages: (id: number): Promise<TaskLinkedMessage[]> =>
request(`/api/v1/tasks/${id}/linked-messages/`),
linkTaskMessage: (id: number, messageId: number): Promise<TaskLinkedMessage> =>
@ -127,6 +130,17 @@ export function createTasksApi(request: RequestFn) {
request(`/api/v1/tasks/${id}/linked-procurement-requests/${linkId}/`, {
method: "DELETE",
}),
getTaskLinkedEmployees: (id: number): Promise<TaskLinkedEmployee[]> =>
request(`/api/v1/tasks/${id}/linked-employees/`),
linkTaskEmployee: (id: number, employeeId: number): Promise<TaskLinkedEmployee> =>
request(`/api/v1/tasks/${id}/linked-employees/`, {
method: "POST",
body: JSON.stringify({ employee_id: employeeId }),
}),
unlinkTaskEmployee: (id: number, linkId: number): Promise<void> =>
request(`/api/v1/tasks/${id}/linked-employees/${linkId}/`, {
method: "DELETE",
}),
getTaskLinkedEvents: (id: number): Promise<TaskLinkedCalendarEvent[]> =>
request(`/api/v1/tasks/${id}/linked-events/`),
linkTaskCalendarEvent: (id: number, eventId: number): Promise<TaskLinkedCalendarEvent> =>

View File

@ -65,6 +65,7 @@ export interface User {
last_login?: string;
last_activity_at?: string;
date_joined?: string;
linked_tasks?: MessageLinkedTask[];
auth?: {
id?: number;
email?: string;
@ -184,6 +185,7 @@ export interface TaskCard {
linked_documents_count?: number;
linked_requests_count?: number;
linked_procurement_requests_count?: number;
linked_employees_count?: number;
linked_objects_count?: number;
comments_count?: number;
created_at: string;
@ -297,12 +299,37 @@ export interface TaskLinkedProcurementRequest {
created_at: string;
}
export interface TaskLinkedEmployee {
id: number;
kind: "employee";
employee_id: number;
employee: {
id: number;
first_name?: string;
last_name?: string;
patronymic?: string;
email?: string;
phone_number?: string;
display_name?: string;
full_name?: string;
avatar?: string;
position?: Position | null;
departments?: EmployeeDepartment[];
is_active?: boolean;
personnel_state?: EmployeePersonnelState | null;
} | null;
can_open?: boolean;
object_url?: string | null;
created_by?: User;
created_at: string;
}
export interface TaskActivity {
id: number;
action: "created" | "updated" | "moved" | "linked" | "unlinked";
action_display?: string;
actor?: User | null;
object_kind?: "message" | "calendar_event" | "document" | "request" | "procurement_request" | "";
object_kind?: "message" | "calendar_event" | "document" | "request" | "procurement_request" | "employee" | "";
object_id?: number | null;
metadata?: Record<string, unknown>;
created_at: string;