feat(tasks): improve board navigation and collaboration

This commit is contained in:
lizerk 2026-08-05 18:33:40 +03:00
parent 60c82b6d5a
commit f4a6b2de9b
18 changed files with 1524 additions and 245 deletions

View File

@ -50,6 +50,7 @@ from tasks.models import (
TaskLabel,
TaskLinkedObject,
TaskLinkedObjectKind,
TaskUserSettings,
)
User = get_user_model()
@ -493,6 +494,16 @@ class TaskBoardSerializer(serializers.ModelSerializer):
allow_null=True,
default=None,
)
is_pinned = serializers.BooleanField(
source="current_user_is_pinned",
read_only=True,
default=False,
)
notifications_enabled = serializers.BooleanField(
source="current_user_notifications_enabled",
read_only=True,
default=True,
)
class Meta:
model = TaskBoard
@ -500,6 +511,7 @@ class TaskBoardSerializer(serializers.ModelSerializer):
"id",
"name",
"description",
"avatar",
"access_scope",
"created_by",
"members",
@ -513,6 +525,8 @@ class TaskBoardSerializer(serializers.ModelSerializer):
"tasks_count",
"is_default_for_current_user",
"group_id",
"is_pinned",
"notifications_enabled",
"created_at",
"updated_at",
]
@ -558,6 +572,7 @@ class TaskBoardSummarySerializer(TaskBoardSerializer):
"id",
"name",
"description",
"avatar",
"access_scope",
"created_by",
"member_details",
@ -567,6 +582,8 @@ class TaskBoardSummarySerializer(TaskBoardSerializer):
"completed_tasks_count",
"is_default_for_current_user",
"group_id",
"is_pinned",
"notifications_enabled",
"created_at",
"updated_at",
]
@ -574,6 +591,7 @@ class TaskBoardSummarySerializer(TaskBoardSerializer):
class TaskBoardGroupSerializer(serializers.ModelSerializer):
boards_count = serializers.IntegerField(read_only=True, default=0)
is_default_for_current_user = serializers.SerializerMethodField()
class Meta:
model = TaskBoardGroup
@ -582,6 +600,7 @@ class TaskBoardGroupSerializer(serializers.ModelSerializer):
"name",
"position",
"boards_count",
"is_default_for_current_user",
"created_at",
"updated_at",
]
@ -602,6 +621,20 @@ class TaskBoardGroupSerializer(serializers.ModelSerializer):
raise serializers.ValidationError("Группа с таким названием уже существует.")
return name
@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
if not hasattr(self, "_default_group_id"):
self._default_group_id = (
TaskUserSettings.objects.filter(user=user)
.values_list("default_group_id", flat=True)
.first()
)
return self._default_group_id == obj.id
class TaskLinkedPostSerializer(serializers.ModelSerializer):
created_by = EmployeeBriefSerializer(read_only=True)

View File

@ -6,7 +6,9 @@ from django.contrib.contenttypes.models import ContentType
from django.contrib.auth import get_user_model
from django.db import transaction
from django.db.models import (
BooleanField,
Count,
DateTimeField,
IntegerField,
Max,
OuterRef,
@ -182,10 +184,10 @@ class TaskBoardViewSet(viewsets.ModelViewSet):
return super().get_serializer_class()
def get_queryset(self):
current_group_id = TaskBoardPlacement.objects.filter(
current_placement = TaskBoardPlacement.objects.filter(
user=self.request.user,
board_id=OuterRef("pk"),
).values("group_id")[:1]
)
queryset = (
TaskBoard.objects.filter(is_archived=False)
.filter(task_board_access_q(self.request.user))
@ -193,9 +195,27 @@ class TaskBoardViewSet(viewsets.ModelViewSet):
.prefetch_related("members", "departments")
.annotate(
current_user_group_id=Subquery(
current_group_id,
current_placement.values("group_id")[:1],
output_field=IntegerField(),
),
current_user_is_pinned=Coalesce(
Subquery(
current_placement.values("is_pinned")[:1],
output_field=BooleanField(),
),
Value(False),
),
current_user_pinned_at=Subquery(
current_placement.values("pinned_at")[:1],
output_field=DateTimeField(),
),
current_user_notifications_enabled=Coalesce(
Subquery(
current_placement.values("notifications_enabled")[:1],
output_field=BooleanField(),
),
Value(True),
),
tasks_count=Count("tasks", distinct=True),
open_tasks_count=Count(
"tasks",
@ -209,7 +229,12 @@ class TaskBoardViewSet(viewsets.ModelViewSet):
),
)
.distinct()
.order_by("name", "id")
.order_by(
"-current_user_is_pinned",
"-current_user_pinned_at",
"name",
"id",
)
)
if not (
self.action == "list"
@ -333,10 +358,16 @@ class TaskBoardViewSet(viewsets.ModelViewSet):
board = self.get_object()
group_id = request.data.get("group")
if group_id in (None, ""):
TaskBoardPlacement.objects.filter(
placement = TaskBoardPlacement.objects.filter(
user=request.user,
board=board,
).delete()
).first()
if placement:
placement.group = None
if not placement.is_pinned and placement.notifications_enabled:
placement.delete()
else:
placement.save(update_fields=["group", "updated_at"])
return Response({"board": board.id, "group_id": None})
try:
@ -358,6 +389,41 @@ class TaskBoardViewSet(viewsets.ModelViewSet):
)
return Response({"board": board.id, "group_id": group.id})
@action(detail=True, methods=["post"])
def pin(self, request, pk=None):
board = self.get_object()
placement, _ = TaskBoardPlacement.objects.get_or_create(
user=request.user,
board=board,
defaults={"group": None},
)
placement.is_pinned = not placement.is_pinned
placement.pinned_at = timezone.now() if placement.is_pinned else None
is_pinned = placement.is_pinned
if not placement.is_pinned and placement.group_id is None and placement.notifications_enabled:
placement.delete()
else:
placement.save(update_fields=["is_pinned", "pinned_at", "updated_at"])
return Response({"ok": True, "is_pinned": is_pinned})
@action(detail=True, methods=["post"])
def notifications(self, request, pk=None):
board = self.get_object()
placement, _ = TaskBoardPlacement.objects.get_or_create(
user=request.user,
board=board,
defaults={"group": None},
)
placement.notifications_enabled = not placement.notifications_enabled
notifications_enabled = placement.notifications_enabled
if placement.notifications_enabled and placement.group_id is None and not placement.is_pinned:
placement.delete()
else:
placement.save(update_fields=["notifications_enabled", "updated_at"])
return Response(
{"ok": True, "notifications_enabled": notifications_enabled}
)
@action(detail=True, methods=["post"], url_path="notifications/read")
def mark_notifications_read(self, request, pk=None):
board = self.get_object()
@ -389,6 +455,31 @@ class TaskBoardGroupViewSet(viewsets.ModelViewSet):
max_position = self.get_queryset().aggregate(value=Max("position"))["value"] or 0
serializer.save(user=self.request.user, position=max_position + 1000)
@action(detail=True, methods=["post"], url_path="set-default")
def set_default(self, request, pk=None):
group = self.get_object()
settings_obj, _ = TaskUserSettings.objects.get_or_create(user=request.user)
is_default = settings_obj.default_group_id != group.id
settings_obj.default_group = group if is_default else None
settings_obj.save(update_fields=["default_group", "updated_at"])
serializer = self.get_serializer(group)
return Response(
{
"group": serializer.data,
"is_default_for_current_user": is_default,
}
)
def perform_destroy(self, instance):
user = self.request.user
instance.delete()
TaskBoardPlacement.objects.filter(
user=user,
group__isnull=True,
is_pinned=False,
notifications_enabled=True,
).delete()
class TaskColumnViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated]

View File

@ -52,7 +52,15 @@ class TaskBoardGroupAdmin(admin.ModelAdmin):
@admin.register(TaskBoardPlacement)
class TaskBoardPlacementAdmin(admin.ModelAdmin):
list_display = ["board", "group", "user", "updated_at"]
list_display = [
"board",
"group",
"user",
"is_pinned",
"notifications_enabled",
"updated_at",
]
list_filter = ["is_pinned", "notifications_enabled", "group"]
search_fields = ["board__name", "group__name", "user__email"]
autocomplete_fields = ["board", "group", "user"]
@ -134,6 +142,12 @@ class TaskActivityAdmin(admin.ModelAdmin):
@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"]
list_display = ["user", "default_board", "default_group", "updated_at"]
search_fields = [
"user__email",
"user__first_name",
"user__last_name",
"default_board__name",
"default_group__name",
]
autocomplete_fields = ["user", "default_board", "default_group"]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2026-08-05 14:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tasks', '0020_taskboardgroup_taskboardplacement_and_more'),
]
operations = [
migrations.AddField(
model_name='taskboard',
name='avatar',
field=models.ImageField(blank=True, null=True, upload_to='task_board_avatars/%Y/%m/', verbose_name='Аватар доски'),
),
]

View File

@ -0,0 +1,44 @@
# Generated by Django 5.2.4 on 2026-08-05 14:24
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tasks', '0021_taskboard_avatar'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='taskboardplacement',
name='is_pinned',
field=models.BooleanField(default=False, verbose_name='Закреплена'),
),
migrations.AddField(
model_name='taskboardplacement',
name='notifications_enabled',
field=models.BooleanField(default=True, verbose_name='Уведомления'),
),
migrations.AddField(
model_name='taskboardplacement',
name='pinned_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Закреплена в'),
),
migrations.AlterField(
model_name='taskboardplacement',
name='group',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='board_placements', to='tasks.taskboardgroup', verbose_name='Группа'),
),
migrations.AddIndex(
model_name='taskboardplacement',
index=models.Index(fields=['user', 'is_pinned'], name='tasks_taskb_user_id_b1c3b1_idx'),
),
migrations.AddIndex(
model_name='taskboardplacement',
index=models.Index(fields=['board', 'user'], name='tasks_taskb_board_i_8de391_idx'),
),
]

View File

