From a889086661acbb76b4989d6b26277281ff650fff Mon Sep 17 00:00:00 2001 From: lizerk Date: Thu, 6 Aug 2026 00:41:43 +0300 Subject: [PATCH] feat: improve task boards and chat controls --- backend/api/v1/tasks/serializers.py | 135 ++- backend/api/v1/tasks/urls.py | 2 + backend/api/v1/tasks/views.py | 239 ++++- backend/tasks/admin.py | 12 +- .../0025_taskcolumnrow_taskcolumn_row.py | 37 + .../0026_move_task_column_row_to_task.py | 50 + .../0027_taskboardplacement_view_state.py | 19 + backend/tasks/models.py | 43 +- backend/tests/api/v1/tasks/test_tasks_api.py | 198 +++- frontend/src/app/messages/page.tsx | 10 +- frontend/src/app/tasks/page.tsx | 891 ++++++++++++++++-- .../components/messages/ChatSettingsModal.tsx | 248 +++++ .../messages/MessagesChatListItem.tsx | 133 ++- .../messages/MessagesFocusSidebar.tsx | 3 +- .../components/tasks/TaskBoardNavigator.tsx | 203 +++- frontend/src/hooks/useMessagesChatList.ts | 5 +- frontend/src/hooks/useMessagesDirectory.ts | 3 +- frontend/src/lib/api/tasks.ts | 31 +- frontend/src/types/api.ts | 22 + 19 files changed, 2167 insertions(+), 117 deletions(-) create mode 100644 backend/tasks/migrations/0025_taskcolumnrow_taskcolumn_row.py create mode 100644 backend/tasks/migrations/0026_move_task_column_row_to_task.py create mode 100644 backend/tasks/migrations/0027_taskboardplacement_view_state.py create mode 100644 frontend/src/components/messages/ChatSettingsModal.tsx diff --git a/backend/api/v1/tasks/serializers.py b/backend/api/v1/tasks/serializers.py index 47b17d19..bd083a2d 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 Max +from django.db.models import Count, Max from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.urls import reverse @@ -44,8 +44,10 @@ from tasks.models import ( TaskBoard, TaskBoardAccessScope, TaskBoardGroup, + TaskBoardPlacement, TaskChecklistItem, TaskColumn, + TaskColumnRow, TaskExternalLink, TaskLabel, TaskLinkedObject, @@ -205,6 +207,54 @@ class TaskColumnSerializer(serializers.ModelSerializer): return super().create(validated_data) +class TaskColumnRowSerializer(serializers.ModelSerializer): + tasks_count = serializers.IntegerField(read_only=True, default=0) + + class Meta: + model = TaskColumnRow + fields = [ + "id", + "column", + "name", + "position", + "color", + "tasks_count", + "created_at", + "updated_at", + ] + read_only_fields = ["id", "tasks_count", "created_at", "updated_at"] + + def validate_column(self, column): + validate_board_access(self, column.board) + if column.parent_id: + raise serializers.ValidationError( + "Дорожки можно создавать только в основной колонке." + ) + if column.is_archived: + raise serializers.ValidationError( + "Нельзя создать дорожку в архивной колонке." + ) + return column + + def validate(self, attrs): + column = attrs.get("column") or getattr(self.instance, "column", None) + if self.instance and column and self.instance.column_id != column.id: + raise serializers.ValidationError( + {"column": "Основную колонку дорожки изменить нельзя."} + ) + name = attrs.get("name", getattr(self.instance, "name", "")).strip() + if not name: + raise serializers.ValidationError({"name": "Укажите название дорожки."}) + duplicate = TaskColumnRow.objects.filter(column=column, name__iexact=name) + if self.instance: + duplicate = duplicate.exclude(pk=self.instance.pk) + if duplicate.exists(): + raise serializers.ValidationError( + {"name": "Дорожка с таким названием уже существует в колонке."} + ) + attrs["name"] = name + return attrs + class TaskSerializer(serializers.ModelSerializer): created_by = EmployeeBriefSerializer(read_only=True) assignee = EmployeeBriefSerializer(read_only=True) @@ -238,6 +288,8 @@ class TaskSerializer(serializers.ModelSerializer): board_name = serializers.CharField(source="board.name", read_only=True) column_name = serializers.CharField(source="column.name", read_only=True) column_color = serializers.CharField(source="column.color", read_only=True) + row_name = serializers.CharField(source="row.name", read_only=True) + row_color = serializers.CharField(source="row.color", read_only=True) linked_posts_count = serializers.SerializerMethodField() linked_messages_count = serializers.SerializerMethodField() linked_events_count = serializers.SerializerMethodField() @@ -263,6 +315,9 @@ class TaskSerializer(serializers.ModelSerializer): "column", "column_name", "column_color", + "row", + "row_name", + "row_color", "title", "description", "created_by", @@ -302,6 +357,8 @@ class TaskSerializer(serializers.ModelSerializer): "board_name", "column_name", "column_color", + "row_name", + "row_color", "priority_display", "completed_at", "linked_posts_count", @@ -443,6 +500,11 @@ class TaskSerializer(serializers.ModelSerializer): user = getattr(request, "user", None) board = attrs.get("board") or getattr(self.instance, "board", None) column = attrs.get("column") or getattr(self.instance, "column", None) + row = ( + attrs.get("row") + if "row" in attrs + else getattr(self.instance, "row", None) + ) labels = attrs.get("labels") participants = attrs.get("participants") @@ -461,6 +523,13 @@ class TaskSerializer(serializers.ModelSerializer): {"column": "Выберите подколонку для размещения задачи."} ) + if row and column: + top_level_column_id = column.parent_id or column.id + if row.column_id != top_level_column_id: + raise serializers.ValidationError( + {"row": "Дорожка должна принадлежать основной колонке задачи."} + ) + if labels is not None and board: invalid_labels = [ label.id for label in labels if label.board_id != board.id @@ -493,8 +562,9 @@ class TaskSerializer(serializers.ModelSerializer): participants = list(validated_data.pop("participants", [])) if "position" not in validated_data: column = validated_data["column"] + row = validated_data.get("row") max_position = ( - Task.objects.filter(column=column).aggregate(Max("position"))[ + Task.objects.filter(column=column, row=row).aggregate(Max("position"))[ "position__max" ] or 0 @@ -560,6 +630,7 @@ class TaskBoardSerializer(serializers.ModelSerializer): read_only=True, ) columns = TaskColumnSerializer(many=True, read_only=True) + column_rows = serializers.SerializerMethodField() labels = TaskLabelSerializer(many=True, read_only=True) tasks = TaskSerializer(many=True, read_only=True) tasks_count = serializers.IntegerField(read_only=True, default=0) @@ -580,6 +651,7 @@ class TaskBoardSerializer(serializers.ModelSerializer): read_only=True, default=True, ) + view_state = serializers.SerializerMethodField() class Meta: model = TaskBoard @@ -596,6 +668,7 @@ class TaskBoardSerializer(serializers.ModelSerializer): "department_details", "is_archived", "columns", + "column_rows", "labels", "tasks", "tasks_count", @@ -603,6 +676,7 @@ class TaskBoardSerializer(serializers.ModelSerializer): "group_id", "is_pinned", "notifications_enabled", + "view_state", "created_at", "updated_at", ] @@ -629,6 +703,63 @@ class TaskBoardSerializer(serializers.ModelSerializer): attrs["departments"] = [] return attrs + @extend_schema_field(serializers.DictField()) + def get_view_state(self, obj): + request = self.context.get("request") + user = getattr(request, "user", None) + if not user or not getattr(user, "is_authenticated", False): + return { + "hidden_column_ids": [], + "collapsed_base_column_ids": [], + "collapsed_row_ids": [], + } + raw_state = ( + TaskBoardPlacement.objects.filter(user=user, board=obj) + .values_list("view_state", flat=True) + .first() + or {} + ) + valid_column_ids = set( + obj.columns.filter(parent__isnull=True, is_archived=False).values_list( + "id", flat=True + ) + ) + valid_row_ids = set( + TaskColumnRow.objects.filter( + column__board=obj, + column__is_archived=False, + ).values_list("id", flat=True) + ) + + def valid_ids(key, allowed_ids): + values = raw_state.get(key, []) if isinstance(raw_state, dict) else [] + if not isinstance(values, list): + return [] + return sorted( + { + value + for value in values + if isinstance(value, int) and value in allowed_ids + } + ) + + return { + "hidden_column_ids": valid_ids("hidden_column_ids", valid_column_ids), + "collapsed_base_column_ids": valid_ids( + "collapsed_base_column_ids", valid_column_ids + ), + "collapsed_row_ids": valid_ids("collapsed_row_ids", valid_row_ids), + } + + @extend_schema_field(TaskColumnRowSerializer(many=True)) + def get_column_rows(self, obj): + rows = TaskColumnRow.objects.filter( + column__board=obj, + ).annotate( + tasks_count=Count("tasks", distinct=True) + ).order_by("column_id", "position", "id") + return TaskColumnRowSerializer(rows, many=True).data + @extend_schema_field(serializers.BooleanField()) def get_is_default_for_current_user(self, obj): request = self.context.get("request") diff --git a/backend/api/v1/tasks/urls.py b/backend/api/v1/tasks/urls.py index cf70e111..6a4fc7b7 100644 --- a/backend/api/v1/tasks/urls.py +++ b/backend/api/v1/tasks/urls.py @@ -5,6 +5,7 @@ from .views import ( TaskBoardGroupViewSet, TaskBoardViewSet, TaskColumnViewSet, + TaskColumnRowViewSet, TaskLabelViewSet, TaskViewSet, ) @@ -15,6 +16,7 @@ router = DefaultRouter() router.register(r"board-groups", TaskBoardGroupViewSet, basename="task-board-group") router.register(r"boards", TaskBoardViewSet, basename="task-board") router.register(r"columns", TaskColumnViewSet, basename="task-column") +router.register(r"column-rows", TaskColumnRowViewSet, basename="task-column-row") router.register(r"labels", TaskLabelViewSet, basename="task-label") router.register(r"", TaskViewSet, basename="task") diff --git a/backend/api/v1/tasks/views.py b/backend/api/v1/tasks/views.py index 63c115e2..edc1f7d5 100644 --- a/backend/api/v1/tasks/views.py +++ b/backend/api/v1/tasks/views.py @@ -58,6 +58,7 @@ from tasks.models import ( TaskBoardGroup, TaskBoardPlacement, TaskColumn, + TaskColumnRow, TaskExternalLink, TaskLabel, TaskLinkedObject, @@ -88,6 +89,7 @@ from .serializers import ( TaskBoardSummarySerializer, TaskChecklistItemSerializer, TaskColumnSerializer, + TaskColumnRowSerializer, TaskExternalLinkSerializer, TaskLabelSerializer, TaskLinkedCalendarEventSerializer, @@ -145,9 +147,9 @@ def create_task_activity( ) -def normalize_column_positions(column): +def normalize_column_positions(column, row=None): task_ids = list( - column.tasks.order_by("position", "-created_at", "id").values_list( + column.tasks.filter(row=row).order_by("position", "-created_at", "id").values_list( "id", flat=True, ) @@ -375,7 +377,11 @@ class TaskBoardViewSet(viewsets.ModelViewSet): ).first() if placement: placement.group = None - if not placement.is_pinned and placement.notifications_enabled: + if ( + not placement.is_pinned + and placement.notifications_enabled + and not placement.view_state + ): placement.delete() else: placement.save(update_fields=["group", "updated_at"]) @@ -411,7 +417,12 @@ class TaskBoardViewSet(viewsets.ModelViewSet): 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: + if ( + not placement.is_pinned + and placement.group_id is None + and placement.notifications_enabled + and not placement.view_state + ): placement.delete() else: placement.save(update_fields=["is_pinned", "pinned_at", "updated_at"]) @@ -427,7 +438,12 @@ class TaskBoardViewSet(viewsets.ModelViewSet): ) 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: + if ( + placement.notifications_enabled + and placement.group_id is None + and not placement.is_pinned + and not placement.view_state + ): placement.delete() else: placement.save(update_fields=["notifications_enabled", "updated_at"]) @@ -435,6 +451,96 @@ class TaskBoardViewSet(viewsets.ModelViewSet): {"ok": True, "notifications_enabled": notifications_enabled} ) + @action(detail=True, methods=["patch"], url_path="view-state") + def view_state(self, request, pk=None): + board = self.get_object() + scope = request.data.get("scope") + collapsed = request.data.get("collapsed") + try: + object_id = int(request.data.get("object_id")) + except (TypeError, ValueError): + object_id = None + + state_keys = { + "column": "hidden_column_ids", + "base": "collapsed_base_column_ids", + "row": "collapsed_row_ids", + } + if scope not in state_keys: + return Response( + {"scope": ["Допустимые значения: column, base, row."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + if object_id is None: + return Response( + {"object_id": ["Укажите корректный идентификатор."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + if not isinstance(collapsed, bool): + return Response( + {"collapsed": ["Укажите логическое значение."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if scope == "row": + object_exists = TaskColumnRow.objects.filter( + id=object_id, + column__board=board, + column__is_archived=False, + ).exists() + else: + object_exists = TaskColumn.objects.filter( + id=object_id, + board=board, + parent__isnull=True, + is_archived=False, + ).exists() + if not object_exists: + return Response( + {"object_id": ["Элемент не принадлежит этой доске."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + + with transaction.atomic(): + placement, _ = TaskBoardPlacement.objects.select_for_update().get_or_create( + user=request.user, + board=board, + defaults={"group": None}, + ) + state = placement.view_state.copy() if isinstance(placement.view_state, dict) else {} + key = state_keys[scope] + values = { + value + for value in state.get(key, []) + if isinstance(value, int) + } + if collapsed: + values.add(object_id) + else: + values.discard(object_id) + state[key] = sorted(values) + state = {name: value for name, value in state.items() if value} + placement.view_state = state + if ( + not state + and placement.group_id is None + and not placement.is_pinned + and placement.notifications_enabled + ): + placement.delete() + else: + placement.save(update_fields=["view_state", "updated_at"]) + + return Response( + { + "hidden_column_ids": state.get("hidden_column_ids", []), + "collapsed_base_column_ids": state.get( + "collapsed_base_column_ids", [] + ), + "collapsed_row_ids": state.get("collapsed_row_ids", []), + } + ) + @action(detail=True, methods=["post"], url_path="notifications/read") def mark_notifications_read(self, request, pk=None): board = self.get_object() @@ -489,6 +595,7 @@ class TaskBoardGroupViewSet(viewsets.ModelViewSet): group__isnull=True, is_pinned=False, notifications_enabled=True, + view_state={}, ).delete() @@ -679,10 +786,59 @@ class TaskColumnViewSet(viewsets.ModelViewSet): ) return Response( - {"board": board.id, "parent": parent_id, "column_ids": column_ids} + { + "board": board.id, + "parent": parent_id, + "column_ids": column_ids, + } ) +class TaskColumnRowViewSet(viewsets.ModelViewSet): + permission_classes = [IsAuthenticated] + serializer_class = TaskColumnRowSerializer + + def get_queryset(self): + queryset = ( + TaskColumnRow.objects.filter( + column__board__is_archived=False, + column__board__in=TaskBoard.objects.filter( + task_board_access_q(self.request.user) + ), + ) + .select_related("column", "column__board") + .annotate(tasks_count=Count("tasks", distinct=True)) + .order_by("column_id", "position", "id") + ) + if column := self.request.query_params.get("column"): + queryset = queryset.filter(column_id=column) + if board := self.request.query_params.get("board"): + queryset = queryset.filter(column__board_id=board) + return queryset + + def perform_create(self, serializer): + row = serializer.save() + send_task_board_update(row.column.board, "created", "column_row", row.id) + + def perform_update(self, serializer): + row = serializer.save() + send_task_board_update(row.column.board, "updated", "column_row", row.id) + + def perform_destroy(self, instance): + board = instance.column.board + row_id = instance.id + leaf_columns = list( + instance.column.subcolumns.filter(is_archived=False).order_by( + "position", + "id", + ) + ) or [instance.column] + instance.delete() + for column in leaf_columns: + normalize_column_positions(column, None) + send_task_board_update(board, "deleted", "column_row", row_id) + + class TaskLabelViewSet(viewsets.ModelViewSet): permission_classes = [IsAuthenticated] serializer_class = TaskLabelSerializer @@ -745,7 +901,7 @@ class TaskViewSet(viewsets.ModelViewSet): task_board_access_q(self.request.user) ) ) - .select_related("board", "column", "created_by", "assignee") + .select_related("board", "column", "row", "created_by", "assignee") .prefetch_related("labels", "participants", "checklist_items") .annotate( linked_posts_count=Count( @@ -840,6 +996,8 @@ class TaskViewSet(viewsets.ModelViewSet): queryset = queryset.filter(board_id=board) if column := params.get("column"): queryset = queryset.filter(column_id=column) + if row := params.get("row"): + queryset = queryset.filter(row_id=row) if assignee := params.get("assignee"): queryset = queryset.filter(assignee_id=assignee) if priority := params.get("priority"): @@ -867,6 +1025,7 @@ class TaskViewSet(viewsets.ModelViewSet): "assignee_id": instance.assignee_id, "priority": instance.priority, "due_date": instance.due_date.isoformat() if instance.due_date else None, + "row_id": instance.row_id, "label_ids": sorted(instance.labels.values_list("id", flat=True)), "participant_ids": sorted( instance.participants.values_list("id", flat=True) @@ -879,6 +1038,7 @@ class TaskViewSet(viewsets.ModelViewSet): "assignee_id": task.assignee_id, "priority": task.priority, "due_date": task.due_date.isoformat() if task.due_date else None, + "row_id": task.row_id, "label_ids": sorted(task.labels.values_list("id", flat=True)), "participant_ids": sorted( task.participants.values_list("id", flat=True) @@ -904,11 +1064,12 @@ class TaskViewSet(viewsets.ModelViewSet): instance.delete() send_task_board_update(board, "deleted", "task", task_id) - def _move_task(self, task, column, position, actor): + def _move_task(self, task, column, row, position, actor): old_column = task.column + old_row = task.row if position in (None, ""): max_position = ( - Task.objects.filter(column=column).exclude(id=task.id).aggregate( + Task.objects.filter(column=column, row=row).exclude(id=task.id).aggregate( Max("position") )["position__max"] or 0 @@ -916,26 +1077,38 @@ class TaskViewSet(viewsets.ModelViewSet): position = max_position + 1000 task.column = column + task.row = row task.position = position task.save( - update_fields=["column", "position", "completed_at", "updated_at"] + update_fields=["column", "row", "position", "completed_at", "updated_at"] ) - normalize_column_positions(column) - if old_column.id != column.id: - normalize_column_positions(old_column) + normalize_column_positions(column, row) + if old_column.id != column.id or old_row != row: + normalize_column_positions(old_column, old_row) task.refresh_from_db() + activity_metadata = { + "from_column_id": old_column.id, + "from_column": old_column.name, + "to_column_id": column.id, + "to_column": column.name, + } + if old_row or row: + activity_metadata.update( + { + "from_row_id": old_row.id if old_row else None, + "from_row": old_row.name if old_row else None, + "to_row_id": row.id if row else None, + "to_row": row.name if row else None, + } + ) + create_task_activity( task, actor, TaskActivityAction.MOVED, - metadata={ - "from_column_id": old_column.id, - "from_column": old_column.name, - "to_column_id": column.id, - "to_column": column.name, - }, + metadata=activity_metadata, ) notify_task_moved(task, actor, old_column, column) send_task_board_update( @@ -943,7 +1116,7 @@ class TaskViewSet(viewsets.ModelViewSet): "moved", "task", task.id, - extra={"column_id": task.column_id}, + extra={"column_id": task.column_id, "row_id": task.row_id}, ) return task @@ -951,6 +1124,7 @@ class TaskViewSet(viewsets.ModelViewSet): def move(self, request, pk=None): task = self.get_object() column_id = request.data.get("column") + raw_row_id = request.data.get("row") if "row" in request.data else None position = request.data.get("position") if not column_id: return Response( @@ -976,6 +1150,27 @@ class TaskViewSet(viewsets.ModelViewSet): status=status.HTTP_400_BAD_REQUEST, ) + row = None + if raw_row_id not in (None, ""): + try: + row_id = int(raw_row_id) + except (TypeError, ValueError): + return Response( + {"row": ["Идентификатор дорожки должен быть числом."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + top_level_column_id = column.parent_id or column.id + try: + row = TaskColumnRow.objects.get( + id=row_id, + column_id=top_level_column_id, + ) + except TaskColumnRow.DoesNotExist: + return Response( + {"row": ["Дорожка не принадлежит основной колонке задачи."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + if position in (None, ""): position = None else: @@ -987,7 +1182,7 @@ class TaskViewSet(viewsets.ModelViewSet): status=status.HTTP_400_BAD_REQUEST, ) - task = self._move_task(task, column, position, request.user) + task = self._move_task(task, column, row, position, request.user) serializer = self.get_serializer(task) return Response(serializer.data) @@ -1074,7 +1269,7 @@ class TaskViewSet(viewsets.ModelViewSet): status=status.HTTP_400_BAD_REQUEST, ) - task = self._move_task(task, done_column, None, request.user) + task = self._move_task(task, done_column, None, None, request.user) return Response(self.get_serializer(task).data) @action(detail=True, methods=["get"], url_path="activity") diff --git a/backend/tasks/admin.py b/backend/tasks/admin.py index a3d104d5..09c18563 100644 --- a/backend/tasks/admin.py +++ b/backend/tasks/admin.py @@ -9,6 +9,7 @@ from .models import ( TaskBoardPlacement, TaskChecklistItem, TaskColumn, + TaskColumnRow, TaskExternalLink, TaskLabel, TaskLinkedObject, @@ -63,6 +64,7 @@ class TaskBoardPlacementAdmin(admin.ModelAdmin): list_filter = ["is_pinned", "notifications_enabled", "group"] search_fields = ["board__name", "group__name", "user__email"] autocomplete_fields = ["board", "group", "user"] + readonly_fields = ["view_state"] @admin.register(TaskColumn) @@ -79,6 +81,13 @@ class TaskColumnAdmin(admin.ModelAdmin): search_fields = ["name", "board__name"] +@admin.register(TaskColumnRow) +class TaskColumnRowAdmin(admin.ModelAdmin): + list_display = ["name", "column", "position", "color"] + list_filter = ["column__board", "column"] + search_fields = ["name", "column__name", "column__board__name"] + + @admin.register(TaskLabel) class TaskLabelAdmin(admin.ModelAdmin): list_display = ["name", "board", "color"] @@ -92,12 +101,13 @@ class TaskAdmin(admin.ModelAdmin): "title", "board", "column", + "row", "assignee", "priority", "due_date", "completed_at", ] - list_filter = ["board", "column", "priority", "due_date"] + list_filter = ["board", "column", "row", "priority", "due_date"] search_fields = ["title", "description", "assignee__email"] filter_horizontal = ["labels", "participants"] diff --git a/backend/tasks/migrations/0025_taskcolumnrow_taskcolumn_row.py b/backend/tasks/migrations/0025_taskcolumnrow_taskcolumn_row.py new file mode 100644 index 00000000..ff209707 --- /dev/null +++ b/backend/tasks/migrations/0025_taskcolumnrow_taskcolumn_row.py @@ -0,0 +1,37 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("tasks", "0024_taskcolumn_parent"), + ] + + operations = [ + migrations.CreateModel( + name="TaskColumnRow", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(max_length=120, verbose_name="Название")), + ("position", models.PositiveIntegerField(default=0, verbose_name="Позиция")), + ("color", models.CharField(blank=True, max_length=32, verbose_name="Цвет")), + ("created_at", models.DateTimeField(auto_now_add=True, verbose_name="Создано")), + ("updated_at", models.DateTimeField(auto_now=True, verbose_name="Обновлено")), + ("column", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="rows", to="tasks.taskcolumn", verbose_name="Основная колонка")), + ], + options={ + "verbose_name": "Дорожка колонки", + "verbose_name_plural": "Дорожки колонок", + "ordering": ["position", "id"], + }, + ), + migrations.AddConstraint( + model_name="taskcolumnrow", + constraint=models.UniqueConstraint(fields=("column", "name"), name="uniq_task_column_row_name"), + ), + migrations.AddField( + model_name="taskcolumn", + name="row", + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="subcolumns", to="tasks.taskcolumnrow", verbose_name="Дорожка"), + ), + ] diff --git a/backend/tasks/migrations/0026_move_task_column_row_to_task.py b/backend/tasks/migrations/0026_move_task_column_row_to_task.py new file mode 100644 index 00000000..2fe4e141 --- /dev/null +++ b/backend/tasks/migrations/0026_move_task_column_row_to_task.py @@ -0,0 +1,50 @@ +from django.db import migrations, models +import django.db.models.deletion + + +def move_column_rows_to_tasks(apps, schema_editor): + Task = apps.get_model("tasks", "Task") + TaskColumn = apps.get_model("tasks", "TaskColumn") + + for column_id, row_id in TaskColumn.objects.exclude(row_id=None).values_list( + "id", + "row_id", + ): + Task.objects.filter(column_id=column_id).update(row_id=row_id) + + +class Migration(migrations.Migration): + dependencies = [ + ("tasks", "0025_taskcolumnrow_taskcolumn_row"), + ] + + operations = [ + migrations.AddField( + model_name="task", + name="row", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="tasks", + to="tasks.taskcolumnrow", + verbose_name="Дорожка", + ), + ), + migrations.RunPython(move_column_rows_to_tasks, migrations.RunPython.noop), + migrations.RemoveField( + model_name="taskcolumn", + name="row", + ), + migrations.RemoveIndex( + model_name="task", + name="tasks_task_board_i_6021b0_idx", + ), + migrations.AddIndex( + model_name="task", + index=models.Index( + fields=["board", "column", "row", "position"], + name="tasks_task_board_i_528a7b_idx", + ), + ), + ] diff --git a/backend/tasks/migrations/0027_taskboardplacement_view_state.py b/backend/tasks/migrations/0027_taskboardplacement_view_state.py new file mode 100644 index 00000000..2ec02a6a --- /dev/null +++ b/backend/tasks/migrations/0027_taskboardplacement_view_state.py @@ -0,0 +1,19 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("tasks", "0026_move_task_column_row_to_task"), + ] + + operations = [ + migrations.AddField( + model_name="taskboardplacement", + name="view_state", + field=models.JSONField( + blank=True, + default=dict, + verbose_name="Состояние представления", + ), + ), + ] diff --git a/backend/tasks/models.py b/backend/tasks/models.py index 9922f314..071bed1a 100644 --- a/backend/tasks/models.py +++ b/backend/tasks/models.py @@ -116,6 +116,11 @@ class TaskBoardPlacement(models.Model): is_pinned = models.BooleanField("Закреплена", default=False) pinned_at = models.DateTimeField("Закреплена в", null=True, blank=True) notifications_enabled = models.BooleanField("Уведомления", default=True) + view_state = models.JSONField( + "Состояние представления", + default=dict, + blank=True, + ) created_at = models.DateTimeField("Создано", auto_now_add=True) updated_at = models.DateTimeField("Обновлено", auto_now=True) @@ -219,6 +224,34 @@ class TaskColumn(models.Model): return self.parent_id is not None +class TaskColumnRow(models.Model): + column = models.ForeignKey( + TaskColumn, + on_delete=models.CASCADE, + related_name="rows", + verbose_name="Основная колонка", + ) + name = models.CharField("Название", max_length=120) + position = models.PositiveIntegerField("Позиция", default=0) + color = models.CharField("Цвет", max_length=32, blank=True) + created_at = models.DateTimeField("Создано", auto_now_add=True) + updated_at = models.DateTimeField("Обновлено", auto_now=True) + + class Meta: + verbose_name = "Дорожка колонки" + verbose_name_plural = "Дорожки колонок" + ordering = ["position", "id"] + constraints = [ + models.UniqueConstraint( + fields=["column", "name"], + name="uniq_task_column_row_name", + ) + ] + + def __str__(self): + return f"{self.column}: {self.name}" + + class TaskLabel(models.Model): board = models.ForeignKey( TaskBoard, @@ -257,6 +290,14 @@ class Task(models.Model): related_name="tasks", verbose_name="Колонка", ) + row = models.ForeignKey( + TaskColumnRow, + on_delete=models.SET_NULL, + related_name="tasks", + null=True, + blank=True, + verbose_name="Дорожка", + ) title = models.CharField("Название", max_length=255) description = models.TextField("Описание", blank=True) created_by = models.ForeignKey( @@ -302,7 +343,7 @@ class Task(models.Model): verbose_name_plural = "Задачи" ordering = ["position", "-created_at", "id"] indexes = [ - models.Index(fields=["board", "column", "position"]), + models.Index(fields=["board", "column", "row", "position"]), models.Index(fields=["assignee", "due_date"]), ] diff --git a/backend/tests/api/v1/tasks/test_tasks_api.py b/backend/tests/api/v1/tasks/test_tasks_api.py index 06030db2..08131d84 100644 --- a/backend/tests/api/v1/tasks/test_tasks_api.py +++ b/backend/tests/api/v1/tasks/test_tasks_api.py @@ -31,6 +31,7 @@ from tasks.models import ( TaskBoardPlacement, TaskChecklistItem, TaskColumn, + TaskColumnRow, TaskExternalLink, TaskUserSettings, ) @@ -442,6 +443,76 @@ def test_board_pin_and_notifications_are_personal_and_keep_ungrouped_settings( assert TaskBoard.objects.filter(id=first_board.id).exists() +def test_board_view_state_is_persisted_per_user(api_client, user): + other_user = make_user("task-view-state-other@example.com", "+79994440096") + board = TaskBoard.objects.create(name="Состояние доски", created_by=user) + column = TaskColumn.objects.create( + board=board, + name="Готово", + position=1000, + ) + row = TaskColumnRow.objects.create( + column=column, + name="Вторая дорожка", + position=1000, + ) + url = reverse( + "api:v1:tasks:task-board-view-state", + kwargs={"pk": board.id}, + ) + api_client.force_authenticate(user=user) + + for payload in [ + {"scope": "column", "object_id": column.id, "collapsed": True}, + {"scope": "base", "object_id": column.id, "collapsed": True}, + {"scope": "row", "object_id": row.id, "collapsed": True}, + ]: + response = api_client.patch(url, payload, format="json") + assert response.status_code == status.HTTP_200_OK + + detail_response = api_client.get( + reverse("api:v1:tasks:task-board-detail", kwargs={"pk": board.id}) + ) + assert detail_response.data["view_state"] == { + "hidden_column_ids": [column.id], + "collapsed_base_column_ids": [column.id], + "collapsed_row_ids": [row.id], + } + assert TaskBoardPlacement.objects.get(user=user, board=board).view_state + + api_client.force_authenticate(user=other_user) + other_detail_response = api_client.get( + reverse("api:v1:tasks:task-board-detail", kwargs={"pk": board.id}) + ) + assert other_detail_response.data["view_state"] == { + "hidden_column_ids": [], + "collapsed_base_column_ids": [], + "collapsed_row_ids": [], + } + + +def test_board_view_state_rejects_objects_from_another_board(api_client, user): + board = TaskBoard.objects.create(name="Первая доска", created_by=user) + other_board = TaskBoard.objects.create(name="Другая доска", created_by=user) + other_column = TaskColumn.objects.create( + board=other_board, + name="Чужая колонка", + position=1000, + ) + api_client.force_authenticate(user=user) + + response = api_client.patch( + reverse( + "api:v1:tasks:task-board-view-state", + kwargs={"pk": board.id}, + ), + {"scope": "column", "object_id": other_column.id, "collapsed": True}, + format="json", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert not TaskBoardPlacement.objects.filter(user=user, board=board).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") @@ -738,6 +809,128 @@ def test_task_cannot_be_created_in_parent_with_subcolumns(api_client, user): assert "column" in response.data +def test_column_rows_are_optional_and_belong_to_top_level_column(api_client, user): + board = TaskBoard.objects.create(name="Локальные дорожки", created_by=user) + parent = TaskColumn.objects.create(board=board, name="Производство", position=1000) + existing_subcolumn = TaskColumn.objects.create( + board=board, + parent=parent, + name="Существующая подколонка", + position=1000, + ) + existing_task = Task.objects.create( + board=board, + column=existing_subcolumn, + title="Существующая задача", + created_by=user, + ) + api_client.force_authenticate(user=user) + + response = api_client.post( + reverse("api:v1:tasks:task-column-row-list"), + { + "column": parent.id, + "name": "Первая линия", + "position": 1000, + "color": "#22c55e", + }, + format="json", + ) + + assert response.status_code == status.HTTP_201_CREATED + assert response.data["column"] == parent.id + row = TaskColumnRow.objects.get(pk=response.data["id"]) + assert TaskColumn.objects.filter(board=board).count() == 2 + existing_subcolumn.refresh_from_db() + existing_task.refresh_from_db() + assert existing_task.column_id == existing_subcolumn.id + assert existing_task.row_id is None + assert row.tasks.count() == 0 + + board_response = api_client.get( + reverse("api:v1:tasks:task-board-detail", kwargs={"pk": board.id}) + ) + assert board_response.data["column_rows"][0]["name"] == "Первая линия" + + +def test_task_row_must_belong_to_tasks_top_level_column(api_client, user): + board = TaskBoard.objects.create(name="Проверка дорожек", created_by=user) + first_parent = TaskColumn.objects.create(board=board, name="Первая", position=1000) + second_parent = TaskColumn.objects.create(board=board, name="Вторая", position=2000) + second_child = TaskColumn.objects.create( + board=board, + parent=second_parent, + name="Подколонка второй", + position=1000, + ) + row = TaskColumnRow.objects.create( + column=first_parent, + name="Линия", + position=1000, + ) + api_client.force_authenticate(user=user) + + response = api_client.post( + reverse("api:v1:tasks:task-list"), + { + "board": board.id, + "column": second_child.id, + "row": row.id, + "title": "Задача в чужой дорожке", + }, + format="json", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "row" in response.data + + +def test_task_moves_between_rows_without_changing_column(api_client, user): + board = TaskBoard.objects.create(name="Строки ячеек", created_by=user) + parent = TaskColumn.objects.create(board=board, name="Производство", position=1000) + first_row = TaskColumnRow.objects.create(column=parent, name="Линия 1", position=1000) + second_row = TaskColumnRow.objects.create(column=parent, name="Линия 2", position=2000) + child = TaskColumn.objects.create( + board=board, + parent=parent, + name="Сборка", + position=1000, + ) + task = Task.objects.create( + board=board, + column=child, + row=first_row, + title="Перемещаемая задача", + created_by=user, + position=1000, + ) + api_client.force_authenticate(user=user) + + response = api_client.post( + reverse("api:v1:tasks:task-move", kwargs={"pk": task.id}), + { + "column": child.id, + "row": second_row.id, + }, + format="json", + ) + + assert response.status_code == status.HTTP_200_OK + assert response.data["column"] == child.id + assert response.data["row"] == second_row.id + task.refresh_from_db() + assert task.column_id == child.id + assert task.row_id == second_row.id + + delete_response = api_client.delete( + reverse("api:v1:tasks:task-column-row-detail", kwargs={"pk": second_row.id}) + ) + assert delete_response.status_code == status.HTTP_204_NO_CONTENT + task.refresh_from_db() + assert task.column_id == child.id + assert task.row_id is None + + def test_reorder_subcolumns_does_not_change_top_level_order(api_client, user): board = TaskBoard.objects.create(name="Порядок подколонок", created_by=user) first_parent = TaskColumn.objects.create( @@ -901,7 +1094,10 @@ def test_create_task_and_move_to_done_column(api_client, user): send_args, send_kwargs = send_update.call_args assert send_args[0].id == board_id assert send_args[1:4] == ("moved", "task", task_id) - assert send_kwargs["extra"] == {"column_id": columns["Готово"]["id"]} + assert send_kwargs["extra"] == { + "column_id": columns["Готово"]["id"], + "row_id": None, + } task = Task.objects.get(id=task_id) assert task.column_id == columns["Готово"]["id"] diff --git a/frontend/src/app/messages/page.tsx b/frontend/src/app/messages/page.tsx index da326ceb..033d5969 100644 --- a/frontend/src/app/messages/page.tsx +++ b/frontend/src/app/messages/page.tsx @@ -33,7 +33,7 @@ export default function MessagesPage() { function StandardMessagesList() { const { setMode, openCreateChat } = useMessagesWorkspace(); - const { user, identity, visibleChats, chatTypeCounts, loading, error } = useMessagesDirectory(); + const { user, identity, visibleChats, chatTypeCounts, loading, error, refreshChat } = useMessagesDirectory(); if (loading) { return ( @@ -73,7 +73,13 @@ function StandardMessagesList() {

Чаты не найдены

) : visibleChats.map((chat) => ( - + ))} diff --git a/frontend/src/app/tasks/page.tsx b/frontend/src/app/tasks/page.tsx index 4802acf2..3a6552d0 100644 --- a/frontend/src/app/tasks/page.tsx +++ b/frontend/src/app/tasks/page.tsx @@ -10,7 +10,9 @@ import { useDraggable, useSensor, useSensors, + type CollisionDetection, type DragEndEvent, + type DragOverEvent, type DragStartEvent, } from "@dnd-kit/core"; import { @@ -27,6 +29,8 @@ import { ChevronDown, ChevronRight, Download, + Eye, + EyeOff, ExternalLink as ExternalLinkIcon, FileSignature, FileText, @@ -43,6 +47,7 @@ import { Play, Plus, RefreshCw, + Rows3, Search, ShoppingCart, Star, @@ -87,9 +92,11 @@ import type { TaskBoard, TaskBoardGroup, TaskBoardSummary, + TaskBoardViewState, TaskCard, TaskChecklistItem, TaskColumn, + TaskColumnRow, TaskComment, TaskExternalLink, TaskLinkedAttendanceRecord, @@ -111,6 +118,7 @@ type TaskFormState = { title: string; description: string; column: number | ""; + row: number | ""; assignee_id: number | ""; participant_ids: number[]; priority: TaskPriority; @@ -144,6 +152,13 @@ type ColumnFormState = { is_done: boolean; }; +type ColumnRowFormState = { + id: number | null; + column: number | null; + name: string; + color: string; +}; + type LinkedItemMode = "document" | "calendar_event" | "external_link"; type LinkedItemTarget = "view" | "form"; @@ -172,12 +187,58 @@ type TaskBoardSocketEvent = { }; const DESKTOP_WIDE_MODE_STORAGE_PREFIX = "tasks.desktop-wide-mode"; +const TASK_SUBCOLUMN_WIDTH = "18rem"; +const EMPTY_BOARD_VIEW_STATE: TaskBoardViewState = { + hidden_column_ids: [], + collapsed_base_column_ids: [], + collapsed_row_ids: [], +}; + +type BoardViewStateScope = "column" | "base" | "row"; +type BoardViewStateKey = keyof TaskBoardViewState; +const BOARD_VIEW_STATE_KEYS: Record = { + column: "hidden_column_ids", + base: "collapsed_base_column_ids", + row: "collapsed_row_ids", +}; + +function normalizeBoardViewState( + state?: Partial | null, +): TaskBoardViewState { + return { + hidden_column_ids: Array.isArray(state?.hidden_column_ids) + ? state.hidden_column_ids + : [], + collapsed_base_column_ids: Array.isArray(state?.collapsed_base_column_ids) + ? state.collapsed_base_column_ids + : [], + collapsed_row_ids: Array.isArray(state?.collapsed_row_ids) + ? state.collapsed_row_ids + : [], + }; +} + +function updateBoardViewStateIds( + state: TaskBoardViewState, + key: BoardViewStateKey, + objectId: number, + collapsed: boolean, +): TaskBoardViewState { + const values = new Set(state[key]); + if (collapsed) values.add(objectId); + else values.delete(objectId); + return { ...state, [key]: Array.from(values) }; +} +const taskPlacementKey = (columnId: number, rowId: number | null) => ( + `${columnId}:${rowId ?? "base"}` +); const emptyForm: TaskFormState = { id: null, title: "", description: "", column: "", + row: "", assignee_id: "", participant_ids: [], priority: "medium", @@ -234,6 +295,13 @@ const emptyColumnForm: ColumnFormState = { is_done: false, }; +const emptyColumnRowForm: ColumnRowFormState = { + id: null, + column: null, + name: "", + color: "#64748b", +}; + const priorityOptions: { value: TaskPriority; label: string; @@ -640,6 +708,7 @@ function TaskDragOverlayCard({ task }: { task: TaskCard }) { function BoardColumn({ column, tasks, + displayTasksCount, onCreateTask, onOpenTask, onEditTask, @@ -658,14 +727,27 @@ function BoardColumn({ onEditColumn, onDeleteColumn, onCreateSubcolumn, + onCreateRow, onColumnMount, + taskRowId = null, + columnSortable = true, + showColumnActions = true, + showHeader = true, + showBody = true, + showVisibilityToggle = false, + contentVisible = true, + onToggleContentVisibility, + sharedGridCell = false, + linkedColumnHighlight = false, + synchronizedColumnDrag = false, fillAvailableHeight = false, hideOnMobile = false, nested = false, }: { column: TaskColumn; tasks: TaskCard[]; - onCreateTask: (columnId: number) => void; + displayTasksCount?: number; + onCreateTask: (columnId: number, rowId: number | null) => void; onOpenTask: (task: TaskCard) => void; onEditTask: (task: TaskCard) => void; onDeleteTask: (task: TaskCard) => void; @@ -683,7 +765,19 @@ function BoardColumn({ onEditColumn: (column: TaskColumn) => void; onDeleteColumn: (column: TaskColumn) => void; onCreateSubcolumn: (column: TaskColumn) => void; + onCreateRow?: (column: TaskColumn) => void; onColumnMount: (columnId: number, node: HTMLElement | null) => void; + taskRowId?: number | null; + columnSortable?: boolean; + showColumnActions?: boolean; + showHeader?: boolean; + showBody?: boolean; + showVisibilityToggle?: boolean; + contentVisible?: boolean; + onToggleContentVisibility?: () => void; + sharedGridCell?: boolean; + linkedColumnHighlight?: boolean; + synchronizedColumnDrag?: boolean; fillAvailableHeight?: boolean; hideOnMobile?: boolean; nested?: boolean; @@ -698,19 +792,27 @@ function BoardColumn({ isDragging, isOver, } = useSortable({ - id: `column-${column.id}`, - data: { type: "column", columnId: column.id, acceptsTasks: true }, + id: columnSortable + ? `column-${column.id}` + : `task-zone-${column.id}-row-${taskRowId ?? "base"}`, + data: { + type: columnSortable ? "column" : "task-zone", + columnId: column.id, + rowId: taskRowId, + acceptsTasks: true, + }, + disabled: !columnSortable, }); const style = { - transform: CSS.Transform.toString(transform), - transition, + transform: columnSortable ? CSS.Transform.toString(transform) : undefined, + transition: columnSortable ? transition : undefined, zIndex: isDragging ? 20 : undefined, }; const setColumnNodeRef = useCallback((node: HTMLElement | null) => { setNodeRef(node); - onColumnMount(column.id, node); - }, [column.id, onColumnMount, setNodeRef]); - const columnBorderClass = isOver + if (columnSortable) onColumnMount(column.id, node); + }, [column.id, columnSortable, onColumnMount, setNodeRef]); + const columnBorderClass = linkedColumnHighlight || (isOver && !synchronizedColumnDrag) ? "border-[var(--accent-primary)]" : "border-[var(--border-subtle)]"; @@ -718,21 +820,27 @@ function BoardColumn({
-

{tasks.length} задач

+

+ {displayTasksCount ?? tasks.length} задач +

+ {showVisibilityToggle ? ( + + ) : null} -
event.stopPropagation()} @@ -782,14 +910,24 @@ function BoardColumn({ {columnMenuOpen ? (
{!column.parent ? ( - + <> + + + ) : null}
) : null} -
+
: null}
-
{tasks.length > 0 ? ( tasks.map((task) => ( @@ -838,7 +976,7 @@ function BoardColumn({

Нет задач

)} - + : null}
); } @@ -849,12 +987,15 @@ function BoardColumnGroup({ children, onCreateTask, onCreateSubcolumn, + onCreateRow, onEditColumn, onDeleteColumn, columnMenuOpen, columnMenuRef, onToggleColumnMenu, onColumnMount, + contentVisible, + onToggleContentVisibility, hideOnMobile = false, }: { column: TaskColumn; @@ -862,12 +1003,15 @@ function BoardColumnGroup({ children: ReactNode; onCreateTask: () => void; onCreateSubcolumn: () => void; + onCreateRow: () => void; onEditColumn: (column: TaskColumn) => void; onDeleteColumn: (column: TaskColumn) => void; columnMenuOpen: boolean; columnMenuRef: RefObject; onToggleColumnMenu: () => void; onColumnMount: (columnId: number, node: HTMLElement | null) => void; + contentVisible: boolean; + onToggleContentVisibility: () => void; hideOnMobile?: boolean; }) { const { @@ -913,13 +1057,26 @@ function BoardColumnGroup({

{column.name}

- - {tasksCount} - -

Подколонки

+

{tasksCount} задач

+ +
-
- {children} + {contentVisible ? ( +
+ {children} +
+ ) : null} + + ); +} + +function BoardColumnRowView({ + row, + children, + gridTemplateColumns, + onCreateTask, + onEdit, + onDelete, + expanded, + onToggle, +}: { + row: TaskColumnRow; + children: ReactNode; + gridTemplateColumns: string; + onCreateTask: () => void; + onEdit: () => void; + onDelete: () => void; + expanded: boolean; + onToggle: () => void; +}) { + return ( +
+
+ +
+ + + +
+ {expanded ? ( +
+ {children} +
+ ) : null} +
+ ); +} + +function BoardBaseColumnsView({ + collapsible, + tasksCount, + headers, + children, + gridTemplateColumns, + expanded, + onToggle, +}: { + collapsible: boolean; + tasksCount: number; + headers?: ReactNode; + children: ReactNode; + gridTemplateColumns: string; + expanded: boolean; + onToggle: () => void; +}) { + return ( +
+ {headers ? ( +
+ {headers} +
+ ) : null} + {collapsible ? ( + + ) : null} + {!collapsible || expanded ? ( +
+ {children} +
+ ) : null}
); } @@ -2312,6 +2618,9 @@ function TasksPageContent() { const [boards, setBoards] = useState([]); const [boardGroups, setBoardGroups] = useState([]); const [board, setBoard] = useState(null); + const [boardViewState, setBoardViewState] = useState( + EMPTY_BOARD_VIEW_STATE, + ); const [selectedBoardId, setSelectedBoardId] = useState(null); const [employees, setEmployees] = useState([]); const [taskParticipantCandidates, setTaskParticipantCandidates] = useState([]); @@ -2327,6 +2636,8 @@ function TasksPageContent() { const [desktopWideMode, setDesktopWideMode] = useState(false); const [selectedColumnTarget, setSelectedColumnTarget] = useState("all"); const [activeTaskId, setActiveTaskId] = useState(null); + const [activeColumnDragId, setActiveColumnDragId] = useState(null); + const [columnDragOverId, setColumnDragOverId] = useState(null); const [viewTaskId, setViewTaskId] = useState(null); const [taskModalOpen, setTaskModalOpen] = useState(false); const [taskParticipantsOpen, setTaskParticipantsOpen] = useState(false); @@ -2342,12 +2653,14 @@ function TasksPageContent() { const [boardGroupModalOpen, setBoardGroupModalOpen] = useState(false); const [boardActionsMenuOpen, setBoardActionsMenuOpen] = useState(false); const [columnModalOpen, setColumnModalOpen] = useState(false); + const [columnRowModalOpen, setColumnRowModalOpen] = useState(false); const [form, setForm] = useState(emptyForm); const [boardForm, setBoardForm] = useState(emptyBoardForm); const [boardGroupForm, setBoardGroupForm] = useState(emptyBoardGroupForm); const [boardMemberSearch, setBoardMemberSearch] = useState(""); const [boardDepartmentSearch, setBoardDepartmentSearch] = useState(""); const [columnForm, setColumnForm] = useState(emptyColumnForm); + const [columnRowForm, setColumnRowForm] = useState(emptyColumnRowForm); const [labelName, setLabelName] = useState(""); const [labelColor, setLabelColor] = useState("#38bdf8"); const [linkedObjectsOpen, setLinkedObjectsOpen] = useState(false); @@ -2418,6 +2731,8 @@ function TasksPageContent() { const cardMenuRef = useRef(null); const columnMenuRef = useRef(null); const selectedBoardIdRef = useRef(null); + const boardViewStateRef = useRef(EMPTY_BOARD_VIEW_STATE); + const pendingBoardViewStateRef = useRef>(new Map()); const taskBoardSyncTimerRef = useRef | null>(null); const boardScrollRef = useRef(null); const boardPanRef = useRef<{ @@ -2527,10 +2842,71 @@ function TasksPageContent() { ? await apiClient.getTaskBoard(boardId) : await apiClient.getDefaultTaskBoard(); setBoard(data); + let nextViewState = normalizeBoardViewState(data.view_state); + pendingBoardViewStateRef.current.forEach((collapsed, mutationKey) => { + const [pendingBoardId, scope, objectId] = mutationKey.split(":"); + if (Number(pendingBoardId) !== data.id || !(scope in BOARD_VIEW_STATE_KEYS)) return; + nextViewState = updateBoardViewStateIds( + nextViewState, + BOARD_VIEW_STATE_KEYS[scope as BoardViewStateScope], + Number(objectId), + collapsed, + ); + }); + boardViewStateRef.current = nextViewState; + setBoardViewState(nextViewState); setSelectedBoardId(data.id); return data; }, []); + const toggleBoardViewState = useCallback(( + scope: BoardViewStateScope, + objectId: number, + ) => { + const boardId = selectedBoardIdRef.current; + if (!boardId) return; + const key = BOARD_VIEW_STATE_KEYS[scope]; + const collapsed = !boardViewStateRef.current[key].includes(objectId); + const mutationKey = `${boardId}:${scope}:${objectId}`; + const nextState = updateBoardViewStateIds( + boardViewStateRef.current, + key, + objectId, + collapsed, + ); + boardViewStateRef.current = nextState; + setBoardViewState(nextState); + pendingBoardViewStateRef.current.set(mutationKey, collapsed); + + void apiClient.updateTaskBoardViewState(boardId, { + scope, + object_id: objectId, + collapsed, + }).catch((viewStateError) => { + if (selectedBoardIdRef.current !== boardId) return; + if (pendingBoardViewStateRef.current.get(mutationKey) !== collapsed) return; + const currentValue = boardViewStateRef.current[key].includes(objectId); + if (currentValue === collapsed) { + const revertedState = updateBoardViewStateIds( + boardViewStateRef.current, + key, + objectId, + !collapsed, + ); + boardViewStateRef.current = revertedState; + setBoardViewState(revertedState); + } + setError(getTaskError( + viewStateError, + "Не удалось сохранить состояние доски", + )); + }).finally(() => { + if (pendingBoardViewStateRef.current.get(mutationKey) === collapsed) { + pendingBoardViewStateRef.current.delete(mutationKey); + } + }); + }, []); + const loadTaskLinkedObjects = useCallback(async (taskId: number) => { setLinkedObjectsLoading(true); try { @@ -2937,6 +3313,9 @@ function TasksPageContent() { setBoards(boardList as TaskBoardSummary[]); setBoardGroups(groupList as TaskBoardGroup[]); setBoard(boardData); + const initialViewState = normalizeBoardViewState(boardData.view_state); + boardViewStateRef.current = initialViewState; + setBoardViewState(initialViewState); setSelectedBoardId(boardData.id); if (window.matchMedia("(max-width: 1023px)").matches) { const firstColumn = (boardData.columns || []).find( @@ -3139,19 +3518,19 @@ function TasksPageContent() { [onlyMine, searchedTasks, user?.id], ); - const tasksByColumn = useMemo(() => { - const grouped = new Map(); - for (const column of board?.columns || []) grouped.set(column.id, []); + const tasksByPlacement = useMemo(() => { + const grouped = new Map(); for (const task of filteredTasks) { - const list = grouped.get(task.column) || []; + const key = taskPlacementKey(task.column, task.row || null); + const list = grouped.get(key) || []; list.push(task); - grouped.set(task.column, list); + grouped.set(key, list); } for (const list of grouped.values()) { list.sort((left, right) => left.position - right.position || left.id - right.id); } return grouped; - }, [board?.columns, filteredTasks]); + }, [filteredTasks]); const activeColumns = useMemo( () => (board?.columns || []).filter((column) => !column.is_archived), @@ -3179,6 +3558,19 @@ function TasksPageContent() { return grouped; }, [activeColumns]); + const rowsByColumn = useMemo(() => { + const grouped = new Map(); + for (const row of board?.column_rows || []) { + const rows = grouped.get(row.column) || []; + rows.push(row); + grouped.set(row.column, rows); + } + for (const rows of grouped.values()) { + rows.sort((left, right) => left.position - right.position || left.id - right.id); + } + return grouped; + }, [board?.column_rows]); + const leafColumns = useMemo( () => topLevelColumns.flatMap((column) => { const subcolumns = subcolumnsByParent.get(column.id) || []; @@ -3227,6 +3619,15 @@ function TasksPageContent() { () => activeColumns.find((column) => column.id === viewTask?.column) || null, [activeColumns, viewTask?.column], ); + const viewTaskRow = useMemo( + () => (board?.column_rows || []).find((row) => row.id === viewTask?.row) || null, + [board?.column_rows, viewTask?.row], + ); + const taskFormRows = useMemo(() => { + const selectedColumn = activeColumns.find((column) => column.id === Number(form.column)); + if (!selectedColumn) return []; + return rowsByColumn.get(selectedColumn.parent || selectedColumn.id) || []; + }, [activeColumns, form.column, rowsByColumn]); const displayedTaskParticipants = useMemo( () => (viewTask?.participants || []).filter( (participant) => participant.id !== viewTask?.assignee?.id, @@ -3341,7 +3742,7 @@ function TasksPageContent() { void loadTaskActivity(viewTaskId); }, [activityOpen, loadTaskActivity, viewTask?.updated_at, viewTaskId]); - const openCreateTask = useCallback((columnId?: number) => { + const openCreateTask = useCallback((columnId?: number, rowId: number | null = null) => { const requestedColumn = leafColumns.find((column) => column.id === columnId); const requestedSubcolumn = columnId ? subcolumnsByParent.get(columnId)?.[0] @@ -3352,6 +3753,7 @@ function TasksPageContent() { setForm({ ...emptyForm, column: firstColumn?.id || "", + row: rowId || "", participant_ids: user?.id ? [user.id] : [], }); setTaskParticipantsOpen(false); @@ -3502,6 +3904,7 @@ function TasksPageContent() { title: task.title, description: task.description || "", column: task.column, + row: task.row || "", assignee_id: task.assignee?.id || "", participant_ids: (task.participants || []).map((participant) => participant.id), priority: task.priority, @@ -3624,6 +4027,34 @@ function TasksPageContent() { setColumnForm(emptyColumnForm); }, [saving]); + const openCreateColumnRow = useCallback((columnId: number) => { + const column = activeColumns.find((item) => item.id === columnId && !item.parent); + if (!column) return; + setColumnMenuId(null); + setColumnRowForm({ + ...emptyColumnRowForm, + column: column.id, + color: column.color || emptyColumnRowForm.color, + }); + setColumnRowModalOpen(true); + }, [activeColumns]); + + const openEditColumnRow = useCallback((row: TaskColumnRow) => { + setColumnRowForm({ + id: row.id, + column: row.column, + name: row.name, + color: row.color || "#64748b", + }); + setColumnRowModalOpen(true); + }, []); + + const closeColumnRowModal = useCallback(() => { + if (saving) return; + setColumnRowModalOpen(false); + setColumnRowForm(emptyColumnRowForm); + }, [saving]); + const toggleBoardMember = useCallback((employeeId: number) => { setBoardForm((current) => ({ ...current, @@ -3990,6 +4421,55 @@ function TasksPageContent() { } }, [board, loadBoard, saving]); + const saveColumnRow = useCallback(async () => { + if (!board || !columnRowForm.column || !columnRowForm.name.trim()) return; + setSaving(true); + setError(null); + try { + const payload = { + name: columnRowForm.name.trim(), + color: columnRowForm.color, + }; + if (columnRowForm.id) { + await apiClient.updateTaskColumnRow(columnRowForm.id, payload); + } else { + const siblingRows = rowsByColumn.get(columnRowForm.column) || []; + const maxPosition = Math.max(0, ...siblingRows.map((row) => row.position || 0)); + await apiClient.createTaskColumnRow({ + ...payload, + column: columnRowForm.column, + position: maxPosition + 1000, + }); + } + await loadBoard(board.id); + setColumnRowModalOpen(false); + setColumnRowForm(emptyColumnRowForm); + } catch (saveError) { + setError(getTaskError( + saveError, + columnRowForm.id ? "Не удалось изменить дорожку" : "Не удалось создать дорожку", + )); + } finally { + setSaving(false); + } + }, [board, columnRowForm, loadBoard, rowsByColumn]); + + const deleteColumnRow = useCallback(async (row: TaskColumnRow) => { + if (!board || saving) return; + if (!window.confirm(`Удалить дорожку "${row.name}"?`)) return; + setSaving(true); + setError(null); + try { + await apiClient.deleteTaskColumnRow(row.id); + await loadBoard(board.id); + } catch (deleteError) { + setError(getTaskError(deleteError, "Не удалось удалить дорожку")); + await loadBoard(board.id); + } finally { + setSaving(false); + } + }, [board, loadBoard, saving]); + const persistTaskFormResources = useCallback(async (taskId: number) => { for (const itemId of [...taskFormRemovedChecklistItemIds]) { await apiClient.deleteTaskChecklistItem(taskId, itemId); @@ -4099,6 +4579,7 @@ function TasksPageContent() { const payload = { board: board.id, column: Number(form.column), + row: form.row || null, title: form.title.trim(), description: form.description.trim(), assignee_id: form.assignee_id || null, @@ -4850,7 +5331,13 @@ function TasksPageContent() { setError(null); setViewColumnMenuOpen(false); try { - await apiClient.moveTask(viewTask.id, { column: columnId }); + const targetColumn = activeColumns.find((column) => column.id === columnId); + const targetRootId = targetColumn?.parent || targetColumn?.id; + const row = viewTask.row && targetRootId + && rowsByColumn.get(targetRootId)?.some((item) => item.id === viewTask.row) + ? viewTask.row + : null; + await apiClient.moveTask(viewTask.id, { column: columnId, row }); await loadBoard(board.id); if (activityOpen) await loadTaskActivity(viewTask.id); } catch (moveError) { @@ -4859,7 +5346,7 @@ function TasksPageContent() { } finally { setSaving(false); } - }, [activityOpen, board, loadBoard, loadTaskActivity, saving, viewTask]); + }, [activeColumns, activityOpen, board, loadBoard, loadTaskActivity, rowsByColumn, saving, viewTask]); const claimTask = useCallback(async (task: TaskCard) => { if ( @@ -4928,35 +5415,89 @@ function TasksPageContent() { const handleDragStart = useCallback((event: DragStartEvent) => { if (event.active.data.current?.type !== "task") { setActiveTaskId(null); + const columnId = Number(event.active.data.current?.columnId) || null; + setActiveColumnDragId(columnId); + setColumnDragOverId(columnId); setColumnMenuId(null); return; } + setActiveColumnDragId(null); + setColumnDragOverId(null); const taskId = Number(String(event.active.id).replace("task-", "")); setActiveTaskId(Number.isFinite(taskId) ? taskId : null); }, []); const handleDragCancel = useCallback(() => { setActiveTaskId(null); + setActiveColumnDragId(null); + setColumnDragOverId(null); }, []); + const boardCollisionDetection = useCallback((args) => { + const collisions = closestCorners(args); + if (args.active.data.current?.type !== "column") return collisions; + + const activeColumnId = Number(args.active.data.current?.columnId); + const activeColumn = activeColumns.find((column) => column.id === activeColumnId); + if (!activeColumn) return collisions; + + return collisions.filter((collision) => { + const candidateId = Number( + args.droppableContainers.find( + (container) => container.id === collision.id, + )?.data.current?.columnId, + ); + let candidate = activeColumns.find((column) => column.id === candidateId); + if (activeColumn.parent === null && candidate?.parent) { + candidate = activeColumns.find((column) => column.id === candidate?.parent); + } + return candidate?.parent === activeColumn.parent; + }); + }, [activeColumns]); + + const handleDragOver = useCallback((event: DragOverEvent) => { + if (event.active.data.current?.type !== "column") { + setColumnDragOverId(null); + return; + } + const activeColumnId = Number(event.active.data.current?.columnId); + const activeColumn = activeColumns.find((column) => column.id === activeColumnId); + if (!activeColumn) return; + + const candidateColumnId = Number(event.over?.data.current?.columnId); + let candidateColumn = activeColumns.find((column) => column.id === candidateColumnId); + if (activeColumn.parent === null && candidateColumn?.parent) { + candidateColumn = activeColumns.find((column) => column.id === candidateColumn?.parent); + } + if (candidateColumn?.parent === activeColumn.parent) { + setColumnDragOverId(candidateColumn.id); + } + }, [activeColumns]); + const handleDragEnd = useCallback(async (event: DragEndEvent) => { setActiveTaskId(null); + setActiveColumnDragId(null); + setColumnDragOverId(null); if (!board || !event.over) return; if (event.active.data.current?.type === "column") { - const activeColumnId = Number(String(event.active.id).replace("column-", "")); - const overColumnId = Number(String(event.over.id).replace("column-", "")); + const activeColumnId = Number(event.active.data.current?.columnId); const activeColumn = activeColumns.find((column) => column.id === activeColumnId); - let overColumn = activeColumns.find((column) => column.id === overColumnId); + const directOverColumnId = Number(event.over.data.current?.columnId); + let overColumn = activeColumns.find((column) => column.id === directOverColumnId); if (activeColumn?.parent === null && overColumn?.parent) { overColumn = activeColumns.find((column) => column.id === overColumn?.parent); } - if (!activeColumn || !overColumn || activeColumn.parent !== overColumn.parent) return; + if ( + !activeColumn + || !overColumn + || activeColumn.parent !== overColumn.parent + ) return; const siblingColumns = activeColumns .filter((column) => column.parent === activeColumn.parent) .sort((left, right) => left.position - right.position || left.id - right.id); const oldIndex = siblingColumns.findIndex((column) => column.id === activeColumnId); - const newIndex = siblingColumns.findIndex((column) => column.id === overColumnId); + const newIndex = siblingColumns.findIndex((column) => column.id === overColumn.id); if (oldIndex < 0 || newIndex < 0 || oldIndex === newIndex) return; const reorderedColumns = arrayMove(siblingColumns, oldIndex, newIndex); @@ -4970,7 +5511,11 @@ function TasksPageContent() { } : current); try { - await apiClient.reorderTaskColumns(board.id, orderedIds, activeColumn.parent); + await apiClient.reorderTaskColumns( + board.id, + orderedIds, + activeColumn.parent, + ); await loadBoard(board.id); } catch (reorderError) { setError(getTaskError(reorderError, "Не удалось изменить порядок колонок")); @@ -4982,12 +5527,13 @@ function TasksPageContent() { const taskId = Number(String(event.active.id).replace("task-", "")); if (!event.over.data.current?.acceptsTasks) return; const columnId = Number(event.over.data.current?.columnId); + const rowId = Number(event.over.data.current?.rowId) || null; if (!taskId || !columnId) return; const task = board.tasks.find((item) => item.id === taskId); - if (!task || task.column === columnId) return; + if (!task || (task.column === columnId && (task.row || null) === rowId)) return; try { - await apiClient.moveTask(taskId, { column: columnId }); + await apiClient.moveTask(taskId, { column: columnId, row: rowId }); await loadBoard(board.id); } catch (moveError) { setError(getTaskError(moveError, "Не удалось переместить задачу")); @@ -5037,11 +5583,12 @@ function TasksPageContent() { onMoveBoard={moveBoardToGroup} wideMode={desktopWideMode} onWideModeChange={changeDesktopWideMode} + userId={user?.id ?? null} className={`${mobileBoardOpen ? "hidden" : "flex"} h-[calc(100dvh-7rem)] w-full ${desktopWideMode ? "lg:flex lg:h-full lg:shrink-0" : ""}`} />
-
+
+ +
+ )} + > +
+
+

Основная колонка

+

+ {activeColumns.find((column) => column.id === columnRowForm.column)?.name || "Колонка"} +

+
+ + +
+ +
+
+

Дорожка

+

+ {viewTaskRow?.name || "Без дорожки"} +

+
{viewTask.completed_at ? (

Завершено

@@ -7110,7 +7826,19 @@ function TasksPageContent() { Колонка + +