feat(tasks): add board subcolumns

This commit is contained in:
lizerk 2026-08-05 19:05:33 +03:00
parent f4a6b2de9b
commit 0e012c3069
17 changed files with 889 additions and 140 deletions

View File

@ -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

View File

@ -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()
)

View File

@ -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"]

View File

@ -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",
),
),
]

View File

@ -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(

View File

@ -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)

View File

@ -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}
>
<option value="">Выберите колонку</option>
{(selectedTaskLinkBoard?.columns || [])
.filter((column) => !column.is_archived)
{getTaskDestinationColumns(selectedTaskLinkBoard?.columns)
.map((column) => (
<option key={column.id} value={column.id}>
{column.name}
{getTaskColumnDisplayName(column, selectedTaskLinkBoard?.columns)}
</option>
))}
</select>

View File

@ -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({
}`}
>
<div
className={`flex items-center justify-between gap-3 rounded-xl border bg-[var(--surface-primary)] px-3 py-3 shadow-sm transition xl:sticky xl:top-0 xl:z-10 ${columnBorderClass} ${
className={`flex items-center justify-between gap-3 rounded-xl border bg-[var(--surface-primary)] px-3 py-3 shadow-sm transition xl:sticky ${nested ? "xl:top-[4.75rem]" : "xl:top-0"} xl:z-10 ${columnBorderClass} ${
isDragging ? "shadow-xl" : ""
}`}
>
@ -774,6 +781,16 @@ function BoardColumn({
</button>
{columnMenuOpen ? (
<div className="app-menu absolute right-0 top-full z-30 mt-2 w-44 rounded-lg p-1.5">
{!column.parent ? (
<button
type="button"
onClick={() => onCreateSubcolumn(column)}
className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition hover:bg-[var(--surface-secondary)]"
>
<Plus size={14} className="app-text-muted" />
Добавить подколонку
</button>
) : null}
<button
type="button"
onClick={() => onEditColumn(column)}
@ -826,6 +843,154 @@ function BoardColumn({
);
}
function BoardColumnGroup({
column,
tasksCount,
children,
onCreateTask,
onCreateSubcolumn,
onEditColumn,
onDeleteColumn,
columnMenuOpen,
columnMenuRef,
onToggleColumnMenu,
onColumnMount,
hideOnMobile = false,
}: {
column: TaskColumn;
tasksCount: number;
children: ReactNode;
onCreateTask: () => void;
onCreateSubcolumn: () => void;
onEditColumn: (column: TaskColumn) => void;
onDeleteColumn: (column: TaskColumn) => void;
columnMenuOpen: boolean;
columnMenuRef: RefObject<HTMLDivElement | null>;
onToggleColumnMenu: () => void;
onColumnMount: (columnId: number, node: HTMLElement | null) => void;
hideOnMobile?: boolean;
}) {
const {
attributes,
listeners,
setActivatorNodeRef,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: `column-${column.id}`,
data: { type: "column", columnId: column.id, acceptsTasks: false },
});
const setGroupNodeRef = useCallback((node: HTMLElement | null) => {
setNodeRef(node);
onColumnMount(column.id, node);
}, [column.id, onColumnMount, setNodeRef]);
return (
<section
ref={setGroupNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 20 : undefined,
}}
className={`${hideOnMobile ? "hidden xl:flex" : "flex"} min-w-0 flex-col ${isDragging ? "opacity-70" : ""}`}
>
<div className="app-surface xl:sticky xl:top-0 xl:z-20 flex items-center justify-between gap-3 rounded-xl border border-[var(--border-subtle)] px-3 py-3 shadow-sm">
<div
ref={setActivatorNodeRef}
className="min-w-0 flex-1 cursor-grab touch-none active:cursor-grabbing"
title="Перетащите, чтобы изменить порядок колонок"
{...attributes}
{...listeners}
>
<div className="flex items-center gap-2">
<span
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: column.color || "#38bdf8" }}
/>
<h2 className="min-w-0 truncate text-sm font-semibold text-[var(--foreground)]">
{column.name}
</h2>
<span className="app-badge rounded-full px-1.5 py-0.5 text-[10px] font-semibold">
{tasksCount}
</span>
</div>
<p className="app-text-muted mt-0.5 text-xs">Подколонки</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onCreateTask();
}}
className="app-icon-button flex h-8 w-8 items-center justify-center rounded-lg"
title="Создать задачу"
aria-label="Создать задачу"
>
<Plus size={16} />
</button>
<div
ref={columnMenuOpen ? columnMenuRef : null}
className="relative"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<button
type="button"
onClick={onToggleColumnMenu}
className="app-icon-button flex h-8 w-8 items-center justify-center rounded-lg"
title="Действия с колонкой"
aria-label={`Действия с колонкой ${column.name}`}
aria-expanded={columnMenuOpen}
aria-haspopup="menu"
>
<ChevronDown
size={14}
className={`transition-transform ${columnMenuOpen ? "" : "-rotate-90"}`}
/>
</button>
{columnMenuOpen ? (
<div className="app-menu absolute right-0 top-full z-30 mt-2 w-48 rounded-lg p-1.5">
<button
type="button"
onClick={onCreateSubcolumn}
className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition hover:bg-[var(--surface-secondary)]"
>
<Plus size={14} className="app-text-muted" />
Добавить подколонку
</button>
<button
type="button"
onClick={() => onEditColumn(column)}
className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition hover:bg-[var(--surface-secondary)]"
>
<Pencil size={14} className="app-text-muted" />
Редактировать
</button>
<button
type="button"
onClick={() => onDeleteColumn(column)}
className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm text-[var(--danger-foreground)] transition hover:bg-[var(--danger-soft)]"
>
<Trash2 size={14} />
Удалить
</button>
</div>
) : null}
</div>
</div>
</div>
<div className="mt-2 flex w-full min-w-0 flex-col gap-2 rounded-xl border border-[var(--border-subtle)] bg-[var(--surface-muted)] p-2 xl:w-auto xl:min-w-max xl:flex-row">
{children}
</div>
</section>
);
}
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<number, TaskColumn[]>();
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<number, number>();
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}
</span>
</button>
{activeColumns.map((column) => {
{topLevelColumns.map((column) => {
const active = selectedColumnTarget === column.id;
return (
<button
@ -5002,7 +5235,7 @@ function TasksPageContent() {
})}
<button
type="button"
onClick={openCreateColumn}
onClick={() => 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}
>
<SortableContext
items={activeColumns.map((column) => `column-${column.id}`)}
items={topLevelColumns.map((column) => `column-${column.id}`)}
strategy={horizontalListSortingStrategy}
>
<div
@ -5053,39 +5286,95 @@ function TasksPageContent() {
}`}
>
<div className="flex w-full min-w-0 gap-3 xl:min-h-full xl:w-auto xl:min-w-max xl:items-stretch">
{activeColumns.map((column) => (
<BoardColumn
key={column.id}
column={column}
tasks={tasksByColumn.get(column.id) || []}
onCreateTask={openCreateTask}
onOpenTask={openTaskView}
onEditTask={openEditTask}
onDeleteTask={deleteTask}
currentUserId={user?.id}
claimingTaskId={claimingTaskId}
onClaimTask={claimTask}
completingTaskId={completingTaskId}
onCompleteTask={completeTask}
openMenuTaskId={cardMenuTaskId}
menuRef={cardMenuRef}
onToggleTaskMenu={toggleCardTaskMenu}
columnMenuOpen={columnMenuId === column.id}
columnMenuRef={columnMenuRef}
onToggleColumnMenu={() => 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 (
<BoardColumn
key={column.id}
column={column}
tasks={tasksByColumn.get(column.id) || []}
onCreateTask={openCreateTask}
onOpenTask={openTaskView}
onEditTask={openEditTask}
onDeleteTask={deleteTask}
currentUserId={user?.id}
claimingTaskId={claimingTaskId}
onClaimTask={claimTask}
completingTaskId={completingTaskId}
onCompleteTask={completeTask}
openMenuTaskId={cardMenuTaskId}
menuRef={cardMenuRef}
onToggleTaskMenu={toggleCardTaskMenu}
columnMenuOpen={columnMenuId === column.id}
columnMenuRef={columnMenuRef}
onToggleColumnMenu={() => toggleColumnMenu(column.id)}
onEditColumn={openEditColumn}
onDeleteColumn={deleteColumn}
onCreateSubcolumn={(parent) => openCreateColumn(parent.id)}
onColumnMount={registerColumnNode}
fillAvailableHeight={desktopWideMode}
hideOnMobile={hideOnMobile}
/>
);
}
return (
<BoardColumnGroup
key={column.id}
column={column}
tasksCount={columnCounts.get(column.id) || 0}
onCreateTask={() => 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}
>
<SortableContext
items={subcolumns.map((subcolumn) => `column-${subcolumn.id}`)}
strategy={horizontalListSortingStrategy}
>
{subcolumns.map((subcolumn) => (
<BoardColumn
key={subcolumn.id}
column={subcolumn}
tasks={tasksByColumn.get(subcolumn.id) || []}
onCreateTask={openCreateTask}
onOpenTask={openTaskView}
onEditTask={openEditTask}
onDeleteTask={deleteTask}
currentUserId={user?.id}
claimingTaskId={claimingTaskId}
onClaimTask={claimTask}
completingTaskId={completingTaskId}
onCompleteTask={completeTask}
openMenuTaskId={cardMenuTaskId}
menuRef={cardMenuRef}
onToggleTaskMenu={toggleCardTaskMenu}
columnMenuOpen={columnMenuId === subcolumn.id}
columnMenuRef={columnMenuRef}
onToggleColumnMenu={() => toggleColumnMenu(subcolumn.id)}
onEditColumn={openEditColumn}
onDeleteColumn={deleteColumn}
onCreateSubcolumn={(parent) => openCreateColumn(parent.id)}
onColumnMount={registerColumnNode}
fillAvailableHeight={desktopWideMode}
nested
/>
))}
</SortableContext>
</BoardColumnGroup>
);
})}
<AddColumnCard
onClick={openCreateColumn}
onClick={() => openCreateColumn()}
viewportHeight={boardViewportHeight}
/>
</div>
@ -5391,7 +5680,11 @@ function TasksPageContent() {
<Modal
isOpen={columnModalOpen}
onClose={closeColumnModal}
title={columnForm.id ? "Редактировать колонку" : "Новая колонка"}
title={columnForm.id
? "Редактировать колонку"
: columnForm.parent
? "Новая подколонка"
: "Новая колонка"}
size="sm"
closeOnClickOutside
footer={(
@ -5417,6 +5710,14 @@ function TasksPageContent() {
)}
>
<div className="space-y-4">
{columnForm.parent ? (
<div className="app-surface-muted rounded-xl border border-[var(--border-subtle)] px-3 py-2">
<p className="app-card-caption">Родительская колонка</p>
<p className="mt-1 text-sm font-medium text-[var(--foreground)]">
{activeColumns.find((column) => column.id === columnForm.parent)?.name || "Колонка"}
</p>
</div>
) : null}
<label className="block">
<span className="app-text-muted mb-1 block text-xs font-medium">Название колонки</span>
<input
@ -5534,8 +5835,11 @@ function TasksPageContent() {
{viewColumnMenuOpen ? (
<div className="app-menu absolute left-0 top-full z-30 mt-2 w-56 rounded-xl p-1.5">
{activeColumns.map((column) => {
{leafColumns.map((column) => {
const selected = column.id === viewTask.column;
const parent = column.parent
? activeColumns.find((item) => item.id === column.parent)
: null;
return (
<button
key={column.id}
@ -5550,7 +5854,9 @@ function TasksPageContent() {
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: column.color || "#38bdf8" }}
/>
<span className="min-w-0 flex-1 truncate">{column.name}</span>
<span className="min-w-0 flex-1 truncate">
{parent ? `${parent.name} / ${column.name}` : column.name}
</span>
{selected ? (
<span className="app-badge rounded-full px-2 py-0.5 text-[10px]">
текущая
@ -6807,9 +7113,21 @@ function TasksPageContent() {
onChange={(event) => setForm((current) => ({ ...current, column: Number(event.target.value) || "" }))}
className="app-select w-full rounded-xl px-3 py-2 text-sm"
>
{(board?.columns || []).filter((column) => !column.is_archived).map((column) => (
<option key={column.id} value={column.id}>{column.name}</option>
))}
{topLevelColumns.map((column) => {
const subcolumns = subcolumnsByParent.get(column.id) || [];
if (subcolumns.length === 0) {
return <option key={column.id} value={column.id}>{column.name}</option>;
}
return (
<optgroup key={column.id} label={column.name}>
{subcolumns.map((subcolumn) => (
<option key={subcolumn.id} value={subcolumn.id}>
{subcolumn.name}
</option>
))}
</optgroup>
);
})}
</select>
</label>

View File

@ -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}
>
<option value="">Выберите колонку</option>
{(selectedTaskLinkBoard?.columns || [])
.filter((column) => !column.is_archived)
{getTaskDestinationColumns(selectedTaskLinkBoard?.columns)
.map((column) => (
<option key={column.id} value={column.id}>
{column.name}
{getTaskColumnDisplayName(column, selectedTaskLinkBoard?.columns)}
</option>
))}
</select>

View File

@ -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}
>
<option value="">Выберите колонку</option>
{(selectedBoard?.columns || [])
.filter((column) => !column.is_archived)
{getTaskDestinationColumns(selectedBoard?.columns)
.map((column) => (
<option key={column.id} value={column.id}>
{column.name}
{getTaskColumnDisplayName(column, selectedBoard?.columns)}
</option>
))}
</select>

View File

@ -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}
>
<option value="">Выберите колонку</option>
{(selectedBoard?.columns || [])
.filter((column) => !column.is_archived)
{getTaskDestinationColumns(selectedBoard?.columns)
.map((column) => (
<option key={column.id} value={column.id}>
{column.name}
{getTaskColumnDisplayName(column, selectedBoard?.columns)}
</option>
))}
</select>

View File

@ -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}
>
<option value="">Выберите колонку</option>
{(selectedBoard?.columns || [])
.filter((column) => !column.is_archived)
{getTaskDestinationColumns(selectedBoard?.columns)
.map((column) => (
<option key={column.id} value={column.id}>
{column.name}
{getTaskColumnDisplayName(column, selectedBoard?.columns)}
</option>
))}
</select>

View File

@ -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}
>
<option value="">Выберите колонку</option>
{(selectedBoard?.columns || [])
.filter((column) => !column.is_archived)
{getTaskDestinationColumns(selectedBoard?.columns)
.map((column) => (
<option key={column.id} value={column.id}>
{column.name}
{getTaskColumnDisplayName(column, selectedBoard?.columns)}
</option>
))}
</select>

View File

@ -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}
>
<option value="">Выберите колонку</option>
{(selectedBoard?.columns || [])
.filter((column) => !column.is_archived)
{getTaskDestinationColumns(selectedBoard?.columns)
.map((column) => (
<option key={column.id} value={column.id}>
{column.name}
{getTaskColumnDisplayName(column, selectedBoard?.columns)}
</option>
))}
</select>

View File

@ -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<string, any>): Promise<TaskLabel> =>
request("/api/v1/tasks/labels/", {

View File

@ -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;
}

View File

@ -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;
}