@ -0,0 +1,26 @@
# Generated by Django 5.2.4 on 2026-08-05
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("tasks", "0022_taskboardplacement_is_pinned_and_more"),
]
operations = [
migrations.AddField(
model_name="taskusersettings",
name="default_group",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="default_for_user_settings",
to="tasks.taskboardgroup",
verbose_name="Группа досок по умолчанию",
),
),
]

View File

@ -22,6 +22,12 @@ class TaskBoardAccessScope(models.TextChoices):
class TaskBoard(models.Model):
name = models.CharField("Название", max_length=255)
description = models.TextField("Описание", blank=True)
avatar = models.ImageField(
"Аватар доски",
upload_to="task_board_avatars/%Y/%m/",
null=True,
blank=True,
)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
@ -101,10 +107,15 @@ class TaskBoardPlacement(models.Model):
)
group = models.ForeignKey(
TaskBoardGroup,
on_delete=models.CASCADE,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="board_placements",
verbose_name="Группа",
)
is_pinned = models.BooleanField("Закреплена", default=False)
pinned_at = models.DateTimeField("Закреплена в", null=True, blank=True)
notifications_enabled = models.BooleanField("Уведомления", default=True)
created_at = models.DateTimeField("Создано", auto_now_add=True)
updated_at = models.DateTimeField("Обновлено", auto_now=True)
@ -117,6 +128,10 @@ class TaskBoardPlacement(models.Model):
name="uniq_task_board_placement_user_board",
)
]
indexes = [
models.Index(fields=["user", "is_pinned"]),
models.Index(fields=["board", "user"]),
]
def __str__(self):
return f"{self.user}: {self.board} -> {self.group}"
@ -137,6 +152,14 @@ class TaskUserSettings(models.Model):
related_name="default_for_user_settings",
verbose_name="Доска по умолчанию",
)
default_group = models.ForeignKey(
TaskBoardGroup,
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)

View File

