diff --git a/backend/api/v1/tasks/serializers.py b/backend/api/v1/tasks/serializers.py index c9141fcf..47b17d19 100644 --- a/backend/api/v1/tasks/serializers.py +++ b/backend/api/v1/tasks/serializers.py @@ -101,38 +101,109 @@ class TaskLabelSerializer(serializers.ModelSerializer): class TaskColumnSerializer(serializers.ModelSerializer): tasks_count = serializers.IntegerField(read_only=True, default=0) + subcolumns_count = serializers.SerializerMethodField() class Meta: model = TaskColumn + validators = [] fields = [ "id", "board", + "parent", "name", "position", "color", "is_done", "is_archived", "tasks_count", + "subcolumns_count", "created_at", "updated_at", ] read_only_fields = [ "id", - "created_by", - "member_details", - "department_details", - "columns", - "labels", - "tasks", - "tasks_count", "created_at", "updated_at", + "tasks_count", + "subcolumns_count", ] - read_only_fields = ["id", "created_at", "updated_at", "tasks_count"] def validate_board(self, board): return validate_board_access(self, board) + @extend_schema_field(serializers.IntegerField()) + def get_subcolumns_count(self, obj): + annotated_count = getattr(obj, "subcolumns_count", None) + if annotated_count is not None: + return annotated_count + return obj.subcolumns.filter(is_archived=False).count() + + def validate(self, attrs): + board = attrs.get("board") or getattr(self.instance, "board", None) + parent = attrs.get("parent", getattr(self.instance, "parent", None)) + + if self.instance and board and self.instance.board_id != board.id: + raise serializers.ValidationError( + {"board": "Доску существующей колонки изменить нельзя."} + ) + + if self.instance and "parent" in attrs: + current_parent_id = self.instance.parent_id + requested_parent_id = parent.id if parent else None + if current_parent_id != requested_parent_id: + raise serializers.ValidationError( + {"parent": "Родителя существующей колонки изменить нельзя."} + ) + + if parent: + if board and parent.board_id != board.id: + raise serializers.ValidationError( + {"parent": "Подколонка должна принадлежать той же доске."} + ) + if parent.parent_id: + raise serializers.ValidationError( + {"parent": "Допускается только один уровень подколонок."} + ) + if parent.is_archived: + raise serializers.ValidationError( + {"parent": "Нельзя добавить подколонку в архивную колонку."} + ) + + if ( + self.instance + and attrs.get("is_archived") is True + and self.instance.subcolumns.exists() + ): + raise serializers.ValidationError( + {"is_archived": "Сначала удалите подколонки."} + ) + + name = attrs.get("name", getattr(self.instance, "name", "")) + if board and name: + duplicate_columns = TaskColumn.objects.filter(name=name) + if parent: + duplicate_columns = duplicate_columns.filter(parent=parent) + else: + duplicate_columns = duplicate_columns.filter( + board=board, + parent__isnull=True, + ) + if self.instance: + duplicate_columns = duplicate_columns.exclude(pk=self.instance.pk) + if duplicate_columns.exists(): + raise serializers.ValidationError( + {"name": "Колонка с таким названием уже существует здесь."} + ) + + return attrs + + def create(self, validated_data): + parent = validated_data.get("parent") + if parent: + validated_data.setdefault("color", parent.color) + validated_data.setdefault("is_done", parent.is_done) + return super().create(validated_data) + class TaskSerializer(serializers.ModelSerializer): created_by = EmployeeBriefSerializer(read_only=True) @@ -385,6 +456,11 @@ class TaskSerializer(serializers.ModelSerializer): {"column": "Колонка должна принадлежать выбранной доске."} ) + if column and column.subcolumns.filter(is_archived=False).exists(): + raise serializers.ValidationError( + {"column": "Выберите подколонку для размещения задачи."} + ) + if labels is not None and board: invalid_labels = [ label.id for label in labels if label.board_id != board.id diff --git a/backend/api/v1/tasks/views.py b/backend/api/v1/tasks/views.py index cae3f1b5..63c115e2 100644 --- a/backend/api/v1/tasks/views.py +++ b/backend/api/v1/tasks/views.py @@ -12,6 +12,7 @@ from django.db.models import ( IntegerField, Max, OuterRef, + Prefetch, Q, Subquery, Value, @@ -241,7 +242,17 @@ class TaskBoardViewSet(viewsets.ModelViewSet): and self.request.query_params.get("view") == "summary" ): queryset = queryset.prefetch_related( - "columns", + Prefetch( + "columns", + queryset=TaskColumn.objects.annotate( + tasks_count=Count("tasks", distinct=True), + subcolumns_count=Count( + "subcolumns", + filter=Q(subcolumns__is_archived=False), + distinct=True, + ), + ).order_by("parent_id", "position", "id"), + ), "labels", "tasks__created_by", "tasks__assignee", @@ -494,8 +505,16 @@ class TaskColumnViewSet(viewsets.ModelViewSet): ) ) .select_related("board") - .annotate(tasks_count=Count("tasks", distinct=True)) - .order_by("board_id", "position", "id") + .select_related("parent") + .annotate( + tasks_count=Count("tasks", distinct=True), + subcolumns_count=Count( + "subcolumns", + filter=Q(subcolumns__is_archived=False), + distinct=True, + ), + ) + .order_by("board_id", "parent_id", "position", "id") ) board = self.request.query_params.get("board") if board: @@ -503,7 +522,18 @@ class TaskColumnViewSet(viewsets.ModelViewSet): return queryset def perform_create(self, serializer): - column = serializer.save() + with transaction.atomic(): + parent = serializer.validated_data.get("parent") + if parent: + parent = TaskColumn.objects.select_for_update().get(pk=parent.pk) + column = serializer.save(parent=parent) + if parent: + task_updates = {"column": column} + if column.is_done: + task_updates["completed_at"] = timezone.now() + else: + task_updates["completed_at"] = None + Task.objects.filter(column=parent).update(**task_updates) send_task_board_update(column.board, "created", "column", column.id) def perform_update(self, serializer): @@ -512,6 +542,11 @@ class TaskColumnViewSet(viewsets.ModelViewSet): def destroy(self, request, *args, **kwargs): column = self.get_object() + if column.subcolumns.exists(): + return Response( + {"detail": "Сначала удалите подколонки этой колонки."}, + status=status.HTTP_400_BAD_REQUEST, + ) if column.tasks.exists(): return Response( { @@ -521,7 +556,14 @@ class TaskColumnViewSet(viewsets.ModelViewSet): }, status=status.HTTP_400_BAD_REQUEST, ) - if column.board.columns.filter(is_archived=False).count() <= 1: + if ( + column.parent_id is None + and column.board.columns.filter( + is_archived=False, + parent__isnull=True, + ).count() + <= 1 + ): return Response( {"detail": "На доске должна остаться хотя бы одна колонка."}, status=status.HTTP_400_BAD_REQUEST, @@ -539,6 +581,7 @@ class TaskColumnViewSet(viewsets.ModelViewSet): """Сохраняет единый для всех участников порядок колонок доски.""" raw_board_id = request.data.get("board") raw_column_ids = request.data.get("column_ids") + raw_parent_id = request.data.get("parent") if not raw_board_id: return Response( {"board": ["Укажите доску."]}, @@ -578,10 +621,32 @@ class TaskColumnViewSet(viewsets.ModelViewSet): pk=board_id, ) + parent_id = None + if raw_parent_id not in (None, ""): + try: + parent_id = int(raw_parent_id) + except (TypeError, ValueError): + return Response( + {"parent": ["Идентификатор родителя должен быть числом."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + get_object_or_404( + TaskColumn.objects.filter( + board=board, + parent__isnull=True, + is_archived=False, + ), + pk=parent_id, + ) + with transaction.atomic(): columns = list( TaskColumn.objects.select_for_update() - .filter(board=board, is_archived=False) + .filter( + board=board, + parent_id=parent_id, + is_archived=False, + ) .order_by("position", "id") ) columns_by_id = {column.id: column for column in columns} @@ -589,7 +654,7 @@ class TaskColumnViewSet(viewsets.ModelViewSet): return Response( { "column_ids": [ - "Список должен содержать все активные колонки доски." + "Список должен содержать все соседние активные колонки." ] }, status=status.HTTP_400_BAD_REQUEST, @@ -613,7 +678,9 @@ class TaskColumnViewSet(viewsets.ModelViewSet): extra={"column_ids": column_ids}, ) - return Response({"board": board.id, "column_ids": column_ids}) + return Response( + {"board": board.id, "parent": parent_id, "column_ids": column_ids} + ) class TaskLabelViewSet(viewsets.ModelViewSet): @@ -903,6 +970,12 @@ class TaskViewSet(viewsets.ModelViewSet): status=status.HTTP_400_BAD_REQUEST, ) + if column.subcolumns.filter(is_archived=False).exists(): + return Response( + {"column": ["Выберите подколонку для размещения задачи."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + if position in (None, ""): position = None else: @@ -985,6 +1058,13 @@ class TaskViewSet(viewsets.ModelViewSet): is_done=True, is_archived=False, ) + .annotate( + active_subcolumns_count=Count( + "subcolumns", + filter=Q(subcolumns__is_archived=False), + ) + ) + .filter(active_subcolumns_count=0) .order_by("position", "id") .first() ) diff --git a/backend/tasks/admin.py b/backend/tasks/admin.py index 2a17be35..a3d104d5 100644 --- a/backend/tasks/admin.py +++ b/backend/tasks/admin.py @@ -19,7 +19,7 @@ from .models import ( class TaskColumnInline(admin.TabularInline): model = TaskColumn extra = 0 - fields = ["name", "position", "color", "is_done", "is_archived"] + fields = ["parent", "name", "position", "color", "is_done", "is_archived"] class TaskLabelInline(admin.TabularInline): @@ -67,7 +67,14 @@ class TaskBoardPlacementAdmin(admin.ModelAdmin): @admin.register(TaskColumn) class TaskColumnAdmin(admin.ModelAdmin): - list_display = ["name", "board", "position", "is_done", "is_archived"] + list_display = [ + "name", + "board", + "parent", + "position", + "is_done", + "is_archived", + ] list_filter = ["is_done", "is_archived", "board"] search_fields = ["name", "board__name"] diff --git a/backend/tasks/migrations/0024_taskcolumn_parent.py b/backend/tasks/migrations/0024_taskcolumn_parent.py new file mode 100644 index 00000000..32b2e9c1 --- /dev/null +++ b/backend/tasks/migrations/0024_taskcolumn_parent.py @@ -0,0 +1,43 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("tasks", "0023_taskusersettings_default_group"), + ] + + operations = [ + migrations.AddField( + model_name="taskcolumn", + name="parent", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="subcolumns", + to="tasks.taskcolumn", + verbose_name="Родительская колонка", + ), + ), + migrations.RemoveConstraint( + model_name="taskcolumn", + name="uniq_task_column_board_name", + ), + migrations.AddConstraint( + model_name="taskcolumn", + constraint=models.UniqueConstraint( + condition=models.Q(("parent__isnull", True)), + fields=("board", "name"), + name="uniq_task_top_column_board_name", + ), + ), + migrations.AddConstraint( + model_name="taskcolumn", + constraint=models.UniqueConstraint( + condition=models.Q(("parent__isnull", False)), + fields=("parent", "name"), + name="uniq_task_subcolumn_parent_name", + ), + ), + ] diff --git a/backend/tasks/models.py b/backend/tasks/models.py index 5c42f7da..9922f314 100644 --- a/backend/tasks/models.py +++ b/backend/tasks/models.py @@ -178,6 +178,14 @@ class TaskColumn(models.Model): related_name="columns", verbose_name="Доска", ) + parent = models.ForeignKey( + "self", + on_delete=models.CASCADE, + related_name="subcolumns", + null=True, + blank=True, + verbose_name="Родительская колонка", + ) name = models.CharField("Название", max_length=120) position = models.PositiveIntegerField("Позиция", default=0) color = models.CharField("Цвет", max_length=32, blank=True) @@ -193,13 +201,23 @@ class TaskColumn(models.Model): constraints = [ models.UniqueConstraint( fields=["board", "name"], - name="uniq_task_column_board_name", - ) + condition=models.Q(parent__isnull=True), + name="uniq_task_top_column_board_name", + ), + models.UniqueConstraint( + fields=["parent", "name"], + condition=models.Q(parent__isnull=False), + name="uniq_task_subcolumn_parent_name", + ), ] def __str__(self): return f"{self.board}: {self.name}" + @property + def is_subcolumn(self): + return self.parent_id is not None + class TaskLabel(models.Model): board = models.ForeignKey( diff --git a/backend/tests/api/v1/tasks/test_tasks_api.py b/backend/tests/api/v1/tasks/test_tasks_api.py index c3ce0809..06030db2 100644 --- a/backend/tests/api/v1/tasks/test_tasks_api.py +++ b/backend/tests/api/v1/tasks/test_tasks_api.py @@ -607,6 +607,185 @@ def test_reorder_columns_requires_complete_board_column_list(api_client, user): ) == [first.id, second.id] +def test_first_subcolumn_receives_existing_parent_tasks(api_client, user): + board = TaskBoard.objects.create(name="Подколонки", created_by=user) + parent = TaskColumn.objects.create( + board=board, + name="В работе", + position=1000, + color="#f59e0b", + ) + task = Task.objects.create( + board=board, + column=parent, + title="Существующая задача", + created_by=user, + ) + api_client.force_authenticate(user=user) + + response = api_client.post( + reverse("api:v1:tasks:task-column-list"), + { + "board": board.id, + "parent": parent.id, + "name": "Согласование", + "position": 1000, + }, + format="json", + ) + + assert response.status_code == status.HTTP_201_CREATED + assert response.data["parent"] == parent.id + assert response.data["color"] == parent.color + task.refresh_from_db() + assert task.column_id == response.data["id"] + + +def test_subcolumns_support_only_one_nesting_level(api_client, user): + board = TaskBoard.objects.create(name="Один уровень", created_by=user) + parent = TaskColumn.objects.create(board=board, name="Родитель", position=1000) + child = TaskColumn.objects.create( + board=board, + parent=parent, + name="Подколонка", + position=1000, + ) + api_client.force_authenticate(user=user) + + response = api_client.post( + reverse("api:v1:tasks:task-column-list"), + { + "board": board.id, + "parent": child.id, + "name": "Лишний уровень", + "position": 1000, + }, + format="json", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "parent" in response.data + + +def test_subcolumn_names_are_unique_only_inside_their_parent(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, + ) + TaskColumn.objects.create( + board=board, + parent=first_parent, + name="В работе", + position=1000, + ) + api_client.force_authenticate(user=user) + + allowed_response = api_client.post( + reverse("api:v1:tasks:task-column-list"), + { + "board": board.id, + "parent": second_parent.id, + "name": "В работе", + "position": 1000, + }, + format="json", + ) + duplicate_response = api_client.post( + reverse("api:v1:tasks:task-column-list"), + { + "board": board.id, + "parent": first_parent.id, + "name": "В работе", + "position": 2000, + }, + format="json", + ) + + assert allowed_response.status_code == status.HTTP_201_CREATED + assert duplicate_response.status_code == status.HTTP_400_BAD_REQUEST + assert "name" in duplicate_response.data + + +def test_task_cannot_be_created_in_parent_with_subcolumns(api_client, user): + board = TaskBoard.objects.create(name="Только листья", created_by=user) + parent = TaskColumn.objects.create(board=board, name="Родитель", position=1000) + TaskColumn.objects.create( + board=board, + parent=parent, + name="Подколонка", + position=1000, + ) + api_client.force_authenticate(user=user) + + response = api_client.post( + reverse("api:v1:tasks:task-list"), + { + "board": board.id, + "column": parent.id, + "title": "Неверное размещение", + }, + format="json", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "column" in response.data + + +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( + board=board, + name="Первая", + position=1000, + ) + second_parent = TaskColumn.objects.create( + board=board, + name="Вторая", + position=2000, + ) + first_child = TaskColumn.objects.create( + board=board, + parent=first_parent, + name="А", + position=1000, + ) + second_child = TaskColumn.objects.create( + board=board, + parent=first_parent, + name="Б", + position=2000, + ) + api_client.force_authenticate(user=user) + + response = api_client.post( + reverse("api:v1:tasks:task-column-reorder"), + { + "board": board.id, + "parent": first_parent.id, + "column_ids": [second_child.id, first_child.id], + }, + format="json", + ) + + assert response.status_code == status.HTTP_200_OK + assert response.data["parent"] == first_parent.id + assert list( + TaskColumn.objects.filter(parent=first_parent).values_list("id", flat=True) + ) == [second_child.id, first_child.id] + assert list( + TaskColumn.objects.filter(board=board, parent__isnull=True).values_list( + "id", flat=True + ) + ) == [first_parent.id, second_parent.id] + + def test_user_can_edit_and_delete_empty_task_column(api_client, user): board = TaskBoard.objects.create(name="Управление колонками", created_by=user) TaskColumn.objects.create(board=board, name="Основная", position=1000) diff --git a/frontend/src/app/messages/[chatId]/page.tsx b/frontend/src/app/messages/[chatId]/page.tsx index 98b90226..4149e686 100644 --- a/frontend/src/app/messages/[chatId]/page.tsx +++ b/frontend/src/app/messages/[chatId]/page.tsx @@ -19,6 +19,7 @@ import MessageReadersModal from "@/components/messages/MessageReadersModal"; import MessageComposer from "@/components/messages/MessageComposer"; import ReactionPickerModal from "@/components/messages/ReactionPickerModal"; import { apiClient } from "@/lib/api"; +import { getTaskColumnDisplayName, getTaskDestinationColumns } from "@/lib/tasks/columns"; import type { ChatMessageSearchResponse, ChatMessageSearchResult, @@ -842,8 +843,8 @@ function MessageDialogPageContent() { ); const selectedTaskLinkColumn = useMemo( () => ( - (selectedTaskLinkBoard?.columns || []).find((column) => column.id === taskLinkColumnId) || - (selectedTaskLinkBoard?.columns || []).find((column) => !column.is_archived) || + getTaskDestinationColumns(selectedTaskLinkBoard?.columns).find((column) => column.id === taskLinkColumnId) || + getTaskDestinationColumns(selectedTaskLinkBoard?.columns)[0] || null ), [selectedTaskLinkBoard?.columns, taskLinkColumnId], @@ -866,7 +867,7 @@ function MessageDialogPageContent() { setTaskLinkBoards(boards); setTaskLinkEmployees(employees); const firstBoardWithTasks = boards.find((item) => (item.tasks || []).length > 0) || boards[0] || null; - const firstColumn = firstBoardWithTasks?.columns?.find((column) => !column.is_archived) || null; + const firstColumn = getTaskDestinationColumns(firstBoardWithTasks?.columns)[0] || null; setTaskLinkBoardId(firstBoardWithTasks?.id || ""); setTaskLinkTaskId(firstBoardWithTasks?.tasks?.[0]?.id || ""); setTaskLinkSearch(""); @@ -890,7 +891,7 @@ function MessageDialogPageContent() { const nextBoard = taskLinkBoards.find((item) => item.id === boardId) || null; setTaskLinkTaskId(nextBoard?.tasks?.[0]?.id || ""); setTaskLinkSearch(""); - setTaskLinkColumnId(nextBoard?.columns?.find((column) => !column.is_archived)?.id || ""); + setTaskLinkColumnId(getTaskDestinationColumns(nextBoard?.columns)[0]?.id || ""); setTaskLinkLabelIds([]); if (!nextBoard?.tasks?.length) { setTaskLinkMode("create"); @@ -1770,11 +1771,10 @@ function MessageDialogPageContent() { disabled={taskLinkLoading || !selectedTaskLinkBoard} > - {(selectedTaskLinkBoard?.columns || []) - .filter((column) => !column.is_archived) + {getTaskDestinationColumns(selectedTaskLinkBoard?.columns) .map((column) => ( ))} diff --git a/frontend/src/app/tasks/page.tsx b/frontend/src/app/tasks/page.tsx index 84a64c3c..4802acf2 100644 --- a/frontend/src/app/tasks/page.tsx +++ b/frontend/src/app/tasks/page.tsx @@ -61,6 +61,7 @@ import { useRef, useState, type PointerEvent as ReactPointerEvent, + type ReactNode, type RefObject, } from "react"; @@ -137,6 +138,7 @@ type BoardGroupFormState = { type ColumnFormState = { id: number | null; + parent: number | null; name: string; color: string; is_done: boolean; @@ -226,6 +228,7 @@ function getEmployeeInitials(employee: User): string { const emptyColumnForm: ColumnFormState = { id: null, + parent: null, name: "", color: "#38bdf8", is_done: false, @@ -654,9 +657,11 @@ function BoardColumn({ onToggleColumnMenu, onEditColumn, onDeleteColumn, + onCreateSubcolumn, onColumnMount, fillAvailableHeight = false, hideOnMobile = false, + nested = false, }: { column: TaskColumn; tasks: TaskCard[]; @@ -677,9 +682,11 @@ function BoardColumn({ onToggleColumnMenu: () => void; onEditColumn: (column: TaskColumn) => void; onDeleteColumn: (column: TaskColumn) => void; + onCreateSubcolumn: (column: TaskColumn) => void; onColumnMount: (columnId: number, node: HTMLElement | null) => void; fillAvailableHeight?: boolean; hideOnMobile?: boolean; + nested?: boolean; }) { const { attributes, @@ -692,7 +699,7 @@ function BoardColumn({ isOver, } = useSortable({ id: `column-${column.id}`, - data: { type: "column", columnId: column.id }, + data: { type: "column", columnId: column.id, acceptsTasks: true }, }); const style = { transform: CSS.Transform.toString(transform), @@ -716,7 +723,7 @@ function BoardColumn({ }`} >
@@ -774,6 +781,16 @@ function BoardColumn({ {columnMenuOpen ? (
+ {!column.parent ? ( + + ) : null} +
event.stopPropagation()} + onClick={(event) => event.stopPropagation()} + > + + {columnMenuOpen ? ( +
+ + + +
+ ) : null} +
+
+
+
+ {children} +
+ + ); +} + function AddColumnCard({ onClick, viewportHeight, @@ -2775,7 +2940,7 @@ function TasksPageContent() { setSelectedBoardId(boardData.id); if (window.matchMedia("(max-width: 1023px)").matches) { const firstColumn = (boardData.columns || []).find( - (column: TaskColumn) => !column.is_archived, + (column: TaskColumn) => !column.is_archived && !column.parent, ); setSelectedColumnTarget(firstColumn?.id || "all"); } @@ -2788,8 +2953,11 @@ function TasksPageContent() { const requestedTask = (boardData.tasks || []).find( (task: TaskCard) => task.id === requestedTaskId, ); + const requestedColumn = (boardData.columns || []).find( + (column: TaskColumn) => column.id === requestedTask?.column, + ); setViewTaskId(requestedTaskId); - setSelectedColumnTarget(requestedTask?.column || "all"); + setSelectedColumnTarget(requestedColumn?.parent || requestedColumn?.id || "all"); setOnlyMine(false); setSearch(""); } @@ -2990,14 +3158,55 @@ function TasksPageContent() { [board?.columns], ); + const topLevelColumns = useMemo( + () => activeColumns + .filter((column) => !column.parent) + .sort((left, right) => left.position - right.position || left.id - right.id), + [activeColumns], + ); + + const subcolumnsByParent = useMemo(() => { + const grouped = new Map(); + for (const column of activeColumns) { + if (!column.parent) continue; + const siblings = grouped.get(column.parent) || []; + siblings.push(column); + grouped.set(column.parent, siblings); + } + for (const siblings of grouped.values()) { + siblings.sort((left, right) => left.position - right.position || left.id - right.id); + } + return grouped; + }, [activeColumns]); + + const leafColumns = useMemo( + () => topLevelColumns.flatMap((column) => { + const subcolumns = subcolumnsByParent.get(column.id) || []; + return subcolumns.length > 0 ? subcolumns : [column]; + }), + [subcolumnsByParent, topLevelColumns], + ); + const columnCounts = useMemo(() => { const counts = new Map(); for (const column of activeColumns) counts.set(column.id, 0); for (const task of filteredTasks) { counts.set(task.column, (counts.get(task.column) || 0) + 1); } + for (const column of topLevelColumns) { + const subcolumns = subcolumnsByParent.get(column.id) || []; + if (subcolumns.length > 0) { + counts.set( + column.id, + (counts.get(column.id) || 0) + subcolumns.reduce( + (total, subcolumn) => total + (counts.get(subcolumn.id) || 0), + 0, + ), + ); + } + } return counts; - }, [activeColumns, filteredTasks]); + }, [activeColumns, filteredTasks, subcolumnsByParent, topLevelColumns]); const mineCount = useMemo( () => searchedTasks.filter((task) => task.assignee?.id === user?.id).length, @@ -3067,16 +3276,16 @@ function TasksPageContent() { useEffect(() => { if ( selectedColumnTarget !== "all" && - !activeColumns.some((column) => column.id === selectedColumnTarget) + !topLevelColumns.some((column) => column.id === selectedColumnTarget) ) { - const fallbackColumnId = activeColumns[0]?.id; + const fallbackColumnId = topLevelColumns[0]?.id; setSelectedColumnTarget( typeof window !== "undefined" && window.matchMedia("(max-width: 1023px)").matches ? fallbackColumnId || "all" : "all", ); } - }, [activeColumns, selectedColumnTarget]); + }, [selectedColumnTarget, topLevelColumns]); useEffect(() => { if (!viewTaskId) { @@ -3133,12 +3342,16 @@ function TasksPageContent() { }, [activityOpen, loadTaskActivity, viewTask?.updated_at, viewTaskId]); const openCreateTask = useCallback((columnId?: number) => { - const firstColumn = board?.columns.find((column) => !column.is_archived); + const requestedColumn = leafColumns.find((column) => column.id === columnId); + const requestedSubcolumn = columnId + ? subcolumnsByParent.get(columnId)?.[0] + : undefined; + const firstColumn = requestedColumn || requestedSubcolumn || leafColumns[0]; taskFormResourcesRequestRef.current += 1; clearTaskFormResources(); setForm({ ...emptyForm, - column: columnId || firstColumn?.id || "", + column: firstColumn?.id || "", participant_ids: user?.id ? [user.id] : [], }); setTaskParticipantsOpen(false); @@ -3146,7 +3359,7 @@ function TasksPageContent() { setLabelName(""); setLabelColor("#38bdf8"); setTaskModalOpen(true); - }, [board?.columns, clearTaskFormResources, user?.id]); + }, [clearTaskFormResources, leafColumns, subcolumnsByParent, user?.id]); const openTaskView = useCallback((task: TaskCard) => { setViewTaskId(task.id); @@ -3379,16 +3592,25 @@ function TasksPageContent() { setBoardDepartmentSearch(""); }, [saving]); - const openCreateColumn = useCallback(() => { + const openCreateColumn = useCallback((parentId: number | null = null) => { + const parent = parentId + ? activeColumns.find((column) => column.id === parentId) + : null; setColumnMenuId(null); - setColumnForm(emptyColumnForm); + setColumnForm({ + ...emptyColumnForm, + parent: parent?.id || null, + color: parent?.color || emptyColumnForm.color, + is_done: parent?.is_done || false, + }); setColumnModalOpen(true); - }, []); + }, [activeColumns]); const openEditColumn = useCallback((column: TaskColumn) => { setColumnMenuId(null); setColumnForm({ id: column.id, + parent: column.parent, name: column.name, color: column.color || "#38bdf8", is_done: column.is_done, @@ -3720,13 +3942,17 @@ function TasksPageContent() { if (columnForm.id) { await apiClient.updateTaskColumn(columnForm.id, payload); } else { + const siblingColumns = (board.columns || []).filter( + (column) => column.parent === columnForm.parent, + ); const maxPosition = Math.max( 0, - ...(board.columns || []).map((column) => column.position || 0), + ...siblingColumns.map((column) => column.position || 0), ); await apiClient.createTaskColumn({ ...payload, board: board.id, + parent: columnForm.parent, position: maxPosition + 1000, }); } @@ -3741,7 +3967,7 @@ function TasksPageContent() { } finally { setSaving(false); } - }, [board, columnForm.color, columnForm.id, columnForm.is_done, columnForm.name, loadBoard]); + }, [board, columnForm.color, columnForm.id, columnForm.is_done, columnForm.name, columnForm.parent, loadBoard]); const deleteColumn = useCallback(async (column: TaskColumn) => { if (!board || saving) return; @@ -4720,25 +4946,31 @@ function TasksPageContent() { 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 oldIndex = activeColumns.findIndex((column) => column.id === activeColumnId); - const newIndex = activeColumns.findIndex((column) => column.id === overColumnId); + const activeColumn = activeColumns.find((column) => column.id === activeColumnId); + let overColumn = activeColumns.find((column) => column.id === overColumnId); + if (activeColumn?.parent === null && overColumn?.parent) { + overColumn = activeColumns.find((column) => column.id === overColumn?.parent); + } + 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); if (oldIndex < 0 || newIndex < 0 || oldIndex === newIndex) return; - const reorderedColumns = arrayMove(activeColumns, oldIndex, newIndex); + const reorderedColumns = arrayMove(siblingColumns, oldIndex, newIndex); const orderedIds = reorderedColumns.map((column) => column.id); setBoard((current) => current ? { ...current, - columns: [ - ...reorderedColumns.map((column, index) => ({ - ...column, - position: (index + 1) * 1000, - })), - ...current.columns.filter((column) => column.is_archived), - ], + columns: current.columns.map((column) => { + const index = orderedIds.indexOf(column.id); + return index >= 0 ? { ...column, position: (index + 1) * 1000 } : column; + }), } : current); try { - await apiClient.reorderTaskColumns(board.id, orderedIds); + await apiClient.reorderTaskColumns(board.id, orderedIds, activeColumn.parent); await loadBoard(board.id); } catch (reorderError) { setError(getTaskError(reorderError, "Не удалось изменить порядок колонок")); @@ -4748,7 +4980,8 @@ function TasksPageContent() { } const taskId = Number(String(event.active.id).replace("task-", "")); - const columnId = Number(String(event.over.id).replace("column-", "")); + if (!event.over.data.current?.acceptsTasks) return; + const columnId = Number(event.over.data.current?.columnId); if (!taskId || !columnId) return; const task = board.tasks.find((item) => item.id === taskId); if (!task || task.column === columnId) return; @@ -4976,7 +5209,7 @@ function TasksPageContent() { {filteredTasks.length} - {activeColumns.map((column) => { + {topLevelColumns.map((column) => { const active = selectedColumnTarget === column.id; return (