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}
- {activeColumns.map((column) => {
+ {topLevelColumns.map((column) => {
const active = selectedColumnTarget === column.id;
return (
openCreateColumn()}
className="app-pill inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full transition"
title="Добавить колонку"
aria-label="Добавить колонку"
@@ -5032,7 +5265,7 @@ function TasksPageContent() {
onDragEnd={handleDragEnd}
>
`column-${column.id}`)}
+ items={topLevelColumns.map((column) => `column-${column.id}`)}
strategy={horizontalListSortingStrategy}
>
- {activeColumns.map((column) => (
-
toggleColumnMenu(column.id)}
- onEditColumn={openEditColumn}
- onDeleteColumn={deleteColumn}
- onColumnMount={registerColumnNode}
- fillAvailableHeight={desktopWideMode}
- hideOnMobile={
- selectedColumnTarget === "all"
- ? activeColumns[0]?.id !== column.id
- : selectedColumnTarget !== column.id
- }
- />
- ))}
+ {topLevelColumns.map((column) => {
+ const subcolumns = subcolumnsByParent.get(column.id) || [];
+ const hideOnMobile = selectedColumnTarget === "all"
+ ? topLevelColumns[0]?.id !== column.id
+ : selectedColumnTarget !== column.id;
+ if (subcolumns.length === 0) {
+ return (
+ toggleColumnMenu(column.id)}
+ onEditColumn={openEditColumn}
+ onDeleteColumn={deleteColumn}
+ onCreateSubcolumn={(parent) => openCreateColumn(parent.id)}
+ onColumnMount={registerColumnNode}
+ fillAvailableHeight={desktopWideMode}
+ hideOnMobile={hideOnMobile}
+ />
+ );
+ }
+
+ return (
+ openCreateTask(subcolumns[0]?.id)}
+ onCreateSubcolumn={() => openCreateColumn(column.id)}
+ onEditColumn={openEditColumn}
+ onDeleteColumn={deleteColumn}
+ columnMenuOpen={columnMenuId === column.id}
+ columnMenuRef={columnMenuRef}
+ onToggleColumnMenu={() => toggleColumnMenu(column.id)}
+ onColumnMount={registerColumnNode}
+ hideOnMobile={hideOnMobile}
+ >
+ `column-${subcolumn.id}`)}
+ strategy={horizontalListSortingStrategy}
+ >
+ {subcolumns.map((subcolumn) => (
+ toggleColumnMenu(subcolumn.id)}
+ onEditColumn={openEditColumn}
+ onDeleteColumn={deleteColumn}
+ onCreateSubcolumn={(parent) => openCreateColumn(parent.id)}
+ onColumnMount={registerColumnNode}
+ fillAvailableHeight={desktopWideMode}
+ nested
+ />
+ ))}
+
+
+ );
+ })}
openCreateColumn()}
viewportHeight={boardViewportHeight}
/>
@@ -5391,7 +5680,11 @@ function TasksPageContent() {
+ {columnForm.parent ? (
+
+
Родительская колонка
+
+ {activeColumns.find((column) => column.id === columnForm.parent)?.name || "Колонка"}
+
+
+ ) : null}
diff --git a/frontend/src/components/ViewEventDetailsModal.tsx b/frontend/src/components/ViewEventDetailsModal.tsx
index 53fe88e6..563434c8 100644
--- a/frontend/src/components/ViewEventDetailsModal.tsx
+++ b/frontend/src/components/ViewEventDetailsModal.tsx
@@ -19,6 +19,7 @@ import { ru } from "date-fns/locale";
import { useState, useEffect, useCallback } from "react";
import { apiClient } from "@/lib/api";
import { displayUserName } from "@/lib/shared";
+import { getTaskColumnDisplayName, getTaskDestinationColumns } from "@/lib/tasks/columns";
import type { TaskBoard, TaskCard, TaskPriority, User } from "@/types/api";
type EventRuleData = {
@@ -229,8 +230,8 @@ export function ViewEventDetailsModal({
const selectedTaskLinkBoard = taskLinkBoards.find((board) => board.id === taskLinkBoardId) || null;
const selectedTaskLinkTask = (selectedTaskLinkBoard?.tasks || []).find((task) => task.id === taskLinkTaskId) || null;
const selectedTaskLinkColumn = (
- (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
);
const availableTaskLinkTasks = selectedTaskLinkBoard?.tasks || [];
@@ -253,7 +254,7 @@ export function ViewEventDetailsModal({
setTaskLinkBoards(boards);
setTaskLinkEmployees(employees);
const firstBoardWithTasks = boards.find((board) => (board.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("");
@@ -294,7 +295,7 @@ export function ViewEventDetailsModal({
const nextBoard = taskLinkBoards.find((board) => board.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");
@@ -755,11 +756,10 @@ export function ViewEventDetailsModal({
disabled={taskLinkLoading || !selectedTaskLinkBoard}
>
- {(selectedTaskLinkBoard?.columns || [])
- .filter((column) => !column.is_archived)
+ {getTaskDestinationColumns(selectedTaskLinkBoard?.columns)
.map((column) => (
))}
diff --git a/frontend/src/components/documents/DocumentTaskLinks.tsx b/frontend/src/components/documents/DocumentTaskLinks.tsx
index b36e7af2..f0141ea3 100644
--- a/frontend/src/components/documents/DocumentTaskLinks.tsx
+++ b/frontend/src/components/documents/DocumentTaskLinks.tsx
@@ -8,6 +8,7 @@ import TaskLinkPill from "@/components/tasks/TaskLinkPill";
import { Modal } from "@/components/ui";
import { apiClient } from "@/lib/api";
import { displayUserName } from "@/lib/shared";
+import { getTaskColumnDisplayName, getTaskDestinationColumns } from "@/lib/tasks/columns";
import type { Document, TaskBoard, TaskCard, TaskPriority, User } from "@/types/api";
type DocumentTaskLinksProps = {
@@ -73,8 +74,8 @@ export function DocumentTaskLinks({
const selectedBoard = boards.find((board) => board.id === boardId) || null;
const selectedTask = (selectedBoard?.tasks || []).find((task) => task.id === taskId) || null;
const selectedColumn = (
- (selectedBoard?.columns || []).find((column) => column.id === columnId) ||
- (selectedBoard?.columns || []).find((column) => !column.is_archived) ||
+ getTaskDestinationColumns(selectedBoard?.columns).find((column) => column.id === columnId) ||
+ getTaskDestinationColumns(selectedBoard?.columns)[0] ||
null
);
@@ -132,7 +133,7 @@ export function DocumentTaskLinks({
const nextEmployees = (employeesResponse.results || employeesResponse || []) as User[];
const firstBoard = nextBoards.find((board) => (board.tasks || []).some((task) => !linkedTaskIds.has(task.id))) || nextBoards[0] || null;
const firstTask = (firstBoard?.tasks || []).find((task) => !linkedTaskIds.has(task.id)) || null;
- const firstColumn = firstBoard?.columns?.find((column) => !column.is_archived) || null;
+ const firstColumn = getTaskDestinationColumns(firstBoard?.columns)[0] || null;
setBoards(nextBoards);
setEmployees(nextEmployees);
@@ -183,7 +184,7 @@ export function DocumentTaskLinks({
const nextTask = (nextBoard?.tasks || []).find((task) => !linkedTaskIds.has(task.id)) || null;
setTaskId(nextTask?.id || "");
setTaskSearch("");
- setColumnId(nextBoard?.columns?.find((column) => !column.is_archived)?.id || "");
+ setColumnId(getTaskDestinationColumns(nextBoard?.columns)[0]?.id || "");
setLabelIds([]);
if (!nextTask) {
setMode("create");
@@ -418,11 +419,10 @@ export function DocumentTaskLinks({
disabled={saving || !selectedBoard}
>
- {(selectedBoard?.columns || [])
- .filter((column) => !column.is_archived)
+ {getTaskDestinationColumns(selectedBoard?.columns)
.map((column) => (
))}
diff --git a/frontend/src/components/employees/EmployeeTaskLinks.tsx b/frontend/src/components/employees/EmployeeTaskLinks.tsx
index 92e22fd4..3a32c9f1 100644
--- a/frontend/src/components/employees/EmployeeTaskLinks.tsx
+++ b/frontend/src/components/employees/EmployeeTaskLinks.tsx
@@ -8,6 +8,7 @@ import TaskLinkPill from "@/components/tasks/TaskLinkPill";
import { Modal } from "@/components/ui";
import { apiClient } from "@/lib/api";
import { displayUserName } from "@/lib/shared";
+import { getTaskColumnDisplayName, getTaskDestinationColumns } from "@/lib/tasks/columns";
import type { TaskBoard, TaskCard, TaskPriority, User } from "@/types/api";
type EmployeeTaskLinksProps = {
@@ -72,8 +73,8 @@ export function EmployeeTaskLinks({
const selectedBoard = boards.find((board) => board.id === boardId) || null;
const selectedTask = (selectedBoard?.tasks || []).find((task) => task.id === taskId) || null;
const selectedColumn = (
- (selectedBoard?.columns || []).find((column) => column.id === columnId) ||
- (selectedBoard?.columns || []).find((column) => !column.is_archived) ||
+ getTaskDestinationColumns(selectedBoard?.columns).find((column) => column.id === columnId) ||
+ getTaskDestinationColumns(selectedBoard?.columns)[0] ||
null
);
const linkedTaskIds = useMemo(
@@ -130,7 +131,7 @@ export function EmployeeTaskLinks({
const nextEmployees = (employeesResponse.results || employeesResponse || []) as User[];
const firstBoard = nextBoards.find((board) => (board.tasks || []).some((task) => !linkedTaskIds.has(task.id))) || nextBoards[0] || null;
const firstTask = (firstBoard?.tasks || []).find((task) => !linkedTaskIds.has(task.id)) || null;
- const firstColumn = firstBoard?.columns?.find((column) => !column.is_archived) || null;
+ const firstColumn = getTaskDestinationColumns(firstBoard?.columns)[0] || null;
setBoards(nextBoards);
setEmployees(nextEmployees);
@@ -180,7 +181,7 @@ export function EmployeeTaskLinks({
const nextTask = (nextBoard?.tasks || []).find((task) => !linkedTaskIds.has(task.id)) || null;
setTaskId(nextTask?.id || "");
setTaskSearch("");
- setColumnId(nextBoard?.columns?.find((column) => !column.is_archived)?.id || "");
+ setColumnId(getTaskDestinationColumns(nextBoard?.columns)[0]?.id || "");
if (!nextTask) {
setMode("create");
}
@@ -409,11 +410,10 @@ export function EmployeeTaskLinks({
disabled={saving || !selectedBoard}
>
- {(selectedBoard?.columns || [])
- .filter((column) => !column.is_archived)
+ {getTaskDestinationColumns(selectedBoard?.columns)
.map((column) => (
))}
diff --git a/frontend/src/components/procurement/ProcurementTaskLinks.tsx b/frontend/src/components/procurement/ProcurementTaskLinks.tsx
index 365a1d9a..8d8c04bf 100644
--- a/frontend/src/components/procurement/ProcurementTaskLinks.tsx
+++ b/frontend/src/components/procurement/ProcurementTaskLinks.tsx
@@ -8,6 +8,7 @@ import TaskLinkPill from "@/components/tasks/TaskLinkPill";
import { Modal } from "@/components/ui";
import { apiClient } from "@/lib/api";
import { displayUserName, formatMoney } from "@/lib/shared";
+import { getTaskColumnDisplayName, getTaskDestinationColumns } from "@/lib/tasks/columns";
import type {
ProcurementRequest,
TaskBoard,
@@ -83,8 +84,8 @@ export function ProcurementTaskLinks({
const selectedBoard = boards.find((board) => board.id === boardId) || null;
const selectedTask = (selectedBoard?.tasks || []).find((task) => task.id === taskId) || null;
const selectedColumn = (
- (selectedBoard?.columns || []).find((column) => column.id === columnId) ||
- (selectedBoard?.columns || []).find((column) => !column.is_archived) ||
+ getTaskDestinationColumns(selectedBoard?.columns).find((column) => column.id === columnId) ||
+ getTaskDestinationColumns(selectedBoard?.columns)[0] ||
null
);
const linkedTaskIds = useMemo(
@@ -143,7 +144,7 @@ export function ProcurementTaskLinks({
const nextEmployees = (employeesResponse.results || employeesResponse || []) as User[];
const firstBoard = nextBoards.find((board) => (board.tasks || []).some((task) => !linkedTaskIds.has(task.id))) || nextBoards[0] || null;
const firstTask = (firstBoard?.tasks || []).find((task) => !linkedTaskIds.has(task.id)) || null;
- const firstColumn = firstBoard?.columns?.find((column) => !column.is_archived) || null;
+ const firstColumn = getTaskDestinationColumns(firstBoard?.columns)[0] || null;
setBoards(nextBoards);
setEmployees(nextEmployees);
@@ -194,7 +195,7 @@ export function ProcurementTaskLinks({
const nextTask = (nextBoard?.tasks || []).find((task) => !linkedTaskIds.has(task.id)) || null;
setTaskId(nextTask?.id || "");
setTaskSearch("");
- setColumnId(nextBoard?.columns?.find((column) => !column.is_archived)?.id || "");
+ setColumnId(getTaskDestinationColumns(nextBoard?.columns)[0]?.id || "");
setLabelIds([]);
if (!nextTask) {
setMode("create");
@@ -432,11 +433,10 @@ export function ProcurementTaskLinks({
disabled={saving || !selectedBoard}
>
- {(selectedBoard?.columns || [])
- .filter((column) => !column.is_archived)
+ {getTaskDestinationColumns(selectedBoard?.columns)
.map((column) => (
))}
diff --git a/frontend/src/components/requests/RequestTaskLinks.tsx b/frontend/src/components/requests/RequestTaskLinks.tsx
index 3fca0891..698f80da 100644
--- a/frontend/src/components/requests/RequestTaskLinks.tsx
+++ b/frontend/src/components/requests/RequestTaskLinks.tsx
@@ -8,6 +8,7 @@ import TaskLinkPill from "@/components/tasks/TaskLinkPill";
import { Modal } from "@/components/ui";
import { apiClient } from "@/lib/api";
import { displayUserName } from "@/lib/shared";
+import { getTaskColumnDisplayName, getTaskDestinationColumns } from "@/lib/tasks/columns";
import type { Request, TaskBoard, TaskCard, TaskPriority, User } from "@/types/api";
type RequestTaskLinksProps = {
@@ -77,8 +78,8 @@ export function RequestTaskLinks({
const selectedBoard = boards.find((board) => board.id === boardId) || null;
const selectedTask = (selectedBoard?.tasks || []).find((task) => task.id === taskId) || null;
const selectedColumn = (
- (selectedBoard?.columns || []).find((column) => column.id === columnId) ||
- (selectedBoard?.columns || []).find((column) => !column.is_archived) ||
+ getTaskDestinationColumns(selectedBoard?.columns).find((column) => column.id === columnId) ||
+ getTaskDestinationColumns(selectedBoard?.columns)[0] ||
null
);
@@ -137,7 +138,7 @@ export function RequestTaskLinks({
const nextEmployees = (employeesResponse.results || employeesResponse || []) as User[];
const firstBoard = nextBoards.find((board) => (board.tasks || []).some((task) => !linkedTaskIds.has(task.id))) || nextBoards[0] || null;
const firstTask = (firstBoard?.tasks || []).find((task) => !linkedTaskIds.has(task.id)) || null;
- const firstColumn = firstBoard?.columns?.find((column) => !column.is_archived) || null;
+ const firstColumn = getTaskDestinationColumns(firstBoard?.columns)[0] || null;
setBoards(nextBoards);
setEmployees(nextEmployees);
@@ -188,7 +189,7 @@ export function RequestTaskLinks({
const nextTask = (nextBoard?.tasks || []).find((task) => !linkedTaskIds.has(task.id)) || null;
setTaskId(nextTask?.id || "");
setTaskSearch("");
- setColumnId(nextBoard?.columns?.find((column) => !column.is_archived)?.id || "");
+ setColumnId(getTaskDestinationColumns(nextBoard?.columns)[0]?.id || "");
setLabelIds([]);
if (!nextTask) {
setMode("create");
@@ -423,11 +424,10 @@ export function RequestTaskLinks({
disabled={saving || !selectedBoard}
>
- {(selectedBoard?.columns || [])
- .filter((column) => !column.is_archived)
+ {getTaskDestinationColumns(selectedBoard?.columns)
.map((column) => (
))}
diff --git a/frontend/src/components/tasks/RelatedTaskLinks.tsx b/frontend/src/components/tasks/RelatedTaskLinks.tsx
index 92885e19..8471793c 100644
--- a/frontend/src/components/tasks/RelatedTaskLinks.tsx
+++ b/frontend/src/components/tasks/RelatedTaskLinks.tsx
@@ -8,6 +8,7 @@ import TaskLinkPill from "@/components/tasks/TaskLinkPill";
import { Modal } from "@/components/ui";
import { apiClient } from "@/lib/api";
import { displayUserName } from "@/lib/shared";
+import { getTaskColumnDisplayName, getTaskDestinationColumns } from "@/lib/tasks/columns";
import type { TaskBoard, TaskCard, TaskPriority, User } from "@/types/api";
type RelatedTaskLinksProps = {
@@ -87,8 +88,8 @@ export function RelatedTaskLinks({
const selectedBoard = boards.find((board) => board.id === boardId) || null;
const selectedTask = (selectedBoard?.tasks || []).find((task) => task.id === taskId) || null;
const selectedColumn = (
- (selectedBoard?.columns || []).find((column) => column.id === columnId) ||
- (selectedBoard?.columns || []).find((column) => !column.is_archived) ||
+ getTaskDestinationColumns(selectedBoard?.columns).find((column) => column.id === columnId) ||
+ getTaskDestinationColumns(selectedBoard?.columns)[0] ||
null
);
const linkedTaskIds = useMemo(
@@ -144,7 +145,7 @@ export function RelatedTaskLinks({
const nextEmployees = (employeesResponse.results || employeesResponse || []) as User[];
const firstBoard = nextBoards.find((board) => (board.tasks || []).some((task) => !linkedTaskIds.has(task.id))) || nextBoards[0] || null;
const firstTask = (firstBoard?.tasks || []).find((task) => !linkedTaskIds.has(task.id)) || null;
- const firstColumn = firstBoard?.columns?.find((column) => !column.is_archived) || null;
+ const firstColumn = getTaskDestinationColumns(firstBoard?.columns)[0] || null;
setBoards(nextBoards);
setEmployees(nextEmployees);
@@ -198,7 +199,7 @@ export function RelatedTaskLinks({
const nextTask = (nextBoard?.tasks || []).find((task) => !linkedTaskIds.has(task.id)) || null;
setTaskId(nextTask?.id || "");
setTaskSearch("");
- setColumnId(nextBoard?.columns?.find((column) => !column.is_archived)?.id || "");
+ setColumnId(getTaskDestinationColumns(nextBoard?.columns)[0]?.id || "");
setLabelIds([]);
if (!nextTask) setMode("create");
};
@@ -431,11 +432,10 @@ export function RelatedTaskLinks({
disabled={saving || !selectedBoard}
>
- {(selectedBoard?.columns || [])
- .filter((column) => !column.is_archived)
+ {getTaskDestinationColumns(selectedBoard?.columns)
.map((column) => (
))}
diff --git a/frontend/src/lib/api/tasks.ts b/frontend/src/lib/api/tasks.ts
index 61e39123..69615841 100644
--- a/frontend/src/lib/api/tasks.ts
+++ b/frontend/src/lib/api/tasks.ts
@@ -131,10 +131,11 @@ export function createTasksApi(request: RequestFn, requestRaw: RawRequestFn) {
reorderTaskColumns: (
board: number,
columnIds: number[],
- ): Promise<{ board: number; column_ids: number[] }> =>
+ parent: number | null = null,
+ ): Promise<{ board: number; parent: number | null; column_ids: number[] }> =>
request("/api/v1/tasks/columns/reorder/", {
method: "POST",
- body: JSON.stringify({ board, column_ids: columnIds }),
+ body: JSON.stringify({ board, parent, column_ids: columnIds }),
}),
createTaskLabel: (data: Record
): Promise =>
request("/api/v1/tasks/labels/", {
diff --git a/frontend/src/lib/tasks/columns.ts b/frontend/src/lib/tasks/columns.ts
new file mode 100644
index 00000000..b801321e
--- /dev/null
+++ b/frontend/src/lib/tasks/columns.ts
@@ -0,0 +1,25 @@
+import type { TaskColumn } from "@/types/api";
+
+export function getTaskDestinationColumns(
+ columns: TaskColumn[] | null | undefined,
+): TaskColumn[] {
+ const activeColumns = (columns || []).filter((column) => !column.is_archived);
+ const topLevelColumns = activeColumns
+ .filter((column) => !column.parent)
+ .sort((left, right) => left.position - right.position || left.id - right.id);
+ return topLevelColumns.flatMap((column) => {
+ const subcolumns = activeColumns
+ .filter((item) => item.parent === column.id)
+ .sort((left, right) => left.position - right.position || left.id - right.id);
+ return subcolumns.length > 0 ? subcolumns : [column];
+ });
+}
+
+export function getTaskColumnDisplayName(
+ column: TaskColumn,
+ columns: TaskColumn[] | null | undefined,
+): string {
+ if (!column.parent) return column.name;
+ const parent = (columns || []).find((item) => item.id === column.parent);
+ return parent ? `${parent.name} / ${column.name}` : column.name;
+}
diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts
index cd8fe23d..123fede1 100644
--- a/frontend/src/types/api.ts
+++ b/frontend/src/types/api.ts
@@ -176,12 +176,14 @@ export type TaskPriority = "low" | "medium" | "high" | "critical";
export interface TaskColumn {
id: number;
board: number;
+ parent: number | null;
name: string;
position: number;
color?: string;
is_done: boolean;
is_archived: boolean;
tasks_count?: number;
+ subcolumns_count?: number;
created_at: string;
updated_at: string;
}