diff --git a/backend/api/v1/tasks/serializers.py b/backend/api/v1/tasks/serializers.py index 7e29ea9e..66a976f7 100644 --- a/backend/api/v1/tasks/serializers.py +++ b/backend/api/v1/tasks/serializers.py @@ -43,6 +43,7 @@ from tasks.models import ( TaskAttachment, TaskBoard, TaskBoardAccessScope, + TaskBoardGroup, TaskChecklistItem, TaskColumn, TaskExternalLink, @@ -486,6 +487,12 @@ class TaskBoardSerializer(serializers.ModelSerializer): tasks = TaskSerializer(many=True, read_only=True) tasks_count = serializers.IntegerField(read_only=True, default=0) is_default_for_current_user = serializers.SerializerMethodField() + group_id = serializers.IntegerField( + source="current_user_group_id", + read_only=True, + allow_null=True, + default=None, + ) class Meta: model = TaskBoard @@ -505,6 +512,7 @@ class TaskBoardSerializer(serializers.ModelSerializer): "tasks", "tasks_count", "is_default_for_current_user", + "group_id", "created_at", "updated_at", ] @@ -558,11 +566,43 @@ class TaskBoardSummarySerializer(TaskBoardSerializer): "open_tasks_count", "completed_tasks_count", "is_default_for_current_user", + "group_id", "created_at", "updated_at", ] +class TaskBoardGroupSerializer(serializers.ModelSerializer): + boards_count = serializers.IntegerField(read_only=True, default=0) + + class Meta: + model = TaskBoardGroup + fields = [ + "id", + "name", + "position", + "boards_count", + "created_at", + "updated_at", + ] + read_only_fields = ["id", "created_at", "updated_at"] + + def validate_name(self, value): + name = value.strip() + if not name: + raise serializers.ValidationError("Укажите название группы.") + request = self.context.get("request") + queryset = TaskBoardGroup.objects.filter( + user=getattr(request, "user", None), + name__iexact=name, + ) + if self.instance: + queryset = queryset.exclude(id=self.instance.id) + if queryset.exists(): + raise serializers.ValidationError("Группа с таким названием уже существует.") + return name + + class TaskLinkedPostSerializer(serializers.ModelSerializer): created_by = EmployeeBriefSerializer(read_only=True) post = serializers.SerializerMethodField() diff --git a/backend/api/v1/tasks/urls.py b/backend/api/v1/tasks/urls.py index 5516f37b..cf70e111 100644 --- a/backend/api/v1/tasks/urls.py +++ b/backend/api/v1/tasks/urls.py @@ -2,6 +2,7 @@ from django.urls import include, path from rest_framework.routers import DefaultRouter from .views import ( + TaskBoardGroupViewSet, TaskBoardViewSet, TaskColumnViewSet, TaskLabelViewSet, @@ -11,6 +12,7 @@ from .views import ( app_name = "tasks" 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"labels", TaskLabelViewSet, basename="task-label") diff --git a/backend/api/v1/tasks/views.py b/backend/api/v1/tasks/views.py index 7e8f4c8c..4c9568fb 100644 --- a/backend/api/v1/tasks/views.py +++ b/backend/api/v1/tasks/views.py @@ -52,6 +52,8 @@ from tasks.models import ( TaskActivityObjectKind, TaskAttachment, TaskBoard, + TaskBoardGroup, + TaskBoardPlacement, TaskColumn, TaskExternalLink, TaskLabel, @@ -79,6 +81,7 @@ from .serializers import ( TaskActivitySerializer, TaskAttachmentSerializer, TaskBoardSerializer, + TaskBoardGroupSerializer, TaskBoardSummarySerializer, TaskChecklistItemSerializer, TaskColumnSerializer, @@ -179,12 +182,20 @@ class TaskBoardViewSet(viewsets.ModelViewSet): return super().get_serializer_class() def get_queryset(self): + current_group_id = TaskBoardPlacement.objects.filter( + user=self.request.user, + board_id=OuterRef("pk"), + ).values("group_id")[:1] queryset = ( TaskBoard.objects.filter(is_archived=False) .filter(task_board_access_q(self.request.user)) .select_related("created_by") .prefetch_related("members", "departments") .annotate( + current_user_group_id=Subquery( + current_group_id, + output_field=IntegerField(), + ), tasks_count=Count("tasks", distinct=True), open_tasks_count=Count( "tasks", @@ -317,6 +328,36 @@ class TaskBoardViewSet(viewsets.ModelViewSet): serializer = self.get_serializer(self.get_queryset().get(id=board.id)) return Response(serializer.data) + @action(detail=True, methods=["post"], url_path="move-to-group") + def move_to_group(self, request, pk=None): + board = self.get_object() + group_id = request.data.get("group") + if group_id in (None, ""): + TaskBoardPlacement.objects.filter( + user=request.user, + board=board, + ).delete() + return Response({"board": board.id, "group_id": None}) + + try: + group_id = int(group_id) + except (TypeError, ValueError): + return Response( + {"group": ["Некорректная группа."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + group = get_object_or_404( + TaskBoardGroup, + id=group_id, + user=request.user, + ) + TaskBoardPlacement.objects.update_or_create( + user=request.user, + board=board, + defaults={"group": group}, + ) + return Response({"board": board.id, "group_id": group.id}) + @action(detail=True, methods=["post"], url_path="notifications/read") def mark_notifications_read(self, request, pk=None): board = self.get_object() @@ -333,6 +374,22 @@ class TaskBoardViewSet(viewsets.ModelViewSet): ) +class TaskBoardGroupViewSet(viewsets.ModelViewSet): + permission_classes = [IsAuthenticated] + serializer_class = TaskBoardGroupSerializer + + def get_queryset(self): + return ( + TaskBoardGroup.objects.filter(user=self.request.user) + .annotate(boards_count=Count("board_placements", distinct=True)) + .order_by("position", "name", "id") + ) + + def perform_create(self, serializer): + max_position = self.get_queryset().aggregate(value=Max("position"))["value"] or 0 + serializer.save(user=self.request.user, position=max_position + 1000) + + class TaskColumnViewSet(viewsets.ModelViewSet): permission_classes = [IsAuthenticated] serializer_class = TaskColumnSerializer diff --git a/backend/tasks/admin.py b/backend/tasks/admin.py index bab896cd..57f28b80 100644 --- a/backend/tasks/admin.py +++ b/backend/tasks/admin.py @@ -5,6 +5,8 @@ from .models import ( TaskActivity, TaskAttachment, TaskBoard, + TaskBoardGroup, + TaskBoardPlacement, TaskChecklistItem, TaskColumn, TaskExternalLink, @@ -41,6 +43,20 @@ class TaskBoardAdmin(admin.ModelAdmin): inlines = [TaskColumnInline, TaskLabelInline] +@admin.register(TaskBoardGroup) +class TaskBoardGroupAdmin(admin.ModelAdmin): + list_display = ["name", "user", "position", "created_at"] + search_fields = ["name", "user__email"] + autocomplete_fields = ["user"] + + +@admin.register(TaskBoardPlacement) +class TaskBoardPlacementAdmin(admin.ModelAdmin): + list_display = ["board", "group", "user", "updated_at"] + search_fields = ["board__name", "group__name", "user__email"] + autocomplete_fields = ["board", "group", "user"] + + @admin.register(TaskColumn) class TaskColumnAdmin(admin.ModelAdmin): list_display = ["name", "board", "position", "is_done", "is_archived"] diff --git a/backend/tasks/migrations/0020_taskboardgroup_taskboardplacement_and_more.py b/backend/tasks/migrations/0020_taskboardgroup_taskboardplacement_and_more.py new file mode 100644 index 00000000..9d7e12f2 --- /dev/null +++ b/backend/tasks/migrations/0020_taskboardgroup_taskboardplacement_and_more.py @@ -0,0 +1,55 @@ +# Generated by Django 5.2.4 on 2026-08-05 13:46 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tasks', '0019_task_participants'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='TaskBoardGroup', + 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='Позиция')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='task_board_groups', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')), + ], + options={ + 'verbose_name': 'Группа досок задач', + 'verbose_name_plural': 'Группы досок задач', + 'ordering': ['position', 'name', 'id'], + }, + ), + migrations.CreateModel( + name='TaskBoardPlacement', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')), + ('board', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_placements', to='tasks.taskboard', verbose_name='Доска')), + ('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='board_placements', to='tasks.taskboardgroup', verbose_name='Группа')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='task_board_placements', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')), + ], + options={ + 'verbose_name': 'Размещение доски задач', + 'verbose_name_plural': 'Размещения досок задач', + }, + ), + migrations.AddConstraint( + model_name='taskboardgroup', + constraint=models.UniqueConstraint(fields=('user', 'name'), name='uniq_task_board_group_user_name'), + ), + migrations.AddConstraint( + model_name='taskboardplacement', + constraint=models.UniqueConstraint(fields=('user', 'board'), name='uniq_task_board_placement_user_board'), + ), + ] diff --git a/backend/tasks/models.py b/backend/tasks/models.py index e62602fe..dcbe861f 100644 --- a/backend/tasks/models.py +++ b/backend/tasks/models.py @@ -59,6 +59,69 @@ class TaskBoard(models.Model): return self.name +class TaskBoardGroup(models.Model): + name = models.CharField("Название", max_length=120) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="task_board_groups", + verbose_name="Пользователь", + ) + position = models.PositiveIntegerField("Позиция", default=0) + created_at = models.DateTimeField("Создано", auto_now_add=True) + updated_at = models.DateTimeField("Обновлено", auto_now=True) + + class Meta: + verbose_name = "Группа досок задач" + verbose_name_plural = "Группы досок задач" + ordering = ["position", "name", "id"] + constraints = [ + models.UniqueConstraint( + fields=["user", "name"], + name="uniq_task_board_group_user_name", + ) + ] + + def __str__(self): + return f"{self.user}: {self.name}" + + +class TaskBoardPlacement(models.Model): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="task_board_placements", + verbose_name="Пользователь", + ) + board = models.ForeignKey( + TaskBoard, + on_delete=models.CASCADE, + related_name="user_placements", + verbose_name="Доска", + ) + group = models.ForeignKey( + TaskBoardGroup, + on_delete=models.CASCADE, + related_name="board_placements", + verbose_name="Группа", + ) + created_at = models.DateTimeField("Создано", auto_now_add=True) + updated_at = models.DateTimeField("Обновлено", auto_now=True) + + class Meta: + verbose_name = "Размещение доски задач" + verbose_name_plural = "Размещения досок задач" + constraints = [ + models.UniqueConstraint( + fields=["user", "board"], + name="uniq_task_board_placement_user_board", + ) + ] + + def __str__(self): + return f"{self.user}: {self.board} -> {self.group}" + + class TaskUserSettings(models.Model): user = models.OneToOneField( settings.AUTH_USER_MODEL, diff --git a/backend/tests/api/v1/tasks/test_tasks_api.py b/backend/tests/api/v1/tasks/test_tasks_api.py index ab4f2fd3..851129d3 100644 --- a/backend/tests/api/v1/tasks/test_tasks_api.py +++ b/backend/tests/api/v1/tasks/test_tasks_api.py @@ -25,6 +25,8 @@ from tasks.models import ( Task, TaskAttachment, TaskBoard, + TaskBoardGroup, + TaskBoardPlacement, TaskChecklistItem, TaskColumn, TaskExternalLink, @@ -165,6 +167,77 @@ def test_board_summary_list_returns_navigation_counts_without_nested_tasks( assert "columns" not in result +def test_board_groups_are_personal_and_board_placement_is_independent( + api_client, + user, +): + other_user = make_user("task-groups-other@example.com", "+79994440093") + board = TaskBoard.objects.create(name="Общая доска", created_by=user) + api_client.force_authenticate(user=user) + + create_response = api_client.post( + reverse("api:v1:tasks:task-board-group-list"), + {"name": "Проекты"}, + format="json", + ) + + assert create_response.status_code == status.HTTP_201_CREATED + group_id = create_response.data["id"] + move_response = api_client.post( + reverse("api:v1:tasks:task-board-move-to-group", kwargs={"pk": board.id}), + {"group": group_id}, + format="json", + ) + assert move_response.status_code == status.HTTP_200_OK + assert move_response.data["group_id"] == group_id + assert TaskBoardPlacement.objects.filter( + user=user, + board=board, + group_id=group_id, + ).exists() + + summary_response = api_client.get( + reverse("api:v1:tasks:task-board-list"), + {"view": "summary"}, + ) + assert summary_response.status_code == status.HTTP_200_OK + assert summary_response.data["results"][0]["group_id"] == group_id + + api_client.force_authenticate(user=other_user) + other_groups_response = api_client.get( + reverse("api:v1:tasks:task-board-group-list") + ) + assert other_groups_response.status_code == status.HTTP_200_OK + assert other_groups_response.data["results"] == [] + other_summary_response = api_client.get( + reverse("api:v1:tasks:task-board-list"), + {"view": "summary"}, + ) + assert other_summary_response.status_code == status.HTTP_200_OK + assert other_summary_response.data["results"][0]["group_id"] is None + + foreign_move_response = api_client.post( + reverse("api:v1:tasks:task-board-move-to-group", kwargs={"pk": board.id}), + {"group": group_id}, + format="json", + ) + assert foreign_move_response.status_code == status.HTTP_404_NOT_FOUND + + api_client.force_authenticate(user=user) + delete_response = api_client.delete( + reverse("api:v1:tasks:task-board-group-detail", kwargs={"pk": group_id}) + ) + assert delete_response.status_code == status.HTTP_204_NO_CONTENT + assert not TaskBoardGroup.objects.filter(id=group_id).exists() + assert not TaskBoardPlacement.objects.filter(user=user, board=board).exists() + + ungrouped_summary_response = api_client.get( + reverse("api:v1:tasks:task-board-list"), + {"view": "summary"}, + ) + assert ungrouped_summary_response.data["results"][0]["group_id"] is None + + 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") diff --git a/frontend/src/app/tasks/page.tsx b/frontend/src/app/tasks/page.tsx index 687ade03..f7583a1f 100644 --- a/frontend/src/app/tasks/page.tsx +++ b/frontend/src/app/tasks/page.tsx @@ -73,6 +73,7 @@ import type { TaskActivity, TaskAttachment, TaskBoard, + TaskBoardGroup, TaskBoardSummary, TaskCard, TaskChecklistItem, @@ -114,6 +115,11 @@ type BoardFormState = { department_ids: number[]; }; +type BoardGroupFormState = { + id: number | null; + name: string; +}; + type ColumnFormState = { id: number | null; name: string; @@ -171,6 +177,11 @@ const emptyBoardForm: BoardFormState = { department_ids: [], }; +const emptyBoardGroupForm: BoardGroupFormState = { + id: null, + name: "", +}; + const taskDescriptionPreviewClass = "app-text-muted mt-1 block max-h-8 w-full max-w-[13rem] overflow-hidden whitespace-normal break-all text-xs leading-4"; @@ -2103,6 +2114,7 @@ function TasksPageContent() { const requestedBoardId = Number(searchParams.get("board") || ""); const requestedTaskId = Number(searchParams.get("task") || ""); const [boards, setBoards] = useState([]); + const [boardGroups, setBoardGroups] = useState([]); const [board, setBoard] = useState(null); const [selectedBoardId, setSelectedBoardId] = useState(null); const [employees, setEmployees] = useState([]); @@ -2131,10 +2143,12 @@ function TasksPageContent() { const [cardMenuTaskId, setCardMenuTaskId] = useState(null); const [columnMenuId, setColumnMenuId] = useState(null); const [boardModalOpen, setBoardModalOpen] = useState(false); + const [boardGroupModalOpen, setBoardGroupModalOpen] = useState(false); const [boardActionsMenuOpen, setBoardActionsMenuOpen] = useState(false); const [columnModalOpen, setColumnModalOpen] = 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); @@ -2281,6 +2295,13 @@ function TasksPageContent() { return results as TaskBoardSummary[]; }, []); + const loadBoardGroups = useCallback(async () => { + const response = await apiClient.getTaskBoardGroups(); + const results = Array.isArray(response) ? response : response.results || []; + setBoardGroups(results as TaskBoardGroup[]); + return results as TaskBoardGroup[]; + }, []); + const loadBoard = useCallback(async (boardId?: number | null) => { setError(null); const data = boardId @@ -2639,17 +2660,22 @@ function TasksPageContent() { if (initialLoad) setLoading(true); else setBoardLoading(true); try { - const [boardData, boardsData, employeesData, departmentsData] = await Promise.all([ + const [boardData, boardsData, boardGroupsData, employeesData, departmentsData] = await Promise.all([ requestedBoardId > 0 ? apiClient.getTaskBoard(requestedBoardId) : apiClient.getDefaultTaskBoard(), apiClient.getTaskBoards({ view: "summary" }), + apiClient.getTaskBoardGroups(), apiClient.getEmployees({ limit: 200, is_active: true, ordering: "last_name" }), apiClient.getDepartments({ limit: 200 }), ]); if (!mounted) return; const boardList = Array.isArray(boardsData) ? boardsData : boardsData.results || []; + const groupList = Array.isArray(boardGroupsData) + ? boardGroupsData + : boardGroupsData.results || []; setBoards(boardList as TaskBoardSummary[]); + setBoardGroups(groupList as TaskBoardGroup[]); setBoard(boardData); setSelectedBoardId(boardData.id); if (window.matchMedia("(max-width: 1023px)").matches) { @@ -3205,6 +3231,22 @@ function TasksPageContent() { setBoardModalOpen(true); }, []); + const openCreateBoardGroup = useCallback(() => { + setBoardGroupForm(emptyBoardGroupForm); + setBoardGroupModalOpen(true); + }, []); + + const openEditBoardGroup = useCallback((group: TaskBoardGroup) => { + setBoardGroupForm({ id: group.id, name: group.name }); + setBoardGroupModalOpen(true); + }, []); + + const closeBoardGroupModal = useCallback(() => { + if (saving) return; + setBoardGroupModalOpen(false); + setBoardGroupForm(emptyBoardGroupForm); + }, [saving]); + const openEditBoard = useCallback(() => { if (!board) return; const memberIds = board.members || []; @@ -3394,6 +3436,69 @@ function TasksPageContent() { router, ]); + const saveBoardGroup = useCallback(async () => { + const name = boardGroupForm.name.trim(); + if (!name || saving) return; + setSaving(true); + setError(null); + try { + if (boardGroupForm.id) { + await apiClient.updateTaskBoardGroup(boardGroupForm.id, { name }); + } else { + await apiClient.createTaskBoardGroup({ name }); + } + await loadBoardGroups(); + setBoardGroupModalOpen(false); + setBoardGroupForm(emptyBoardGroupForm); + } catch (groupError) { + setError(getTaskError(groupError, "Не удалось сохранить группу досок")); + } finally { + setSaving(false); + } + }, [boardGroupForm.id, boardGroupForm.name, loadBoardGroups, saving]); + + const deleteBoardGroup = useCallback(async (group: TaskBoardGroup) => { + if (saving) return; + if (!window.confirm(`Удалить группу «${group.name}»? Доски останутся в разделе «Без группы».`)) { + return; + } + setSaving(true); + setError(null); + try { + await apiClient.deleteTaskBoardGroup(group.id); + await Promise.all([loadBoardGroups(), loadBoards()]); + } catch (groupError) { + setError(getTaskError(groupError, "Не удалось удалить группу досок")); + } finally { + setSaving(false); + } + }, [loadBoardGroups, loadBoards, saving]); + + const moveBoardToGroup = useCallback(async (boardId: number, groupId: number | null) => { + const previousGroupId = boards.find((item) => item.id === boardId)?.group_id || null; + if (previousGroupId === groupId || saving) return; + setBoards((current) => current.map((item) => ( + item.id === boardId ? { ...item, group_id: groupId } : item + ))); + setBoard((current) => current?.id === boardId ? { ...current, group_id: groupId } : current); + setSaving(true); + setError(null); + try { + await apiClient.moveTaskBoardToGroup(boardId, groupId); + await loadBoardGroups(); + } catch (groupError) { + setBoards((current) => current.map((item) => ( + item.id === boardId ? { ...item, group_id: previousGroupId } : item + ))); + setBoard((current) => current?.id === boardId + ? { ...current, group_id: previousGroupId } + : current); + setError(getTaskError(groupError, "Не удалось переместить доску")); + } finally { + setSaving(false); + } + }, [boards, loadBoardGroups, saving]); + const saveColumn = useCallback(async () => { if (!board || !columnForm.name.trim()) return; setSaving(true); @@ -4474,11 +4579,15 @@ function TasksPageContent() {
+ + + +
+ )} + > + + + ; onSelect: (boardId: number) => void; onCreate: () => void; + onCreateGroup: () => void; + onEditGroup: (group: TaskBoardGroup) => void; + onDeleteGroup: (group: TaskBoardGroup) => void; + onMoveBoard: (boardId: number, groupId: number | null) => void; wideMode: boolean; onWideModeChange: (enabled: boolean) => void; className?: string; @@ -53,24 +62,32 @@ function matchesBoard(board: TaskBoardSummary, query: string) { function buildSections( boards: TaskBoardSummary[], - currentUserId: number | undefined, + groups: TaskBoardGroup[], query: string, ): BoardSection[] { const filtered = query ? boards.filter((board) => matchesBoard(board, query)) : boards; - if (query) return [{ key: "search", label: "Результаты", boards: filtered }]; - - const defaultBoards = filtered.filter((board) => board.is_default_for_current_user); - const remaining = filtered.filter((board) => !board.is_default_for_current_user); - const ownBoards = remaining.filter((board) => board.created_by?.id === currentUserId); - const sharedBoards = remaining.filter((board) => board.created_by?.id !== currentUserId); + if (query) { + return filtered.length > 0 + ? [{ key: "search", label: "Результаты", groupId: null, boards: filtered }] + : []; + } return [ - { key: "default", label: "По умолчанию", boards: defaultBoards }, - { key: "own", label: "Мои доски", boards: ownBoards }, - { key: "shared", label: "Доступные", boards: sharedBoards }, - ].filter((section) => section.boards.length > 0); + { + key: "ungrouped", + label: "Без группы", + groupId: null, + boards: filtered.filter((board) => !board.group_id), + }, + ...groups.map((group) => ({ + key: `group-${group.id}`, + label: group.name, + groupId: group.id, + boards: filtered.filter((board) => board.group_id === group.id), + })), + ]; } function BoardRow({ @@ -78,62 +95,105 @@ function BoardRow({ active, unread, onSelect, + groups, + menuOpen, + onToggleMenu, + onMove, }: { board: TaskBoardSummary; active: boolean; unread: number; onSelect: () => void; + groups: TaskBoardGroup[]; + menuOpen: boolean; + onToggleMenu: () => void; + onMove: (groupId: number | null) => void; }) { return ( - + + + {menuOpen ? ( +
+

Переместить в

+ {[{ id: null, name: "Без группы" }, ...groups].map((group) => { + const selected = (board.group_id || null) === group.id; + return ( + + ); + })} +
+ ) : null} + ); } export default function TaskBoardNavigator({ boards, + groups, selectedBoardId, - currentUserId, unreadCounts, onSelect, onCreate, + onCreateGroup, + onEditGroup, + onDeleteGroup, + onMoveBoard, wideMode, onWideModeChange, className = "", @@ -143,6 +203,9 @@ export default function TaskBoardNavigator({ const [unreadFilter, setUnreadFilter] = useState("all"); const [accessFilter, setAccessFilter] = useState("all"); const [collapsed, setCollapsed] = useState>(new Set()); + const [boardMenuId, setBoardMenuId] = useState(null); + const [groupMenuId, setGroupMenuId] = useState(null); + const menusRef = useRef(null); const normalizedQuery = query.trim().toLowerCase(); const filteredBoards = useMemo(() => boards.filter((board) => { const unread = unreadCounts[board.id] || 0; @@ -153,8 +216,8 @@ export default function TaskBoardNavigator({ return true; }), [accessFilter, boards, unreadCounts, unreadFilter]); const sections = useMemo( - () => buildSections(filteredBoards, currentUserId, normalizedQuery), - [currentUserId, filteredBoards, normalizedQuery], + () => buildSections(filteredBoards, groups, normalizedQuery), + [filteredBoards, groups, normalizedQuery], ); const activeFiltersCount = [unreadFilter !== "all", accessFilter !== "all"].filter(Boolean).length; @@ -167,6 +230,18 @@ export default function TaskBoardNavigator({ }); }; + useEffect(() => { + if (boardMenuId === null && groupMenuId === null) return undefined; + const closeMenus = (event: MouseEvent) => { + if (!menusRef.current?.contains(event.target as Node)) { + setBoardMenuId(null); + setGroupMenuId(null); + } + }; + document.addEventListener("mousedown", closeMenus); + return () => document.removeEventListener("mousedown", closeMenus); + }, [boardMenuId, groupMenuId]); + return (