feat(tasks): improve board navigation and collaboration
This commit is contained in:
parent
60c82b6d5a
commit
f4a6b2de9b
@ -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)
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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"]
|
||||
|
||||
18
backend/tasks/migrations/0021_taskboard_avatar.py
Normal file
18
backend/tasks/migrations/0021_taskboard_avatar.py
Normal 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='Аватар доски'),
|
||||
),
|
||||
]
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -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="Группа досок по умолчанию",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -972,6 +972,7 @@ html[data-bs-theme="dark"] .shadow-xl {
|
||||
}
|
||||
|
||||
.tasks-board-scroll::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)]"
|
||||
}`}
|
||||
|
||||
54
frontend/src/components/tasks/TaskBoardAvatar.tsx
Normal file
54
frontend/src/components/tasks/TaskBoardAvatar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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[] }> =>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user