Add task links for feed posts and board defaults

This commit is contained in:
lizerk 2026-07-06 23:17:45 +03:00
parent 0cb40e992f
commit c0cff00e82
17 changed files with 1027 additions and 75 deletions

View File

@ -4,6 +4,7 @@ from __future__ import annotations
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 rest_framework import serializers
from feed.constants import TYPE_DEPARTMENT, TYPE_EMPLOYEE
@ -13,6 +14,53 @@ from ..utils import build_media_url
Employee = get_user_model()
def _linked_task_payloads(post: Post, 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(Post)
accessible_boards = TaskBoard.objects.filter(
is_archived=False,
).filter(task_board_access_q(user))
links = (
TaskLinkedObject.objects.filter(
kind=TaskLinkedObjectKind.POST,
content_type=content_type,
object_id=post.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 AuthorMiniSerializer(serializers.ModelSerializer):
full_name = serializers.SerializerMethodField()
avatar = serializers.SerializerMethodField()
@ -68,6 +116,7 @@ class PostListSerializer(serializers.ModelSerializer):
# Переопределяем поля для полных URL
image = serializers.SerializerMethodField()
attachment = serializers.SerializerMethodField()
linked_tasks = serializers.SerializerMethodField()
class Meta:
model = Post
@ -91,6 +140,7 @@ class PostListSerializer(serializers.ModelSerializer):
"author_id",
"author",
"is_liked",
"linked_tasks",
]
read_only_fields = (
"created_at",
@ -105,6 +155,7 @@ class PostListSerializer(serializers.ModelSerializer):
"department_id",
"department_name",
"is_liked",
"linked_tasks",
)
@extend_schema_field(OpenApiTypes.URI)
@ -126,6 +177,11 @@ class PostListSerializer(serializers.ModelSerializer):
return bool(getattr(obj, "pinned_department", False))
return bool(getattr(obj, "pinned_global", False))
def get_linked_tasks(self, obj):
request = self.context.get("request")
user = getattr(request, "user", None)
return _linked_task_payloads(obj, user)
class PostSerializer(PostListSerializer):
"""Сериализатор для деталей поста"""

View File

@ -16,6 +16,7 @@ from communications.utils import user_can_access_chat
from documents.models import Document
from employees.models import Department, EmployeeDepartment
from employees.services.personnel_state import resolve_employee_personnel_state
from feed.models import Post
from guests.models import Guest, GuestVisit
from procurement.models import ProcurementRequest
from requests_app.models import Request as EmployeeRequest
@ -26,6 +27,7 @@ from tasks.access import (
user_can_access_document,
user_can_access_employee,
user_can_access_employee_request,
user_can_access_feed_post,
user_can_access_guest,
user_can_access_guest_visit,
user_can_access_attendance_record,
@ -148,6 +150,7 @@ class TaskSerializer(serializers.ModelSerializer):
board_name = serializers.CharField(source="board.name", read_only=True)
column_name = serializers.CharField(source="column.name", read_only=True)
column_color = serializers.CharField(source="column.color", read_only=True)
linked_posts_count = serializers.SerializerMethodField()
linked_messages_count = serializers.SerializerMethodField()
linked_events_count = serializers.SerializerMethodField()
linked_documents_count = serializers.SerializerMethodField()
@ -181,6 +184,7 @@ class TaskSerializer(serializers.ModelSerializer):
"due_date",
"position",
"completed_at",
"linked_posts_count",
"linked_messages_count",
"linked_events_count",
"linked_documents_count",
@ -203,6 +207,7 @@ class TaskSerializer(serializers.ModelSerializer):
"column_color",
"priority_display",
"completed_at",
"linked_posts_count",
"linked_messages_count",
"linked_events_count",
"linked_documents_count",
@ -218,6 +223,14 @@ class TaskSerializer(serializers.ModelSerializer):
"updated_at",
]
@extend_schema_field(serializers.IntegerField())
def get_linked_posts_count(self, obj):
if hasattr(obj, "linked_posts_count"):
return obj.linked_posts_count
return obj.linked_objects.filter(
kind=TaskLinkedObjectKind.POST,
).count()
@extend_schema_field(serializers.IntegerField())
def get_linked_messages_count(self, obj):
if hasattr(obj, "linked_messages_count"):
@ -380,6 +393,7 @@ class TaskBoardSerializer(serializers.ModelSerializer):
labels = TaskLabelSerializer(many=True, read_only=True)
tasks = TaskSerializer(many=True, read_only=True)
tasks_count = serializers.IntegerField(read_only=True, default=0)
is_default_for_current_user = serializers.SerializerMethodField()
class Meta:
model = TaskBoard
@ -397,10 +411,94 @@ class TaskBoardSerializer(serializers.ModelSerializer):
"labels",
"tasks",
"tasks_count",
"is_default_for_current_user",
"created_at",
"updated_at",
]
@extend_schema_field(serializers.BooleanField())
def get_is_default_for_current_user(self, obj):
request = self.context.get("request")
user = getattr(request, "user", None)
if not user or not getattr(user, "is_authenticated", False):
return False
settings_obj = getattr(user, "task_settings", None)
return bool(settings_obj and settings_obj.default_board_id == obj.id)
class TaskLinkedPostSerializer(serializers.ModelSerializer):
created_by = EmployeeBriefSerializer(read_only=True)
post = serializers.SerializerMethodField()
post_id = serializers.IntegerField(source="object_id", read_only=True)
can_open = serializers.SerializerMethodField()
object_url = serializers.SerializerMethodField()
class Meta:
model = TaskLinkedObject
fields = [
"id",
"kind",
"post_id",
"post",
"can_open",
"object_url",
"created_by",
"created_at",
]
read_only_fields = fields
@extend_schema_field(serializers.JSONField(allow_null=True))
def get_post(self, obj):
if obj.kind != TaskLinkedObjectKind.POST:
return None
post = getattr(obj, "content_object", None)
if post is None:
return None
request = self.context.get("request")
from api.v1.utils import build_media_url
return {
"id": post.id,
"type": post.type,
"department": post.department_id,
"department_id": post.department_id,
"department_name": getattr(post.department, "name", None),
"title": post.title,
"body": post.body,
"image": build_media_url(post.image, request),
"attachment": build_media_url(post.attachment, request),
"created_at": post.created_at,
"pinned": post.pinned_global or post.pinned_department,
"pinned_global": post.pinned_global,
"pinned_department": post.pinned_department,
"likes_count": post.likes_count,
"comments_count": get_comment_count(post),
"author_id": post.author_id,
"author": EmployeeBriefSerializer(post.author).data,
}
@extend_schema_field(serializers.BooleanField())
def get_can_open(self, obj):
post = getattr(obj, "content_object", None)
request = self.context.get("request")
user = getattr(request, "user", None)
if obj.kind != TaskLinkedObjectKind.POST or post is None or not user:
return False
return user_can_access_feed_post(user, post)
@extend_schema_field(serializers.CharField(allow_null=True))
def get_object_url(self, obj):
post = getattr(obj, "content_object", None)
if post is None or not self.get_can_open(obj):
return None
return f"/?post={post.id}"
def get_post_content_type():
return ContentType.objects.get_for_model(Post)
class TaskLinkedMessageSerializer(serializers.ModelSerializer):
created_by = EmployeeBriefSerializer(read_only=True)

View File

@ -15,6 +15,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 feed.models import Post
from guests.models import Guest, GuestVisit
from procurement.models import ProcurementRequest
from requests_app.models import Request as EmployeeRequest
@ -30,6 +31,7 @@ from tasks.access import (
user_can_access_document,
user_can_access_employee,
user_can_access_employee_request,
user_can_access_feed_post,
user_can_access_guest,
user_can_access_guest_visit,
user_can_access_attendance_record,
@ -44,6 +46,7 @@ from tasks.models import (
TaskLabel,
TaskLinkedObject,
TaskLinkedObjectKind,
TaskUserSettings,
)
from tasks.realtime import (
get_task_board_recipient_ids,
@ -63,6 +66,7 @@ from .serializers import (
TaskLinkedGuestSerializer,
TaskLinkedGuestVisitSerializer,
TaskLinkedMessageSerializer,
TaskLinkedPostSerializer,
TaskLinkedProcurementRequestSerializer,
TaskLinkedRequestSerializer,
TaskSerializer,
@ -74,6 +78,7 @@ from .serializers import (
get_guest_content_type,
get_guest_visit_content_type,
get_message_content_type,
get_post_content_type,
get_procurement_request_content_type,
get_request_content_type,
)
@ -168,7 +173,21 @@ class TaskBoardViewSet(viewsets.ModelViewSet):
@action(detail=False, methods=["get"], url_path="default")
def default(self, request):
board = self.get_queryset().order_by("id").first()
queryset = self.get_queryset()
settings_obj = (
TaskUserSettings.objects.select_related("default_board")
.filter(user=request.user)
.first()
)
board = None
if settings_obj and settings_obj.default_board_id:
board = queryset.filter(id=settings_obj.default_board_id).first()
if board is None:
settings_obj.default_board = None
settings_obj.save(update_fields=["default_board", "updated_at"])
if board is None:
board = queryset.order_by("id").first()
if not board:
board = TaskBoard.objects.create(
name="Рабочая доска",
@ -179,6 +198,16 @@ class TaskBoardViewSet(viewsets.ModelViewSet):
serializer = self.get_serializer(self.get_queryset().get(id=board.id))
return Response(serializer.data)
@action(detail=True, methods=["post"], url_path="set-default")
def set_default(self, request, pk=None):
board = self.get_object()
TaskUserSettings.objects.update_or_create(
user=request.user,
defaults={"default_board": board},
)
serializer = self.get_serializer(self.get_queryset().get(id=board.id))
return Response(serializer.data)
class TaskColumnViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated]
@ -281,6 +310,11 @@ class TaskViewSet(viewsets.ModelViewSet):
.select_related("board", "column", "created_by", "assignee")
.prefetch_related("labels")
.annotate(
linked_posts_count=Count(
"linked_objects",
filter=Q(linked_objects__kind=TaskLinkedObjectKind.POST),
distinct=True,
),
linked_messages_count=Count(
"linked_objects",
filter=Q(linked_objects__kind=TaskLinkedObjectKind.MESSAGE),
@ -799,6 +833,45 @@ class TaskViewSet(viewsets.ModelViewSet):
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
@action(detail=False, methods=["get"], url_path="linked-post-tasks")
def linked_post_tasks(self, request):
post_id = request.query_params.get("post_id")
try:
post_id = int(post_id)
except (TypeError, ValueError):
return Response(
{"post_id": ["Укажите ID новости."]},
status=status.HTTP_400_BAD_REQUEST,
)
try:
post = Post.objects.select_related("author", "department").get(
id=post_id,
)
except Post.DoesNotExist:
return Response(
{"post_id": ["Новость не найдена."]},
status=status.HTTP_404_NOT_FOUND,
)
if not user_can_access_feed_post(request.user, post):
return Response(
{"detail": "Нет доступа к этой новости."},
status=status.HTTP_403_FORBIDDEN,
)
queryset = (
self.get_queryset()
.filter(
linked_objects__kind=TaskLinkedObjectKind.POST,
linked_objects__content_type=get_post_content_type(),
linked_objects__object_id=post.id,
)
.distinct()
)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
@action(detail=False, methods=["get"], url_path="linked-guest-tasks")
def linked_guest_tasks(self, request):
guest_id = request.query_params.get("guest_id")
@ -1004,6 +1077,121 @@ class TaskViewSet(viewsets.ModelViewSet):
.order_by("-created_at", "-id")
)
def _post_links_queryset(self, task):
return (
TaskLinkedObject.objects.filter(
task=task,
kind=TaskLinkedObjectKind.POST,
)
.select_related("created_by", "content_type")
.order_by("-created_at", "-id")
)
@action(detail=True, methods=["get", "post"], url_path="linked-posts")
def linked_posts(self, request, pk=None):
task = self.get_object()
if request.method == "GET":
serializer = TaskLinkedPostSerializer(
self._post_links_queryset(task),
many=True,
context={"request": request},
)
return Response(serializer.data)
post_id = request.data.get("post_id")
try:
post_id = int(post_id)
except (TypeError, ValueError):
return Response(
{"post_id": ["Укажите ID новости."]},
status=status.HTTP_400_BAD_REQUEST,
)
try:
post = Post.objects.select_related("author", "department").get(
id=post_id,
)
except Post.DoesNotExist:
return Response(
{"post_id": ["Новость не найдена."]},
status=status.HTTP_404_NOT_FOUND,
)
if not user_can_access_feed_post(request.user, post):
return Response(
{"detail": "Нет доступа к этой новости."},
status=status.HTTP_403_FORBIDDEN,
)
link, created = TaskLinkedObject.objects.get_or_create(
task=task,
kind=TaskLinkedObjectKind.POST,
content_type=get_post_content_type(),
object_id=post.id,
defaults={"created_by": request.user},
)
serializer = TaskLinkedPostSerializer(
link,
context={"request": request},
)
if created:
create_task_activity(
task,
request.user,
TaskActivityAction.LINKED,
object_kind=TaskLinkedObjectKind.POST,
object_id=post.id,
metadata={
"object_label": post.title,
"object_type": "Новость",
},
)
send_task_board_update(
task.board,
"linked",
"post",
post.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-posts/(?P<link_id>\d+)",
)
def unlink_post(self, request, pk=None, link_id=None):
task = self.get_object()
try:
link = self._post_links_queryset(task).get(id=link_id)
except TaskLinkedObject.DoesNotExist:
return Response(
{"detail": "Связь не найдена."},
status=status.HTTP_404_NOT_FOUND,
)
post_id = link.object_id
link.delete()
create_task_activity(
task,
request.user,
TaskActivityAction.UNLINKED,
object_kind=TaskLinkedObjectKind.POST,
object_id=post_id,
metadata={"object_type": "Новость"},
)
send_task_board_update(
task.board,
"unlinked",
"post",
post_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-messages")
def linked_messages(self, request, pk=None):
task = self.get_object()

View File

@ -2,6 +2,7 @@ from django.db.models import Q
from attendance.models import AttendanceRecord
from documents.models import Document
from employees.models import Department, EmployeeDepartment, RoleAssignment
from feed.models import Post
from requests_app.enums import RequestStatus
from requests_app.models import Request as EmployeeRequest
from schedule.models import Event
@ -175,3 +176,7 @@ def user_can_access_attendance_record(
or user.is_superuser
or record.employee_id == user.id
)
def user_can_access_feed_post(user, post: Post | None) -> bool:
return bool(user and user.is_authenticated and post is not None)

View File

@ -7,6 +7,7 @@ from .models import (
TaskColumn,
TaskLabel,
TaskLinkedObject,
TaskUserSettings,
)
@ -73,3 +74,10 @@ class TaskActivityAdmin(admin.ModelAdmin):
list_display = ["task", "action", "actor", "object_kind", "object_id", "created_at"]
list_filter = ["action", "object_kind", "created_at"]
search_fields = ["task__title", "actor__email", "object_id"]
@admin.register(TaskUserSettings)
class TaskUserSettingsAdmin(admin.ModelAdmin):
list_display = ["user", "default_board", "updated_at"]
search_fields = ["user__email", "user__first_name", "user__last_name", "default_board__name"]
autocomplete_fields = ["user", "default_board"]

View File

@ -0,0 +1,23 @@
# Generated by Django 5.2.4 on 2026-07-06 19:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tasks', '0010_alter_taskactivity_object_kind_and_more'),
]
operations = [
migrations.AlterField(
model_name='taskactivity',
name='object_kind',
field=models.CharField(blank=True, choices=[('post', 'Новость'), ('message', 'Сообщение'), ('calendar_event', 'Календарное событие'), ('document', 'Документ'), ('request', 'Заявление'), ('procurement_request', 'Заявка на закупку'), ('employee', 'Сотрудник'), ('guest', 'Гость'), ('guest_visit', 'Заявка на гостевой визит'), ('attendance_record', 'Запись посещаемости')], max_length=32, verbose_name='Тип объекта'),
),
migrations.AlterField(
model_name='tasklinkedobject',
name='kind',
field=models.CharField(choices=[('post', 'Новость'), ('message', 'Сообщение'), ('calendar_event', 'Календарное событие'), ('document', 'Документ'), ('request', 'Заявление'), ('procurement_request', 'Заявка на закупку'), ('employee', 'Сотрудник'), ('guest', 'Гость'), ('guest_visit', 'Заявка на гостевой визит'), ('attendance_record', 'Запись посещаемости')], max_length=32, verbose_name='Тип связи'),
),
]

View File

@ -0,0 +1,30 @@
# Generated by Django 5.2.4 on 2026-07-06 20:13
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tasks', '0011_alter_taskactivity_object_kind_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='TaskUserSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')),
('default_board', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='default_for_user_settings', to='tasks.taskboard', verbose_name='Доска по умолчанию')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='task_settings', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')),
],
options={
'verbose_name': 'Настройки задач пользователя',
'verbose_name_plural': 'Настройки задач пользователей',
},
),
]

