From 8cad1832cdf50068ffb95ab7a5b1e41910f1ed15 Mon Sep 17 00:00:00 2001 From: lizerk Date: Fri, 7 Aug 2026 17:51:07 +0300 Subject: [PATCH] feat(tasks): share board groups collaboratively --- backend/api/v1/tasks/serializers.py | 53 ++++-- backend/api/v1/tasks/views.py | 126 ++++++++----- backend/tasks/access.py | 9 + backend/tasks/admin.py | 16 +- .../0037_shared_task_board_groups.py | 95 ++++++++++ backend/tasks/models.py | 19 +- backend/tests/api/v1/tasks/test_tasks_api.py | 166 +++++++++++++++++- frontend/src/app/tasks/page.tsx | 97 +++++++--- .../components/tasks/TaskBoardNavigator.tsx | 113 ++++++++++-- frontend/src/lib/api/tasks.ts | 10 +- frontend/src/types/api.ts | 3 + 11 files changed, 591 insertions(+), 116 deletions(-) create mode 100644 backend/tasks/migrations/0037_shared_task_board_groups.py diff --git a/backend/api/v1/tasks/serializers.py b/backend/api/v1/tasks/serializers.py index ef9f43ce..77c4b648 100644 --- a/backend/api/v1/tasks/serializers.py +++ b/backend/api/v1/tasks/serializers.py @@ -1,6 +1,6 @@ from urllib.parse import urlsplit -from django.db.models import Count, Max +from django.db.models import Count, Max, Q from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.urls import reverse @@ -860,22 +860,19 @@ class TaskBoardSerializer(serializers.ModelSerializer): @extend_schema_field(serializers.ListField(child=serializers.IntegerField())) def get_group_ids(self, obj): - placements = getattr(obj, "current_user_placements", None) - if placements is not None: - placement = placements[0] if placements else None - else: + groups = getattr(obj, "current_user_groups", None) + if groups is None: request = self.context.get("request") user = getattr(request, "user", None) if not user or not user.is_authenticated: return [] - placement = ( - TaskBoardPlacement.objects.filter(user=user, board=obj) - .prefetch_related("groups") - .first() + groups = TaskBoardGroup.objects.filter( + Q(user=user) | Q(is_shared=True), + boards=obj, + ).exclude( + hidden_by=user, ) - if not placement: - return [] - return sorted(group.id for group in placement.groups.all()) + return sorted(group.id for group in groups) @extend_schema_field(serializers.IntegerField(allow_null=True)) def get_group_id(self, obj): @@ -1003,6 +1000,8 @@ class TaskBoardSummarySerializer(TaskBoardSerializer): class TaskBoardGroupSerializer(serializers.ModelSerializer): boards_count = serializers.IntegerField(read_only=True, default=0) is_default_for_current_user = serializers.SerializerMethodField() + is_hidden = serializers.SerializerMethodField() + is_owner = serializers.SerializerMethodField() class Meta: model = TaskBoardGroup @@ -1010,8 +1009,11 @@ class TaskBoardGroupSerializer(serializers.ModelSerializer): "id", "name", "position", + "is_shared", "boards_count", "is_default_for_current_user", + "is_hidden", + "is_owner", "created_at", "updated_at", ] @@ -1022,10 +1024,12 @@ class TaskBoardGroupSerializer(serializers.ModelSerializer): if not name: raise serializers.ValidationError("Укажите название группы.") request = self.context.get("request") - queryset = TaskBoardGroup.objects.filter( - user=getattr(request, "user", None), - name__iexact=name, + owner_id = ( + self.instance.user_id + if self.instance + else getattr(getattr(request, "user", None), "id", None) ) + queryset = TaskBoardGroup.objects.filter(user_id=owner_id, name__iexact=name) if self.instance: queryset = queryset.exclude(id=self.instance.id) if queryset.exists(): @@ -1048,6 +1052,25 @@ class TaskBoardGroupSerializer(serializers.ModelSerializer): ) return self._default_group_id == obj.id + @extend_schema_field(serializers.BooleanField()) + def get_is_hidden(self, obj): + hidden_by = getattr(obj, "current_user_hidden_by", None) + if hidden_by is not None: + return bool(hidden_by) + request = self.context.get("request") + user = getattr(request, "user", None) + return bool( + user + and getattr(user, "is_authenticated", False) + and obj.hidden_by.filter(id=user.id).exists() + ) + + @extend_schema_field(serializers.BooleanField()) + def get_is_owner(self, obj): + request = self.context.get("request") + user = getattr(request, "user", None) + return bool(user and obj.user_id == getattr(user, "id", None)) + class TaskLinkedPostSerializer(serializers.ModelSerializer): created_by = EmployeeBriefSerializer(read_only=True) diff --git a/backend/api/v1/tasks/views.py b/backend/api/v1/tasks/views.py index d3ce442a..eb6b0c50 100644 --- a/backend/api/v1/tasks/views.py +++ b/backend/api/v1/tasks/views.py @@ -37,6 +37,7 @@ from rest_framework.response import Response from schedule.models import Event from tasks.access import ( + task_board_group_access_q, task_board_access_q, user_can_access_calendar_event, user_can_access_document, @@ -339,9 +340,20 @@ class TaskBoardViewSet(viewsets.ModelViewSet): "user_placements", queryset=TaskBoardPlacement.objects.filter( user=self.request.user, - ).prefetch_related("groups"), + ), to_attr="current_user_placements", ), + Prefetch( + "board_groups", + queryset=( + TaskBoardGroup.objects.filter( + Q(user=self.request.user) | Q(is_shared=True), + ) + .exclude(hidden_by=self.request.user) + .distinct() + ), + to_attr="current_user_groups", + ), ) .annotate( current_user_is_pinned=Coalesce( @@ -627,9 +639,8 @@ class TaskBoardViewSet(viewsets.ModelViewSet): status=status.HTTP_400_BAD_REQUEST, ) group = get_object_or_404( - TaskBoardGroup, + TaskBoardGroup.objects.filter(task_board_group_access_q(request.user)), id=group_id, - user=request.user, ) return Response(self._set_board_groups(board, [group])) @@ -649,12 +660,12 @@ class TaskBoardViewSet(viewsets.ModelViewSet): {"groups": ["Идентификаторы групп должны быть числами."]}, status=status.HTTP_400_BAD_REQUEST, ) - groups = list( - TaskBoardGroup.objects.filter( - user=request.user, - id__in=group_ids, - ).order_by("id") + available_groups = ( + TaskBoardGroup.objects.filter(task_board_group_access_q(request.user)) + .exclude(hidden_by=request.user) + .distinct() ) + groups = list(available_groups.filter(id__in=group_ids).order_by("id")) if len(groups) != len(group_ids): return Response( {"groups": ["Одна или несколько групп не найдены."]}, @@ -664,27 +675,30 @@ class TaskBoardViewSet(viewsets.ModelViewSet): def _set_board_groups(self, board, groups): with transaction.atomic(): - placement = TaskBoardPlacement.objects.select_for_update().filter( - user=self.request.user, - board=board, - ).first() - if placement is None and groups: - placement = TaskBoardPlacement.objects.create( - user=self.request.user, - board=board, + TaskBoard.objects.select_for_update().get(pk=board.pk) + editable_groups = ( + TaskBoardGroup.objects.filter( + task_board_group_access_q(self.request.user), + boards=board, ) - if placement: - placement.groups.set(groups) - if ( - not groups - and not placement.is_pinned - and placement.notifications_enabled - and not placement.view_state - ): - placement.delete() - else: - placement.save(update_fields=["updated_at"]) - group_ids = sorted(group.id for group in groups) + .exclude(hidden_by=self.request.user) + .distinct() + ) + desired_ids = {group.id for group in groups} + removable_groups = list(editable_groups.exclude(id__in=desired_ids)) + if removable_groups: + board.board_groups.remove(*removable_groups) + if groups: + board.board_groups.add(*groups) + group_ids = sorted( + TaskBoardGroup.objects.filter( + Q(user=self.request.user) | Q(is_shared=True), + boards=board, + ) + .exclude(hidden_by=self.request.user) + .values_list("id", flat=True) + .distinct() + ) return { "board": board.id, "group_ids": group_ids, @@ -703,7 +717,6 @@ class TaskBoardViewSet(viewsets.ModelViewSet): is_pinned = placement.is_pinned if ( not placement.is_pinned - and not placement.groups.exists() and placement.notifications_enabled and not placement.view_state ): @@ -723,7 +736,6 @@ class TaskBoardViewSet(viewsets.ModelViewSet): notifications_enabled = placement.notifications_enabled if ( placement.notifications_enabled - and not placement.groups.exists() and not placement.is_pinned and not placement.view_state ): @@ -803,7 +815,6 @@ class TaskBoardViewSet(viewsets.ModelViewSet): placement.view_state = state if ( not state - and not placement.groups.exists() and not placement.is_pinned and placement.notifications_enabled ): @@ -840,9 +851,28 @@ class TaskBoardGroupViewSet(viewsets.ModelViewSet): serializer_class = TaskBoardGroupSerializer def get_queryset(self): + accessible_boards = TaskBoard.objects.filter( + task_board_access_q(self.request.user) + ) return ( - TaskBoardGroup.objects.filter(user=self.request.user) - .annotate(boards_count=Count("board_placements", distinct=True)) + TaskBoardGroup.objects.filter( + task_board_group_access_q(self.request.user) + ) + .prefetch_related( + Prefetch( + "hidden_by", + queryset=Employee.objects.filter(id=self.request.user.id), + to_attr="current_user_hidden_by", + ) + ) + .annotate( + boards_count=Count( + "boards", + filter=Q(boards__in=accessible_boards), + distinct=True, + ) + ) + .distinct() .order_by("position", "name", "id") ) @@ -855,6 +885,7 @@ class TaskBoardGroupViewSet(viewsets.ModelViewSet): @action(detail=True, methods=["post"], url_path="set-default") def set_default(self, request, pk=None): group = self.get_object() + group.hidden_by.remove(request.user) 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 @@ -867,16 +898,25 @@ class TaskBoardGroupViewSet(viewsets.ModelViewSet): } ) - def perform_destroy(self, instance): - user = self.request.user - instance.delete() - TaskBoardPlacement.objects.filter( - user=user, - groups__isnull=True, - is_pinned=False, - notifications_enabled=True, - view_state={}, - ).delete() + @action(detail=True, methods=["post"], url_path="toggle-hidden") + def toggle_hidden(self, request, pk=None): + group = self.get_object() + is_hidden = group.hidden_by.filter(id=request.user.id).exists() + if is_hidden: + group.hidden_by.remove(request.user) + else: + group.hidden_by.add(request.user) + TaskUserSettings.objects.filter( + user=request.user, + default_group=group, + ).update(default_group=None) + group.current_user_hidden_by = [] if is_hidden else [request.user] + return Response( + { + "group": self.get_serializer(group).data, + "is_hidden": not is_hidden, + } + ) class TaskColumnViewSet(viewsets.ModelViewSet): diff --git a/backend/tasks/access.py b/backend/tasks/access.py index c1a181b9..50a76c8b 100644 --- a/backend/tasks/access.py +++ b/backend/tasks/access.py @@ -51,6 +51,15 @@ def task_board_access_q(user): return q +def task_board_group_access_q(user): + """Groups owned by the user or shared through at least one accessible board.""" + accessible_boards = TaskBoard.objects.filter(task_board_access_q(user)) + return Q(user=user) | Q( + is_shared=True, + boards__in=accessible_boards, + ) + + def user_can_access_task_board(user, board: TaskBoard | None) -> bool: if not user or not user.is_authenticated or board is None: return False diff --git a/backend/tasks/admin.py b/backend/tasks/admin.py index 160d49ef..00c9a657 100644 --- a/backend/tasks/admin.py +++ b/backend/tasks/admin.py @@ -43,30 +43,26 @@ class TaskBoardAdmin(admin.ModelAdmin): @admin.register(TaskBoardGroup) class TaskBoardGroupAdmin(admin.ModelAdmin): - list_display = ["name", "user", "position", "created_at"] + list_display = ["name", "user", "is_shared", "position", "created_at"] + list_filter = ["is_shared"] search_fields = ["name", "user__email"] - autocomplete_fields = ["user"] + autocomplete_fields = ["user", "boards", "hidden_by"] @admin.register(TaskBoardPlacement) class TaskBoardPlacementAdmin(admin.ModelAdmin): list_display = [ "board", - "group_names", "user", "is_pinned", "notifications_enabled", "updated_at", ] - list_filter = ["is_pinned", "notifications_enabled", "groups"] - search_fields = ["board__name", "groups__name", "user__email"] - autocomplete_fields = ["board", "groups", "user"] + list_filter = ["is_pinned", "notifications_enabled"] + search_fields = ["board__name", "user__email"] + autocomplete_fields = ["board", "user"] readonly_fields = ["view_state"] - @admin.display(description="Группы") - def group_names(self, obj): - return ", ".join(obj.groups.values_list("name", flat=True)) or "—" - @admin.register(TaskColumn) class TaskColumnAdmin(admin.ModelAdmin): diff --git a/backend/tasks/migrations/0037_shared_task_board_groups.py b/backend/tasks/migrations/0037_shared_task_board_groups.py new file mode 100644 index 00000000..113b143e --- /dev/null +++ b/backend/tasks/migrations/0037_shared_task_board_groups.py @@ -0,0 +1,95 @@ +# Generated by Django 5.2.4 on 2026-08-07 14:41 + +from django.conf import settings +from django.db import migrations, models + + +def copy_personal_group_boards(apps, schema_editor): + placement_model = apps.get_model("tasks", "TaskBoardPlacement") + group_model = apps.get_model("tasks", "TaskBoardGroup") + placement_boards = dict( + placement_model.objects.values_list("id", "board_id") + ) + old_through = placement_model.groups.through + new_through = group_model.boards.through + links = [] + for placement_id, group_id in old_through.objects.values_list( + "taskboardplacement_id", + "taskboardgroup_id", + ).iterator(): + board_id = placement_boards.get(placement_id) + if board_id: + links.append( + new_through( + taskboardgroup_id=group_id, + taskboard_id=board_id, + ) + ) + new_through.objects.bulk_create(links, ignore_conflicts=True) + + +def restore_personal_group_placements(apps, schema_editor): + placement_model = apps.get_model("tasks", "TaskBoardPlacement") + group_model = apps.get_model("tasks", "TaskBoardGroup") + old_through = placement_model.groups.through + new_through = group_model.boards.through + group_owners = dict(group_model.objects.values_list("id", "user_id")) + for group_id, board_id in new_through.objects.values_list( + "taskboardgroup_id", + "taskboard_id", + ).iterator(): + owner_id = group_owners.get(group_id) + if not owner_id: + continue + placement, _ = placement_model.objects.get_or_create( + user_id=owner_id, + board_id=board_id, + ) + old_through.objects.get_or_create( + taskboardplacement_id=placement.id, + taskboardgroup_id=group_id, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('tasks', '0036_taskboardplacement_multiple_groups'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="taskboardgroup", + name="boards", + field=models.ManyToManyField( + blank=True, + related_name="board_groups", + to="tasks.taskboard", + verbose_name="Доски", + ), + ), + migrations.AddField( + model_name="taskboardgroup", + name="hidden_by", + field=models.ManyToManyField( + blank=True, + related_name="hidden_task_board_groups", + to=settings.AUTH_USER_MODEL, + verbose_name="Скрыта пользователями", + ), + ), + migrations.AddField( + model_name="taskboardgroup", + name="is_shared", + field=models.BooleanField(default=False, verbose_name="Общая"), + ), + migrations.RunPython( + copy_personal_group_boards, + restore_personal_group_placements, + ), + migrations.RemoveField( + model_name="taskboardplacement", + name="groups", + ), + ] diff --git a/backend/tasks/models.py b/backend/tasks/models.py index 17d71cd8..4794e005 100644 --- a/backend/tasks/models.py +++ b/backend/tasks/models.py @@ -114,6 +114,19 @@ class TaskBoardGroup(models.Model): related_name="task_board_groups", verbose_name="Пользователь", ) + boards = models.ManyToManyField( + TaskBoard, + blank=True, + related_name="board_groups", + verbose_name="Доски", + ) + is_shared = models.BooleanField("Общая", default=False) + hidden_by = models.ManyToManyField( + settings.AUTH_USER_MODEL, + blank=True, + related_name="hidden_task_board_groups", + verbose_name="Скрыта пользователями", + ) position = models.PositiveIntegerField("Позиция", default=0) created_at = models.DateTimeField("Создано", auto_now_add=True) updated_at = models.DateTimeField("Обновлено", auto_now=True) @@ -146,12 +159,6 @@ class TaskBoardPlacement(models.Model): related_name="user_placements", verbose_name="Доска", ) - groups = models.ManyToManyField( - TaskBoardGroup, - 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) diff --git a/backend/tests/api/v1/tasks/test_tasks_api.py b/backend/tests/api/v1/tasks/test_tasks_api.py index 865a266f..ddef64b4 100644 --- a/backend/tests/api/v1/tasks/test_tasks_api.py +++ b/backend/tests/api/v1/tasks/test_tasks_api.py @@ -338,8 +338,7 @@ def test_board_groups_are_personal_and_board_placement_is_independent( ) assert groups_response.status_code == status.HTTP_200_OK assert groups_response.data["group_ids"] == sorted([group_id, second_group_id]) - placement = TaskBoardPlacement.objects.get(user=user, board=board) - assert set(placement.groups.values_list("id", flat=True)) == { + assert set(board.board_groups.values_list("id", flat=True)) == { group_id, second_group_id, } @@ -390,8 +389,7 @@ def test_board_groups_are_personal_and_board_placement_is_independent( ) assert delete_response.status_code == status.HTTP_204_NO_CONTENT assert not TaskBoardGroup.objects.filter(id=group_id).exists() - placement.refresh_from_db() - assert list(placement.groups.values_list("id", flat=True)) == [second_group_id] + assert list(board.board_groups.values_list("id", flat=True)) == [second_group_id] grouped_summary_response = api_client.get( reverse("api:v1:tasks:task-board-list"), @@ -411,6 +409,164 @@ def test_board_groups_are_personal_and_board_placement_is_independent( assert not TaskBoardPlacement.objects.filter(user=user, board=board).exists() +def test_shared_board_group_is_collaborative_without_granting_board_access( + api_client, +): + owner = make_user("shared-group-owner@example.com", "+79994440110") + first_member = make_user("shared-group-first@example.com", "+79994440111") + second_member = make_user("shared-group-second@example.com", "+79994440112") + role_member = make_user("shared-group-role@example.com", "+79994440113") + outsider = make_user("shared-group-outsider@example.com", "+79994440114") + department = Department.objects.create(name="Общий проектный отдел") + role = department.roles.create(name="Участник общей группы") + RoleAssignment.objects.create( + employee=role_member, + role=role, + is_active=True, + ) + first_board = TaskBoard.objects.create( + name="Первая закрытая доска", + created_by=owner, + access_scope="restricted", + ) + first_board.members.add(first_member) + role_board = TaskBoard.objects.create( + name="Доска отдела", + created_by=owner, + access_scope="restricted", + ) + role_board.departments.add(department) + second_board = TaskBoard.objects.create( + name="Добавленная участником доска", + created_by=first_member, + access_scope="restricted", + ) + second_board.members.add(second_member) + + api_client.force_authenticate(user=owner) + create_response = api_client.post( + reverse("api:v1:tasks:task-board-group-list"), + {"name": "Общая работа", "is_shared": True}, + format="json", + ) + assert create_response.status_code == status.HTTP_201_CREATED + group_id = create_response.data["id"] + for board in (first_board, role_board): + response = api_client.post( + reverse("api:v1:tasks:task-board-set-groups", kwargs={"pk": board.id}), + {"groups": [group_id]}, + format="json", + ) + assert response.status_code == status.HTTP_200_OK + + api_client.force_authenticate(user=first_member) + group_response = api_client.get( + reverse("api:v1:tasks:task-board-group-list") + ) + assert group_response.data["results"][0]["id"] == group_id + assert group_response.data["results"][0]["boards_count"] == 1 + board_response = api_client.get( + reverse("api:v1:tasks:task-board-list"), + {"view": "summary"}, + ) + assert {item["id"] for item in board_response.data["results"]} == { + first_board.id, + second_board.id, + } + assert next( + item for item in board_response.data["results"] if item["id"] == first_board.id + )["group_ids"] == [group_id] + assert next( + item for item in board_response.data["results"] if item["id"] == second_board.id + )["group_ids"] == [] + + add_response = api_client.post( + reverse( + "api:v1:tasks:task-board-set-groups", + kwargs={"pk": second_board.id}, + ), + {"groups": [group_id]}, + format="json", + ) + assert add_response.status_code == status.HTTP_200_OK + rename_response = api_client.patch( + reverse("api:v1:tasks:task-board-group-detail", kwargs={"pk": group_id}), + {"name": "Переименована участником"}, + format="json", + ) + assert rename_response.status_code == status.HTTP_200_OK + + hide_response = api_client.post( + reverse("api:v1:tasks:task-board-group-toggle-hidden", kwargs={"pk": group_id}) + ) + assert hide_response.status_code == status.HTTP_200_OK + assert hide_response.data["is_hidden"] is True + hidden_boards_response = api_client.get( + reverse("api:v1:tasks:task-board-list"), + {"view": "summary"}, + ) + assert all( + group_id not in item["group_ids"] + for item in hidden_boards_response.data["results"] + ) + unhide_response = api_client.post( + reverse("api:v1:tasks:task-board-group-toggle-hidden", kwargs={"pk": group_id}) + ) + assert unhide_response.data["is_hidden"] is False + default_response = api_client.post( + reverse("api:v1:tasks:task-board-group-set-default", kwargs={"pk": group_id}) + ) + assert default_response.status_code == status.HTTP_200_OK + assert TaskUserSettings.objects.get(user=first_member).default_group_id == group_id + remove_board_response = api_client.post( + reverse( + "api:v1:tasks:task-board-set-groups", + kwargs={"pk": first_board.id}, + ), + {"groups": []}, + format="json", + ) + assert remove_board_response.status_code == status.HTTP_200_OK + assert not first_board.board_groups.filter(id=group_id).exists() + + api_client.force_authenticate(user=role_member) + role_groups_response = api_client.get( + reverse("api:v1:tasks:task-board-group-list") + ) + assert role_groups_response.data["results"][0]["id"] == group_id + role_boards_response = api_client.get( + reverse("api:v1:tasks:task-board-list"), + {"view": "summary"}, + ) + assert [item["id"] for item in role_boards_response.data["results"]] == [ + role_board.id + ] + assert role_boards_response.data["results"][0]["group_ids"] == [group_id] + + api_client.force_authenticate(user=outsider) + outsider_groups_response = api_client.get( + reverse("api:v1:tasks:task-board-group-list") + ) + assert outsider_groups_response.data["results"] == [] + outsider_update_response = api_client.patch( + reverse("api:v1:tasks:task-board-group-detail", kwargs={"pk": group_id}), + {"name": "Недоступное изменение"}, + format="json", + ) + assert outsider_update_response.status_code == status.HTTP_404_NOT_FOUND + + api_client.force_authenticate(user=second_member) + second_groups_response = api_client.get( + reverse("api:v1:tasks:task-board-group-list") + ) + assert second_groups_response.data["results"][0]["id"] == group_id + delete_response = api_client.delete( + reverse("api:v1:tasks:task-board-group-detail", kwargs={"pk": group_id}) + ) + assert delete_response.status_code == status.HTTP_204_NO_CONTENT + assert not TaskBoardGroup.objects.filter(id=group_id).exists() + + 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) @@ -496,7 +652,6 @@ def test_board_pin_and_notifications_are_personal_and_keep_ungrouped_settings( 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 not placement.groups.exists() assert placement.is_pinned is True assert placement.notifications_enabled is False @@ -522,7 +677,6 @@ def test_board_pin_and_notifications_are_personal_and_keep_ungrouped_settings( format="json", ) placement.refresh_from_db() - assert not placement.groups.exists() assert placement.is_pinned is True assert placement.notifications_enabled is False diff --git a/frontend/src/app/tasks/page.tsx b/frontend/src/app/tasks/page.tsx index 6fa212d0..79fcffbd 100644 --- a/frontend/src/app/tasks/page.tsx +++ b/frontend/src/app/tasks/page.tsx @@ -161,6 +161,7 @@ type BoardFormState = { type BoardGroupFormState = { id: number | null; name: string; + is_shared: boolean; }; type ColumnFormState = { @@ -280,6 +281,7 @@ const emptyBoardForm: BoardFormState = { const emptyBoardGroupForm: BoardGroupFormState = { id: null, name: "", + is_shared: false, }; const taskDescriptionPreviewClass = @@ -5171,7 +5173,11 @@ function TasksPageContent() { }, []); const openEditBoardGroup = useCallback((group: TaskBoardGroup) => { - setBoardGroupForm({ id: group.id, name: group.name }); + setBoardGroupForm({ + id: group.id, + name: group.name, + is_shared: group.is_shared, + }); setBoardGroupModalOpen(true); }, []); @@ -5508,11 +5514,17 @@ function TasksPageContent() { setError(null); try { if (boardGroupForm.id) { - await apiClient.updateTaskBoardGroup(boardGroupForm.id, { name }); + await apiClient.updateTaskBoardGroup(boardGroupForm.id, { + name, + is_shared: boardGroupForm.is_shared, + }); } else { - await apiClient.createTaskBoardGroup({ name }); + await apiClient.createTaskBoardGroup({ + name, + is_shared: boardGroupForm.is_shared, + }); } - await loadBoardGroups(); + await Promise.all([loadBoardGroups(), loadBoards()]); setBoardGroupModalOpen(false); setBoardGroupForm(emptyBoardGroupForm); } catch (groupError) { @@ -5520,7 +5532,14 @@ function TasksPageContent() { } finally { setSaving(false); } - }, [boardGroupForm.id, boardGroupForm.name, loadBoardGroups, saving]); + }, [ + boardGroupForm.id, + boardGroupForm.is_shared, + boardGroupForm.name, + loadBoardGroups, + loadBoards, + saving, + ]); const deleteBoardGroup = useCallback(async (group: TaskBoardGroup) => { if (saving) return; @@ -5560,6 +5579,20 @@ function TasksPageContent() { } }, [saving]); + const toggleBoardGroupHidden = useCallback(async (group: TaskBoardGroup) => { + if (saving) return; + setSaving(true); + setError(null); + try { + await apiClient.toggleTaskBoardGroupHidden(group.id); + await Promise.all([loadBoardGroups(), loadBoards()]); + } catch (groupError) { + setError(getTaskError(groupError, "Не удалось изменить видимость группы")); + } finally { + setSaving(false); + } + }, [loadBoardGroups, loadBoards, saving]); + const setBoardGroupMemberships = useCallback(async ( boardId: number, groupIds: number[], @@ -7338,6 +7371,7 @@ function TasksPageContent() { onEditGroup={openEditBoardGroup} onDeleteGroup={deleteBoardGroup} onToggleDefaultGroup={(group) => void toggleDefaultBoardGroup(group)} + onToggleHiddenGroup={(group) => void toggleBoardGroupHidden(group)} onEditBoard={openEditBoard} onDeleteBoard={(targetBoard) => void deleteBoard(targetBoard)} onSetDefaultBoard={(targetBoard) => void setDefaultBoard(targetBoard)} @@ -8127,22 +8161,43 @@ function TasksPageContent() { )} > - +
+ + +
void; onDeleteGroup: (group: TaskBoardGroup) => void; onToggleDefaultGroup: (group: TaskBoardGroup) => void; + onToggleHiddenGroup: (group: TaskBoardGroup) => void; onEditBoard: (board: TaskBoardSummary) => void; onDeleteBoard: (board: TaskBoardSummary) => void; onSetDefaultBoard: (board: TaskBoardSummary) => void; @@ -304,6 +307,7 @@ export default function TaskBoardNavigator({ onEditGroup, onDeleteGroup, onToggleDefaultGroup, + onToggleHiddenGroup, onEditBoard, onDeleteBoard, onSetDefaultBoard, @@ -328,11 +332,20 @@ export default function TaskBoardNavigator({ const [moveGroupQuery, setMoveGroupQuery] = useState(""); const [moveSubmitting, setMoveSubmitting] = useState(false); const [participantsBoard, setParticipantsBoard] = useState(null); + const [hiddenGroupsOpen, setHiddenGroupsOpen] = useState(false); const menusRef = useRef(null); const defaultGroupInitializedRef = useRef(false); const normalizedQuery = query.trim().toLowerCase(); + const visibleGroups = useMemo( + () => groups.filter((group) => !group.is_hidden), + [groups], + ); + const hiddenGroups = useMemo( + () => groups.filter((group) => group.is_hidden), + [groups], + ); const effectiveGroupFilter = ( - typeof groupFilter !== "number" || groups.some((group) => group.id === groupFilter) + typeof groupFilter !== "number" || visibleGroups.some((group) => group.id === groupFilter) ) ? groupFilter : "all"; const filteredBoards = useMemo(() => boards.filter((board) => { const unread = unreadCounts[board.id] || 0; @@ -359,31 +372,31 @@ export default function TaskBoardNavigator({ return { all: buildStats(filteredBoards), ungrouped: buildStats(filteredBoards.filter((board) => boardGroupIds(board).length === 0)), - byId: new Map(groups.map((group) => [ + byId: new Map(visibleGroups.map((group) => [ group.id, buildStats(filteredBoards.filter((board) => boardGroupIds(board).includes(group.id))), ])), }; - }, [filteredBoards, groups, unreadCounts]); + }, [filteredBoards, unreadCounts, visibleGroups]); const groupNames = useMemo( - () => new Map(groups.map((group) => [group.id, group.name])), - [groups], + () => new Map(visibleGroups.map((group) => [group.id, group.name])), + [visibleGroups], ); 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]); + ? visibleGroups.filter((group) => group.name.toLowerCase().includes(moveQuery)) + : visibleGroups; + }, [moveGroupQuery, visibleGroups]); useEffect(() => { - if (defaultGroupInitializedRef.current || groups.length === 0) return; - const defaultGroup = groups.find((group) => group.is_default_for_current_user); + if (defaultGroupInitializedRef.current || visibleGroups.length === 0) return; + const defaultGroup = visibleGroups.find((group) => group.is_default_for_current_user); setGroupFilter(defaultGroup?.id ?? "all"); defaultGroupInitializedRef.current = true; - }, [groups]); + }, [visibleGroups]); useEffect(() => { if (!userId) { @@ -612,7 +625,7 @@ export default function TaskBoardNavigator({ ); })} - {groups.map((group) => { + {visibleGroups.map((group) => { const active = effectiveGroupFilter === group.id; const stats = groupStats.byId.get(group.id) || { total: 0, unread: 0 }; return ( @@ -628,6 +641,9 @@ export default function TaskBoardNavigator({ className="inline-flex min-w-0 items-center gap-1.5 py-1.5 pl-3 pr-7 text-xs font-medium" > {group.name} + {group.is_shared ? ( + + ) : null} @@ -679,6 +695,18 @@ export default function TaskBoardNavigator({ Редактировать + + {hiddenGroups.length > 0 ? ( + + ) : null} ) : null} @@ -774,6 +816,51 @@ export default function TaskBoardNavigator({ + setHiddenGroupsOpen(false)} + title="Скрытые группы" + size="sm" + closeOnClickOutside + > +
+ {hiddenGroups.map((group) => ( +
+ +
+
+ + {group.name} + + {group.is_shared ? ( + + ) : null} +
+

+ {group.boards_count} досок +

+
+ +
+ ))} + {hiddenGroups.length === 0 ? ( +

+ Скрытых групп нет +

+ ) : null} +
+
+ setParticipantsBoard(null)} @@ -963,7 +1050,7 @@ export default function TaskBoardNavigator({ ) : null} - {groups.length > 5 ? ( + {visibleGroups.length > 5 ? (