diff --git a/backend/api/v1/tasks/serializers.py b/backend/api/v1/tasks/serializers.py
index 47b17d19..bd083a2d 100644
--- a/backend/api/v1/tasks/serializers.py
+++ b/backend/api/v1/tasks/serializers.py
@@ -1,6 +1,6 @@
from urllib.parse import urlsplit
-from django.db.models import Max
+from django.db.models import Count, Max
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
@@ -44,8 +44,10 @@ from tasks.models import (
TaskBoard,
TaskBoardAccessScope,
TaskBoardGroup,
+ TaskBoardPlacement,
TaskChecklistItem,
TaskColumn,
+ TaskColumnRow,
TaskExternalLink,
TaskLabel,
TaskLinkedObject,
@@ -205,6 +207,54 @@ class TaskColumnSerializer(serializers.ModelSerializer):
return super().create(validated_data)
+class TaskColumnRowSerializer(serializers.ModelSerializer):
+ tasks_count = serializers.IntegerField(read_only=True, default=0)
+
+ class Meta:
+ model = TaskColumnRow
+ fields = [
+ "id",
+ "column",
+ "name",
+ "position",
+ "color",
+ "tasks_count",
+ "created_at",
+ "updated_at",
+ ]
+ read_only_fields = ["id", "tasks_count", "created_at", "updated_at"]
+
+ def validate_column(self, column):
+ validate_board_access(self, column.board)
+ if column.parent_id:
+ raise serializers.ValidationError(
+ "Дорожки можно создавать только в основной колонке."
+ )
+ if column.is_archived:
+ raise serializers.ValidationError(
+ "Нельзя создать дорожку в архивной колонке."
+ )
+ return column
+
+ def validate(self, attrs):
+ column = attrs.get("column") or getattr(self.instance, "column", None)
+ if self.instance and column and self.instance.column_id != column.id:
+ raise serializers.ValidationError(
+ {"column": "Основную колонку дорожки изменить нельзя."}
+ )
+ name = attrs.get("name", getattr(self.instance, "name", "")).strip()
+ if not name:
+ raise serializers.ValidationError({"name": "Укажите название дорожки."})
+ duplicate = TaskColumnRow.objects.filter(column=column, name__iexact=name)
+ if self.instance:
+ duplicate = duplicate.exclude(pk=self.instance.pk)
+ if duplicate.exists():
+ raise serializers.ValidationError(
+ {"name": "Дорожка с таким названием уже существует в колонке."}
+ )
+ attrs["name"] = name
+ return attrs
+
class TaskSerializer(serializers.ModelSerializer):
created_by = EmployeeBriefSerializer(read_only=True)
assignee = EmployeeBriefSerializer(read_only=True)
@@ -238,6 +288,8 @@ class TaskSerializer(serializers.ModelSerializer):
board_name = serializers.CharField(source="board.name", read_only=True)
column_name = serializers.CharField(source="column.name", read_only=True)
column_color = serializers.CharField(source="column.color", read_only=True)
+ row_name = serializers.CharField(source="row.name", read_only=True)
+ row_color = serializers.CharField(source="row.color", read_only=True)
linked_posts_count = serializers.SerializerMethodField()
linked_messages_count = serializers.SerializerMethodField()
linked_events_count = serializers.SerializerMethodField()
@@ -263,6 +315,9 @@ class TaskSerializer(serializers.ModelSerializer):
"column",
"column_name",
"column_color",
+ "row",
+ "row_name",
+ "row_color",
"title",
"description",
"created_by",
@@ -302,6 +357,8 @@ class TaskSerializer(serializers.ModelSerializer):
"board_name",
"column_name",
"column_color",
+ "row_name",
+ "row_color",
"priority_display",
"completed_at",
"linked_posts_count",
@@ -443,6 +500,11 @@ class TaskSerializer(serializers.ModelSerializer):
user = getattr(request, "user", None)
board = attrs.get("board") or getattr(self.instance, "board", None)
column = attrs.get("column") or getattr(self.instance, "column", None)
+ row = (
+ attrs.get("row")
+ if "row" in attrs
+ else getattr(self.instance, "row", None)
+ )
labels = attrs.get("labels")
participants = attrs.get("participants")
@@ -461,6 +523,13 @@ class TaskSerializer(serializers.ModelSerializer):
{"column": "Выберите подколонку для размещения задачи."}
)
+ if row and column:
+ top_level_column_id = column.parent_id or column.id
+ if row.column_id != top_level_column_id:
+ raise serializers.ValidationError(
+ {"row": "Дорожка должна принадлежать основной колонке задачи."}
+ )
+
if labels is not None and board:
invalid_labels = [
label.id for label in labels if label.board_id != board.id
@@ -493,8 +562,9 @@ class TaskSerializer(serializers.ModelSerializer):
participants = list(validated_data.pop("participants", []))
if "position" not in validated_data:
column = validated_data["column"]
+ row = validated_data.get("row")
max_position = (
- Task.objects.filter(column=column).aggregate(Max("position"))[
+ Task.objects.filter(column=column, row=row).aggregate(Max("position"))[
"position__max"
]
or 0
@@ -560,6 +630,7 @@ class TaskBoardSerializer(serializers.ModelSerializer):
read_only=True,
)
columns = TaskColumnSerializer(many=True, read_only=True)
+ column_rows = serializers.SerializerMethodField()
labels = TaskLabelSerializer(many=True, read_only=True)
tasks = TaskSerializer(many=True, read_only=True)
tasks_count = serializers.IntegerField(read_only=True, default=0)
@@ -580,6 +651,7 @@ class TaskBoardSerializer(serializers.ModelSerializer):
read_only=True,
default=True,
)
+ view_state = serializers.SerializerMethodField()
class Meta:
model = TaskBoard
@@ -596,6 +668,7 @@ class TaskBoardSerializer(serializers.ModelSerializer):
"department_details",
"is_archived",
"columns",
+ "column_rows",
"labels",
"tasks",
"tasks_count",
@@ -603,6 +676,7 @@ class TaskBoardSerializer(serializers.ModelSerializer):
"group_id",
"is_pinned",
"notifications_enabled",
+ "view_state",
"created_at",
"updated_at",
]
@@ -629,6 +703,63 @@ class TaskBoardSerializer(serializers.ModelSerializer):
attrs["departments"] = []
return attrs
+ @extend_schema_field(serializers.DictField())
+ def get_view_state(self, obj):
+ request = self.context.get("request")
+ user = getattr(request, "user", None)
+ if not user or not getattr(user, "is_authenticated", False):
+ return {
+ "hidden_column_ids": [],
+ "collapsed_base_column_ids": [],
+ "collapsed_row_ids": [],
+ }
+ raw_state = (
+ TaskBoardPlacement.objects.filter(user=user, board=obj)
+ .values_list("view_state", flat=True)
+ .first()
+ or {}
+ )
+ valid_column_ids = set(
+ obj.columns.filter(parent__isnull=True, is_archived=False).values_list(
+ "id", flat=True
+ )
+ )
+ valid_row_ids = set(
+ TaskColumnRow.objects.filter(
+ column__board=obj,
+ column__is_archived=False,
+ ).values_list("id", flat=True)
+ )
+
+ def valid_ids(key, allowed_ids):
+ values = raw_state.get(key, []) if isinstance(raw_state, dict) else []
+ if not isinstance(values, list):
+ return []
+ return sorted(
+ {
+ value
+ for value in values
+ if isinstance(value, int) and value in allowed_ids
+ }
+ )
+
+ return {
+ "hidden_column_ids": valid_ids("hidden_column_ids", valid_column_ids),
+ "collapsed_base_column_ids": valid_ids(
+ "collapsed_base_column_ids", valid_column_ids
+ ),
+ "collapsed_row_ids": valid_ids("collapsed_row_ids", valid_row_ids),
+ }
+
+ @extend_schema_field(TaskColumnRowSerializer(many=True))
+ def get_column_rows(self, obj):
+ rows = TaskColumnRow.objects.filter(
+ column__board=obj,
+ ).annotate(
+ tasks_count=Count("tasks", distinct=True)
+ ).order_by("column_id", "position", "id")
+ return TaskColumnRowSerializer(rows, many=True).data
+
@extend_schema_field(serializers.BooleanField())
def get_is_default_for_current_user(self, obj):
request = self.context.get("request")
diff --git a/backend/api/v1/tasks/urls.py b/backend/api/v1/tasks/urls.py
index cf70e111..6a4fc7b7 100644
--- a/backend/api/v1/tasks/urls.py
+++ b/backend/api/v1/tasks/urls.py
@@ -5,6 +5,7 @@ from .views import (
TaskBoardGroupViewSet,
TaskBoardViewSet,
TaskColumnViewSet,
+ TaskColumnRowViewSet,
TaskLabelViewSet,
TaskViewSet,
)
@@ -15,6 +16,7 @@ router = DefaultRouter()
router.register(r"board-groups", TaskBoardGroupViewSet, basename="task-board-group")
router.register(r"boards", TaskBoardViewSet, basename="task-board")
router.register(r"columns", TaskColumnViewSet, basename="task-column")
+router.register(r"column-rows", TaskColumnRowViewSet, basename="task-column-row")
router.register(r"labels", TaskLabelViewSet, basename="task-label")
router.register(r"", TaskViewSet, basename="task")
diff --git a/backend/api/v1/tasks/views.py b/backend/api/v1/tasks/views.py
index 63c115e2..edc1f7d5 100644
--- a/backend/api/v1/tasks/views.py
+++ b/backend/api/v1/tasks/views.py
@@ -58,6 +58,7 @@ from tasks.models import (
TaskBoardGroup,
TaskBoardPlacement,
TaskColumn,
+ TaskColumnRow,
TaskExternalLink,
TaskLabel,
TaskLinkedObject,
@@ -88,6 +89,7 @@ from .serializers import (
TaskBoardSummarySerializer,
TaskChecklistItemSerializer,
TaskColumnSerializer,
+ TaskColumnRowSerializer,
TaskExternalLinkSerializer,
TaskLabelSerializer,
TaskLinkedCalendarEventSerializer,
@@ -145,9 +147,9 @@ def create_task_activity(
)
-def normalize_column_positions(column):
+def normalize_column_positions(column, row=None):
task_ids = list(
- column.tasks.order_by("position", "-created_at", "id").values_list(
+ column.tasks.filter(row=row).order_by("position", "-created_at", "id").values_list(
"id",
flat=True,
)
@@ -375,7 +377,11 @@ class TaskBoardViewSet(viewsets.ModelViewSet):
).first()
if placement:
placement.group = None
- if not placement.is_pinned and placement.notifications_enabled:
+ if (
+ not placement.is_pinned
+ and placement.notifications_enabled
+ and not placement.view_state
+ ):
placement.delete()
else:
placement.save(update_fields=["group", "updated_at"])
@@ -411,7 +417,12 @@ class TaskBoardViewSet(viewsets.ModelViewSet):
placement.is_pinned = not placement.is_pinned
placement.pinned_at = timezone.now() if placement.is_pinned else None
is_pinned = placement.is_pinned
- if not placement.is_pinned and placement.group_id is None and placement.notifications_enabled:
+ if (
+ not placement.is_pinned
+ and placement.group_id is None
+ and placement.notifications_enabled
+ and not placement.view_state
+ ):
placement.delete()
else:
placement.save(update_fields=["is_pinned", "pinned_at", "updated_at"])
@@ -427,7 +438,12 @@ class TaskBoardViewSet(viewsets.ModelViewSet):
)
placement.notifications_enabled = not placement.notifications_enabled
notifications_enabled = placement.notifications_enabled
- if placement.notifications_enabled and placement.group_id is None and not placement.is_pinned:
+ if (
+ placement.notifications_enabled
+ and placement.group_id is None
+ and not placement.is_pinned
+ and not placement.view_state
+ ):
placement.delete()
else:
placement.save(update_fields=["notifications_enabled", "updated_at"])
@@ -435,6 +451,96 @@ class TaskBoardViewSet(viewsets.ModelViewSet):
{"ok": True, "notifications_enabled": notifications_enabled}
)
+ @action(detail=True, methods=["patch"], url_path="view-state")
+ def view_state(self, request, pk=None):
+ board = self.get_object()
+ scope = request.data.get("scope")
+ collapsed = request.data.get("collapsed")
+ try:
+ object_id = int(request.data.get("object_id"))
+ except (TypeError, ValueError):
+ object_id = None
+
+ state_keys = {
+ "column": "hidden_column_ids",
+ "base": "collapsed_base_column_ids",
+ "row": "collapsed_row_ids",
+ }
+ if scope not in state_keys:
+ return Response(
+ {"scope": ["Допустимые значения: column, base, row."]},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+ if object_id is None:
+ return Response(
+ {"object_id": ["Укажите корректный идентификатор."]},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+ if not isinstance(collapsed, bool):
+ return Response(
+ {"collapsed": ["Укажите логическое значение."]},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ if scope == "row":
+ object_exists = TaskColumnRow.objects.filter(
+ id=object_id,
+ column__board=board,
+ column__is_archived=False,
+ ).exists()
+ else:
+ object_exists = TaskColumn.objects.filter(
+ id=object_id,
+ board=board,
+ parent__isnull=True,
+ is_archived=False,
+ ).exists()
+ if not object_exists:
+ return Response(
+ {"object_id": ["Элемент не принадлежит этой доске."]},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ with transaction.atomic():
+ placement, _ = TaskBoardPlacement.objects.select_for_update().get_or_create(
+ user=request.user,
+ board=board,
+ defaults={"group": None},
+ )
+ state = placement.view_state.copy() if isinstance(placement.view_state, dict) else {}
+ key = state_keys[scope]
+ values = {
+ value
+ for value in state.get(key, [])
+ if isinstance(value, int)
+ }
+ if collapsed:
+ values.add(object_id)
+ else:
+ values.discard(object_id)
+ state[key] = sorted(values)
+ state = {name: value for name, value in state.items() if value}
+ placement.view_state = state
+ if (
+ not state
+ and placement.group_id is None
+ and not placement.is_pinned
+ and placement.notifications_enabled
+ ):
+ placement.delete()
+ else:
+ placement.save(update_fields=["view_state", "updated_at"])
+
+ return Response(
+ {
+ "hidden_column_ids": state.get("hidden_column_ids", []),
+ "collapsed_base_column_ids": state.get(
+ "collapsed_base_column_ids", []
+ ),
+ "collapsed_row_ids": state.get("collapsed_row_ids", []),
+ }
+ )
+
@action(detail=True, methods=["post"], url_path="notifications/read")
def mark_notifications_read(self, request, pk=None):
board = self.get_object()
@@ -489,6 +595,7 @@ class TaskBoardGroupViewSet(viewsets.ModelViewSet):
group__isnull=True,
is_pinned=False,
notifications_enabled=True,
+ view_state={},
).delete()
@@ -679,10 +786,59 @@ class TaskColumnViewSet(viewsets.ModelViewSet):
)
return Response(
- {"board": board.id, "parent": parent_id, "column_ids": column_ids}
+ {
+ "board": board.id,
+ "parent": parent_id,
+ "column_ids": column_ids,
+ }
)
+class TaskColumnRowViewSet(viewsets.ModelViewSet):
+ permission_classes = [IsAuthenticated]
+ serializer_class = TaskColumnRowSerializer
+
+ def get_queryset(self):
+ queryset = (
+ TaskColumnRow.objects.filter(
+ column__board__is_archived=False,
+ column__board__in=TaskBoard.objects.filter(
+ task_board_access_q(self.request.user)
+ ),
+ )
+ .select_related("column", "column__board")
+ .annotate(tasks_count=Count("tasks", distinct=True))
+ .order_by("column_id", "position", "id")
+ )
+ if column := self.request.query_params.get("column"):
+ queryset = queryset.filter(column_id=column)
+ if board := self.request.query_params.get("board"):
+ queryset = queryset.filter(column__board_id=board)
+ return queryset
+
+ def perform_create(self, serializer):
+ row = serializer.save()
+ send_task_board_update(row.column.board, "created", "column_row", row.id)
+
+ def perform_update(self, serializer):
+ row = serializer.save()
+ send_task_board_update(row.column.board, "updated", "column_row", row.id)
+
+ def perform_destroy(self, instance):
+ board = instance.column.board
+ row_id = instance.id
+ leaf_columns = list(
+ instance.column.subcolumns.filter(is_archived=False).order_by(
+ "position",
+ "id",
+ )
+ ) or [instance.column]
+ instance.delete()
+ for column in leaf_columns:
+ normalize_column_positions(column, None)
+ send_task_board_update(board, "deleted", "column_row", row_id)
+
+
class TaskLabelViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated]
serializer_class = TaskLabelSerializer
@@ -745,7 +901,7 @@ class TaskViewSet(viewsets.ModelViewSet):
task_board_access_q(self.request.user)
)
)
- .select_related("board", "column", "created_by", "assignee")
+ .select_related("board", "column", "row", "created_by", "assignee")
.prefetch_related("labels", "participants", "checklist_items")
.annotate(
linked_posts_count=Count(
@@ -840,6 +996,8 @@ class TaskViewSet(viewsets.ModelViewSet):
queryset = queryset.filter(board_id=board)
if column := params.get("column"):
queryset = queryset.filter(column_id=column)
+ if row := params.get("row"):
+ queryset = queryset.filter(row_id=row)
if assignee := params.get("assignee"):
queryset = queryset.filter(assignee_id=assignee)
if priority := params.get("priority"):
@@ -867,6 +1025,7 @@ class TaskViewSet(viewsets.ModelViewSet):
"assignee_id": instance.assignee_id,
"priority": instance.priority,
"due_date": instance.due_date.isoformat() if instance.due_date else None,
+ "row_id": instance.row_id,
"label_ids": sorted(instance.labels.values_list("id", flat=True)),
"participant_ids": sorted(
instance.participants.values_list("id", flat=True)
@@ -879,6 +1038,7 @@ class TaskViewSet(viewsets.ModelViewSet):
"assignee_id": task.assignee_id,
"priority": task.priority,
"due_date": task.due_date.isoformat() if task.due_date else None,
+ "row_id": task.row_id,
"label_ids": sorted(task.labels.values_list("id", flat=True)),
"participant_ids": sorted(
task.participants.values_list("id", flat=True)
@@ -904,11 +1064,12 @@ class TaskViewSet(viewsets.ModelViewSet):
instance.delete()
send_task_board_update(board, "deleted", "task", task_id)
- def _move_task(self, task, column, position, actor):
+ def _move_task(self, task, column, row, position, actor):
old_column = task.column
+ old_row = task.row
if position in (None, ""):
max_position = (
- Task.objects.filter(column=column).exclude(id=task.id).aggregate(
+ Task.objects.filter(column=column, row=row).exclude(id=task.id).aggregate(
Max("position")
)["position__max"]
or 0
@@ -916,26 +1077,38 @@ class TaskViewSet(viewsets.ModelViewSet):
position = max_position + 1000
task.column = column
+ task.row = row
task.position = position
task.save(
- update_fields=["column", "position", "completed_at", "updated_at"]
+ update_fields=["column", "row", "position", "completed_at", "updated_at"]
)
- normalize_column_positions(column)
- if old_column.id != column.id:
- normalize_column_positions(old_column)
+ normalize_column_positions(column, row)
+ if old_column.id != column.id or old_row != row:
+ normalize_column_positions(old_column, old_row)
task.refresh_from_db()
+ activity_metadata = {
+ "from_column_id": old_column.id,
+ "from_column": old_column.name,
+ "to_column_id": column.id,
+ "to_column": column.name,
+ }
+ if old_row or row:
+ activity_metadata.update(
+ {
+ "from_row_id": old_row.id if old_row else None,
+ "from_row": old_row.name if old_row else None,
+ "to_row_id": row.id if row else None,
+ "to_row": row.name if row else None,
+ }
+ )
+
create_task_activity(
task,
actor,
TaskActivityAction.MOVED,
- metadata={
- "from_column_id": old_column.id,
- "from_column": old_column.name,
- "to_column_id": column.id,
- "to_column": column.name,
- },
+ metadata=activity_metadata,
)
notify_task_moved(task, actor, old_column, column)
send_task_board_update(
@@ -943,7 +1116,7 @@ class TaskViewSet(viewsets.ModelViewSet):
"moved",
"task",
task.id,
- extra={"column_id": task.column_id},
+ extra={"column_id": task.column_id, "row_id": task.row_id},
)
return task
@@ -951,6 +1124,7 @@ class TaskViewSet(viewsets.ModelViewSet):
def move(self, request, pk=None):
task = self.get_object()
column_id = request.data.get("column")
+ raw_row_id = request.data.get("row") if "row" in request.data else None
position = request.data.get("position")
if not column_id:
return Response(
@@ -976,6 +1150,27 @@ class TaskViewSet(viewsets.ModelViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
+ row = None
+ if raw_row_id not in (None, ""):
+ try:
+ row_id = int(raw_row_id)
+ except (TypeError, ValueError):
+ return Response(
+ {"row": ["Идентификатор дорожки должен быть числом."]},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+ top_level_column_id = column.parent_id or column.id
+ try:
+ row = TaskColumnRow.objects.get(
+ id=row_id,
+ column_id=top_level_column_id,
+ )
+ except TaskColumnRow.DoesNotExist:
+ return Response(
+ {"row": ["Дорожка не принадлежит основной колонке задачи."]},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
if position in (None, ""):
position = None
else:
@@ -987,7 +1182,7 @@ class TaskViewSet(viewsets.ModelViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
- task = self._move_task(task, column, position, request.user)
+ task = self._move_task(task, column, row, position, request.user)
serializer = self.get_serializer(task)
return Response(serializer.data)
@@ -1074,7 +1269,7 @@ class TaskViewSet(viewsets.ModelViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
- task = self._move_task(task, done_column, None, request.user)
+ task = self._move_task(task, done_column, None, None, request.user)
return Response(self.get_serializer(task).data)
@action(detail=True, methods=["get"], url_path="activity")
diff --git a/backend/tasks/admin.py b/backend/tasks/admin.py
index a3d104d5..09c18563 100644
--- a/backend/tasks/admin.py
+++ b/backend/tasks/admin.py
@@ -9,6 +9,7 @@ from .models import (
TaskBoardPlacement,
TaskChecklistItem,
TaskColumn,
+ TaskColumnRow,
TaskExternalLink,
TaskLabel,
TaskLinkedObject,
@@ -63,6 +64,7 @@ class TaskBoardPlacementAdmin(admin.ModelAdmin):
list_filter = ["is_pinned", "notifications_enabled", "group"]
search_fields = ["board__name", "group__name", "user__email"]
autocomplete_fields = ["board", "group", "user"]
+ readonly_fields = ["view_state"]
@admin.register(TaskColumn)
@@ -79,6 +81,13 @@ class TaskColumnAdmin(admin.ModelAdmin):
search_fields = ["name", "board__name"]
+@admin.register(TaskColumnRow)
+class TaskColumnRowAdmin(admin.ModelAdmin):
+ list_display = ["name", "column", "position", "color"]
+ list_filter = ["column__board", "column"]
+ search_fields = ["name", "column__name", "column__board__name"]
+
+
@admin.register(TaskLabel)
class TaskLabelAdmin(admin.ModelAdmin):
list_display = ["name", "board", "color"]
@@ -92,12 +101,13 @@ class TaskAdmin(admin.ModelAdmin):
"title",
"board",
"column",
+ "row",
"assignee",
"priority",
"due_date",
"completed_at",
]
- list_filter = ["board", "column", "priority", "due_date"]
+ list_filter = ["board", "column", "row", "priority", "due_date"]
search_fields = ["title", "description", "assignee__email"]
filter_horizontal = ["labels", "participants"]
diff --git a/backend/tasks/migrations/0025_taskcolumnrow_taskcolumn_row.py b/backend/tasks/migrations/0025_taskcolumnrow_taskcolumn_row.py
new file mode 100644
index 00000000..ff209707
--- /dev/null
+++ b/backend/tasks/migrations/0025_taskcolumnrow_taskcolumn_row.py
@@ -0,0 +1,37 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("tasks", "0024_taskcolumn_parent"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="TaskColumnRow",
+ fields=[
+ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+ ("name", models.CharField(max_length=120, verbose_name="Название")),
+ ("position", models.PositiveIntegerField(default=0, verbose_name="Позиция")),
+ ("color", models.CharField(blank=True, max_length=32, verbose_name="Цвет")),
+ ("created_at", models.DateTimeField(auto_now_add=True, verbose_name="Создано")),
+ ("updated_at", models.DateTimeField(auto_now=True, verbose_name="Обновлено")),
+ ("column", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="rows", to="tasks.taskcolumn", verbose_name="Основная колонка")),
+ ],
+ options={
+ "verbose_name": "Дорожка колонки",
+ "verbose_name_plural": "Дорожки колонок",
+ "ordering": ["position", "id"],
+ },
+ ),
+ migrations.AddConstraint(
+ model_name="taskcolumnrow",
+ constraint=models.UniqueConstraint(fields=("column", "name"), name="uniq_task_column_row_name"),
+ ),
+ migrations.AddField(
+ model_name="taskcolumn",
+ name="row",
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="subcolumns", to="tasks.taskcolumnrow", verbose_name="Дорожка"),
+ ),
+ ]
diff --git a/backend/tasks/migrations/0026_move_task_column_row_to_task.py b/backend/tasks/migrations/0026_move_task_column_row_to_task.py
new file mode 100644
index 00000000..2fe4e141
--- /dev/null
+++ b/backend/tasks/migrations/0026_move_task_column_row_to_task.py
@@ -0,0 +1,50 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+def move_column_rows_to_tasks(apps, schema_editor):
+ Task = apps.get_model("tasks", "Task")
+ TaskColumn = apps.get_model("tasks", "TaskColumn")
+
+ for column_id, row_id in TaskColumn.objects.exclude(row_id=None).values_list(
+ "id",
+ "row_id",
+ ):
+ Task.objects.filter(column_id=column_id).update(row_id=row_id)
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("tasks", "0025_taskcolumnrow_taskcolumn_row"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="task",
+ name="row",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="tasks",
+ to="tasks.taskcolumnrow",
+ verbose_name="Дорожка",
+ ),
+ ),
+ migrations.RunPython(move_column_rows_to_tasks, migrations.RunPython.noop),
+ migrations.RemoveField(
+ model_name="taskcolumn",
+ name="row",
+ ),
+ migrations.RemoveIndex(
+ model_name="task",
+ name="tasks_task_board_i_6021b0_idx",
+ ),
+ migrations.AddIndex(
+ model_name="task",
+ index=models.Index(
+ fields=["board", "column", "row", "position"],
+ name="tasks_task_board_i_528a7b_idx",
+ ),
+ ),
+ ]
diff --git a/backend/tasks/migrations/0027_taskboardplacement_view_state.py b/backend/tasks/migrations/0027_taskboardplacement_view_state.py
new file mode 100644
index 00000000..2ec02a6a
--- /dev/null
+++ b/backend/tasks/migrations/0027_taskboardplacement_view_state.py
@@ -0,0 +1,19 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("tasks", "0026_move_task_column_row_to_task"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="taskboardplacement",
+ name="view_state",
+ field=models.JSONField(
+ blank=True,
+ default=dict,
+ verbose_name="Состояние представления",
+ ),
+ ),
+ ]
diff --git a/backend/tasks/models.py b/backend/tasks/models.py
index 9922f314..071bed1a 100644
--- a/backend/tasks/models.py
+++ b/backend/tasks/models.py
@@ -116,6 +116,11 @@ class TaskBoardPlacement(models.Model):
is_pinned = models.BooleanField("Закреплена", default=False)
pinned_at = models.DateTimeField("Закреплена в", null=True, blank=True)
notifications_enabled = models.BooleanField("Уведомления", default=True)
+ view_state = models.JSONField(
+ "Состояние представления",
+ default=dict,
+ blank=True,
+ )
created_at = models.DateTimeField("Создано", auto_now_add=True)
updated_at = models.DateTimeField("Обновлено", auto_now=True)
@@ -219,6 +224,34 @@ class TaskColumn(models.Model):
return self.parent_id is not None
+class TaskColumnRow(models.Model):
+ column = models.ForeignKey(
+ TaskColumn,
+ on_delete=models.CASCADE,
+ related_name="rows",
+ verbose_name="Основная колонка",
+ )
+ name = models.CharField("Название", max_length=120)
+ position = models.PositiveIntegerField("Позиция", default=0)
+ color = models.CharField("Цвет", max_length=32, blank=True)
+ created_at = models.DateTimeField("Создано", auto_now_add=True)
+ updated_at = models.DateTimeField("Обновлено", auto_now=True)
+
+ class Meta:
+ verbose_name = "Дорожка колонки"
+ verbose_name_plural = "Дорожки колонок"
+ ordering = ["position", "id"]
+ constraints = [
+ models.UniqueConstraint(
+ fields=["column", "name"],
+ name="uniq_task_column_row_name",
+ )
+ ]
+
+ def __str__(self):
+ return f"{self.column}: {self.name}"
+
+
class TaskLabel(models.Model):
board = models.ForeignKey(
TaskBoard,
@@ -257,6 +290,14 @@ class Task(models.Model):
related_name="tasks",
verbose_name="Колонка",
)
+ row = models.ForeignKey(
+ TaskColumnRow,
+ on_delete=models.SET_NULL,
+ related_name="tasks",
+ null=True,
+ blank=True,
+ verbose_name="Дорожка",
+ )
title = models.CharField("Название", max_length=255)
description = models.TextField("Описание", blank=True)
created_by = models.ForeignKey(
@@ -302,7 +343,7 @@ class Task(models.Model):
verbose_name_plural = "Задачи"
ordering = ["position", "-created_at", "id"]
indexes = [
- models.Index(fields=["board", "column", "position"]),
+ models.Index(fields=["board", "column", "row", "position"]),
models.Index(fields=["assignee", "due_date"]),
]
diff --git a/backend/tests/api/v1/tasks/test_tasks_api.py b/backend/tests/api/v1/tasks/test_tasks_api.py
index 06030db2..08131d84 100644
--- a/backend/tests/api/v1/tasks/test_tasks_api.py
+++ b/backend/tests/api/v1/tasks/test_tasks_api.py
@@ -31,6 +31,7 @@ from tasks.models import (
TaskBoardPlacement,
TaskChecklistItem,
TaskColumn,
+ TaskColumnRow,
TaskExternalLink,
TaskUserSettings,
)
@@ -442,6 +443,76 @@ def test_board_pin_and_notifications_are_personal_and_keep_ungrouped_settings(
assert TaskBoard.objects.filter(id=first_board.id).exists()
+def test_board_view_state_is_persisted_per_user(api_client, user):
+ other_user = make_user("task-view-state-other@example.com", "+79994440096")
+ board = TaskBoard.objects.create(name="Состояние доски", created_by=user)
+ column = TaskColumn.objects.create(
+ board=board,
+ name="Готово",
+ position=1000,
+ )
+ row = TaskColumnRow.objects.create(
+ column=column,
+ name="Вторая дорожка",
+ position=1000,
+ )
+ url = reverse(
+ "api:v1:tasks:task-board-view-state",
+ kwargs={"pk": board.id},
+ )
+ api_client.force_authenticate(user=user)
+
+ for payload in [
+ {"scope": "column", "object_id": column.id, "collapsed": True},
+ {"scope": "base", "object_id": column.id, "collapsed": True},
+ {"scope": "row", "object_id": row.id, "collapsed": True},
+ ]:
+ response = api_client.patch(url, payload, format="json")
+ assert response.status_code == status.HTTP_200_OK
+
+ detail_response = api_client.get(
+ reverse("api:v1:tasks:task-board-detail", kwargs={"pk": board.id})
+ )
+ assert detail_response.data["view_state"] == {
+ "hidden_column_ids": [column.id],
+ "collapsed_base_column_ids": [column.id],
+ "collapsed_row_ids": [row.id],
+ }
+ assert TaskBoardPlacement.objects.get(user=user, board=board).view_state
+
+ api_client.force_authenticate(user=other_user)
+ other_detail_response = api_client.get(
+ reverse("api:v1:tasks:task-board-detail", kwargs={"pk": board.id})
+ )
+ assert other_detail_response.data["view_state"] == {
+ "hidden_column_ids": [],
+ "collapsed_base_column_ids": [],
+ "collapsed_row_ids": [],
+ }
+
+
+def test_board_view_state_rejects_objects_from_another_board(api_client, user):
+ board = TaskBoard.objects.create(name="Первая доска", created_by=user)
+ other_board = TaskBoard.objects.create(name="Другая доска", created_by=user)
+ other_column = TaskColumn.objects.create(
+ board=other_board,
+ name="Чужая колонка",
+ position=1000,
+ )
+ api_client.force_authenticate(user=user)
+
+ response = api_client.patch(
+ reverse(
+ "api:v1:tasks:task-board-view-state",
+ kwargs={"pk": board.id},
+ ),
+ {"scope": "column", "object_id": other_column.id, "collapsed": True},
+ format="json",
+ )
+
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+ assert not TaskBoardPlacement.objects.filter(user=user, board=board).exists()
+
def test_user_cannot_set_inaccessible_board_as_default(api_client):
owner = make_user("task-default-owner@example.com", "+79994440091")
outsider = make_user("task-default-outsider@example.com", "+79994440092")
@@ -738,6 +809,128 @@ def test_task_cannot_be_created_in_parent_with_subcolumns(api_client, user):
assert "column" in response.data
+def test_column_rows_are_optional_and_belong_to_top_level_column(api_client, user):
+ board = TaskBoard.objects.create(name="Локальные дорожки", created_by=user)
+ parent = TaskColumn.objects.create(board=board, name="Производство", position=1000)
+ existing_subcolumn = TaskColumn.objects.create(
+ board=board,
+ parent=parent,
+ name="Существующая подколонка",
+ position=1000,
+ )
+ existing_task = Task.objects.create(
+ board=board,
+ column=existing_subcolumn,
+ title="Существующая задача",
+ created_by=user,
+ )
+ api_client.force_authenticate(user=user)
+
+ response = api_client.post(
+ reverse("api:v1:tasks:task-column-row-list"),
+ {
+ "column": parent.id,
+ "name": "Первая линия",
+ "position": 1000,
+ "color": "#22c55e",
+ },
+ format="json",
+ )
+
+ assert response.status_code == status.HTTP_201_CREATED
+ assert response.data["column"] == parent.id
+ row = TaskColumnRow.objects.get(pk=response.data["id"])
+ assert TaskColumn.objects.filter(board=board).count() == 2
+ existing_subcolumn.refresh_from_db()
+ existing_task.refresh_from_db()
+ assert existing_task.column_id == existing_subcolumn.id
+ assert existing_task.row_id is None
+ assert row.tasks.count() == 0
+
+ board_response = api_client.get(
+ reverse("api:v1:tasks:task-board-detail", kwargs={"pk": board.id})
+ )
+ assert board_response.data["column_rows"][0]["name"] == "Первая линия"
+
+
+def test_task_row_must_belong_to_tasks_top_level_column(api_client, user):
+ board = TaskBoard.objects.create(name="Проверка дорожек", created_by=user)
+ first_parent = TaskColumn.objects.create(board=board, name="Первая", position=1000)
+ second_parent = TaskColumn.objects.create(board=board, name="Вторая", position=2000)
+ second_child = TaskColumn.objects.create(
+ board=board,
+ parent=second_parent,
+ name="Подколонка второй",
+ position=1000,
+ )
+ row = TaskColumnRow.objects.create(
+ column=first_parent,
+ name="Линия",
+ position=1000,
+ )
+ api_client.force_authenticate(user=user)
+
+ response = api_client.post(
+ reverse("api:v1:tasks:task-list"),
+ {
+ "board": board.id,
+ "column": second_child.id,
+ "row": row.id,
+ "title": "Задача в чужой дорожке",
+ },
+ format="json",
+ )
+
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+ assert "row" in response.data
+
+
+def test_task_moves_between_rows_without_changing_column(api_client, user):
+ board = TaskBoard.objects.create(name="Строки ячеек", created_by=user)
+ parent = TaskColumn.objects.create(board=board, name="Производство", position=1000)
+ first_row = TaskColumnRow.objects.create(column=parent, name="Линия 1", position=1000)
+ second_row = TaskColumnRow.objects.create(column=parent, name="Линия 2", position=2000)
+ child = TaskColumn.objects.create(
+ board=board,
+ parent=parent,
+ name="Сборка",
+ position=1000,
+ )
+ task = Task.objects.create(
+ board=board,
+ column=child,
+ row=first_row,
+ title="Перемещаемая задача",
+ created_by=user,
+ position=1000,
+ )
+ api_client.force_authenticate(user=user)
+
+ response = api_client.post(
+ reverse("api:v1:tasks:task-move", kwargs={"pk": task.id}),
+ {
+ "column": child.id,
+ "row": second_row.id,
+ },
+ format="json",
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data["column"] == child.id
+ assert response.data["row"] == second_row.id
+ task.refresh_from_db()
+ assert task.column_id == child.id
+ assert task.row_id == second_row.id
+
+ delete_response = api_client.delete(
+ reverse("api:v1:tasks:task-column-row-detail", kwargs={"pk": second_row.id})
+ )
+ assert delete_response.status_code == status.HTTP_204_NO_CONTENT
+ task.refresh_from_db()
+ assert task.column_id == child.id
+ assert task.row_id is None
+
+
def test_reorder_subcolumns_does_not_change_top_level_order(api_client, user):
board = TaskBoard.objects.create(name="Порядок подколонок", created_by=user)
first_parent = TaskColumn.objects.create(
@@ -901,7 +1094,10 @@ def test_create_task_and_move_to_done_column(api_client, user):
send_args, send_kwargs = send_update.call_args
assert send_args[0].id == board_id
assert send_args[1:4] == ("moved", "task", task_id)
- assert send_kwargs["extra"] == {"column_id": columns["Готово"]["id"]}
+ assert send_kwargs["extra"] == {
+ "column_id": columns["Готово"]["id"],
+ "row_id": None,
+ }
task = Task.objects.get(id=task_id)
assert task.column_id == columns["Готово"]["id"]
diff --git a/frontend/src/app/messages/page.tsx b/frontend/src/app/messages/page.tsx
index da326ceb..033d5969 100644
--- a/frontend/src/app/messages/page.tsx
+++ b/frontend/src/app/messages/page.tsx
@@ -33,7 +33,7 @@ export default function MessagesPage() {
function StandardMessagesList() {
const { setMode, openCreateChat } = useMessagesWorkspace();
- const { user, identity, visibleChats, chatTypeCounts, loading, error } = useMessagesDirectory();
+ const { user, identity, visibleChats, chatTypeCounts, loading, error, refreshChat } = useMessagesDirectory();
if (loading) {
return (
@@ -73,7 +73,13 @@ function StandardMessagesList() {
Чаты не найдены
) : visibleChats.map((chat) => (
-
+
))}
diff --git a/frontend/src/app/tasks/page.tsx b/frontend/src/app/tasks/page.tsx
index 4802acf2..3a6552d0 100644
--- a/frontend/src/app/tasks/page.tsx
+++ b/frontend/src/app/tasks/page.tsx
@@ -10,7 +10,9 @@ import {
useDraggable,
useSensor,
useSensors,
+ type CollisionDetection,
type DragEndEvent,
+ type DragOverEvent,
type DragStartEvent,
} from "@dnd-kit/core";
import {
@@ -27,6 +29,8 @@ import {
ChevronDown,
ChevronRight,
Download,
+ Eye,
+ EyeOff,
ExternalLink as ExternalLinkIcon,
FileSignature,
FileText,
@@ -43,6 +47,7 @@ import {
Play,
Plus,
RefreshCw,
+ Rows3,
Search,
ShoppingCart,
Star,
@@ -87,9 +92,11 @@ import type {
TaskBoard,
TaskBoardGroup,
TaskBoardSummary,
+ TaskBoardViewState,
TaskCard,
TaskChecklistItem,
TaskColumn,
+ TaskColumnRow,
TaskComment,
TaskExternalLink,
TaskLinkedAttendanceRecord,
@@ -111,6 +118,7 @@ type TaskFormState = {
title: string;
description: string;
column: number | "";
+ row: number | "";
assignee_id: number | "";
participant_ids: number[];
priority: TaskPriority;
@@ -144,6 +152,13 @@ type ColumnFormState = {
is_done: boolean;
};
+type ColumnRowFormState = {
+ id: number | null;
+ column: number | null;
+ name: string;
+ color: string;
+};
+
type LinkedItemMode = "document" | "calendar_event" | "external_link";
type LinkedItemTarget = "view" | "form";
@@ -172,12 +187,58 @@ type TaskBoardSocketEvent = {
};
const DESKTOP_WIDE_MODE_STORAGE_PREFIX = "tasks.desktop-wide-mode";
+const TASK_SUBCOLUMN_WIDTH = "18rem";
+const EMPTY_BOARD_VIEW_STATE: TaskBoardViewState = {
+ hidden_column_ids: [],
+ collapsed_base_column_ids: [],
+ collapsed_row_ids: [],
+};
+
+type BoardViewStateScope = "column" | "base" | "row";
+type BoardViewStateKey = keyof TaskBoardViewState;
+const BOARD_VIEW_STATE_KEYS: Record = {
+ column: "hidden_column_ids",
+ base: "collapsed_base_column_ids",
+ row: "collapsed_row_ids",
+};
+
+function normalizeBoardViewState(
+ state?: Partial | null,
+): TaskBoardViewState {
+ return {
+ hidden_column_ids: Array.isArray(state?.hidden_column_ids)
+ ? state.hidden_column_ids
+ : [],
+ collapsed_base_column_ids: Array.isArray(state?.collapsed_base_column_ids)
+ ? state.collapsed_base_column_ids
+ : [],
+ collapsed_row_ids: Array.isArray(state?.collapsed_row_ids)
+ ? state.collapsed_row_ids
+ : [],
+ };
+}
+
+function updateBoardViewStateIds(
+ state: TaskBoardViewState,
+ key: BoardViewStateKey,
+ objectId: number,
+ collapsed: boolean,
+): TaskBoardViewState {
+ const values = new Set(state[key]);
+ if (collapsed) values.add(objectId);
+ else values.delete(objectId);
+ return { ...state, [key]: Array.from(values) };
+}
+const taskPlacementKey = (columnId: number, rowId: number | null) => (
+ `${columnId}:${rowId ?? "base"}`
+);
const emptyForm: TaskFormState = {
id: null,
title: "",
description: "",
column: "",
+ row: "",
assignee_id: "",
participant_ids: [],
priority: "medium",
@@ -234,6 +295,13 @@ const emptyColumnForm: ColumnFormState = {
is_done: false,
};
+const emptyColumnRowForm: ColumnRowFormState = {
+ id: null,
+ column: null,
+ name: "",
+ color: "#64748b",
+};
+
const priorityOptions: {
value: TaskPriority;
label: string;
@@ -640,6 +708,7 @@ function TaskDragOverlayCard({ task }: { task: TaskCard }) {
function BoardColumn({
column,
tasks,
+ displayTasksCount,
onCreateTask,
onOpenTask,
onEditTask,
@@ -658,14 +727,27 @@ function BoardColumn({
onEditColumn,
onDeleteColumn,
onCreateSubcolumn,
+ onCreateRow,
onColumnMount,
+ taskRowId = null,
+ columnSortable = true,
+ showColumnActions = true,
+ showHeader = true,
+ showBody = true,
+ showVisibilityToggle = false,
+ contentVisible = true,
+ onToggleContentVisibility,
+ sharedGridCell = false,
+ linkedColumnHighlight = false,
+ synchronizedColumnDrag = false,
fillAvailableHeight = false,
hideOnMobile = false,
nested = false,
}: {
column: TaskColumn;
tasks: TaskCard[];
- onCreateTask: (columnId: number) => void;
+ displayTasksCount?: number;
+ onCreateTask: (columnId: number, rowId: number | null) => void;
onOpenTask: (task: TaskCard) => void;
onEditTask: (task: TaskCard) => void;
onDeleteTask: (task: TaskCard) => void;
@@ -683,7 +765,19 @@ function BoardColumn({
onEditColumn: (column: TaskColumn) => void;
onDeleteColumn: (column: TaskColumn) => void;
onCreateSubcolumn: (column: TaskColumn) => void;
+ onCreateRow?: (column: TaskColumn) => void;
onColumnMount: (columnId: number, node: HTMLElement | null) => void;
+ taskRowId?: number | null;
+ columnSortable?: boolean;
+ showColumnActions?: boolean;
+ showHeader?: boolean;
+ showBody?: boolean;
+ showVisibilityToggle?: boolean;
+ contentVisible?: boolean;
+ onToggleContentVisibility?: () => void;
+ sharedGridCell?: boolean;
+ linkedColumnHighlight?: boolean;
+ synchronizedColumnDrag?: boolean;
fillAvailableHeight?: boolean;
hideOnMobile?: boolean;
nested?: boolean;
@@ -698,19 +792,27 @@ function BoardColumn({
isDragging,
isOver,
} = useSortable({
- id: `column-${column.id}`,
- data: { type: "column", columnId: column.id, acceptsTasks: true },
+ id: columnSortable
+ ? `column-${column.id}`
+ : `task-zone-${column.id}-row-${taskRowId ?? "base"}`,
+ data: {
+ type: columnSortable ? "column" : "task-zone",
+ columnId: column.id,
+ rowId: taskRowId,
+ acceptsTasks: true,
+ },
+ disabled: !columnSortable,
});
const style = {
- transform: CSS.Transform.toString(transform),
- transition,
+ transform: columnSortable ? CSS.Transform.toString(transform) : undefined,
+ transition: columnSortable ? transition : undefined,
zIndex: isDragging ? 20 : undefined,
};
const setColumnNodeRef = useCallback((node: HTMLElement | null) => {
setNodeRef(node);
- onColumnMount(column.id, node);
- }, [column.id, onColumnMount, setNodeRef]);
- const columnBorderClass = isOver
+ if (columnSortable) onColumnMount(column.id, node);
+ }, [column.id, columnSortable, onColumnMount, setNodeRef]);
+ const columnBorderClass = linkedColumnHighlight || (isOver && !synchronizedColumnDrag)
? "border-[var(--accent-primary)]"
: "border-[var(--border-subtle)]";
@@ -718,21 +820,27 @@ function BoardColumn({
-
{tasks.length} задач
+
+ {displayTasksCount ?? tasks.length} задач
+
+ {showVisibilityToggle ? (
+
+ ) : null}
-
event.stopPropagation()}
@@ -782,14 +910,24 @@ function BoardColumn({
{columnMenuOpen ? (
{!column.parent ? (
-
+ <>
+
+
+ >
) : null}
) : null}
-
+
: null}
-
{tasks.length > 0 ? (
tasks.map((task) => (
@@ -838,7 +976,7 @@ function BoardColumn({
Нет задач
)}
-
+ : null}
);
}
@@ -849,12 +987,15 @@ function BoardColumnGroup({
children,
onCreateTask,
onCreateSubcolumn,
+ onCreateRow,
onEditColumn,
onDeleteColumn,
columnMenuOpen,
columnMenuRef,
onToggleColumnMenu,
onColumnMount,
+ contentVisible,
+ onToggleContentVisibility,
hideOnMobile = false,
}: {
column: TaskColumn;
@@ -862,12 +1003,15 @@ function BoardColumnGroup({
children: ReactNode;
onCreateTask: () => void;
onCreateSubcolumn: () => void;
+ onCreateRow: () => void;
onEditColumn: (column: TaskColumn) => void;
onDeleteColumn: (column: TaskColumn) => void;
columnMenuOpen: boolean;
columnMenuRef: RefObject;
onToggleColumnMenu: () => void;
onColumnMount: (columnId: number, node: HTMLElement | null) => void;
+ contentVisible: boolean;
+ onToggleContentVisibility: () => void;
hideOnMobile?: boolean;
}) {
const {
@@ -913,13 +1057,26 @@ function BoardColumnGroup({
{column.name}
-
- {tasksCount}
-
- Подколонки
+ {tasksCount} задач
+
+
-
- {children}
+ {contentVisible ? (
+
+ {children}
+
+ ) : null}
+
+ );
+}
+
+function BoardColumnRowView({
+ row,
+ children,
+ gridTemplateColumns,
+ onCreateTask,
+ onEdit,
+ onDelete,
+ expanded,
+ onToggle,
+}: {
+ row: TaskColumnRow;
+ children: ReactNode;
+ gridTemplateColumns: string;
+ onCreateTask: () => void;
+ onEdit: () => void;
+ onDelete: () => void;
+ expanded: boolean;
+ onToggle: () => void;
+}) {
+ return (
+
+
+
+
+ {expanded ? (
+
+ {children}
+
+ ) : null}
+
+ );
+}
+
+function BoardBaseColumnsView({
+ collapsible,
+ tasksCount,
+ headers,
+ children,
+ gridTemplateColumns,
+ expanded,
+ onToggle,
+}: {
+ collapsible: boolean;
+ tasksCount: number;
+ headers?: ReactNode;
+ children: ReactNode;
+ gridTemplateColumns: string;
+ expanded: boolean;
+ onToggle: () => void;
+}) {
+ return (
+
+ {headers ? (
+
+ {headers}
+
+ ) : null}
+ {collapsible ? (
+
+ ) : null}
+ {!collapsible || expanded ? (
+
+ {children}
+
+ ) : null}
);
}
@@ -2312,6 +2618,9 @@ function TasksPageContent() {
const [boards, setBoards] = useState
([]);
const [boardGroups, setBoardGroups] = useState([]);
const [board, setBoard] = useState(null);
+ const [boardViewState, setBoardViewState] = useState(
+ EMPTY_BOARD_VIEW_STATE,
+ );
const [selectedBoardId, setSelectedBoardId] = useState(null);
const [employees, setEmployees] = useState([]);
const [taskParticipantCandidates, setTaskParticipantCandidates] = useState([]);
@@ -2327,6 +2636,8 @@ function TasksPageContent() {
const [desktopWideMode, setDesktopWideMode] = useState(false);
const [selectedColumnTarget, setSelectedColumnTarget] = useState("all");
const [activeTaskId, setActiveTaskId] = useState(null);
+ const [activeColumnDragId, setActiveColumnDragId] = useState(null);
+ const [columnDragOverId, setColumnDragOverId] = useState(null);
const [viewTaskId, setViewTaskId] = useState(null);
const [taskModalOpen, setTaskModalOpen] = useState(false);
const [taskParticipantsOpen, setTaskParticipantsOpen] = useState(false);
@@ -2342,12 +2653,14 @@ function TasksPageContent() {
const [boardGroupModalOpen, setBoardGroupModalOpen] = useState(false);
const [boardActionsMenuOpen, setBoardActionsMenuOpen] = useState(false);
const [columnModalOpen, setColumnModalOpen] = useState(false);
+ const [columnRowModalOpen, setColumnRowModalOpen] = useState(false);
const [form, setForm] = useState(emptyForm);
const [boardForm, setBoardForm] = useState(emptyBoardForm);
const [boardGroupForm, setBoardGroupForm] = useState(emptyBoardGroupForm);
const [boardMemberSearch, setBoardMemberSearch] = useState("");
const [boardDepartmentSearch, setBoardDepartmentSearch] = useState("");
const [columnForm, setColumnForm] = useState(emptyColumnForm);
+ const [columnRowForm, setColumnRowForm] = useState(emptyColumnRowForm);
const [labelName, setLabelName] = useState("");
const [labelColor, setLabelColor] = useState("#38bdf8");
const [linkedObjectsOpen, setLinkedObjectsOpen] = useState(false);
@@ -2418,6 +2731,8 @@ function TasksPageContent() {
const cardMenuRef = useRef(null);
const columnMenuRef = useRef(null);
const selectedBoardIdRef = useRef(null);
+ const boardViewStateRef = useRef(EMPTY_BOARD_VIEW_STATE);
+ const pendingBoardViewStateRef = useRef