@ -12,6 +12,7 @@ from tasks.models import (
Task,
TaskActivityAction,
TaskActivityObjectKind,
TaskBoardPlacement,
TaskLinkedObject,
TaskLinkedObjectKind,
)
@ -57,11 +58,18 @@ def _send_task_notification(
action_object=None,
extra_data: dict | None = None,
action_url: str | None = None,
preference_checked: bool = False,
):
if not recipient or not getattr(recipient, "is_active", False):
return
return False
if _actor_id(actor) and recipient.id == _actor_id(actor):
return
return False
if not preference_checked and TaskBoardPlacement.objects.filter(
board_id=task.board_id,
user_id=recipient.id,
notifications_enabled=False,
).exists():
return False
notify.send(
sender=actor,
@ -73,6 +81,7 @@ def _send_task_notification(
action_url=action_url or ActionURLs.task_detail(task),
data=_task_data(task, title, extra_data),
)
return True
def _notify_many(
@ -87,9 +96,21 @@ def _notify_many(
extra_data: dict | None = None,
action_url: str | None = None,
):
recipient_list = [
recipient
for recipient in recipients
if recipient and getattr(recipient, "is_active", False)
]
muted_ids = set(
TaskBoardPlacement.objects.filter(
board_id=task.board_id,
user_id__in=[recipient.id for recipient in recipient_list],
notifications_enabled=False,
).values_list("user_id", flat=True)
)
seen = set()
for recipient in recipients:
if not recipient or recipient.id in seen:
for recipient in recipient_list:
if recipient.id in seen or recipient.id in muted_ids:
continue
seen.add(recipient.id)
_send_task_notification(
@ -102,6 +123,7 @@ def _notify_many(
action_object=action_object,
extra_data=extra_data,
action_url=action_url,
preference_checked=True,
)
@ -444,6 +466,12 @@ def notify_board_members_added(board, actor, members):
continue
if _actor_id(actor) and member.id == _actor_id(actor):
continue
if TaskBoardPlacement.objects.filter(
board=board,
user=member,
notifications_enabled=False,
).exists():
continue
notify.send(
sender=actor,
recipient=member,
@ -508,7 +536,7 @@ def dispatch_task_due_notifications() -> dict[str, int]:
task=task,
):
continue
_send_task_notification(
sent = _send_task_notification(
recipient=recipient,
verb=verb,
task=task,
@ -516,8 +544,7 @@ def dispatch_task_due_notifications() -> dict[str, int]:
description=description,
actor=None,
extra_data={"due_date": task.due_date.isoformat()},
)
sent = True
) or sent
if not sent:
continue
if verb == NotificationVerbs.TASK_OVERDUE:

View File

@ -1,8 +1,63 @@
import logging
from django.core.files.base import ContentFile
from django.db import transaction
from django.db.models.signals import post_delete, post_save
from django.db.models.signals import post_delete, post_save, pre_save
from django.dispatch import receiver
from .models import Task, TaskAttachment
from common.image_utils import compress_avatar
from .models import Task, TaskAttachment, TaskBoard
logger = logging.getLogger(__name__)
def _delete_file_after_commit(field_file):
if not field_file or not field_file.name:
return
storage = field_file.storage
name = field_file.name
transaction.on_commit(
lambda: storage.delete(name) if storage.exists(name) else None
)
@receiver(pre_save, sender=TaskBoard)
def compress_and_cleanup_task_board_avatar(sender, instance, **kwargs):
old_avatar = None
if instance.pk:
old_avatar = (
sender.objects.filter(pk=instance.pk)
.only("avatar")
.values_list("avatar", flat=True)
.first()
)
new_avatar = instance.avatar
if new_avatar and not getattr(new_avatar, "_committed", True):
try:
compressed = compress_avatar(new_avatar.read())
if compressed:
new_avatar.save(
new_avatar.name,
ContentFile(compressed),
save=False,
)
except Exception:
new_avatar.seek(0)
logger.exception("Не удалось сжать аватар доски %s", instance.pk)
new_avatar_name = new_avatar.name if new_avatar else None
if old_avatar and old_avatar != new_avatar_name:
storage = instance._meta.get_field("avatar").storage
transaction.on_commit(
lambda: storage.delete(old_avatar) if storage.exists(old_avatar) else None
)
@receiver(post_delete, sender=TaskBoard)
def delete_task_board_avatar(sender, instance, **kwargs):
_delete_file_after_commit(instance.avatar)
@receiver(post_save, sender=Task)
@ -15,9 +70,4 @@ def ensure_task_core_participants(sender, instance, **kwargs):
@receiver(post_delete, sender=TaskAttachment)
def delete_task_attachment_file(sender, instance, **kwargs):
if not instance.file or not instance.file.name:
return
storage = instance.file.storage
name = instance.file.name
transaction.on_commit(lambda: storage.delete(name) if storage.exists(name) else None)
_delete_file_after_commit(instance.file)

View File

@ -9,7 +9,7 @@ from rest_framework import status
from rest_framework.test import APIClient
from notifications.models import Notification
from tasks.models import Task, TaskBoard, TaskColumn
from tasks.models import Task, TaskBoard, TaskBoardPlacement, TaskColumn
from tasks.notifications.config import NotificationVerbs
from tasks.notifications.handlers import dispatch_task_due_notifications
@ -97,6 +97,44 @@ def test_create_task_notifies_assignee(api_client):
).exists()
def test_muted_board_suppresses_task_and_due_notifications(api_client):
owner = make_user("task-muted-owner@example.com", "+79995550024")
assignee = make_user("task-muted-assignee@example.com", "+79995550025")
board, column, _done = make_board(owner)
TaskBoardPlacement.objects.create(
board=board,
user=assignee,
notifications_enabled=False,
)
api_client.force_authenticate(user=owner)
response = api_client.post(
reverse("api:v1:tasks:task-list"),
{
"board": board.id,
"column": column.id,
"title": "Задача без уведомлений",
"assignee_id": assignee.id,
"due_date": timezone.localdate().isoformat(),
},
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
assert not Notification.objects.filter(
recipient=assignee,
data__board_id=board.id,
).exists()
dispatch_task_due_notifications()
assert not Notification.objects.filter(
recipient=assignee,
verb=NotificationVerbs.TASK_DUE_SOON,
data__board_id=board.id,
).exists()
def test_reassign_task_notifies_old_and_new_assignees(api_client):
owner = make_user("task-reassign-owner@example.com", "+79995550003")
old_assignee = make_user("task-reassign-old@example.com", "+79995550004")

View File

@ -1,5 +1,7 @@
from unittest.mock import patch
import base64
from datetime import date, timedelta
from pathlib import Path
from unittest.mock import patch
import pytest
from django.contrib.auth import get_user_model
@ -167,6 +169,54 @@ def test_board_summary_list_returns_navigation_counts_without_nested_tasks(
assert "columns" not in result
def test_board_avatar_can_be_uploaded_listed_and_removed(
api_client,
user,
settings,
tmp_path,
django_capture_on_commit_callbacks,
):
settings.MEDIA_ROOT = tmp_path
board = TaskBoard.objects.create(name="Доска с аватаром", created_by=user)
api_client.force_authenticate(user=user)
image_bytes = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
)
with patch("tasks.signals.compress_avatar", return_value=image_bytes):
response = api_client.patch(
reverse("api:v1:tasks:task-board-detail", kwargs={"pk": board.id}),
{"avatar": SimpleUploadedFile("board.png", image_bytes, "image/png")},
format="multipart",
)
assert response.status_code == status.HTTP_200_OK
assert response.data["avatar"].endswith(".png")
board.refresh_from_db()
avatar_path = board.avatar.path
assert board.avatar.name.startswith("task_board_avatars/")
summary_response = api_client.get(
reverse("api:v1:tasks:task-board-list"),
{"view": "summary"},
)
summary = next(
item for item in summary_response.data["results"] if item["id"] == board.id
)
assert summary["avatar"].endswith(".png")
with django_capture_on_commit_callbacks(execute=True):
remove_response = api_client.patch(
reverse("api:v1:tasks:task-board-detail", kwargs={"pk": board.id}),
{"avatar": None},
format="json",
)
assert remove_response.status_code == status.HTTP_200_OK
assert remove_response.data["avatar"] is None
assert not Path(avatar_path).exists()
def test_board_groups_are_personal_and_board_placement_is_independent(
api_client,
user,
@ -238,6 +288,160 @@ def test_board_groups_are_personal_and_board_placement_is_independent(
assert ungrouped_summary_response.data["results"][0]["group_id"] is None
def test_user_can_toggle_personal_default_board_group(api_client, user):
other_user = make_user("task-default-group-other@example.com", "+79994440095")
api_client.force_authenticate(user=user)
first_group = TaskBoardGroup.objects.create(name="Производство", user=user)
second_group = TaskBoardGroup.objects.create(name="Личные проекты", user=user)
set_response = api_client.post(
reverse(
"api:v1:tasks:task-board-group-set-default",
kwargs={"pk": first_group.id},
)
)
assert set_response.status_code == status.HTTP_200_OK
assert set_response.data["is_default_for_current_user"] is True
assert set_response.data["group"]["is_default_for_current_user"] is True
assert TaskUserSettings.objects.get(user=user).default_group_id == first_group.id
list_response = api_client.get(
reverse("api:v1:tasks:task-board-group-list")
)
groups_by_id = {item["id"]: item for item in list_response.data["results"]}
assert groups_by_id[first_group.id]["is_default_for_current_user"] is True
assert groups_by_id[second_group.id]["is_default_for_current_user"] is False
api_client.force_authenticate(user=other_user)
foreign_response = api_client.post(
reverse(
"api:v1:tasks:task-board-group-set-default",
kwargs={"pk": first_group.id},
)
)
assert foreign_response.status_code == status.HTTP_404_NOT_FOUND
api_client.force_authenticate(user=user)
unset_response = api_client.post(
reverse(
"api:v1:tasks:task-board-group-set-default",
kwargs={"pk": first_group.id},
)
)
assert unset_response.status_code == status.HTTP_200_OK
assert unset_response.data["is_default_for_current_user"] is False
assert unset_response.data["group"]["is_default_for_current_user"] is False
settings_obj = TaskUserSettings.objects.get(user=user)
assert settings_obj.default_group_id is None
api_client.post(
reverse(
"api:v1:tasks:task-board-group-set-default",
kwargs={"pk": second_group.id},
)
)
api_client.delete(
reverse(
"api:v1:tasks:task-board-group-detail",
kwargs={"pk": second_group.id},
)
)
settings_obj.refresh_from_db()
assert settings_obj.default_group_id is None
def test_board_pin_and_notifications_are_personal_and_keep_ungrouped_settings(
api_client,
user,
):
other_user = make_user("task-settings-other@example.com", "+79994440094")
first_board = TaskBoard.objects.create(name="Альфа", created_by=user)
pinned_board = TaskBoard.objects.create(name="Якорь", created_by=user)
api_client.force_authenticate(user=user)
pin_response = api_client.post(
reverse("api:v1:tasks:task-board-pin", kwargs={"pk": pinned_board.id})
)
notifications_response = api_client.post(
reverse(
"api:v1:tasks:task-board-notifications",
kwargs={"pk": pinned_board.id},
)
)
assert pin_response.status_code == status.HTTP_200_OK
assert pin_response.data["is_pinned"] is True
assert notifications_response.status_code == status.HTTP_200_OK
assert notifications_response.data["notifications_enabled"] is False
placement = TaskBoardPlacement.objects.get(user=user, board=pinned_board)
assert placement.group_id is None
assert placement.is_pinned is True
assert placement.notifications_enabled is False
group_response = api_client.post(
reverse("api:v1:tasks:task-board-group-list"),
{"name": "Закреплённые проекты"},
format="json",
)
api_client.post(
reverse(
"api:v1:tasks:task-board-move-to-group",
kwargs={"pk": pinned_board.id},
),
{"group": group_response.data["id"]},
format="json",
)
api_client.post(
reverse(
"api:v1:tasks:task-board-move-to-group",
kwargs={"pk": pinned_board.id},
),
{"group": None},
format="json",
)
placement.refresh_from_db()
assert placement.group_id is None
assert placement.is_pinned is True
assert placement.notifications_enabled is False
summary_response = api_client.get(
reverse("api:v1:tasks:task-board-list"),
{"view": "summary"},
)
summaries = summary_response.data["results"]
assert summaries[0]["id"] == pinned_board.id
assert summaries[0]["is_pinned"] is True
assert summaries[0]["notifications_enabled"] is False
api_client.force_authenticate(user=other_user)
other_summary_response = api_client.get(
reverse("api:v1:tasks:task-board-list"),
{"view": "summary"},
)
other_summary = next(
item
for item in other_summary_response.data["results"]
if item["id"] == pinned_board.id
)
assert other_summary["is_pinned"] is False
assert other_summary["notifications_enabled"] is True
api_client.force_authenticate(user=user)
api_client.post(
reverse("api:v1:tasks:task-board-pin", kwargs={"pk": pinned_board.id})
)
assert TaskBoardPlacement.objects.filter(user=user, board=pinned_board).exists()
api_client.post(
reverse(
"api:v1:tasks:task-board-notifications",
kwargs={"pk": pinned_board.id},
)
)
assert not TaskBoardPlacement.objects.filter(user=user, board=pinned_board).exists()
assert TaskBoard.objects.filter(id=first_board.id).exists()
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")

View File

@ -972,6 +972,7 @@ html[data-bs-theme="dark"] .shadow-xl {
}
.tasks-board-scroll::-webkit-scrollbar {
width: 8px;
height: 8px;
}

View File

@ -31,6 +31,7 @@ import {
FileSignature,
FileText,
History,
ImagePlus,
Kanban,
Link2,
ListChecks,
@ -52,9 +53,19 @@ import {
X,
type LucideIcon,
} from "lucide-react";
import { Suspense, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import {
Suspense,
useCallback,
useEffect,
useMemo,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
type RefObject,
} from "react";
import { AppShell } from "@/components/AppShell";
import TaskBoardAvatar from "@/components/tasks/TaskBoardAvatar";
import TaskBoardNavigator from "@/components/tasks/TaskBoardNavigator";
import { RequestAvatar } from "@/components/requests/RequestAvatar";
import { CommentComposer, CommentDeleteButton } from "@/components/shared/CommentControls";
@ -110,6 +121,10 @@ type BoardFormState = {
id: number | null;
name: string;
description: string;
avatar: string | null;
avatar_file: File | null;
avatar_preview: string | null;
avatar_removed: boolean;
access: "all" | "private" | "restricted";
member_ids: number[];
department_ids: number[];
@ -172,6 +187,10 @@ const emptyBoardForm: BoardFormState = {
id: null,
name: "",
description: "",
avatar: null,
avatar_file: null,
avatar_preview: null,
avatar_removed: false,
access: "all",
member_ids: [],
department_ids: [],
@ -684,16 +703,23 @@ function BoardColumn({
setNodeRef(node);
onColumnMount(column.id, node);
}, [column.id, onColumnMount, setNodeRef]);
const columnBorderClass = isOver
? "border-[var(--accent-primary)]"
: "border-[var(--border-subtle)]";
return (
<section
ref={setColumnNodeRef}
style={style}
className={`${hideOnMobile ? "hidden xl:flex" : "flex"} max-h-[calc(100dvh-15rem)] min-h-[28rem] w-full min-w-0 flex-col rounded-xl border bg-[var(--surface-muted)] transition xl:w-auto xl:min-w-[18rem] ${fillAvailableHeight ? "lg:h-full lg:min-h-0 lg:max-h-none" : ""} ${
isOver ? "border-[var(--accent-primary)]" : "border-[var(--border-subtle)]"
} ${isDragging ? "opacity-70 shadow-xl" : ""}`}
className={`${hideOnMobile ? "hidden xl:flex" : "flex"} max-h-[calc(100dvh-15rem)] min-h-[28rem] w-full min-w-0 flex-col transition xl:max-h-none xl:w-auto xl:min-w-[18rem] ${fillAvailableHeight ? "xl:min-h-full" : ""} ${
isDragging ? "opacity-70" : ""
}`}
>
<div className="flex items-center justify-between gap-3 border-b border-[var(--border-subtle)] px-3 py-3">
<div
className={`flex items-center justify-between gap-3 rounded-xl border bg-[var(--surface-primary)] px-3 py-3 shadow-sm transition xl:sticky xl:top-0 xl:z-10 ${columnBorderClass} ${
isDragging ? "shadow-xl" : ""
}`}
>
<div
ref={setActivatorNodeRef}
className="min-w-0 flex-1 cursor-grab touch-none overflow-hidden active:cursor-grabbing"
@ -769,7 +795,9 @@ function BoardColumn({
</div>
</div>
</div>
<div className="tasks-column-scroll min-h-0 flex-1 space-y-2 overflow-y-auto p-3">
<div
className={`tasks-column-scroll mt-2 min-h-0 flex-1 space-y-2 overflow-y-auto rounded-xl border bg-[var(--surface-primary)] p-3 transition xl:flex-none xl:overflow-visible ${columnBorderClass}`}
>
{tasks.length > 0 ? (
tasks.map((task) => (
<TaskCardView
@ -800,16 +828,19 @@ function BoardColumn({
function AddColumnCard({
onClick,
fillAvailableHeight = false,
viewportHeight,
}: {
onClick: () => void;
fillAvailableHeight?: boolean;
viewportHeight: number;
}) {
const cardHeight = viewportHeight > 0 ? Math.max(320, viewportHeight - 12) : 448;
return (
<button
type="button"
onClick={onClick}
className={`group hidden max-h-[calc(100vh-14rem)] min-h-[28rem] min-w-[18rem] flex-col items-center justify-center rounded-xl border border-dashed border-[var(--border-subtle)] bg-[var(--surface-muted)] p-4 text-center transition hover:border-[var(--accent-primary)] hover:bg-[var(--surface-elevated)] xl:flex ${fillAvailableHeight ? "lg:h-full lg:min-h-0 lg:max-h-none" : ""}`}
style={{ height: cardHeight }}
className="group hidden min-w-[18rem] self-start rounded-xl border border-dashed border-[var(--border-subtle)] bg-[var(--surface-muted)] p-4 text-center transition hover:border-[var(--accent-primary)] hover:bg-[var(--surface-elevated)] xl:sticky xl:top-0 xl:flex xl:flex-col xl:items-center xl:justify-center"
>
<span className="app-selected mb-3 flex h-10 w-10 items-center justify-center rounded-xl transition group-hover:scale-105">
<Plus size={18} />
@ -2224,6 +2255,13 @@ function TasksPageContent() {
const selectedBoardIdRef = useRef<number | null>(null);
const taskBoardSyncTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const boardScrollRef = useRef<HTMLDivElement | null>(null);
const boardPanRef = useRef<{
pointerId: number;
startX: number;
startY: number;
scrollLeft: number;
scrollTop: number;
} | null>(null);
const columnNodeRefs = useRef<Map<number, HTMLElement>>(new Map());
const taskAttachmentInputRef = useRef<HTMLInputElement | null>(null);
const taskFormAttachmentInputRef = useRef<HTMLInputElement | null>(null);
@ -2232,6 +2270,8 @@ function TasksPageContent() {
const taskFormResourcesRequestRef = useRef(0);
const readingBoardNotificationsRef = useRef<Set<number>>(new Set());
const initialLoadCompleteRef = useRef(false);
const [boardPanning, setBoardPanning] = useState(false);
const [boardViewportHeight, setBoardViewportHeight] = useState(0);
const sensors = useSensors(
useSensor(PointerSensor, {
@ -2239,6 +2279,20 @@ function TasksPageContent() {
}),
);
useEffect(() => {
const container = boardScrollRef.current;
if (!container || loading) return undefined;
const updateViewportHeight = () => {
setBoardViewportHeight(container.clientHeight);
};
updateViewportHeight();
const observer = new ResizeObserver(updateViewportHeight);
observer.observe(container);
return () => observer.disconnect();
}, [board?.id, desktopWideMode, loading]);
useEffect(() => {
if (
typeof window !== "undefined" &&
@ -2543,6 +2597,47 @@ function TasksPageContent() {
window.requestAnimationFrame(() => scrollToColumn(columnId));
}, [scrollToColumn]);
const startBoardPan = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button !== 1) return;
const container = boardScrollRef.current;
if (!container) return;
event.preventDefault();
event.stopPropagation();
boardPanRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
scrollLeft: container.scrollLeft,
scrollTop: container.scrollTop,
};
container.setPointerCapture(event.pointerId);
setBoardPanning(true);
}, []);
const moveBoardPan = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
const pan = boardPanRef.current;
const container = boardScrollRef.current;
if (!pan || !container || pan.pointerId !== event.pointerId) return;
event.preventDefault();
event.stopPropagation();
container.scrollLeft = pan.scrollLeft - (event.clientX - pan.startX);
container.scrollTop = pan.scrollTop - (event.clientY - pan.startY);
}, []);
const finishBoardPan = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
const pan = boardPanRef.current;
const container = boardScrollRef.current;
if (!pan || pan.pointerId !== event.pointerId) return;
if (container?.hasPointerCapture(event.pointerId)) {
container.releasePointerCapture(event.pointerId);
}
boardPanRef.current = null;
setBoardPanning(false);
}, []);
const syncTaskBoardState = useCallback(async (eventBoardId?: number | null) => {
const currentBoardId = selectedBoardIdRef.current;
try {
@ -3247,15 +3342,24 @@ function TasksPageContent() {
setBoardGroupForm(emptyBoardGroupForm);
}, [saving]);
const openEditBoard = useCallback(() => {
if (!board) return;
const memberIds = board.members || [];
const departmentIds = board.departments || [];
const openEditBoard = useCallback((targetBoard?: TaskBoard | TaskBoardSummary) => {
const editableBoard = targetBoard || board;
if (!editableBoard) return;
const memberIds = editableBoard.member_details?.map((member) => member.id)
|| ("members" in editableBoard ? editableBoard.members : [])
|| [];
const departmentIds = editableBoard.department_details?.map((department) => department.id)
|| ("departments" in editableBoard ? editableBoard.departments : [])
|| [];
setBoardForm({
id: board.id,
name: board.name || "",
description: board.description || "",
access: board.access_scope || (
id: editableBoard.id,
name: editableBoard.name || "",
description: editableBoard.description || "",
avatar: editableBoard.avatar || null,
avatar_file: null,
avatar_preview: null,
avatar_removed: false,
access: editableBoard.access_scope || (
memberIds.length > 0 || departmentIds.length > 0 ? "restricted" : "all"
),
member_ids: memberIds,
@ -3316,6 +3420,34 @@ function TasksPageContent() {
}));
}, []);
const handleBoardAvatarChange = useCallback((file: File | null) => {
if (!file) return;
if (!file.type.startsWith("image/")) {
setError("Для аватара доски можно выбрать только изображение");
return;
}
const reader = new FileReader();
reader.onloadend = () => {
setBoardForm((current) => ({
...current,
avatar_file: file,
avatar_preview: typeof reader.result === "string" ? reader.result : null,
avatar_removed: false,
}));
};
reader.readAsDataURL(file);
}, []);
const removeBoardAvatar = useCallback(() => {
setBoardForm((current) => ({
...current,
avatar_file: null,
avatar_preview: null,
avatar_removed: true,
}));
}, []);
const selectBoard = useCallback((boardId: number) => {
if (!boardId) return;
if (boardId === selectedBoardId && requestedBoardId === boardId && requestedTaskId <= 0) {
@ -3341,14 +3473,16 @@ function TasksPageContent() {
}
}, [board, loadBoard, loadBoards]);
const setDefaultSelectedBoard = useCallback(async () => {
if (!board || saving || board.is_default_for_current_user) return;
const setDefaultBoard = useCallback(async (
targetBoard: Pick<TaskBoardSummary, "id" | "is_default_for_current_user">,
) => {
if (saving || targetBoard.is_default_for_current_user) return;
setBoardActionsMenuOpen(false);
setSaving(true);
setError(null);
try {
const updatedBoard = await apiClient.setDefaultTaskBoard(board.id);
const updatedBoard = await apiClient.setDefaultTaskBoard(targetBoard.id);
setBoard((current) => (
current
? {
@ -3366,51 +3500,102 @@ function TasksPageContent() {
} finally {
setSaving(false);
}
}, [board, saving]);
}, [saving]);
const deleteSelectedBoard = useCallback(async () => {
if (!board || saving) return;
if (!window.confirm(`Удалить доску "${board.name}"? Все задачи этой доски будут удалены.`)) return;
const deleteBoard = useCallback(async (
targetBoard: Pick<TaskBoardSummary, "id" | "name">,
) => {
if (saving) return;
if (!window.confirm(`Удалить доску "${targetBoard.name}"? Все задачи этой доски будут удалены.`)) return;
setBoardActionsMenuOpen(false);
setSaving(true);
setError(null);
try {
await apiClient.deleteTaskBoard(board.id);
await apiClient.deleteTaskBoard(targetBoard.id);
const nextBoards = await loadBoards();
const nextBoard = nextBoards.find((item) => item.id !== board.id) || null;
if (nextBoard) {
router.replace(`/tasks?board=${nextBoard.id}`, { scroll: false });
} else {
setBoard(null);
setSelectedBoardId(null);
router.replace("/tasks", { scroll: false });
if (selectedBoardId === targetBoard.id) {
const nextBoard = nextBoards[0] || null;
if (nextBoard) {
router.replace(`/tasks?board=${nextBoard.id}`, { scroll: false });
} else {
setBoard(null);
setSelectedBoardId(null);
router.replace("/tasks", { scroll: false });
}
setSearch("");
setOnlyMine(false);
setSelectedColumnTarget("all");
}
setSearch("");
setOnlyMine(false);
setSelectedColumnTarget("all");
} catch (deleteError) {
setError(getTaskError(deleteError, "Не удалось удалить доску"));
} finally {
setSaving(false);
}
}, [board, loadBoards, router, saving]);
}, [loadBoards, router, saving, selectedBoardId]);
const deleteSelectedBoard = useCallback(() => {
if (!board) return;
void deleteBoard(board);
}, [board, deleteBoard]);
const toggleBoardPin = useCallback(async (targetBoard: TaskBoardSummary) => {
if (saving) return;
setSaving(true);
setError(null);
try {
const result = await apiClient.toggleTaskBoardPin(targetBoard.id);
await loadBoards();
setBoard((current) => current?.id === targetBoard.id
? { ...current, is_pinned: result.is_pinned }
: current);
} catch (pinError) {
setError(getTaskError(pinError, "Не удалось изменить закрепление доски"));
} finally {
setSaving(false);
}
}, [loadBoards, saving]);
const toggleBoardNotifications = useCallback(async (targetBoard: TaskBoardSummary) => {
if (saving) return;
setSaving(true);
setError(null);
try {
const result = await apiClient.toggleTaskBoardNotifications(targetBoard.id);
setBoards((current) => current.map((item) => item.id === targetBoard.id
? { ...item, notifications_enabled: result.notifications_enabled }
: item));
setBoard((current) => current?.id === targetBoard.id
? { ...current, notifications_enabled: result.notifications_enabled }
: current);
} catch (notificationError) {
setError(getTaskError(notificationError, "Не удалось изменить уведомления доски"));
} finally {
setSaving(false);
}
}, [saving]);
const saveBoard = useCallback(async () => {
if (!boardForm.name.trim()) return;
setSaving(true);
setError(null);
try {
const payload = {
const payload: Record<string, unknown> = {
name: boardForm.name.trim(),
description: boardForm.description.trim(),
access_scope: boardForm.access,
members: boardForm.access === "restricted" ? boardForm.member_ids : [],
departments: boardForm.access === "restricted" ? boardForm.department_ids : [],
};
const saved = boardForm.id
if (boardForm.id && boardForm.avatar_removed) {
payload.avatar = null;
}
let saved = boardForm.id
? await apiClient.updateTaskBoard(boardForm.id, payload)
: await apiClient.createTaskBoard(payload);
if (boardForm.avatar_file) {
saved = await apiClient.uploadTaskBoardAvatar(saved.id, boardForm.avatar_file);
}
await loadBoards();
await loadBoard(saved.id);
router.push(`/tasks?board=${saved.id}`, { scroll: false });
@ -3426,6 +3611,8 @@ function TasksPageContent() {
}
}, [
boardForm.access,
boardForm.avatar_file,
boardForm.avatar_removed,
boardForm.department_ids,
boardForm.description,
boardForm.id,
@ -3474,9 +3661,28 @@ function TasksPageContent() {
}
}, [loadBoardGroups, loadBoards, saving]);
const toggleDefaultBoardGroup = useCallback(async (group: TaskBoardGroup) => {
if (saving) return;
setSaving(true);
setError(null);
try {
const result = await apiClient.toggleDefaultTaskBoardGroup(group.id);
setBoardGroups((current) => current.map((item) => ({
...item,
is_default_for_current_user: result.is_default_for_current_user
? item.id === group.id
: false,
})));
} catch (groupError) {
setError(getTaskError(groupError, "Не удалось изменить группу по умолчанию"));
} finally {
setSaving(false);
}
}, [saving]);
const moveBoardToGroup = useCallback(async (boardId: number, groupId: number | null) => {
const previousGroupId = boards.find((item) => item.id === boardId)?.group_id || null;
if (previousGroupId === groupId || saving) return;
if (previousGroupId === groupId || saving) return false;
setBoards((current) => current.map((item) => (
item.id === boardId ? { ...item, group_id: groupId } : item
)));
@ -3486,6 +3692,7 @@ function TasksPageContent() {
try {
await apiClient.moveTaskBoardToGroup(boardId, groupId);
await loadBoardGroups();
return true;
} catch (groupError) {
setBoards((current) => current.map((item) => (
item.id === boardId ? { ...item, group_id: previousGroupId } : item
@ -3494,6 +3701,7 @@ function TasksPageContent() {
? { ...current, group_id: previousGroupId }
: current);
setError(getTaskError(groupError, "Не удалось переместить доску"));
return false;
} finally {
setSaving(false);
}
@ -4587,10 +4795,16 @@ function TasksPageContent() {
onCreateGroup={openCreateBoardGroup}
onEditGroup={openEditBoardGroup}
onDeleteGroup={deleteBoardGroup}
onToggleDefaultGroup={(group) => void toggleDefaultBoardGroup(group)}
onEditBoard={openEditBoard}
onDeleteBoard={(targetBoard) => void deleteBoard(targetBoard)}
onSetDefaultBoard={(targetBoard) => void setDefaultBoard(targetBoard)}
onToggleBoardPin={(targetBoard) => void toggleBoardPin(targetBoard)}
onToggleBoardNotifications={(targetBoard) => void toggleBoardNotifications(targetBoard)}
onMoveBoard={moveBoardToGroup}
wideMode={desktopWideMode}
onWideModeChange={changeDesktopWideMode}
className={`${mobileBoardOpen ? "hidden" : "flex"} h-[calc(100dvh-7rem)] w-full ${desktopWideMode ? "lg:flex lg:h-full lg:w-[24rem] lg:shrink-0" : ""}`}
className={`${mobileBoardOpen ? "hidden" : "flex"} h-[calc(100dvh-7rem)] w-full ${desktopWideMode ? "lg:flex lg:h-full lg:shrink-0" : ""}`}
/>
<div className={`${mobileBoardOpen ? "flex" : "hidden"} relative min-h-0 min-w-0 flex-1 flex-col gap-4 ${desktopWideMode ? "lg:flex lg:h-full" : ""}`}>
@ -4606,6 +4820,13 @@ function TasksPageContent() {
>
<ArrowLeft size={17} />
</button>
{board ? (
<TaskBoardAvatar
name={board.name}
src={board.avatar}
size="md"
/>
) : null}
<div className="min-w-0">
<h1 className="truncate text-xl font-semibold text-[var(--foreground)]">
{board?.name || "Доска задач"}
@ -4642,7 +4863,7 @@ function TasksPageContent() {
<div className="app-menu absolute right-0 top-full z-20 mt-2 w-56 rounded-xl py-1.5">
<button
type="button"
onClick={openEditBoard}
onClick={() => openEditBoard()}
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} className="app-text-muted shrink-0" />
@ -4669,7 +4890,7 @@ function TasksPageContent() {
</button>
<button
type="button"
onClick={() => void setDefaultSelectedBoard()}
onClick={() => board && void setDefaultBoard(board)}
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)}
>
@ -4816,9 +5037,22 @@ function TasksPageContent() {
>
<div
ref={boardScrollRef}
className={`tasks-board-scroll min-w-0 overflow-x-hidden pb-3 xl:overflow-x-auto ${desktopWideMode ? "lg:min-h-0 lg:flex-1" : ""}`}
onPointerDownCapture={startBoardPan}
onPointerMoveCapture={moveBoardPan}
onPointerUpCapture={finishBoardPan}
onPointerCancelCapture={finishBoardPan}
onAuxClick={(event) => {
if (event.button === 1) event.preventDefault();
}}
className={`tasks-board-scroll min-w-0 overflow-hidden pb-3 xl:overflow-auto ${
boardPanning ? "cursor-grabbing select-none" : ""
} ${
desktopWideMode
? "lg:min-h-0 lg:flex-1"
: "xl:max-h-[calc(100dvh-15rem)]"
}`}
>
<div className={`flex w-full min-w-0 gap-3 xl:w-auto xl:min-w-max ${desktopWideMode ? "lg:h-full" : ""}`}>
<div className="flex w-full min-w-0 gap-3 xl:min-h-full xl:w-auto xl:min-w-max xl:items-stretch">
{activeColumns.map((column) => (
<BoardColumn
key={column.id}
@ -4852,7 +5086,7 @@ function TasksPageContent() {
))}
<AddColumnCard
onClick={openCreateColumn}
fillAvailableHeight={desktopWideMode}
viewportHeight={boardViewportHeight}
/>
</div>
</div>
@ -4894,6 +5128,48 @@ function TasksPageContent() {
)}
>
<div className="space-y-4">
<div>
<span className="app-text-muted mb-2 block text-xs font-medium">Аватар доски</span>
<div className="flex items-center gap-3">
<TaskBoardAvatar
name={boardForm.name || "Доска"}
src={
boardForm.avatar_preview
|| (!boardForm.avatar_removed ? boardForm.avatar : null)
}
size="lg"
/>
<div className="flex min-w-0 flex-wrap gap-2">
<label className="app-action-secondary inline-flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium">
<ImagePlus size={15} />
<span>
{boardForm.avatar_preview || (boardForm.avatar && !boardForm.avatar_removed)
? "Заменить"
: "Загрузить"}
</span>
<input
type="file"
accept="image/*"
className="hidden"
onChange={(event) => {
void handleBoardAvatarChange(event.target.files?.[0] || null);
event.currentTarget.value = "";
}}
/>
</label>
{boardForm.avatar_preview || (boardForm.avatar && !boardForm.avatar_removed) ? (
<button
type="button"
onClick={removeBoardAvatar}
className="app-action-danger inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium"
>
<Trash2 size={15} />
Удалить
</button>
) : null}
</div>
</div>
</div>
<label className="block">
<span className="app-text-muted mb-1 block text-xs font-medium">Название доски</span>
<input

View File

@ -81,7 +81,7 @@ export default function MessagesChatListItem({
data-testid="messages-chat-list-item"
href={`/messages/${chat.id}`}
className={`block w-full rounded-xl border p-3 text-left transition ${
active || unread > 0
active
? "app-selected"
: "border-transparent hover:border-[var(--border-subtle)] hover:bg-[var(--surface-secondary)]"
}`}

View File

@ -0,0 +1,54 @@
"use client";
import Image from "next/image";
import { resolveMediaUrl } from "@/lib/url";
type TaskBoardAvatarProps = {
name: string;
src?: string | null;
size?: "sm" | "md" | "lg";
className?: string;
};
const sizeClasses = {
sm: "h-10 w-10",
md: "h-10 w-10",
lg: "h-16 w-16",
};
function getBoardInitials(name: string): string {
const letters = Array.from(name.trim()).filter((character) => /[\p{L}\p{N}]/u.test(character));
return letters.slice(0, 2).join("").toUpperCase() || "Д";
}
export default function TaskBoardAvatar({
name,
src,
size = "sm",
className = "",
}: TaskBoardAvatarProps) {
const imageUrl = resolveMediaUrl(src);
return (
<span
className={`app-avatar-fallback flex shrink-0 items-center justify-center overflow-hidden rounded-full ${sizeClasses[size]} ${className}`}
aria-hidden="true"
>
{imageUrl ? (
<Image
src={imageUrl}
alt=""
width={64}
height={64}
unoptimized
className="h-full w-full object-cover"
/>
) : (
<span className={`${size === "lg" ? "text-lg" : "text-xs"} font-semibold`}>
{getBoardInitials(name)}
</span>
)}
</span>
);
}

View File

@ -1,16 +1,20 @@
"use client";
import {
Bell,
BellOff,
Check,
ChevronDown,
ChevronLeft,
ChevronRight,
Filter,
FolderPlus,
FolderInput,
Kanban,
LockKeyhole,
Maximize2,
Minimize2,
Plus,
Pencil,
Pin,
Search,
Star,
Trash2,
@ -18,17 +22,13 @@ import {
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import TaskBoardAvatar from "@/components/tasks/TaskBoardAvatar";
import { Modal } from "@/components/ui";
import type { TaskBoardGroup, TaskBoardSummary } from "@/types/api";
type BoardSection = {
key: string;
label: string;
groupId: number | null;
boards: TaskBoardSummary[];
};
type BoardUnreadFilter = "all" | "unread" | "read";
type BoardAccessFilter = "all" | "private" | "shared";
type BoardGroupFilter = "all" | "ungrouped" | number;
type TaskBoardNavigatorProps = {
boards: TaskBoardSummary[];
@ -40,7 +40,16 @@ type TaskBoardNavigatorProps = {
onCreateGroup: () => void;
onEditGroup: (group: TaskBoardGroup) => void;
onDeleteGroup: (group: TaskBoardGroup) => void;
onMoveBoard: (boardId: number, groupId: number | null) => void;
onToggleDefaultGroup: (group: TaskBoardGroup) => void;
onEditBoard: (board: TaskBoardSummary) => void;
onDeleteBoard: (board: TaskBoardSummary) => void;
onSetDefaultBoard: (board: TaskBoardSummary) => void;
onToggleBoardPin: (board: TaskBoardSummary) => void;
onToggleBoardNotifications: (board: TaskBoardSummary) => void;
onMoveBoard: (
boardId: number,
groupId: number | null,
) => boolean | void | Promise<boolean | void>;
wideMode: boolean;
onWideModeChange: (enabled: boolean) => void;
className?: string;
@ -60,55 +69,59 @@ function matchesBoard(board: TaskBoardSummary, query: string) {
return haystack.includes(query);
}
function buildSections(
boards: TaskBoardSummary[],
groups: TaskBoardGroup[],
query: string,
): BoardSection[] {
const filtered = query
? boards.filter((board) => matchesBoard(board, query))
: boards;
if (query) {
return filtered.length > 0
? [{ key: "search", label: "Результаты", groupId: null, boards: filtered }]
: [];
}
return [
{
key: "ungrouped",
label: "Без группы",
groupId: null,
boards: filtered.filter((board) => !board.group_id),
},
...groups.map((group) => ({
key: `group-${group.id}`,
label: group.name,
groupId: group.id,
boards: filtered.filter((board) => board.group_id === group.id),
})),
];
}
function BoardRow({
board,
active,
unread,
onSelect,
groups,
menuOpen,
onToggleMenu,
onEdit,
onDelete,
onSetDefault,
onTogglePin,
onToggleNotifications,
onMove,
compact = false,
}: {
board: TaskBoardSummary;
active: boolean;
unread: number;
onSelect: () => void;
groups: TaskBoardGroup[];
menuOpen: boolean;
onToggleMenu: () => void;
onMove: (groupId: number | null) => void;
onEdit: () => void;
onDelete: () => void;
onSetDefault: () => void;
onTogglePin: () => void;
onToggleNotifications: () => void;
onMove: () => void;
compact?: boolean;
}) {
if (compact) {
return (
<div className="relative flex justify-center">
<button
type="button"
onClick={onSelect}
className={`relative rounded-xl p-1.5 transition ${
active ? "app-selected" : "hover:bg-[var(--surface-secondary)]"
}`}
title={board.name}
aria-label={`Открыть доску «${board.name}»`}
aria-current={active ? "page" : undefined}
>
<TaskBoardAvatar name={board.name} src={board.avatar} />
{unread > 0 ? (
<span className="absolute -right-1 -top-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-sky-500 px-1 text-[9px] font-bold text-white">
{unread > 99 ? "99+" : unread}
</span>
) : null}
</button>
</div>
);
}
return (
<div className="relative">
<button
@ -119,9 +132,11 @@ function BoardRow({
}`}
aria-current={active ? "page" : undefined}
>
<span className={`mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${active ? "app-badge-accent" : "app-surface-muted"}`}>
<Kanban size={17} />
</span>
<TaskBoardAvatar
name={board.name}
src={board.avatar}
className="mt-0.5"
/>
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-center gap-1.5">
<span className="min-w-0 flex-1 truncate text-sm font-semibold text-[var(--foreground)]">
@ -130,6 +145,12 @@ function BoardRow({
{board.is_default_for_current_user ? (
<Star size={12} className="app-accent-text shrink-0 fill-current" />
) : null}
{board.is_pinned ? (
<Pin size={12} className="app-accent-text shrink-0 fill-current" />
) : null}
{board.notifications_enabled === false ? (
<BellOff size={12} className="app-text-muted shrink-0" />
) : null}
{unread > 0 ? (
<span className="flex h-5 min-w-5 shrink-0 items-center justify-center rounded-full bg-sky-500 px-1.5 text-[10px] font-bold text-white">
{unread > 99 ? "99+" : unread}
@ -154,29 +175,79 @@ function BoardRow({
type="button"
onClick={onToggleMenu}
className="app-action-ghost absolute right-2 top-2 flex h-7 w-7 items-center justify-center rounded-md"
title="Переместить доску"
aria-label={`Переместить доску «${board.name}»`}
title="Управление доской"
aria-label={`Управление доской «${board.name}»`}
aria-expanded={menuOpen}
>
<ChevronDown size={14} className={`transition-transform ${menuOpen ? "rotate-180" : ""}`} />
<ChevronRight size={14} className={`transition-transform ${menuOpen ? "rotate-90" : ""}`} />
</button>
{menuOpen ? (
<div className="app-surface-elevated absolute right-2 top-10 z-30 min-w-52 overflow-hidden rounded-lg border border-[var(--border-subtle)] py-1 shadow-xl">
<p className="app-card-caption px-3 py-2">Переместить в</p>
{[{ id: null, name: "Без группы" }, ...groups].map((group) => {
const selected = (board.group_id || null) === group.id;
return (
<button
key={group.id ?? "ungrouped"}
type="button"
onClick={() => onMove(group.id)}
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)]"
>
<Check size={14} className={selected ? "app-accent-text" : "invisible"} />
<span className="min-w-0 flex-1 truncate">{group.name}</span>
</button>
);
})}
<button
type="button"
onClick={onEdit}
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} className="app-text-muted" />
Редактировать
</button>
<button
type="button"
onClick={onTogglePin}
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)]"
>
<Pin
size={14}
className={board.is_pinned ? "app-accent-text fill-current" : "app-text-muted"}
/>
{board.is_pinned ? "Открепить" : "Закрепить"}
</button>
<button
type="button"
onClick={onSetDefault}
disabled={board.is_default_for_current_user}
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"
>
<Star
size={14}
className={board.is_default_for_current_user ? "app-accent-text fill-current" : "app-text-muted"}
/>
{board.is_default_for_current_user
? "Доска по умолчанию"
: "Сделать по умолчанию"}
</button>
<button
type="button"
onClick={onToggleNotifications}
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)]"
>
{board.notifications_enabled === false ? (
<Bell size={14} className="app-text-muted" />
) : (
<BellOff size={14} className="app-text-muted" />
)}
{board.notifications_enabled === false
? "Включить уведомления"
: "Отключить уведомления"}
</button>
<div className="my-1 border-t border-[var(--border-subtle)]" />
<button
type="button"
onClick={onMove}
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)]"
>
<FolderInput size={14} className="app-text-muted" />
Переместить в группу
</button>
<div className="my-1 border-t border-[var(--border-subtle)]" />
<button
type="button"
onClick={onDelete}
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)]"
>
<Trash2 size={14} />
Удалить
</button>
</div>
) : null}
</div>
@ -193,6 +264,12 @@ export default function TaskBoardNavigator({
onCreateGroup,
onEditGroup,
onDeleteGroup,
onToggleDefaultGroup,
onEditBoard,
onDeleteBoard,
onSetDefaultBoard,
onToggleBoardPin,
onToggleBoardNotifications,
onMoveBoard,
wideMode,
onWideModeChange,
@ -202,11 +279,20 @@ export default function TaskBoardNavigator({
const [filtersOpen, setFiltersOpen] = useState(false);
const [unreadFilter, setUnreadFilter] = useState<BoardUnreadFilter>("all");
const [accessFilter, setAccessFilter] = useState<BoardAccessFilter>("all");
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const [groupFilter, setGroupFilter] = useState<BoardGroupFilter>("all");
const [boardMenuId, setBoardMenuId] = useState<number | null>(null);
const [groupMenuId, setGroupMenuId] = useState<number | null>(null);
const [navigatorCollapsed, setNavigatorCollapsed] = useState(false);
const [moveBoardTarget, setMoveBoardTarget] = useState<TaskBoardSummary | null>(null);
const [moveGroupId, setMoveGroupId] = useState<number | null>(null);
const [moveGroupQuery, setMoveGroupQuery] = useState("");
const [moveSubmitting, setMoveSubmitting] = useState(false);
const menusRef = useRef<HTMLDivElement>(null);
const defaultGroupInitializedRef = useRef(false);
const normalizedQuery = query.trim().toLowerCase();
const effectiveGroupFilter = (
typeof groupFilter !== "number" || groups.some((group) => group.id === groupFilter)
) ? groupFilter : "all";
const filteredBoards = useMemo(() => boards.filter((board) => {
const unread = unreadCounts[board.id] || 0;
if (unreadFilter === "unread" && unread === 0) return false;
@ -215,19 +301,67 @@ export default function TaskBoardNavigator({
if (accessFilter === "shared" && board.access_scope === "private") return false;
return true;
}), [accessFilter, boards, unreadCounts, unreadFilter]);
const sections = useMemo(
() => buildSections(filteredBoards, groups, normalizedQuery),
[filteredBoards, groups, normalizedQuery],
);
const activeFiltersCount = [unreadFilter !== "all", accessFilter !== "all"].filter(Boolean).length;
const toggleSection = (key: string) => {
setCollapsed((current) => {
const next = new Set(current);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
const visibleBoards = useMemo(() => filteredBoards.filter((board) => {
if (effectiveGroupFilter === "ungrouped" && board.group_id) return false;
if (typeof effectiveGroupFilter === "number" && board.group_id !== effectiveGroupFilter) return false;
return !normalizedQuery || matchesBoard(board, normalizedQuery);
}), [effectiveGroupFilter, filteredBoards, normalizedQuery]);
const groupStats = useMemo(() => {
const buildStats = (matchingBoards: TaskBoardSummary[]) => ({
total: matchingBoards.length,
unread: matchingBoards.reduce(
(total, board) => total + (unreadCounts[board.id] || 0),
0,
),
});
return {
all: buildStats(filteredBoards),
ungrouped: buildStats(filteredBoards.filter((board) => !board.group_id)),
byId: new Map(groups.map((group) => [
group.id,
buildStats(filteredBoards.filter((board) => board.group_id === group.id)),
])),
};
}, [filteredBoards, groups, unreadCounts]);
const activeFiltersCount = [unreadFilter !== "all", accessFilter !== "all"].filter(Boolean).length;
const compactNavigator = wideMode && navigatorCollapsed;
const filteredMoveGroups = useMemo(() => {
const moveQuery = moveGroupQuery.trim().toLowerCase();
return moveQuery
? groups.filter((group) => group.name.toLowerCase().includes(moveQuery))
: groups;
}, [groups, moveGroupQuery]);
useEffect(() => {
if (defaultGroupInitializedRef.current || groups.length === 0) return;
const defaultGroup = groups.find((group) => group.is_default_for_current_user);
setGroupFilter(defaultGroup?.id ?? "all");
defaultGroupInitializedRef.current = true;
}, [groups]);
const closeMoveModal = () => {
if (moveSubmitting) return;
setMoveBoardTarget(null);
setMoveGroupId(null);
setMoveGroupQuery("");
};
const confirmMove = async () => {
if (!moveBoardTarget || moveSubmitting) return;
if ((moveBoardTarget.group_id || null) === moveGroupId) {
closeMoveModal();
return;
}
setMoveSubmitting(true);
try {
const moved = await onMoveBoard(moveBoardTarget.id, moveGroupId);
if (moved === false) return;
setMoveBoardTarget(null);
setMoveGroupId(null);
setMoveGroupQuery("");
} finally {
setMoveSubmitting(false);
}
};
useEffect(() => {
@ -243,42 +377,72 @@ export default function TaskBoardNavigator({
}, [boardMenuId, groupMenuId]);
return (
<aside className={`app-surface flex min-h-0 flex-col overflow-hidden rounded-2xl ${className}`}>
<div className="flex items-center justify-between gap-2 p-4 pb-0">
<p className="app-card-caption min-w-0 truncate">Доски</p>
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
onClick={() => onWideModeChange(!wideMode)}
className="app-action-ghost hidden h-9 w-9 items-center justify-center rounded-lg lg:flex"
title={wideMode ? "Вернуть обычный вид" : "Развернуть доску"}
aria-label={wideMode ? "Вернуть обычный вид" : "Развернуть доску"}
aria-pressed={wideMode}
>
{wideMode ? <Minimize2 size={17} /> : <Maximize2 size={17} />}
</button>
<button
type="button"
onClick={onCreateGroup}
className="app-action-ghost flex h-9 w-9 items-center justify-center rounded-lg"
title="Создать группу"
aria-label="Создать группу досок"
>
<FolderPlus size={17} />
</button>
<button
type="button"
onClick={onCreate}
className="app-action-primary inline-flex h-9 items-center justify-center gap-1.5 rounded-lg px-3 text-sm font-semibold"
>
<Plus size={14} />
Создать доску
</button>
<>
<aside
ref={menusRef}
className={`app-surface flex min-h-0 flex-col overflow-hidden rounded-2xl transition-[width] duration-200 ${
wideMode ? (compactNavigator ? "lg:w-[4.75rem]" : "lg:w-[24rem]") : ""
} ${className}`}
>
<div
className={`flex items-center gap-2 pb-0 ${
compactNavigator ? "justify-center p-3" : "justify-between p-4"
}`}
>
{wideMode ? (
<button
type="button"
onClick={() => {
setNavigatorCollapsed((current) => !current);
setBoardMenuId(null);
setGroupMenuId(null);
}}
className={`app-action-ghost flex items-center justify-center rounded-lg ${
compactNavigator ? "h-9 w-9" : "min-w-0 gap-1 px-1 py-1"
}`}
title={compactNavigator ? "Развернуть список досок" : "Свернуть список досок"}
aria-label={compactNavigator ? "Развернуть список досок" : "Свернуть список досок"}
aria-expanded={!compactNavigator}
>
{compactNavigator ? (
<Kanban size={17} />
) : (
<>
<ChevronLeft size={14} className="shrink-0" />
<span className="app-card-caption min-w-0 truncate">Доски</span>
</>
)}
</button>
) : (
<p className="app-card-caption min-w-0 truncate">Доски</p>
)}
{!compactNavigator ? (
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
onClick={() => onWideModeChange(!wideMode)}
className="app-action-ghost hidden h-9 w-9 items-center justify-center rounded-lg lg:flex"
title={wideMode ? "Вернуть обычный вид" : "Развернуть доску"}
aria-label={wideMode ? "Вернуть обычный вид" : "Развернуть доску"}
aria-pressed={wideMode}
>
{wideMode ? <Minimize2 size={17} /> : <Maximize2 size={17} />}
</button>
<button
type="button"
onClick={onCreate}
className="app-action-primary inline-flex h-9 items-center justify-center gap-1.5 rounded-lg px-3 text-sm font-semibold"
>
<Plus size={14} />
Создать доску
</button>
</div>
) : null}
</div>
</div>
{!compactNavigator ? (
<div className="p-4 pb-0">
<div className="mb-4 flex gap-2">
<div className="mb-3 flex gap-2">
<label className="relative min-w-0 flex-1">
<Search size={16} className="app-text-muted pointer-events-none absolute left-3 top-1/2 -translate-y-1/2" />
<input
@ -346,46 +510,97 @@ export default function TaskBoardNavigator({
) : null}
</div>
) : null}
</div>
<div ref={menusRef} className="tasks-column-scroll min-h-0 flex-1 overflow-y-auto px-4 pb-4">
{sections.length > 0 ? sections.map((section) => {
const isCollapsed = collapsed.has(section.key);
return (
<section key={section.key} className="mb-2 last:mb-0">
<div className="relative flex items-center gap-1">
<div className="mb-4 flex flex-wrap gap-2">
{[
{ key: "all" as const, label: "Все", stats: groupStats.all },
{ key: "ungrouped" as const, label: "Без группы", stats: groupStats.ungrouped },
].map(({ key, label, stats }) => {
const active = effectiveGroupFilter === key;
return (
<button
key={key}
type="button"
onClick={() => setGroupFilter(key)}
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition ${
active ? "app-pill-active" : "app-pill"
}`}
>
<span>{label}</span>
<span className={`app-badge inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-bold ${
active ? "app-pill-count-active" : "app-pill-count"
}`}>
<span>{stats.total}</span>
{stats.unread > 0 ? (
<>
<span className="app-text-muted">•</span>
<span className="app-accent-text">{stats.unread}</span>
</>
) : null}
</span>
</button>
);
})}
{groups.map((group) => {
const active = effectiveGroupFilter === group.id;
const stats = groupStats.byId.get(group.id) || { total: 0, unread: 0 };
return (
<div
key={group.id}
className={`relative inline-flex items-center rounded-full transition ${
active ? "app-pill-active" : "app-pill"
}`}
>
<button
type="button"
onClick={() => toggleSection(section.key)}
className="app-action-ghost flex min-w-0 flex-1 items-center gap-2 rounded-lg px-2 py-2 text-left"
aria-expanded={!isCollapsed}
onClick={() => setGroupFilter(group.id)}
className="inline-flex min-w-0 items-center gap-1.5 py-1.5 pl-3 pr-7 text-xs font-medium"
>
<ChevronDown
size={14}
className={`app-text-muted shrink-0 transition-transform ${isCollapsed ? "-rotate-90" : ""}`}
/>
<span className="app-card-caption min-w-0 flex-1 truncate">{section.label}</span>
<span className="app-text-muted text-[11px]">{section.boards.length}</span>
<span className="max-w-28 truncate">{group.name}</span>
<span className={`app-badge inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-bold ${
active ? "app-pill-count-active" : "app-pill-count"
}`}>
<span>{stats.total}</span>
{stats.unread > 0 ? (
<>
<span className="app-text-muted">•</span>
<span className="app-accent-text">{stats.unread}</span>
</>
) : null}
</span>
</button>
{section.groupId !== null ? (
<button
type="button"
onClick={() => setGroupMenuId((current) => current === section.groupId ? null : section.groupId)}
className="app-action-ghost flex h-8 w-8 shrink-0 items-center justify-center rounded-lg"
title="Управление группой"
aria-label={`Управление группой «${section.label}»`}
aria-expanded={groupMenuId === section.groupId}
>
<ChevronDown size={14} className={`transition-transform ${groupMenuId === section.groupId ? "rotate-180" : ""}`} />
</button>
) : null}
{groupMenuId === section.groupId && section.groupId !== null ? (
<div className="app-surface-elevated absolute right-0 top-9 z-30 min-w-44 overflow-hidden rounded-lg border border-[var(--border-subtle)] py-1 shadow-xl">
<button
type="button"
onClick={() => setGroupMenuId((current) => current === group.id ? null : group.id)}
className="absolute right-0.5 flex h-6 w-6 items-center justify-center rounded-full"
title={`Управление группой «${group.name}»`}
aria-label={`Управление группой «${group.name}»`}
aria-expanded={groupMenuId === group.id}
>
<ChevronRight size={12} className={`transition-transform ${groupMenuId === group.id ? "rotate-90" : ""}`} />
</button>
{groupMenuId === group.id ? (
<div className="app-surface-elevated absolute left-0 top-8 z-30 min-w-44 overflow-hidden rounded-lg border border-[var(--border-subtle)] py-1 shadow-xl">
<button
type="button"
onClick={() => {
const group = groups.find((item) => item.id === section.groupId);
if (group) onEditGroup(group);
onToggleDefaultGroup(group);
setGroupMenuId(null);
}}
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)]"
>
<Star
size={14}
className={group.is_default_for_current_user ? "app-accent-text fill-current" : ""}
/>
{group.is_default_for_current_user
? "Убрать группу по умолчанию"
: "Сделать группой по умолчанию"}
</button>
<button
type="button"
onClick={() => {
onEditGroup(group);
setGroupMenuId(null);
}}
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)]"
@ -396,8 +611,8 @@ export default function TaskBoardNavigator({
<button
type="button"
onClick={() => {
const group = groups.find((item) => item.id === section.groupId);
if (group) onDeleteGroup(group);
if (groupFilter === group.id) setGroupFilter("all");
onDeleteGroup(group);
setGroupMenuId(null);
}}
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)]"
@ -408,40 +623,175 @@ export default function TaskBoardNavigator({
</div>
) : null}
</div>
{!isCollapsed ? (
<div className="space-y-1">
{section.boards.map((board) => (
<BoardRow
key={board.id}
board={board}
active={selectedBoardId === board.id}
unread={unreadCounts[board.id] || 0}
onSelect={() => onSelect(board.id)}
groups={groups}
menuOpen={boardMenuId === board.id}
onToggleMenu={() => setBoardMenuId((current) => current === board.id ? null : board.id)}
onMove={(groupId) => {
onMoveBoard(board.id, groupId);
setBoardMenuId(null);
}}
/>
))}
{section.boards.length === 0 ? (
<p className="app-text-muted px-3 py-3 text-xs">В группе пока нет досок</p>
) : null}
</div>
) : null}
</section>
);
}) : (
);
})}
<button
type="button"
onClick={onCreateGroup}
className="app-pill inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full transition"
title="Создать группу"
aria-label="Создать группу досок"
>
<Plus size={14} />
</button>
</div>
</div>
) : null}
<div className={`tasks-column-scroll min-h-0 flex-1 overflow-y-auto pb-4 ${compactNavigator ? "px-2 pt-2" : "px-4"}`}>
{visibleBoards.length > 0 ? (
<div className="space-y-1">
{visibleBoards.map((board) => (
<BoardRow
key={board.id}
board={board}
active={selectedBoardId === board.id}
unread={unreadCounts[board.id] || 0}
compact={compactNavigator}
onSelect={() => onSelect(board.id)}
menuOpen={boardMenuId === board.id}
onToggleMenu={() => setBoardMenuId((current) => current === board.id ? null : board.id)}
onEdit={() => {
onEditBoard(board);
setBoardMenuId(null);
}}
onDelete={() => {
onDeleteBoard(board);
setBoardMenuId(null);
}}
onSetDefault={() => {
onSetDefaultBoard(board);
setBoardMenuId(null);
}}
onTogglePin={() => {
onToggleBoardPin(board);
setBoardMenuId(null);
}}
onToggleNotifications={() => {
onToggleBoardNotifications(board);
setBoardMenuId(null);
}}
onMove={() => {
setMoveBoardTarget(board);
setMoveGroupId(board.group_id || null);
setMoveGroupQuery("");
setBoardMenuId(null);
}}
/>
))}
</div>
) : compactNavigator ? (
<div className="app-text-muted flex justify-center py-5" title="Доски не найдены">
<Kanban size={20} />
</div>
) : (
<div className="app-surface-muted m-2 rounded-xl border border-dashed border-[var(--border-subtle)] px-4 py-8 text-center">
<Kanban size={24} className="app-text-muted mx-auto mb-2" />
<p className="text-sm font-medium text-[var(--foreground)]">
{normalizedQuery ? "Доски не найдены" : "Досок пока нет"}
{normalizedQuery ? "Доски не найдены" : "В этой группе пока нет досок"}
</p>
</div>
)}
</div>
</aside>
<Modal
isOpen={Boolean(moveBoardTarget)}
onClose={closeMoveModal}
title="Переместить доску"
size="sm"
closeOnClickOutside
closeOnEsc={!moveSubmitting}
footer={(
<div className="flex justify-end gap-2">
<button
type="button"
onClick={closeMoveModal}
disabled={moveSubmitting}
className="app-action-secondary rounded-lg px-4 py-2 text-sm font-medium disabled:opacity-60"
>
Отмена
</button>
<button
type="button"
onClick={() => void confirmMove()}
disabled={moveSubmitting || (moveBoardTarget?.group_id || null) === moveGroupId}
className="app-action-primary rounded-lg px-4 py-2 text-sm font-medium disabled:opacity-60"
>
{moveSubmitting ? "Перемещение..." : "Переместить"}
</button>
</div>
)}
>
<div className="space-y-4">
{moveBoardTarget ? (
<div className="app-surface-muted flex items-center gap-3 rounded-xl p-3">
<TaskBoardAvatar
name={moveBoardTarget.name}
src={moveBoardTarget.avatar}
size="md"
/>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-[var(--foreground)]">
{moveBoardTarget.name}
</p>
<p className="app-text-muted mt-0.5 text-xs">Выберите новую группу</p>
</div>
</div>
) : null}
{groups.length > 5 ? (
<label className="relative block">
<Search
size={15}
className="app-text-muted pointer-events-none absolute left-3 top-1/2 -translate-y-1/2"
/>
<input
value={moveGroupQuery}
onChange={(event) => setMoveGroupQuery(event.target.value)}
className="app-input w-full rounded-lg py-2.5 pl-9 pr-3 text-sm"
placeholder="Поиск групп"
aria-label="Поиск групп досок"
/>
</label>
) : null}
<div className="tasks-column-scroll max-h-[45vh] space-y-1 overflow-y-auto pr-1">
<button
type="button"
onClick={() => setMoveGroupId(null)}
className={`flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm transition ${
moveGroupId === null ? "app-selected" : "hover:bg-[var(--surface-secondary)]"
}`}
>
<Check size={15} className={moveGroupId === null ? "app-accent-text" : "invisible"} />
<span className="min-w-0 flex-1 truncate">Без группы</span>
</button>
{filteredMoveGroups.map((group) => (
<button
key={group.id}
type="button"
onClick={() => setMoveGroupId(group.id)}
className={`flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm transition ${
moveGroupId === group.id ? "app-selected" : "hover:bg-[var(--surface-secondary)]"
}`}
>
<Check
size={15}
className={moveGroupId === group.id ? "app-accent-text" : "invisible"}
/>
<span className="min-w-0 flex-1 truncate">{group.name}</span>
<span className="app-badge rounded-full px-2 py-0.5 text-[10px]">
{group.boards_count}
</span>
</button>
))}
{filteredMoveGroups.length === 0 ? (
<p className="app-text-muted px-3 py-5 text-center text-sm">Группы не найдены</p>
) : null}
</div>
</div>
</Modal>
</>
);
}

View File

@ -53,6 +53,14 @@ export function createTasksApi(request: RequestFn, requestRaw: RawRequestFn) {
method: "PATCH",
body: JSON.stringify(data),
}),
uploadTaskBoardAvatar: (id: number, file: File): Promise<TaskBoard> => {
const formData = new FormData();
formData.append("avatar", file);
return request(`/api/v1/tasks/boards/${id}/`, {
method: "PATCH",
body: formData,
});
},
deleteTaskBoard: (id: number): Promise<void> =>
request(`/api/v1/tasks/boards/${id}/`, { method: "DELETE" }),
moveTaskBoardToGroup: (
@ -65,6 +73,12 @@ export function createTasksApi(request: RequestFn, requestRaw: RawRequestFn) {
}),
setDefaultTaskBoard: (id: number): Promise<TaskBoard> =>
request(`/api/v1/tasks/boards/${id}/set-default/`, { method: "POST" }),
toggleTaskBoardPin: (id: number): Promise<{ ok: boolean; is_pinned: boolean }> =>
request(`/api/v1/tasks/boards/${id}/pin/`, { method: "POST" }),
toggleTaskBoardNotifications: (
id: number,
): Promise<{ ok: boolean; notifications_enabled: boolean }> =>
request(`/api/v1/tasks/boards/${id}/notifications/`, { method: "POST" }),
getTaskBoardGroups: (): Promise<
TaskBoardGroup[] | { results: TaskBoardGroup[] }
> =>
@ -84,6 +98,15 @@ export function createTasksApi(request: RequestFn, requestRaw: RawRequestFn) {
}),
deleteTaskBoardGroup: (id: number): Promise<void> =>
request(`/api/v1/tasks/board-groups/${id}/`, { method: "DELETE" }),
toggleDefaultTaskBoardGroup: (
id: number,
): Promise<{
group: TaskBoardGroup;
is_default_for_current_user: boolean;
}> =>
request(`/api/v1/tasks/board-groups/${id}/set-default/`, {
method: "POST",
}),
markTaskBoardNotificationsRead: (
id: number,
): Promise<{ status: string; count: number; notification_ids: number[] }> =>

View File

@ -234,6 +234,7 @@ export interface TaskBoard {
id: number;
name: string;
description?: string;
avatar?: string | null;
access_scope?: "all" | "private" | "restricted";
created_by?: User;
members?: number[];
@ -247,6 +248,8 @@ export interface TaskBoard {
tasks_count?: number;
is_default_for_current_user?: boolean;
group_id?: number | null;
is_pinned?: boolean;
notifications_enabled?: boolean;
created_at: string;
updated_at: string;
}
@ -255,6 +258,7 @@ export interface TaskBoardSummary {
id: number;
name: string;
description?: string;
avatar?: string | null;
access_scope?: "all" | "private" | "restricted";
created_by?: User;
member_details?: User[];
@ -264,6 +268,8 @@ export interface TaskBoardSummary {
completed_tasks_count: number;
is_default_for_current_user?: boolean;
group_id?: number | null;
is_pinned?: boolean;
notifications_enabled?: boolean;
created_at: string;
updated_at: string;
}
@ -273,6 +279,7 @@ export interface TaskBoardGroup {
name: string;
position: number;
boards_count: number;
is_default_for_current_user: boolean;
created_at: string;
updated_at: string;
}