From 0dad6b84de767f5c0aa9e529cd8cbd154da11ca0 Mon Sep 17 00:00:00 2001 From: lizerk Date: Wed, 5 Aug 2026 16:43:43 +0300 Subject: [PATCH] refactor task and messenger workspaces --- backend/api/v1/tasks/serializers.py | 22 ++ backend/api/v1/tasks/views.py | 41 +- backend/tests/api/v1/tasks/test_tasks_api.py | 40 ++ frontend/src/app/tasks/page.tsx | 358 +++++++++--------- frontend/src/components/AppShell.tsx | 24 +- .../messages/MessagesWorkspaceShell.tsx | 13 +- .../components/tasks/TaskBoardNavigator.tsx | 311 +++++++++++++++ frontend/src/types/api.ts | 16 + 8 files changed, 618 insertions(+), 207 deletions(-) create mode 100644 frontend/src/components/tasks/TaskBoardNavigator.tsx diff --git a/backend/api/v1/tasks/serializers.py b/backend/api/v1/tasks/serializers.py index 50e2e29b..7e29ea9e 100644 --- a/backend/api/v1/tasks/serializers.py +++ b/backend/api/v1/tasks/serializers.py @@ -541,6 +541,28 @@ class TaskBoardSerializer(serializers.ModelSerializer): return bool(settings_obj and settings_obj.default_board_id == obj.id) +class TaskBoardSummarySerializer(TaskBoardSerializer): + open_tasks_count = serializers.IntegerField(read_only=True, default=0) + completed_tasks_count = serializers.IntegerField(read_only=True, default=0) + + class Meta(TaskBoardSerializer.Meta): + fields = [ + "id", + "name", + "description", + "access_scope", + "created_by", + "member_details", + "department_details", + "tasks_count", + "open_tasks_count", + "completed_tasks_count", + "is_default_for_current_user", + "created_at", + "updated_at", + ] + + class TaskLinkedPostSerializer(serializers.ModelSerializer): created_by = EmployeeBriefSerializer(read_only=True) post = serializers.SerializerMethodField() diff --git a/backend/api/v1/tasks/views.py b/backend/api/v1/tasks/views.py index 7911dcf0..7e8f4c8c 100644 --- a/backend/api/v1/tasks/views.py +++ b/backend/api/v1/tasks/views.py @@ -79,6 +79,7 @@ from .serializers import ( TaskActivitySerializer, TaskAttachmentSerializer, TaskBoardSerializer, + TaskBoardSummarySerializer, TaskChecklistItemSerializer, TaskColumnSerializer, TaskExternalLinkSerializer, @@ -169,14 +170,41 @@ class TaskBoardViewSet(viewsets.ModelViewSet): permission_classes = [IsAuthenticated] serializer_class = TaskBoardSerializer + def get_serializer_class(self): + if ( + self.action == "list" + and self.request.query_params.get("view") == "summary" + ): + return TaskBoardSummarySerializer + return super().get_serializer_class() + def get_queryset(self): - return ( + queryset = ( TaskBoard.objects.filter(is_archived=False) .filter(task_board_access_q(self.request.user)) .select_related("created_by") - .prefetch_related( - "members", - "departments", + .prefetch_related("members", "departments") + .annotate( + tasks_count=Count("tasks", distinct=True), + open_tasks_count=Count( + "tasks", + filter=Q(tasks__completed_at__isnull=True), + distinct=True, + ), + completed_tasks_count=Count( + "tasks", + filter=Q(tasks__completed_at__isnull=False), + distinct=True, + ), + ) + .distinct() + .order_by("name", "id") + ) + if not ( + self.action == "list" + and self.request.query_params.get("view") == "summary" + ): + queryset = queryset.prefetch_related( "columns", "labels", "tasks__created_by", @@ -188,10 +216,7 @@ class TaskBoardViewSet(viewsets.ModelViewSet): "tasks__attachments", "tasks__checklist_items", ) - .annotate(tasks_count=Count("tasks", distinct=True)) - .distinct() - .order_by("name", "id") - ) + return queryset def perform_create(self, serializer): board = serializer.save(created_by=self.request.user) diff --git a/backend/tests/api/v1/tasks/test_tasks_api.py b/backend/tests/api/v1/tasks/test_tasks_api.py index 796e158f..ab4f2fd3 100644 --- a/backend/tests/api/v1/tasks/test_tasks_api.py +++ b/backend/tests/api/v1/tasks/test_tasks_api.py @@ -125,6 +125,46 @@ def test_user_can_set_personal_default_board(api_client, user): assert other_default_response.data["is_default_for_current_user"] is False +def test_board_summary_list_returns_navigation_counts_without_nested_tasks( + api_client, + user, +): + board = TaskBoard.objects.create(name="Навигация", created_by=user) + column = TaskColumn.objects.create(board=board, name="Новые", position=1000) + done_column = TaskColumn.objects.create( + board=board, + name="Готово", + position=2000, + is_done=True, + ) + Task.objects.create( + board=board, + column=column, + title="Открытая", + created_by=user, + ) + Task.objects.create( + board=board, + column=done_column, + title="Готовая", + created_by=user, + ) + api_client.force_authenticate(user=user) + + response = api_client.get( + reverse("api:v1:tasks:task-board-list"), + {"view": "summary"}, + ) + + assert response.status_code == status.HTTP_200_OK + result = response.data["results"][0] + assert result["tasks_count"] == 2 + assert result["open_tasks_count"] == 1 + assert result["completed_tasks_count"] == 1 + assert "tasks" not in result + assert "columns" not in result + + 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 2b99ae6d..687ade03 100644 --- a/frontend/src/app/tasks/page.tsx +++ b/frontend/src/app/tasks/page.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { useSearchParams } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { DndContext, DragOverlay, @@ -22,6 +22,7 @@ import { import { CSS } from "@dnd-kit/utilities"; import { CalendarDays, + ArrowLeft, Check, ChevronDown, ChevronRight, @@ -34,9 +35,7 @@ import { Link2, ListChecks, Loader2, - Maximize2, MessageSquare, - Minimize2, Newspaper, Paperclip, Pencil, @@ -56,6 +55,7 @@ import { import { Suspense, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { AppShell } from "@/components/AppShell"; +import TaskBoardNavigator from "@/components/tasks/TaskBoardNavigator"; import { RequestAvatar } from "@/components/requests/RequestAvatar"; import { CommentComposer, CommentDeleteButton } from "@/components/shared/CommentControls"; import { Modal } from "@/components/ui"; @@ -73,6 +73,7 @@ import type { TaskActivity, TaskAttachment, TaskBoard, + TaskBoardSummary, TaskCard, TaskChecklistItem, TaskColumn, @@ -625,6 +626,7 @@ function BoardColumn({ onDeleteColumn, onColumnMount, fillAvailableHeight = false, + hideOnMobile = false, }: { column: TaskColumn; tasks: TaskCard[]; @@ -647,6 +649,7 @@ function BoardColumn({ onDeleteColumn: (column: TaskColumn) => void; onColumnMount: (columnId: number, node: HTMLElement | null) => void; fillAvailableHeight?: boolean; + hideOnMobile?: boolean; }) { const { attributes, @@ -675,7 +678,7 @@ function BoardColumn({
@@ -795,7 +798,7 @@ function AddColumnCard({ +
+

+ {board?.name || "Доска задач"} +

+
-
+
-
-
- - -
- {boardMenuOpen ? ( -
-
- {boards.map((item) => { - const active = selectedBoardId === item.id; - const unread = unreadTaskBoardCounts[item.id] || 0; - return ( - - ); - })} -
-
- ) : null} -
- + + {onlyMine ? ( + + ) : null} - {onlyMine ? ( - - ) : null}
{error ? ( @@ -4715,6 +4688,12 @@ function TasksPageContent() { ) : null}
+ {boardLoading ? ( +
+ +
+ ) : null} +
-
+
{activeColumns.map((column) => ( ))}
+
+
void; suppressPushOnboardingPrompt?: boolean; @@ -597,6 +598,7 @@ export function AppShell({ children, desktopWideMode = false, desktopImmersiveMode = false, + standaloneAppMode = false, mobileFullscreen = false, onDesktopWideModeChange, suppressPushOnboardingPrompt = false, @@ -677,8 +679,8 @@ export function AppShell({ } return ( -
-
+
+
setIsMobileCalendarOpen(true)} />
-
- {!desktopImmersiveMode ? : null} +
+ {!desktopImmersiveMode && !standaloneAppMode ? : null}
- {!mobileFullscreen && !suppressPushOnboardingPrompt ? : null} + {!mobileFullscreen && !standaloneAppMode && !suppressPushOnboardingPrompt ? : null} {children}
- {!desktopImmersiveMode ? : null}
- setIsMobileLeftNavOpen(false)} > setIsMobileLeftNavOpen(false)} /> - + : null} - setIsMobileCalendarOpen(false)} onOpenCalendarModal={cal.handleOpenCalendarModal} @@ -723,9 +725,9 @@ export function AppShell({ setEventsRefreshTrigger={cal.handleSetEventsRefreshTrigger} setSidebarEvents={cal.handleSetSidebarEvents} onCalendarChange={cal.handleCalendarChange} - /> + /> : null} - + {!standaloneAppMode ? : null}
); diff --git a/frontend/src/components/messages/MessagesWorkspaceShell.tsx b/frontend/src/components/messages/MessagesWorkspaceShell.tsx index 12438fa3..627e599f 100644 --- a/frontend/src/components/messages/MessagesWorkspaceShell.tsx +++ b/frontend/src/components/messages/MessagesWorkspaceShell.tsx @@ -1,7 +1,7 @@ "use client"; import type { ReactNode } from "react"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useMemo, useState, useSyncExternalStore } from "react"; import { usePathname } from "next/navigation"; import { AppShell } from "@/components/AppShell"; @@ -21,12 +21,20 @@ type MessagesWorkspaceShellProps = { children: ReactNode; }; +function subscribeToMessengerLaunch() { + return () => undefined; +} + export default function MessagesWorkspaceShell({ children }: MessagesWorkspaceShellProps) { const pathname = usePathname(); const { user } = useUser(); const [createChatOpen, setCreateChatOpen] = useState(false); const [storedMode, setStoredMode] = useMessagesWorkspaceMode(user?.id); - const messengerLaunch = useMemo(() => isMessengerLaunch(), []); + const messengerLaunch = useSyncExternalStore( + subscribeToMessengerLaunch, + isMessengerLaunch, + () => false, + ); const mode = resolveEffectiveMessagesWorkspaceMode(storedMode, messengerLaunch); const setMode = useCallback((nextMode: typeof mode) => { if (!messengerLaunch) setStoredMode(nextMode); @@ -49,6 +57,7 @@ export default function MessagesWorkspaceShell({ children }: MessagesWorkspaceSh setMode(enabled ? "wide" : "standard")} suppressPushOnboardingPrompt={workspaceMode} diff --git a/frontend/src/components/tasks/TaskBoardNavigator.tsx b/frontend/src/components/tasks/TaskBoardNavigator.tsx new file mode 100644 index 00000000..b9d92751 --- /dev/null +++ b/frontend/src/components/tasks/TaskBoardNavigator.tsx @@ -0,0 +1,311 @@ +"use client"; + +import { + ChevronDown, + Filter, + Kanban, + LockKeyhole, + Maximize2, + Minimize2, + Plus, + Search, + Star, + Users, +} from "lucide-react"; +import { useMemo, useState } from "react"; + +import type { TaskBoardSummary } from "@/types/api"; + +type BoardSection = { + key: string; + label: string; + boards: TaskBoardSummary[]; +}; + +type BoardUnreadFilter = "all" | "unread" | "read"; +type BoardAccessFilter = "all" | "private" | "shared"; + +type TaskBoardNavigatorProps = { + boards: TaskBoardSummary[]; + selectedBoardId: number | null; + currentUserId?: number; + unreadCounts: Record; + onSelect: (boardId: number) => void; + onCreate: () => void; + wideMode: boolean; + onWideModeChange: (enabled: boolean) => void; + className?: string; +}; + +function matchesBoard(board: TaskBoardSummary, query: string) { + const haystack = [ + board.name, + board.description, + board.created_by?.first_name, + board.created_by?.last_name, + ...(board.department_details || []).map((department) => department.name), + ] + .filter(Boolean) + .join(" ") + .toLowerCase(); + return haystack.includes(query); +} + +function buildSections( + boards: TaskBoardSummary[], + currentUserId: number | undefined, + 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); + + return [ + { key: "default", label: "По умолчанию", boards: defaultBoards }, + { key: "own", label: "Мои доски", boards: ownBoards }, + { key: "shared", label: "Доступные", boards: sharedBoards }, + ].filter((section) => section.boards.length > 0); +} + +function BoardRow({ + board, + active, + unread, + onSelect, +}: { + board: TaskBoardSummary; + active: boolean; + unread: number; + onSelect: () => void; +}) { + return ( + + ); +} + +export default function TaskBoardNavigator({ + boards, + selectedBoardId, + currentUserId, + unreadCounts, + onSelect, + onCreate, + wideMode, + onWideModeChange, + className = "", +}: TaskBoardNavigatorProps) { + const [query, setQuery] = useState(""); + const [filtersOpen, setFiltersOpen] = useState(false); + const [unreadFilter, setUnreadFilter] = useState("all"); + const [accessFilter, setAccessFilter] = useState("all"); + const [collapsed, setCollapsed] = useState>(new Set()); + const normalizedQuery = query.trim().toLowerCase(); + const filteredBoards = useMemo(() => boards.filter((board) => { + const unread = unreadCounts[board.id] || 0; + if (unreadFilter === "unread" && unread === 0) return false; + if (unreadFilter === "read" && unread > 0) return false; + if (accessFilter === "private" && board.access_scope !== "private") return false; + if (accessFilter === "shared" && board.access_scope === "private") return false; + return true; + }), [accessFilter, boards, unreadCounts, unreadFilter]); + const sections = useMemo( + () => buildSections(filteredBoards, currentUserId, normalizedQuery), + [currentUserId, filteredBoards, normalizedQuery], + ); + const activeFiltersCount = [unreadFilter !== "all", accessFilter !== "all"].filter(Boolean).length; + + const toggleSection = (key: string) => { + setCollapsed((current) => { + const next = new Set(current); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }; + + return ( + + ); +} diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 5bd78d83..b9bfb172 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -250,6 +250,22 @@ export interface TaskBoard { updated_at: string; } +export interface TaskBoardSummary { + id: number; + name: string; + description?: string; + access_scope?: "all" | "private" | "restricted"; + created_by?: User; + member_details?: User[]; + department_details?: Department[]; + tasks_count: number; + open_tasks_count: number; + completed_tasks_count: number; + is_default_for_current_user?: boolean; + created_at: string; + updated_at: string; +} + export interface TaskLinkedPost { id: number; kind: "post";