feat(tasks): add board subcolumns
This commit is contained in:
parent
f4a6b2de9b
commit
0e012c3069
@ -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
|
||||
|
||||
@ -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()
|
||||
)
|
||||
|
||||
@ -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"]
|
||||
|
||||
|
||||
43
backend/tasks/migrations/0024_taskcolumn_parent.py
Normal file
43
backend/tasks/migrations/0024_taskcolumn_parent.py
Normal 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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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/", {
|
||||
|
||||
25
frontend/src/lib/tasks/columns.ts
Normal file
25
frontend/src/lib/tasks/columns.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user