refactor task and messenger workspaces
This commit is contained in:
parent
fababde18c
commit
0dad6b84de
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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}
|
||||
|
||||
311
frontend/src/components/tasks/TaskBoardNavigator.tsx
Normal file
311
frontend/src/components/tasks/TaskBoardNavigator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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";
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user