View File

@ -47,6 +47,32 @@ class TaskBoard(models.Model):
return self.name
class TaskUserSettings(models.Model):
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="task_settings",
verbose_name="Пользователь",
)
default_board = models.ForeignKey(
TaskBoard,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="default_for_user_settings",
verbose_name="Доска по умолчанию",
)
created_at = models.DateTimeField("Создано", auto_now_add=True)
updated_at = models.DateTimeField("Обновлено", auto_now=True)
class Meta:
verbose_name = "Настройки задач пользователя"
verbose_name_plural = "Настройки задач пользователей"
def __str__(self):
return f"{self.user} - задачи"
class TaskColumn(models.Model):
board = models.ForeignKey(
TaskBoard,
@ -174,6 +200,7 @@ class Task(models.Model):
class TaskLinkedObjectKind(models.TextChoices):
POST = "post", "Новость"
MESSAGE = "message", "Сообщение"
CALENDAR_EVENT = "calendar_event", "Календарное событие"
DOCUMENT = "document", "Документ"

View File

@ -1,6 +1,7 @@
from __future__ import annotations
import pytest
from django.contrib.contenttypes.models import ContentType
from django.core.files.uploadedfile import SimpleUploadedFile
from employees.models import DepartmentPermission
from employees.models import (
@ -10,6 +11,13 @@ from employees.models import (
)
from feed.models import Post
from rest_framework import status
from tasks.models import (
Task,
TaskBoard,
TaskColumn,
TaskLinkedObject,
TaskLinkedObjectKind,
)
from tests.test_config import API_POSTS_URL
@ -336,6 +344,91 @@ def test_list_department_posts_filtered_by_department(
assert payload[0]["department_id"] == department_a.id
def test_post_list_includes_visible_linked_tasks_only(
auth_client_factory,
make_user,
):
viewer = make_user("post-linked-task-viewer@example.com")
hidden_member = make_user("post-linked-task-hidden@example.com")
post = Post.objects.create(
author=viewer,
type="company",
title="Новость со связью",
body="body",
)
visible_board = TaskBoard.objects.create(
name="Доступная доска новости",
created_by=viewer,
)
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=viewer,
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",
)
content_type = ContentType.objects.get_for_model(Post)
visible_link = TaskLinkedObject.objects.create(
task=visible_task,
kind=TaskLinkedObjectKind.POST,
content_type=content_type,
object_id=post.id,
created_by=viewer,
)
TaskLinkedObject.objects.create(
task=hidden_task,
kind=TaskLinkedObjectKind.POST,
content_type=content_type,
object_id=post.id,
created_by=hidden_member,
)
client = auth_client_factory(viewer)
response = client.get(API_POSTS_URL)
assert response.status_code == status.HTTP_200_OK
payload = response.data["results"] if isinstance(response.data, dict) else response.data
post_payload = next(item for item in payload if item["id"] == post.id)
assert post_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_department_pin_scope_is_separate_from_global_feed(
auth_client_factory,
make_user,

View File

@ -13,12 +13,13 @@ from attendance.models import AttendanceAnalysisRun, AttendanceRecord
from communications.models import Chat, ChatMembership, Message
from documents.models import Document
from employees.models import Department, EmployeeDepartment, RoleAssignment
from feed.models import Post
from guests.models import Guest, GuestVisit
from procurement.constants import ProcurementStatus, UrgencyLevel
from procurement.models import ProcurementRequest
from requests_app.models import Request as EmployeeRequest
from schedule.models import Calendar, CalendarRelation, Event
from tasks.models import Task, TaskBoard, TaskColumn
from tasks.models import Task, TaskBoard, TaskColumn, TaskUserSettings
from tasks.realtime import get_task_board_recipient_ids
pytestmark = pytest.mark.django_db
@ -77,6 +78,61 @@ def test_default_board_creates_work_columns(api_client, user):
assert TaskColumn.objects.count() == 4
def test_user_can_set_personal_default_board(api_client, user):
api_client.force_authenticate(user=user)
first_board = api_client.get(
reverse("api:v1:tasks:task-board-default")
).data
second_board = TaskBoard.objects.create(
name="Персональная доска по умолчанию",
created_by=user,
)
TaskColumn.objects.create(
board=second_board,
name="Новые",
position=1000,
)
set_response = api_client.post(
reverse("api:v1:tasks:task-board-set-default", kwargs={"pk": second_board.id})
)
assert set_response.status_code == status.HTTP_200_OK
assert set_response.data["id"] == second_board.id
assert set_response.data["is_default_for_current_user"] is True
assert TaskUserSettings.objects.get(user=user).default_board_id == second_board.id
default_response = api_client.get(reverse("api:v1:tasks:task-board-default"))
assert default_response.status_code == status.HTTP_200_OK
assert default_response.data["id"] == second_board.id
assert default_response.data["is_default_for_current_user"] is True
other_user = make_user("task-default-other@example.com", "+79994440090")
api_client.force_authenticate(user=other_user)
other_default_response = api_client.get(reverse("api:v1:tasks:task-board-default"))
assert other_default_response.status_code == status.HTTP_200_OK
assert other_default_response.data["id"] == first_board["id"]
assert other_default_response.data["is_default_for_current_user"] is False
def test_user_cannot_set_inaccessible_board_as_default(api_client):
owner = make_user("task-default-owner@example.com", "+79994440091")
outsider = make_user("task-default-outsider@example.com", "+79994440092")
board = TaskBoard.objects.create(
name="Закрытая доска",
created_by=owner,
)
board.members.add(owner)
api_client.force_authenticate(user=outsider)
response = api_client.post(
reverse("api:v1:tasks:task-board-set-default", kwargs={"pk": board.id})
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert not TaskUserSettings.objects.filter(user=outsider).exists()
def test_create_task_and_move_to_done_column(api_client, user):
api_client.force_authenticate(user=user)
board_response = api_client.get(
@ -820,6 +876,75 @@ def test_task_linked_guest_respects_task_board_access(api_client):
assert reverse_linked_response.data == []
def test_task_linked_post_respects_task_board_access(api_client):
owner = make_user("task-post-owner@example.com", "+79994440045")
board_member = make_user("task-post-member@example.com", "+79994440046")
outsider = make_user("task-post-outsider@example.com", "+79994440047")
post = Post.objects.create(
author=owner,
type="company",
title="Новости задач",
body="Связать публикацию с задачей",
)
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-posts", kwargs={"pk": task.id}),
{"post_id": post.id},
format="json",
)
assert link_response.status_code == status.HTTP_201_CREATED
assert link_response.data["post_id"] == post.id
assert link_response.data["post"]["title"] == "Новости задач"
assert link_response.data["post"]["body"] == "Связать публикацию с задачей"
assert link_response.data["can_open"] is True
assert link_response.data["object_url"] == f"/?post={post.id}"
linked_response = api_client.get(
reverse("api:v1:tasks:task-linked-posts", kwargs={"pk": task.id})
)
assert linked_response.status_code == status.HTTP_200_OK
assert linked_response.data[0]["post"]["author"]["id"] == owner.id
reverse_linked_response = api_client.get(
reverse("api:v1:tasks:task-linked-post-tasks"),
{"post_id": post.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_posts_count"] == 1
api_client.force_authenticate(user=outsider)
task_detail_response = api_client.get(
reverse("api:v1:tasks:task-linked-posts", 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-post-tasks"),
{"post_id": post.id},
)
assert reverse_linked_response.status_code == status.HTTP_200_OK
assert reverse_linked_response.data == []
def test_task_linked_guest_visit_respects_visit_access(api_client):
owner = make_user("task-guest-visit-owner@example.com", "+79994440039")
inviter = make_user("task-guest-visit-inviter@example.com", "+79994440040")

View File

@ -492,6 +492,16 @@ html[data-bs-theme="dark"] .app-input::-webkit-calendar-picker-indicator {
background: var(--surface-secondary);
}
.app-menu-danger-item {
border: 1px solid transparent;
background: transparent;
color: var(--danger-foreground);
}
.app-menu-danger-item:hover {
background: var(--danger-soft);
}
.app-action-danger {
border: 1px solid color-mix(in srgb, var(--danger-foreground) 18%, var(--border-strong));
background: var(--surface-elevated);

View File

@ -1,12 +1,14 @@
"use client";
import Link from "next/link";
import { ChevronRight, Pencil, Pin, Plus, Trash2 } from "lucide-react";
import { ChevronRight, Link2, Pencil, Pin, Plus, Trash2 } from "lucide-react";
import { AppShell } from "../components/AppShell";
import { FeedPostCard } from "@/components/feed/FeedPostCard";
import { Modal } from "@/components/ui";
import { PostCommentsModal } from "@/components/feed/PostCommentsModal";
import { RequestAvatar } from "@/components/requests/RequestAvatar";
import { RelatedTaskLinks } from "@/components/tasks/RelatedTaskLinks";
import TaskLinkPill from "@/components/tasks/TaskLinkPill";
import { useNotifications } from "@/contexts/NotificationsContext";
import { apiClient } from "@/lib/api";
import { userProfileLink } from "@/lib/shared";
@ -76,6 +78,7 @@ function HomePageContent() {
const [likesUsersEndpointUnavailable, setLikesUsersEndpointUnavailable] = useState(false);
const [imageCacheBuster, setImageCacheBuster] = useState<Record<number, number>>({});
const [postMenuOpenId, setPostMenuOpenId] = useState<number | null>(null);
const [taskLinkPost, setTaskLinkPost] = useState<Post | null>(null);
const postMenuRef = useRef<HTMLDivElement | null>(null);
const formatUserName = (u: LikeUser) => {
@ -208,6 +211,18 @@ function HomePageContent() {
);
}, []);
const getPostTaskSubtitle = (post: Post) => {
if (post.type === "department") {
return post.department_name ? `Отдел: ${post.department_name}` : "Отдел";
}
return "Компания";
};
const getPostDefaultTaskTitle = (post: Post) => {
const title = (post.title || "").trim();
return title ? `Задача по новости: ${title}` : `Задача по новости #${post.id}`;
};
const refreshPosts = useCallback(async () => {
const response = await apiClient.getPosts();
setPosts(sortPostsPinnedFirst(response.results));
@ -476,6 +491,11 @@ function HomePageContent() {
setCommentsOpen(true);
};
const openTaskLinkModal = (post: Post) => {
setPostMenuOpenId(null);
setTaskLinkPost(post);
};
const applyCommentCountDelta = (postId: number, delta: number) => {
setPosts((prev) =>
prev.map((item) =>
@ -686,6 +706,7 @@ function HomePageContent() {
: diffHours < 24
? `${diffHours} ч. назад`
: `${Math.floor(diffHours / 24)} дн. назад`;
const linkedTasks = post.linked_tasks || [];
return (
<FeedPostCard
@ -699,71 +720,93 @@ function HomePageContent() {
</>
}
headerActions={(
(canEditPost(post) || canDeletePost(post)) ? (
<div
ref={postMenuOpenId === post.id ? postMenuRef : null}
className="relative shrink-0"
<div
ref={postMenuOpenId === post.id ? postMenuRef : null}
className="relative shrink-0"
>
<button
type="button"
onClick={() => setPostMenuOpenId((prev) => (prev === post.id ? null : post.id))}
className="app-action-ghost flex h-8 w-8 items-center justify-center rounded-md"
title="Действия с публикацией"
aria-label="Действия с публикацией"
aria-expanded={postMenuOpenId === post.id}
aria-haspopup="menu"
>
<button
type="button"
onClick={() => setPostMenuOpenId((prev) => (prev === post.id ? null : post.id))}
className="app-action-ghost flex h-8 w-8 items-center justify-center rounded-md"
title="Действия с публикацией"
aria-label="Действия с публикацией"
aria-expanded={postMenuOpenId === post.id}
aria-haspopup="menu"
>
<ChevronRight
size={15}
className={`transition-transform duration-200 ${postMenuOpenId === post.id ? "rotate-90" : ""}`}
/>
</button>
<ChevronRight
size={15}
className={`transition-transform duration-200 ${postMenuOpenId === post.id ? "rotate-90" : ""}`}
/>
</button>
{postMenuOpenId === post.id ? (
<div className="app-menu absolute right-0 top-full z-20 mt-2 w-48 rounded-xl py-1.5">
{canPinPost ? (
<button
type="button"
disabled={postActionId === post.id}
onClick={() => void handlePinToggle(post)}
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)] disabled:opacity-50"
>
<Pin size={14} className={post.pinned ? "fill-current app-accent-text" : ""} />
{post.pinned ? "Открепить в общей ленте" : "Закрепить в общей ленте"}
</button>
) : null}
{canEditPost(post) ? (
<button
type="button"
onClick={() => {
setPostMenuOpenId(null);
openEditPostModal(post);
}}
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)]"
>
<Pencil size={14} />
Редактировать
</button>
) : null}
{canDeletePost(post) ? (
<button
type="button"
disabled={postActionId === post.id}
onClick={() => {
setPostMenuOpenId(null);
void removePost(post);
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--danger-foreground)] transition hover:bg-[var(--danger-soft)] disabled:opacity-50"
>
<Trash2 size={14} />
Удалить
</button>
) : null}
</div>
) : null}
</div>
) : null
{postMenuOpenId === post.id ? (
<div className="app-menu absolute right-0 top-full z-20 mt-2 w-56 rounded-xl py-1.5">
<button
type="button"
onClick={() => openTaskLinkModal(post)}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<Link2 size={14} />
Связать с задачей
</button>
{canPinPost ? (
<button
type="button"
disabled={postActionId === post.id}
onClick={() => void handlePinToggle(post)}
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)] disabled:opacity-50"
>
<Pin size={14} className={post.pinned ? "fill-current app-accent-text" : ""} />
{post.pinned ? "Открепить в общей ленте" : "Закрепить в общей ленте"}
</button>
) : null}
{canEditPost(post) ? (
<button
type="button"
onClick={() => {
setPostMenuOpenId(null);
openEditPostModal(post);
}}
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)]"
>
<Pencil size={14} />
Редактировать
</button>
) : null}
{canDeletePost(post) ? (
<button
type="button"
disabled={postActionId === post.id}
onClick={() => {
setPostMenuOpenId(null);
void removePost(post);
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--danger-foreground)] transition hover:bg-[var(--danger-soft)] disabled:opacity-50"
>
<Trash2 size={14} />
Удалить
</button>
) : null}
</div>
) : null}
</div>
)}
footerAction={linkedTasks.length > 0 ? (
<div className="flex max-w-full flex-wrap justify-end 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 rounded-full px-2 py-0.5 text-[11px] font-medium">
+{linkedTasks.length - 3}
</span>
) : null}
</div>
) : null}
imageSrc={withImageCacheBuster(resolveMediaUrl(post.image), post.id)}
isLikeActive={Boolean(post.is_liked)}
likeDisabled={likeBusyId === post.id}
@ -935,6 +978,24 @@ function HomePageContent() {
onCommentCountChange={applyCommentCountDelta}
post={activePost}
/>
{taskLinkPost ? (
<RelatedTaskLinks
key={`post-task-link-${taskLinkPost.id}`}
entityLabel="Новость"
entityTitle={taskLinkPost.title || `Новость #${taskLinkPost.id}`}
entitySubtitle={getPostTaskSubtitle(taskLinkPost)}
defaultTaskTitle={getPostDefaultTaskTitle(taskLinkPost)}
defaultTaskDescription={(taskLinkPost.body || taskLinkPost.content || "").trim()}
successMessage="Новость связана с задачей"
variant="dialog"
open
loadLinkedTasks={() => apiClient.getPostLinkedTasks(taskLinkPost.id)}
linkTask={(taskId) => apiClient.linkTaskPost(taskId, taskLinkPost.id)}
onClose={() => setTaskLinkPost(null)}
onLinked={() => void refreshPosts()}
/>
) : null}
</AppShell>
);
}

View File

@ -27,12 +27,14 @@ import {
Link2,
Loader2,
MessageSquare,
Newspaper,
Paperclip,
Pencil,
Plus,
RefreshCw,
Search,
ShoppingCart,
Star,
Tag,
Trash2,
UserRound,
@ -63,6 +65,7 @@ import type {
TaskLinkedGuest,
TaskLinkedGuestVisit,
TaskLinkedMessage,
TaskLinkedPost,
TaskLinkedProcurementRequest,
TaskLinkedRequest,
TaskPriority,
@ -272,11 +275,11 @@ function TaskCardView({
onClick={() => onOpen(task)}
className="min-w-0 flex-1 text-left"
>
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<div className="flex min-w-0 items-center gap-2">
<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)]">
<h3 className="min-w-0 max-w-[9rem] truncate text-sm font-semibold text-[var(--foreground)]">
{task.title}
</h3>
</div>
@ -384,11 +387,11 @@ function TaskDragOverlayCard({ task }: { task: TaskCard }) {
<GripVertical size={14} />
</div>
<div className="min-w-0 flex-1 text-left">
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<div className="flex min-w-0 items-center gap-2">
<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)]">
<h3 className="min-w-0 max-w-[9rem] truncate text-sm font-semibold text-[var(--foreground)]">
{task.title}
</h3>
</div>
@ -487,13 +490,13 @@ function BoardColumn({
}`}
>
<div className="flex items-center justify-between gap-3 border-b border-[var(--border-subtle)] px-3 py-3">
<div className="min-w-0">
<div className="min-w-0 flex-1 overflow-hidden">
<div className="flex items-center gap-2">
<span
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: column.color || "#38bdf8" }}
/>
<h2 className="truncate text-sm font-semibold text-[var(--foreground)]">
<h2 className="min-w-0 truncate text-sm font-semibold text-[var(--foreground)]">
{column.name}
</h2>
</div>
@ -550,6 +553,117 @@ function AddColumnCard({ onClick }: { onClick: () => void }) {
);
}
function getLinkedPostOriginLabel(post: TaskLinkedPost["post"]) {
if (!post) return "";
if (post.type === "department") {
return post.department_name ? `Отдел: ${post.department_name}` : "Отдел";
}
return "Компания";
}
function LinkedPostCard({
link,
onUnlink,
disabled,
}: {
link: TaskLinkedPost;
onUnlink: (linkId: number) => void;
disabled?: boolean;
}) {
const post = link.post;
const canOpen = Boolean(link.can_open && link.object_url);
const postText = (post?.body || post?.content || "").trim();
const originLabel = getLinkedPostOriginLabel(post);
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">
<Newspaper size={12} />
Новость
</span>
{originLabel ? (
<span className="app-badge rounded-full px-2 py-1 text-[11px]">
{originLabel}
</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)]">
{post?.title || "Новость не найдена"}
</p>
{post?.created_at ? (
<p className="app-text-muted mt-0.5 text-xs">
{formatDateTime(post.created_at)}
</p>
) : null}
</div>
{postText ? (
<p className="app-text-wrap line-clamp-4 whitespace-pre-wrap text-sm leading-5 text-[var(--foreground)]">
{postText}
</p>
) : null}
<div className="mt-2 flex flex-wrap items-center gap-2">
{post?.author ? (
<span className="app-badge inline-flex max-w-full items-center gap-1 rounded-full px-2 py-1 text-[11px]">
<span className="truncate">{displayUserName(post.author)}</span>
</span>
) : null}
{typeof post?.likes_count === "number" ? (
<span className="app-badge rounded-full px-2 py-1 text-[11px]">
{post.likes_count} лайк.
</span>
) : null}
{typeof post?.comments_count === "number" ? (
<span className="app-badge rounded-full px-2 py-1 text-[11px]">
{post.comments_count} комм.
</span>
) : null}
<span className="app-text-muted text-[11px]">
Связал: {link.created_by ? displayUserName(link.created_by) : "не указано"}
</span>
</div>
</>
);
return (
<article className="app-surface-muted rounded-xl border border-[var(--border-subtle)] p-3">
<div className="relative">
{canOpen && link.object_url ? (
<Link
href={link.object_url}
className="block rounded-lg pr-10 transition hover:bg-[var(--surface-elevated)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-primary)]"
title="Открыть связанную новость"
>
{mainContent}
</Link>
) : (
<div className="pr-10">
{mainContent}
</div>
)}
<button
type="button"
onClick={() => onUnlink(link.id)}
disabled={disabled}
className="app-icon-button absolute right-0 top-0 z-10 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg disabled:opacity-50"
title="Убрать связь"
aria-label="Убрать связь с новостью"
>
<Trash2 size={14} />
</button>
</div>
</article>
);
}
function LinkedMessageCard({
link,
onUnlink,
@ -1569,6 +1683,7 @@ export default function TasksPage() {
const [labelName, setLabelName] = useState("");
const [labelColor, setLabelColor] = useState("#38bdf8");
const [linkedObjectsOpen, setLinkedObjectsOpen] = useState(false);
const [linkedPosts, setLinkedPosts] = useState<TaskLinkedPost[]>([]);
const [linkedMessages, setLinkedMessages] = useState<TaskLinkedMessage[]>([]);
const [linkedEvents, setLinkedEvents] = useState<TaskLinkedCalendarEvent[]>([]);
const [linkedDocuments, setLinkedDocuments] = useState<TaskLinkedDocument[]>([]);
@ -1623,6 +1738,7 @@ export default function TasksPage() {
setLinkedObjectsLoading(true);
try {
const [
posts,
messages,
events,
documents,
@ -1633,6 +1749,7 @@ export default function TasksPage() {
guestVisits,
attendanceRecords,
] = await Promise.all([
apiClient.getTaskLinkedPosts(taskId),
apiClient.getTaskLinkedMessages(taskId),
apiClient.getTaskLinkedEvents(taskId),
apiClient.getTaskLinkedDocuments(taskId),
@ -1643,6 +1760,7 @@ export default function TasksPage() {
apiClient.getTaskLinkedGuestVisits(taskId),
apiClient.getTaskLinkedAttendanceRecords(taskId),
]);
setLinkedPosts(posts);
setLinkedMessages(messages);
setLinkedEvents(events);
setLinkedDocuments(documents);
@ -1984,7 +2102,7 @@ export default function TasksPage() {
() => activeColumns.find((column) => column.id === viewTask?.column) || null,
[activeColumns, viewTask?.column],
);
const linkedObjectsCount = linkedMessages.length + linkedEvents.length + linkedDocuments.length + linkedRequests.length + linkedProcurementRequests.length + linkedEmployees.length + linkedGuests.length + linkedGuestVisits.length + linkedAttendanceRecords.length;
const linkedObjectsCount = linkedPosts.length + linkedMessages.length + linkedEvents.length + linkedDocuments.length + linkedRequests.length + linkedProcurementRequests.length + linkedEmployees.length + linkedGuests.length + linkedGuestVisits.length + linkedAttendanceRecords.length;
const linkedObjectsBadgeCount = viewTask?.linked_objects_count ?? linkedObjectsCount;
const commentsBadgeCount = viewTask?.comments_count ?? taskComments.length;
@ -1999,6 +2117,7 @@ export default function TasksPage() {
useEffect(() => {
if (!viewTaskId) {
setLinkedPosts([]);
setLinkedMessages([]);
setLinkedEvents([]);
setLinkedDocuments([]);
@ -2206,6 +2325,33 @@ export default function TasksPage() {
}
}, [board, loadBoard, loadBoards]);
const setDefaultSelectedBoard = useCallback(async () => {
if (!board || saving || board.is_default_for_current_user) return;
setBoardActionsMenuOpen(false);
setSaving(true);
setError(null);
try {
const updatedBoard = await apiClient.setDefaultTaskBoard(board.id);
setBoard((current) => (
current
? {
...current,
is_default_for_current_user: current.id === updatedBoard.id,
}
: current
));
setBoards((current) => current.map((item) => ({
...item,
is_default_for_current_user: item.id === updatedBoard.id,
})));
} catch (defaultError) {
setError(getTaskError(defaultError, "Не удалось выбрать доску по умолчанию"));
} finally {
setSaving(false);
}
}, [board, saving]);
const deleteSelectedBoard = useCallback(async () => {
if (!board || saving) return;
if (!window.confirm(`Удалить доску "${board.name}"? Все задачи этой доски будут удалены.`)) return;
@ -2413,6 +2559,23 @@ export default function TasksPage() {
}
}, [board, loadBoard, saving, viewTask]);
const unlinkPostFromViewedTask = useCallback(async (linkId: number) => {
if (!board || !viewTask || saving) return;
setSaving(true);
setError(null);
try {
await apiClient.unlinkTaskPost(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 unlinkMessageFromViewedTask = useCallback(async (linkId: number) => {
if (!board || !viewTask || saving) return;
@ -2680,6 +2843,26 @@ export default function TasksPage() {
<RefreshCw size={14} className="app-text-muted shrink-0" />
<span>Обновить</span>
</button>
<button
type="button"
onClick={() => void setDefaultSelectedBoard()}
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)] disabled:cursor-default disabled:opacity-70 disabled:hover:bg-transparent"
disabled={saving || Boolean(board?.is_default_for_current_user)}
>
<Star
size={14}
className={`shrink-0 ${
board?.is_default_for_current_user
? "fill-current app-accent-text"
: "app-text-muted"
}`}
/>
<span>
{board?.is_default_for_current_user
? "Доска по умолчанию"
: "Сделать доской по умолчанию"}
</span>
</button>
<button
type="button"
onClick={() => void deleteSelectedBoard()}
@ -2753,6 +2936,13 @@ export default function TasksPage() {
>
<Kanban size={15} className="shrink-0" />
<span className="min-w-0 flex-1 truncate">{item.name}</span>
{item.is_default_for_current_user ? (
<Star
size={12}
className="app-accent-text shrink-0 fill-current"
aria-label="Доска по умолчанию"
/>
) : null}
</button>
);
})}
@ -3352,6 +3542,14 @@ export default function TasksPage() {
</div>
) : linkedObjectsCount > 0 ? (
<>
{linkedPosts.map((link) => (
<LinkedPostCard
key={link.id}
link={link}
onUnlink={unlinkPostFromViewedTask}
disabled={saving}
/>
))}
{linkedMessages.map((link) => (
<LinkedMessageCard
key={link.id}

View File

@ -253,7 +253,7 @@ function Header({ mobileNavPlacement, suppressMobileChrome = false, onOpenLeftNa
) : null}
<div className="app-divider my-1 border-t" />
<button
className="app-action-danger w-full px-4 py-2 text-left text-sm transition"
className="app-menu-danger-item w-full px-4 py-2 text-left text-sm transition"
onClick={() => { setUserMenuOpen(false); logout(); }}
>Выйти</button>
</div>

View File

@ -86,7 +86,7 @@ export function MobileLeftDrawer({ isOpen, onClose, children }: LeftNavDrawerPro
onClick={() => { onClose(); router.push("/settings"); }}
>Настройки</button>
<button
className="app-action-danger w-full rounded-lg px-3 py-2 text-left text-sm transition"
className="app-menu-danger-item w-full rounded-lg px-3 py-2 text-left text-sm transition"
onClick={() => { onClose(); logout(); }}
>Выйти</button>
</div>

View File

@ -13,6 +13,7 @@ import type {
TaskLinkedGuest,
TaskLinkedGuestVisit,
TaskLinkedMessage,
TaskLinkedPost,
TaskLinkedProcurementRequest,
TaskLinkedRequest,
} from "@/types/api";
@ -38,6 +39,8 @@ export function createTasksApi(request: RequestFn) {
}),
deleteTaskBoard: (id: number): Promise<void> =>
request(`/api/v1/tasks/boards/${id}/`, { method: "DELETE" }),
setDefaultTaskBoard: (id: number): Promise<TaskBoard> =>
request(`/api/v1/tasks/boards/${id}/set-default/`, { method: "POST" }),
createTaskColumn: (data: Record<string, any>): Promise<TaskColumn> =>
request("/api/v1/tasks/columns/", {
method: "POST",
@ -79,6 +82,8 @@ export function createTasksApi(request: RequestFn) {
request(`/api/v1/tasks/${id}/comments/${commentId}/`, {
method: "DELETE",
}),
getPostLinkedTasks: (postId: number): Promise<TaskCard[]> =>
request(`/api/v1/tasks/linked-post-tasks/?post_id=${postId}`),
getCalendarEventLinkedTasks: (eventId: number): Promise<TaskCard[]> =>
request(`/api/v1/tasks/linked-event-tasks/?event_id=${eventId}`),
getDocumentLinkedTasks: (documentId: number): Promise<TaskCard[]> =>
@ -95,6 +100,17 @@ export function createTasksApi(request: RequestFn) {
request(`/api/v1/tasks/linked-guest-visit-tasks/?guest_visit_id=${guestVisitId}`),
getAttendanceRecordLinkedTasks: (attendanceRecordId: number): Promise<TaskCard[]> =>
request(`/api/v1/tasks/linked-attendance-record-tasks/?attendance_record_id=${attendanceRecordId}`),
getTaskLinkedPosts: (id: number): Promise<TaskLinkedPost[]> =>
request(`/api/v1/tasks/${id}/linked-posts/`),
linkTaskPost: (id: number, postId: number): Promise<TaskLinkedPost> =>
request(`/api/v1/tasks/${id}/linked-posts/`, {
method: "POST",
body: JSON.stringify({ post_id: postId }),
}),
unlinkTaskPost: (id: number, linkId: number): Promise<void> =>
request(`/api/v1/tasks/${id}/linked-posts/${linkId}/`, {
method: "DELETE",
}),
getTaskLinkedMessages: (id: number): Promise<TaskLinkedMessage[]> =>
request(`/api/v1/tasks/${id}/linked-messages/`),
linkTaskMessage: (id: number, messageId: number): Promise<TaskLinkedMessage> =>

View File

@ -180,6 +180,7 @@ export interface TaskCard {
due_date?: string | null;
position: number;
completed_at?: string | null;
linked_posts_count?: number;
linked_messages_count?: number;
linked_events_count?: number;
linked_documents_count?: number;
@ -209,10 +210,22 @@ export interface TaskBoard {
labels: TaskLabel[];
tasks: TaskCard[];
tasks_count?: number;
is_default_for_current_user?: boolean;
created_at: string;
updated_at: string;
}
export interface TaskLinkedPost {
id: number;
kind: "post";
post_id: number;
post: Post | null;
can_open?: boolean;
object_url?: string | null;
created_by?: User;
created_at: string;
}
export interface TaskLinkedMessage {
id: number;
kind: "message";
@ -412,7 +425,7 @@ export interface TaskActivity {
action: "created" | "updated" | "moved" | "linked" | "unlinked";
action_display?: string;
actor?: User | null;
object_kind?: "message" | "calendar_event" | "document" | "request" | "procurement_request" | "employee" | "guest" | "guest_visit" | "attendance_record" | "";
object_kind?: "post" | "message" | "calendar_event" | "document" | "request" | "procurement_request" | "employee" | "guest" | "guest_visit" | "attendance_record" | "";
object_id?: number | null;
metadata?: Record<string, unknown>;
created_at: string;
@ -561,6 +574,7 @@ export interface Post {
likes_count: number;
comments_count: number;
is_liked?: boolean;
linked_tasks?: MessageLinkedTask[];
}
export interface Comment {