refactor task and messenger workspaces

This commit is contained in:
lizerk 2026-08-05 16:43:43 +03:00
parent fababde18c
commit 0dad6b84de
8 changed files with 618 additions and 207 deletions

View File

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

View File

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

View File

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

View File

@ -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({
<section
ref={setColumnNodeRef}
style={style}
className={`flex max-h-[calc(100vh-14rem)] min-h-[28rem] min-w-[18rem] flex-col rounded-xl border bg-[var(--surface-muted)] transition ${fillAvailableHeight ? "lg:h-full lg:min-h-0 lg:max-h-none" : ""} ${
className={`${hideOnMobile ? "hidden xl:flex" : "flex"} max-h-[calc(100dvh-15rem)] min-h-[28rem] w-full min-w-0 flex-col rounded-xl border bg-[var(--surface-muted)] transition xl:w-auto xl:min-w-[18rem] ${fillAvailableHeight ? "lg:h-full lg:min-h-0 lg:max-h-none" : ""} ${
isOver ? "border-[var(--accent-primary)]" : "border-[var(--border-subtle)]"
} ${isDragging ? "opacity-70 shadow-xl" : ""}`}
>
@ -795,7 +798,7 @@ function AddColumnCard({
<button
type="button"
onClick={onClick}
className={`group flex max-h-[calc(100vh-14rem)] min-h-[28rem] min-w-[18rem] flex-col items-center justify-center rounded-xl border border-dashed border-[var(--border-subtle)] bg-[var(--surface-muted)] p-4 text-center transition hover:border-[var(--accent-primary)] hover:bg-[var(--surface-elevated)] ${fillAvailableHeight ? "lg:h-full lg:min-h-0 lg:max-h-none" : ""}`}
className={`group hidden max-h-[calc(100vh-14rem)] min-h-[28rem] min-w-[18rem] flex-col items-center justify-center rounded-xl border border-dashed border-[var(--border-subtle)] bg-[var(--surface-muted)] p-4 text-center transition hover:border-[var(--accent-primary)] hover:bg-[var(--surface-elevated)] xl:flex ${fillAvailableHeight ? "lg:h-full lg:min-h-0 lg:max-h-none" : ""}`}
>
<span className="app-selected mb-3 flex h-10 w-10 items-center justify-center rounded-xl transition group-hover:scale-105">
<Plus size={18} />
@ -2095,16 +2098,18 @@ export default function TasksPage() {
function TasksPageContent() {
const { user } = useUser();
const { unreadTaskBoardCounts, markTaskBoardAsRead } = useNotifications();
const router = useRouter();
const searchParams = useSearchParams();
const requestedBoardId = Number(searchParams.get("board") || "");
const requestedTaskId = Number(searchParams.get("task") || "");
const [boards, setBoards] = useState<TaskBoard[]>([]);
const [boards, setBoards] = useState<TaskBoardSummary[]>([]);
const [board, setBoard] = useState<TaskBoard | null>(null);
const [selectedBoardId, setSelectedBoardId] = useState<number | null>(null);
const [employees, setEmployees] = useState<User[]>([]);
const [taskParticipantCandidates, setTaskParticipantCandidates] = useState<User[]>([]);
const [departments, setDepartments] = useState<Department[]>([]);
const [loading, setLoading] = useState(true);
const [boardLoading, setBoardLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [claimingTaskId, setClaimingTaskId] = useState<number | null>(null);
const [completingTaskId, setCompletingTaskId] = useState<number | null>(null);
@ -2126,7 +2131,6 @@ function TasksPageContent() {
const [cardMenuTaskId, setCardMenuTaskId] = useState<number | null>(null);
const [columnMenuId, setColumnMenuId] = useState<number | null>(null);
const [boardModalOpen, setBoardModalOpen] = useState(false);
const [boardMenuOpen, setBoardMenuOpen] = useState(false);
const [boardActionsMenuOpen, setBoardActionsMenuOpen] = useState(false);
const [columnModalOpen, setColumnModalOpen] = useState(false);
const [form, setForm] = useState<TaskFormState>(emptyForm);
@ -2198,7 +2202,6 @@ function TasksPageContent() {
const [activityOpen, setActivityOpen] = useState(false);
const [taskActivities, setTaskActivities] = useState<TaskActivity[]>([]);
const [taskActivityLoading, setTaskActivityLoading] = useState(false);
const boardMenuRef = useRef<HTMLDivElement | null>(null);
const boardActionsMenuRef = useRef<HTMLDivElement | null>(null);
const taskMenuRef = useRef<HTMLDivElement | null>(null);
const viewColumnMenuRef = useRef<HTMLDivElement | null>(null);
@ -2214,6 +2217,7 @@ function TasksPageContent() {
const taskFormChecklistDraftRef = useRef<HTMLInputElement | null>(null);
const taskFormResourcesRequestRef = useRef(0);
const readingBoardNotificationsRef = useRef<Set<number>>(new Set());
const initialLoadCompleteRef = useRef(false);
const sensors = useSensors(
useSensor(PointerSensor, {
@ -2221,19 +2225,15 @@ function TasksPageContent() {
}),
);
const otherBoardsUnreadCount = useMemo(
() => boards.reduce(
(total, item) => (
item.id === selectedBoardId
? total
: total + (unreadTaskBoardCounts[item.id] || 0)
),
0,
),
[boards, selectedBoardId, unreadTaskBoardCounts],
);
useEffect(() => {
if (
typeof window !== "undefined" &&
window.matchMedia("(max-width: 1023px)").matches &&
requestedBoardId <= 0 &&
requestedTaskId <= 0
) {
return;
}
if (!selectedBoardId || (unreadTaskBoardCounts[selectedBoardId] || 0) <= 0) {
return;
}
@ -2249,7 +2249,13 @@ function TasksPageContent() {
.finally(() => {
readingBoardNotificationsRef.current.delete(selectedBoardId);
});
}, [markTaskBoardAsRead, selectedBoardId, unreadTaskBoardCounts]);
}, [
markTaskBoardAsRead,
requestedBoardId,
requestedTaskId,
selectedBoardId,
unreadTaskBoardCounts,
]);
useEffect(() => {
if (!user?.id) return;
@ -2269,10 +2275,10 @@ function TasksPageContent() {
}, [user?.id]);
const loadBoards = useCallback(async () => {
const response = await apiClient.getTaskBoards();
const results = response.results || response || [];
setBoards(results);
return results as TaskBoard[];
const response = await apiClient.getTaskBoards({ view: "summary" });
const results = Array.isArray(response) ? response : response.results || [];
setBoards(results as TaskBoardSummary[]);
return results as TaskBoardSummary[];
}, []);
const loadBoard = useCallback(async (boardId?: number | null) => {
@ -2629,36 +2635,51 @@ function TasksPageContent() {
useEffect(() => {
let mounted = true;
async function load() {
setLoading(true);
const initialLoad = !initialLoadCompleteRef.current;
if (initialLoad) setLoading(true);
else setBoardLoading(true);
try {
const [boardData, boardsData, employeesData, departmentsData] = await Promise.all([
requestedBoardId > 0
? apiClient.getTaskBoard(requestedBoardId)
: apiClient.getDefaultTaskBoard(),
apiClient.getTaskBoards(),
apiClient.getTaskBoards({ view: "summary" }),
apiClient.getEmployees({ limit: 200, is_active: true, ordering: "last_name" }),
apiClient.getDepartments({ limit: 200 }),
]);
if (!mounted) return;
const boardList = boardsData.results || boardsData || [];
setBoards(boardList);
const boardList = Array.isArray(boardsData) ? boardsData : boardsData.results || [];
setBoards(boardList as TaskBoardSummary[]);
setBoard(boardData);
setSelectedBoardId(boardData.id);
if (window.matchMedia("(max-width: 1023px)").matches) {
const firstColumn = (boardData.columns || []).find(
(column: TaskColumn) => !column.is_archived,
);
setSelectedColumnTarget(firstColumn?.id || "all");
}
setEmployees(employeesData.results || employeesData || []);
setDepartments(departmentsData.results || departmentsData || []);
if (
requestedTaskId > 0 &&
(boardData.tasks || []).some((task: TaskCard) => task.id === requestedTaskId)
) {
const requestedTask = (boardData.tasks || []).find(
(task: TaskCard) => task.id === requestedTaskId,
);
setViewTaskId(requestedTaskId);
setSelectedColumnTarget("all");
setSelectedColumnTarget(requestedTask?.column || "all");
setOnlyMine(false);
setSearch("");
}
} catch (loadError) {
if (mounted) setError(getTaskError(loadError, "Не удалось загрузить доску"));
} finally {
if (mounted) setLoading(false);
if (mounted) {
initialLoadCompleteRef.current = true;
setLoading(false);
setBoardLoading(false);
}
}
}
@ -2668,22 +2689,6 @@ function TasksPageContent() {
};
}, [requestedBoardId, requestedTaskId]);
useEffect(() => {
if (!boardMenuOpen) return undefined;
function handlePointerDown(event: MouseEvent) {
if (
boardMenuRef.current &&
!boardMenuRef.current.contains(event.target as Node)
) {
setBoardMenuOpen(false);
}
}
document.addEventListener("mousedown", handlePointerDown);
return () => document.removeEventListener("mousedown", handlePointerDown);
}, [boardMenuOpen]);
useEffect(() => {
if (!boardActionsMenuOpen) return undefined;
@ -2943,7 +2948,12 @@ function TasksPageContent() {
selectedColumnTarget !== "all" &&
!activeColumns.some((column) => column.id === selectedColumnTarget)
) {
setSelectedColumnTarget("all");
const fallbackColumnId = activeColumns[0]?.id;
setSelectedColumnTarget(
typeof window !== "undefined" && window.matchMedia("(max-width: 1023px)").matches
? fallbackColumnId || "all"
: "all",
);
}
}, [activeColumns, selectedColumnTarget]);
@ -3264,22 +3274,16 @@ function TasksPageContent() {
}));
}, []);
const selectBoard = useCallback(async (boardId: number) => {
if (!boardId || boardId === selectedBoardId) return;
setLoading(true);
setError(null);
try {
await loadBoard(boardId);
setSearch("");
setOnlyMine(false);
setSelectedColumnTarget("all");
setBoardMenuOpen(false);
} catch (loadError) {
setError(getTaskError(loadError, "Не удалось загрузить доску"));
} finally {
setLoading(false);
const selectBoard = useCallback((boardId: number) => {
if (!boardId) return;
if (boardId === selectedBoardId && requestedBoardId === boardId && requestedTaskId <= 0) {
return;
}
}, [loadBoard, selectedBoardId]);
setError(null);
setSearch("");
setOnlyMine(false);
router.push(`/tasks?board=${boardId}`, { scroll: false });
}, [requestedBoardId, requestedTaskId, router, selectedBoardId]);
const refreshSelectedBoard = useCallback(async () => {
if (!board) return;
@ -3333,8 +3337,13 @@ function TasksPageContent() {
await apiClient.deleteTaskBoard(board.id);
const nextBoards = await loadBoards();
const nextBoard = nextBoards.find((item) => item.id !== board.id) || null;
await loadBoard(nextBoard?.id || null);
await loadBoards();
if (nextBoard) {
router.replace(`/tasks?board=${nextBoard.id}`, { scroll: false });
} else {
setBoard(null);
setSelectedBoardId(null);
router.replace("/tasks", { scroll: false });
}
setSearch("");
setOnlyMine(false);
setSelectedColumnTarget("all");
@ -3343,7 +3352,7 @@ function TasksPageContent() {
} finally {
setSaving(false);
}
}, [board, loadBoard, loadBoards, saving]);
}, [board, loadBoards, router, saving]);
const saveBoard = useCallback(async () => {
if (!boardForm.name.trim()) return;
@ -3362,6 +3371,7 @@ function TasksPageContent() {
: await apiClient.createTaskBoard(payload);
await loadBoards();
await loadBoard(saved.id);
router.push(`/tasks?board=${saved.id}`, { scroll: false });
setBoardModalOpen(false);
setBoardForm(emptyBoardForm);
setSearch("");
@ -3381,6 +3391,7 @@ function TasksPageContent() {
boardForm.name,
loadBoard,
loadBoards,
router,
]);
const saveColumn = useCallback(async () => {
@ -4452,38 +4463,54 @@ function TasksPageContent() {
);
}
const mobileBoardOpen = requestedBoardId > 0 || requestedTaskId > 0;
return (
<AppShell
desktopWideMode={desktopWideMode}
onDesktopWideModeChange={changeDesktopWideMode}
>
<div className={`${desktopWideMode ? "w-full lg:flex lg:min-h-0 lg:flex-1 lg:flex-col lg:gap-4 lg:space-y-0" : "mx-auto max-w-[1600px]"} space-y-4`}>
<section className="app-surface rounded-2xl p-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="min-w-0">
<p className="app-card-caption">Доска</p>
<h1 className="truncate text-xl font-semibold text-[var(--foreground)]">
{board?.name || "Доска задач"}
</h1>
<div className={`${desktopWideMode ? "w-full lg:flex lg:min-h-0 lg:flex-1 lg:flex-col" : "mx-auto w-full max-w-[1600px]"}`}>
<div className={`min-h-0 gap-4 ${desktopWideMode ? "lg:flex lg:flex-1" : ""}`}>
<TaskBoardNavigator
boards={boards}
selectedBoardId={selectedBoardId}
currentUserId={user?.id}
unreadCounts={unreadTaskBoardCounts}
onSelect={selectBoard}
onCreate={openCreateBoard}
wideMode={desktopWideMode}
onWideModeChange={changeDesktopWideMode}
className={`${mobileBoardOpen ? "hidden" : "flex"} h-[calc(100dvh-7rem)] w-full ${desktopWideMode ? "lg:flex lg:h-full lg:w-[24rem] lg:shrink-0" : ""}`}
/>
<div className={`${mobileBoardOpen ? "flex" : "hidden"} relative min-h-0 min-w-0 flex-1 flex-col gap-4 ${desktopWideMode ? "lg:flex lg:h-full" : ""}`}>
<section className="app-surface shrink-0 rounded-2xl p-4">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<button
type="button"
onClick={() => router.push("/tasks", { scroll: false })}
className={`app-icon-button flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${desktopWideMode ? "lg:hidden" : ""}`}
title="К списку досок"
aria-label="К списку досок"
>
<ArrowLeft size={17} />
</button>
<div className="min-w-0">
<h1 className="truncate text-xl font-semibold text-[var(--foreground)]">
{board?.name || "Доска задач"}
</h1>
</div>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => changeDesktopWideMode(!desktopWideMode)}
className="app-action-ghost hidden h-8 w-8 items-center justify-center rounded-md lg:inline-flex"
title={desktopWideMode ? "Вернуть обычный вид" : "Развернуть доску"}
aria-label={desktopWideMode ? "Вернуть обычный вид" : "Развернуть доску"}
aria-pressed={desktopWideMode}
>
{desktopWideMode ? <Minimize2 size={17} /> : <Maximize2 size={17} />}
</button>
<button
type="button"
onClick={openCreateBoard}
className="app-action-primary inline-flex min-h-10 items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-semibold"
onClick={() => openCreateTask()}
className={`app-action-primary hidden min-h-10 items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-semibold ${desktopWideMode ? "lg:inline-flex" : ""}`}
>
<Plus size={16} />
Создать доску
Создать задачу
</button>
<div ref={boardActionsMenuRef} className="relative">
<button
@ -4520,6 +4547,17 @@ function TasksPageContent() {
<RefreshCw size={14} className="app-text-muted shrink-0" />
<span>Обновить</span>
</button>
<button
type="button"
onClick={() => {
setBoardActionsMenuOpen(false);
openCreateColumn();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<Plus size={14} className="app-text-muted shrink-0" />
<span>Добавить колонку</span>
</button>
<button
type="button"
onClick={() => void setDefaultSelectedBoard()}
@ -4568,84 +4606,36 @@ function TasksPageContent() {
</div>
<div className="mt-4 flex flex-wrap items-center gap-2">
<div ref={boardMenuRef} className="relative">
<div className="app-pill inline-flex max-w-full items-center overflow-hidden rounded-full text-xs font-medium transition">
<button
type="button"
onClick={() => setBoardMenuOpen(false)}
className="inline-flex min-w-0 items-center gap-1.5 py-1.5 pl-3 pr-1.5 transition hover:opacity-85"
>
<Kanban size={14} className="shrink-0" />
<span className="max-w-[220px] truncate">
{board?.name || "Доска"}
</span>
{otherBoardsUnreadCount > 0 ? (
<span
className="flex h-4 min-w-4 shrink-0 items-center justify-center rounded-full bg-sky-500 px-1 text-[9px] font-bold text-white"
title={`Непрочитанных уведомлений на других досках: ${otherBoardsUnreadCount}`}
>
{otherBoardsUnreadCount > 99 ? "99+" : otherBoardsUnreadCount}
</span>
) : null}
</button>
<button
type="button"
onClick={() => setBoardMenuOpen((prev) => !prev)}
className="flex h-7 w-7 shrink-0 items-center justify-center transition hover:bg-[var(--surface-tertiary)]"
title="Показать доски"
aria-label="Показать список досок"
aria-expanded={boardMenuOpen}
aria-haspopup="menu"
>
<ChevronDown
size={12}
className={`opacity-70 transition-transform ${boardMenuOpen ? "rotate-180" : ""}`}
/>
</button>
</div>
{boardMenuOpen ? (
<div className="app-menu absolute left-0 top-full z-20 mt-2 w-72 rounded-lg p-2">
<div className="tasks-column-scroll max-h-80 overflow-y-auto">
{boards.map((item) => {
const active = selectedBoardId === item.id;
const unread = unreadTaskBoardCounts[item.id] || 0;
return (
<button
key={item.id}
type="button"
onClick={() => void selectBoard(item.id)}
className={`flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition ${
active
? "app-selected app-accent-text font-medium"
: "text-[var(--foreground)] hover:bg-[var(--surface-secondary)]"
}`}
>
<Kanban size={15} className="shrink-0" />
<span className="min-w-0 flex-1 truncate">{item.name}</span>
{unread > 0 ? (
<span className="flex h-5 min-w-5 shrink-0 items-center justify-center rounded-full bg-sky-500 px-1.5 text-[10px] font-bold text-white">
{unread > 99 ? "99+" : unread}
</span>
) : null}
{item.is_default_for_current_user ? (
<Star
size={12}
className="app-accent-text shrink-0 fill-current"
aria-label="Доска по умолчанию"
/>
) : null}
</button>
);
})}
</div>
</div>
) : null}
</div>
<button
type="button"
onClick={() => setOnlyMine((current) => !current)}
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition ${
onlyMine ? "app-pill-active" : "app-pill"
}`}
>
<UserRound size={14} />
<span>Мои задачи</span>
<span className={`app-badge px-1.5 py-0.5 text-[10px] font-bold ${
onlyMine ? "app-pill-count-active" : "app-pill-count"
}`}>
{mineCount}
</span>
</button>
{onlyMine ? (
<button
type="button"
onClick={() => setOnlyMine(false)}
className="app-action-ghost inline-flex h-7 w-7 items-center justify-center rounded-full"
title="Сбросить фильтр"
aria-label="Сбросить фильтр моих задач"
>
<X size={13} />
</button>
) : null}
<button
type="button"
onClick={focusAllColumns}
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition ${
className={`hidden items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition xl:inline-flex ${
selectedColumnTarget === "all" ? "app-pill-active" : "app-pill"
}`}
>
@ -4682,30 +4672,13 @@ function TasksPageContent() {
})}
<button
type="button"
onClick={() => setOnlyMine((current) => !current)}
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition ${
onlyMine ? "app-pill-active" : "app-pill"
}`}
onClick={openCreateColumn}
className="app-pill inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full transition"
title="Добавить колонку"
aria-label="Добавить колонку"
>
<UserRound size={14} />
<span>Мои задачи</span>
<span className={`app-badge px-1.5 py-0.5 text-[10px] font-bold ${
onlyMine ? "app-pill-count-active" : "app-pill-count"
}`}>
{mineCount}
</span>
<Plus size={14} />
</button>
{onlyMine ? (
<button
type="button"
onClick={() => setOnlyMine(false)}
className="app-action-ghost inline-flex h-7 w-7 items-center justify-center rounded-full"
title="Сбросить фильтр"
aria-label="Сбросить фильтр моих задач"
>
<X size={13} />
</button>
) : null}
</div>
{error ? (
@ -4715,6 +4688,12 @@ function TasksPageContent() {
) : null}
</section>
{boardLoading ? (
<div className="pointer-events-none absolute inset-0 z-40 flex items-center justify-center rounded-2xl bg-[color-mix(in_srgb,var(--background)_72%,transparent)]">
<Loader2 size={24} className="animate-spin text-sky-500" />
</div>
) : null}
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
@ -4728,9 +4707,9 @@ function TasksPageContent() {
>
<div
ref={boardScrollRef}
className={`tasks-board-scroll overflow-x-auto pb-3 ${desktopWideMode ? "lg:min-h-0 lg:flex-1" : ""}`}
className={`tasks-board-scroll min-w-0 overflow-x-hidden pb-3 xl:overflow-x-auto ${desktopWideMode ? "lg:min-h-0 lg:flex-1" : ""}`}
>
<div className={`flex min-w-max gap-3 ${desktopWideMode ? "lg:h-full" : ""}`}>
<div className={`flex w-full min-w-0 gap-3 xl:w-auto xl:min-w-max ${desktopWideMode ? "lg:h-full" : ""}`}>
{activeColumns.map((column) => (
<BoardColumn
key={column.id}
@ -4755,6 +4734,11 @@ function TasksPageContent() {
onDeleteColumn={deleteColumn}
onColumnMount={registerColumnNode}
fillAvailableHeight={desktopWideMode}
hideOnMobile={
selectedColumnTarget === "all"
? activeColumns[0]?.id !== column.id
: selectedColumnTarget !== column.id
}
/>
))}
<AddColumnCard
@ -4769,6 +4753,8 @@ function TasksPageContent() {
</DragOverlay>
</DndContext>
</div>
</div>
</div>
<Modal
isOpen={boardModalOpen}

View File

@ -23,6 +23,7 @@ type AppShellProps = {
children: ReactNode;
desktopWideMode?: boolean;
desktopImmersiveMode?: boolean;
standaloneAppMode?: boolean;
mobileFullscreen?: boolean;
onDesktopWideModeChange?: (enabled: boolean) => 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 (
<div className={`${mobileFullscreen ? 'h-[100dvh] overflow-hidden' : 'min-h-screen pt-10'} ${desktopImmersiveMode ? "lg:pt-0" : "lg:pt-14"} ${desktopWideMode ? "lg:h-[100dvh] lg:overflow-hidden" : ""} app-shell flex flex-col ${mobileNavPlacement === "bottom" && !mobileFullscreen ? "pb-[calc(env(safe-area-inset-bottom)+3.75rem)] lg:pb-0" : ""}`}>
<div className={desktopImmersiveMode ? "lg:hidden" : ""}>
<div className={`${mobileFullscreen || standaloneAppMode ? 'h-[100dvh] overflow-hidden' : 'min-h-screen pt-10'} ${desktopImmersiveMode || standaloneAppMode ? "lg:pt-0" : "lg:pt-14"} ${desktopWideMode ? "lg:h-[100dvh] lg:overflow-hidden" : ""} app-shell flex flex-col ${mobileNavPlacement === "bottom" && !mobileFullscreen && !standaloneAppMode ? "pb-[calc(env(safe-area-inset-bottom)+3.75rem)] lg:pb-0" : ""}`}>
<div className={standaloneAppMode ? "hidden" : desktopImmersiveMode ? "lg:hidden" : ""}>
<Header
mobileNavPlacement={mobileNavPlacement}
suppressMobileChrome={mobileFullscreen}
@ -686,13 +688,13 @@ export function AppShell({
onOpenCalendar={() => setIsMobileCalendarOpen(true)}
/>
</div>
<div className={`mx-auto flex w-full flex-1 min-h-0 max-w-6xl ${desktopWideMode ? "lg:max-w-none" : ""} ${desktopImmersiveMode ? "gap-0 px-0 py-0 lg:gap-0 lg:px-3 lg:py-3" : mobileFullscreen ? `gap-0 px-0 py-0 lg:py-4 ${desktopWideMode ? "lg:gap-3 lg:px-3" : "lg:gap-6 lg:px-8"}` : `gap-6 px-4 py-4 sm:px-8 lg:py-4 ${desktopWideMode ? "lg:gap-3 lg:px-3" : ""}`}`}>
{!desktopImmersiveMode ? <LeftNav fixedDesktop compact={desktopWideMode} /> : null}
<div className={`mx-auto flex w-full flex-1 min-h-0 max-w-6xl ${desktopWideMode ? "lg:max-w-none" : ""} ${standaloneAppMode ? "gap-0 px-0 py-0" : desktopImmersiveMode ? "gap-0 px-0 py-0 lg:gap-0 lg:px-3 lg:py-3" : mobileFullscreen ? `gap-0 px-0 py-0 lg:py-4 ${desktopWideMode ? "lg:gap-3 lg:px-3" : "lg:gap-6 lg:px-8"}` : `gap-6 px-4 py-4 sm:px-8 lg:py-4 ${desktopWideMode ? "lg:gap-3 lg:px-3" : ""}`}`}>
{!desktopImmersiveMode && !standaloneAppMode ? <LeftNav fixedDesktop compact={desktopWideMode} /> : null}
<main className={`flex-1 min-w-0 min-h-0 space-y-6 ${mobileFullscreen ? 'overflow-visible' : ''} ${desktopWideMode ? "lg:flex lg:flex-col lg:gap-4 lg:space-y-0 lg:overflow-hidden" : ""}`}>
{!mobileFullscreen && !suppressPushOnboardingPrompt ? <PushOnboardingPrompt /> : null}
{!mobileFullscreen && !standaloneAppMode && !suppressPushOnboardingPrompt ? <PushOnboardingPrompt /> : null}
{children}
</main>
{!desktopImmersiveMode ? <CalendarSidebar
{!desktopImmersiveMode && !standaloneAppMode ? <CalendarSidebar
onOpenCalendarModal={cal.handleOpenCalendarModal}
onOpenEventModal={cal.handleOpenEventModal}
onOpenParticipantsModal={cal.handleOpenParticipantsModal}
@ -706,14 +708,14 @@ export function AppShell({
/> : null}
</div>
<MobileLeftDrawer
{!standaloneAppMode ? <MobileLeftDrawer
isOpen={isMobileLeftNavOpen}
onClose={() => setIsMobileLeftNavOpen(false)}
>
<RoutedLeftNavContent onNavigate={() => setIsMobileLeftNavOpen(false)} />
</MobileLeftDrawer>
</MobileLeftDrawer> : null}
<MobileCalendarDrawer
{!standaloneAppMode ? <MobileCalendarDrawer
isOpen={isMobileCalendarOpen}
onClose={() => setIsMobileCalendarOpen(false)}
onOpenCalendarModal={cal.handleOpenCalendarModal}
@ -723,9 +725,9 @@ export function AppShell({
setEventsRefreshTrigger={cal.handleSetEventsRefreshTrigger}
setSidebarEvents={cal.handleSetSidebarEvents}
onCalendarChange={cal.handleCalendarChange}
/>
/> : null}
<CalendarModals {...cal} />
{!standaloneAppMode ? <CalendarModals {...cal} /> : null}
<GuestInfoRequestPrompt userId={user.id} />
</div>
);

View File

@ -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
<AppShell
desktopWideMode={workspaceMode}
desktopImmersiveMode={immersiveMode}
standaloneAppMode={messengerLaunch}
mobileFullscreen={mobileFullscreen}
onDesktopWideModeChange={(enabled) => setMode(enabled ? "wide" : "standard")}
suppressPushOnboardingPrompt={workspaceMode}

View File

@ -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<number, number>;
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 (
<button
type="button"
onClick={onSelect}
className={`group flex w-full items-start gap-3 rounded-lg px-3 py-2.5 text-left transition ${
active ? "app-selected" : "hover:bg-[var(--surface-secondary)]"
}`}
aria-current={active ? "page" : undefined}
>
<span className={`mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${active ? "app-badge-accent" : "app-surface-muted"}`}>
<Kanban size={17} />
</span>
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-center gap-1.5">
<span className="min-w-0 flex-1 truncate text-sm font-semibold text-[var(--foreground)]">
{board.name}
</span>
{board.is_default_for_current_user ? (
<Star size={12} className="app-accent-text shrink-0 fill-current" />
) : null}
{unread > 0 ? (
<span className="flex h-5 min-w-5 shrink-0 items-center justify-center rounded-full bg-sky-500 px-1.5 text-[10px] font-bold text-white">
{unread > 99 ? "99+" : unread}
</span>
) : null}
</span>
{board.description ? (
<span className="app-text-muted mt-0.5 line-clamp-2 block text-xs">
{board.description}
</span>
) : null}
<span className="app-text-muted mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px]">
<span>{board.open_tasks_count} открыто</span>
<span className="inline-flex items-center gap-1">
{board.access_scope === "private" ? <LockKeyhole size={11} /> : <Users size={11} />}
{board.access_scope === "private" ? "Личная" : "Общая"}
</span>
</span>
</span>
</button>
);
}
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<BoardUnreadFilter>("all");
const [accessFilter, setAccessFilter] = useState<BoardAccessFilter>("all");
const [collapsed, setCollapsed] = useState<Set<string>>(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 (
<aside className={`app-surface flex min-h-0 flex-col overflow-hidden rounded-2xl ${className}`}>
<div className="flex items-center justify-between gap-2 p-4 pb-0">
<p className="app-card-caption min-w-0 truncate">Доски</p>
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
onClick={() => onWideModeChange(!wideMode)}
className="app-action-ghost hidden h-9 w-9 items-center justify-center rounded-lg lg:flex"
title={wideMode ? "Вернуть обычный вид" : "Развернуть доску"}
aria-label={wideMode ? "Вернуть обычный вид" : "Развернуть доску"}
aria-pressed={wideMode}
>
{wideMode ? <Minimize2 size={17} /> : <Maximize2 size={17} />}
</button>
<button
type="button"
onClick={onCreate}
className="app-action-primary inline-flex h-9 items-center justify-center gap-1.5 rounded-lg px-3 text-sm font-semibold"
>
<Plus size={14} />
Создать доску
</button>
</div>
</div>
<div className="p-4 pb-0">
<div className="mb-4 flex gap-2">
<label className="relative min-w-0 flex-1">
<Search size={16} className="app-text-muted pointer-events-none absolute left-3 top-1/2 -translate-y-1/2" />
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
className="app-input w-full rounded-lg py-2.5 pl-9 pr-3 text-sm"
placeholder="Поиск досок"
aria-label="Поиск досок"
/>
</label>
<button
type="button"
onClick={() => setFiltersOpen((current) => !current)}
className={`relative inline-flex shrink-0 items-center justify-center rounded-lg p-2.5 transition ${
filtersOpen
? "app-selected app-accent-text"
: "app-surface-muted app-text-muted hover:bg-[var(--surface-tertiary)]"
}`}
title="Дополнительные фильтры"
aria-label="Дополнительные фильтры досок"
aria-expanded={filtersOpen}
>
<Filter size={16} />
{activeFiltersCount > 0 ? (
<span className="app-badge app-badge-accent absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full border px-1 text-[10px] font-bold">
{activeFiltersCount}
</span>
) : null}
</button>
</div>
{filtersOpen ? (
<div className="app-surface-muted mb-4 flex flex-col gap-2 rounded-xl p-3">
<select
value={unreadFilter}
onChange={(event) => setUnreadFilter(event.target.value as BoardUnreadFilter)}
className="app-select rounded-lg px-3 py-2 text-sm"
aria-label="Фильтр досок по уведомлениям"
>
<option value="all">Все доски</option>
<option value="unread">Только с непрочитанными</option>
<option value="read">Без непрочитанных</option>
</select>
<select
value={accessFilter}
onChange={(event) => setAccessFilter(event.target.value as BoardAccessFilter)}
className="app-select rounded-lg px-3 py-2 text-sm"
aria-label="Фильтр досок по доступу"
>
<option value="all">Любой доступ</option>
<option value="private">Только личные</option>
<option value="shared">Только общие</option>
</select>
{activeFiltersCount > 0 ? (
<button
type="button"
onClick={() => {
setUnreadFilter("all");
setAccessFilter("all");
}}
className="app-surface-elevated rounded-lg px-3 py-2 text-sm font-medium text-[var(--muted-foreground)] transition hover:bg-[var(--surface-secondary)]"
>
Очистить фильтры
</button>
) : null}
</div>
) : null}
</div>
<div className="tasks-column-scroll min-h-0 flex-1 overflow-y-auto px-4 pb-4">
{sections.length > 0 ? sections.map((section) => {
const isCollapsed = collapsed.has(section.key);
return (
<section key={section.key} className="mb-2 last:mb-0">
<button
type="button"
onClick={() => toggleSection(section.key)}
className="app-action-ghost flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left"
aria-expanded={!isCollapsed}
>
<ChevronDown
size={14}
className={`app-text-muted transition-transform ${isCollapsed ? "-rotate-90" : ""}`}
/>
<span className="app-card-caption min-w-0 flex-1 truncate">{section.label}</span>
<span className="app-text-muted text-[11px]">{section.boards.length}</span>
</button>
{!isCollapsed ? (
<div className="space-y-1">
{section.boards.map((board) => (
<BoardRow
key={board.id}
board={board}
active={selectedBoardId === board.id}
unread={unreadCounts[board.id] || 0}
onSelect={() => onSelect(board.id)}
/>
))}
</div>
) : null}
</section>
);
}) : (
<div className="app-surface-muted m-2 rounded-xl border border-dashed border-[var(--border-subtle)] px-4 py-8 text-center">
<Kanban size={24} className="app-text-muted mx-auto mb-2" />
<p className="text-sm font-medium text-[var(--foreground)]">
{normalizedQuery ? "Доски не найдены" : "Досок пока нет"}
</p>
</div>
)}
</div>
</aside>
);
}

View File

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