add personal task board groups

This commit is contained in:
lizerk 2026-08-05 16:57:20 +03:00
parent 0dad6b84de
commit 60c82b6d5a
11 changed files with 703 additions and 67 deletions

View File

@ -43,6 +43,7 @@ from tasks.models import (
TaskAttachment,
TaskBoard,
TaskBoardAccessScope,
TaskBoardGroup,
TaskChecklistItem,
TaskColumn,
TaskExternalLink,
@ -486,6 +487,12 @@ class TaskBoardSerializer(serializers.ModelSerializer):
tasks = TaskSerializer(many=True, read_only=True)
tasks_count = serializers.IntegerField(read_only=True, default=0)
is_default_for_current_user = serializers.SerializerMethodField()
group_id = serializers.IntegerField(
source="current_user_group_id",
read_only=True,
allow_null=True,
default=None,
)
class Meta:
model = TaskBoard
@ -505,6 +512,7 @@ class TaskBoardSerializer(serializers.ModelSerializer):
"tasks",
"tasks_count",
"is_default_for_current_user",
"group_id",
"created_at",
"updated_at",
]
@ -558,11 +566,43 @@ class TaskBoardSummarySerializer(TaskBoardSerializer):
"open_tasks_count",
"completed_tasks_count",
"is_default_for_current_user",
"group_id",
"created_at",
"updated_at",
]
class TaskBoardGroupSerializer(serializers.ModelSerializer):
boards_count = serializers.IntegerField(read_only=True, default=0)
class Meta:
model = TaskBoardGroup
fields = [
"id",
"name",
"position",
"boards_count",
"created_at",
"updated_at",
]
read_only_fields = ["id", "created_at", "updated_at"]
def validate_name(self, value):
name = value.strip()
if not name:
raise serializers.ValidationError("Укажите название группы.")
request = self.context.get("request")
queryset = TaskBoardGroup.objects.filter(
user=getattr(request, "user", None),
name__iexact=name,
)
if self.instance:
queryset = queryset.exclude(id=self.instance.id)
if queryset.exists():
raise serializers.ValidationError("Группа с таким названием уже существует.")
return name
class TaskLinkedPostSerializer(serializers.ModelSerializer):
created_by = EmployeeBriefSerializer(read_only=True)
post = serializers.SerializerMethodField()

View File

@ -2,6 +2,7 @@ from django.urls import include, path
from rest_framework.routers import DefaultRouter
from .views import (
TaskBoardGroupViewSet,
TaskBoardViewSet,
TaskColumnViewSet,
TaskLabelViewSet,
@ -11,6 +12,7 @@ from .views import (
app_name = "tasks"
router = DefaultRouter()
router.register(r"board-groups", TaskBoardGroupViewSet, basename="task-board-group")
router.register(r"boards", TaskBoardViewSet, basename="task-board")
router.register(r"columns", TaskColumnViewSet, basename="task-column")
router.register(r"labels", TaskLabelViewSet, basename="task-label")

View File

@ -52,6 +52,8 @@ from tasks.models import (
TaskActivityObjectKind,
TaskAttachment,
TaskBoard,
TaskBoardGroup,
TaskBoardPlacement,
TaskColumn,
TaskExternalLink,
TaskLabel,
@ -79,6 +81,7 @@ from .serializers import (
TaskActivitySerializer,
TaskAttachmentSerializer,
TaskBoardSerializer,
TaskBoardGroupSerializer,
TaskBoardSummarySerializer,
TaskChecklistItemSerializer,
TaskColumnSerializer,
@ -179,12 +182,20 @@ class TaskBoardViewSet(viewsets.ModelViewSet):
return super().get_serializer_class()
def get_queryset(self):
current_group_id = TaskBoardPlacement.objects.filter(
user=self.request.user,
board_id=OuterRef("pk"),
).values("group_id")[:1]
queryset = (
TaskBoard.objects.filter(is_archived=False)
.filter(task_board_access_q(self.request.user))
.select_related("created_by")
.prefetch_related("members", "departments")
.annotate(
current_user_group_id=Subquery(
current_group_id,
output_field=IntegerField(),
),
tasks_count=Count("tasks", distinct=True),
open_tasks_count=Count(
"tasks",
@ -317,6 +328,36 @@ class TaskBoardViewSet(viewsets.ModelViewSet):
serializer = self.get_serializer(self.get_queryset().get(id=board.id))
return Response(serializer.data)
@action(detail=True, methods=["post"], url_path="move-to-group")
def move_to_group(self, request, pk=None):
board = self.get_object()
group_id = request.data.get("group")
if group_id in (None, ""):
TaskBoardPlacement.objects.filter(
user=request.user,
board=board,
).delete()
return Response({"board": board.id, "group_id": None})
try:
group_id = int(group_id)
except (TypeError, ValueError):
return Response(
{"group": ["Некорректная группа."]},
status=status.HTTP_400_BAD_REQUEST,
)
group = get_object_or_404(
TaskBoardGroup,
id=group_id,
user=request.user,
)
TaskBoardPlacement.objects.update_or_create(
user=request.user,
board=board,
defaults={"group": group},
)
return Response({"board": board.id, "group_id": group.id})
@action(detail=True, methods=["post"], url_path="notifications/read")
def mark_notifications_read(self, request, pk=None):
board = self.get_object()
@ -333,6 +374,22 @@ class TaskBoardViewSet(viewsets.ModelViewSet):
)
class TaskBoardGroupViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated]
serializer_class = TaskBoardGroupSerializer
def get_queryset(self):
return (
TaskBoardGroup.objects.filter(user=self.request.user)
.annotate(boards_count=Count("board_placements", distinct=True))
.order_by("position", "name", "id")
)
def perform_create(self, serializer):
max_position = self.get_queryset().aggregate(value=Max("position"))["value"] or 0
serializer.save(user=self.request.user, position=max_position + 1000)
class TaskColumnViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated]
serializer_class = TaskColumnSerializer

View File

@ -5,6 +5,8 @@ from .models import (
TaskActivity,
TaskAttachment,
TaskBoard,
TaskBoardGroup,
TaskBoardPlacement,
TaskChecklistItem,
TaskColumn,
TaskExternalLink,
@ -41,6 +43,20 @@ class TaskBoardAdmin(admin.ModelAdmin):
inlines = [TaskColumnInline, TaskLabelInline]
@admin.register(TaskBoardGroup)
class TaskBoardGroupAdmin(admin.ModelAdmin):
list_display = ["name", "user", "position", "created_at"]
search_fields = ["name", "user__email"]
autocomplete_fields = ["user"]
@admin.register(TaskBoardPlacement)
class TaskBoardPlacementAdmin(admin.ModelAdmin):
list_display = ["board", "group", "user", "updated_at"]
search_fields = ["board__name", "group__name", "user__email"]
autocomplete_fields = ["board", "group", "user"]
@admin.register(TaskColumn)
class TaskColumnAdmin(admin.ModelAdmin):
list_display = ["name", "board", "position", "is_done", "is_archived"]

View File

@ -0,0 +1,55 @@
# Generated by Django 5.2.4 on 2026-08-05 13:46
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tasks', '0019_task_participants'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='TaskBoardGroup',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=120, verbose_name='Название')),
('position', models.PositiveIntegerField(default=0, verbose_name='Позиция')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='task_board_groups', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')),
],
options={
'verbose_name': 'Группа досок задач',
'verbose_name_plural': 'Группы досок задач',
'ordering': ['position', 'name', 'id'],
},
),
migrations.CreateModel(
name='TaskBoardPlacement',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')),
('board', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_placements', to='tasks.taskboard', verbose_name='Доска')),
('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='board_placements', to='tasks.taskboardgroup', verbose_name='Группа')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='task_board_placements', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')),
],
options={
'verbose_name': 'Размещение доски задач',
'verbose_name_plural': 'Размещения досок задач',
},
),
migrations.AddConstraint(
model_name='taskboardgroup',
constraint=models.UniqueConstraint(fields=('user', 'name'), name='uniq_task_board_group_user_name'),
),
migrations.AddConstraint(
model_name='taskboardplacement',
constraint=models.UniqueConstraint(fields=('user', 'board'), name='uniq_task_board_placement_user_board'),
),
]

View File

@ -59,6 +59,69 @@ class TaskBoard(models.Model):
return self.name
class TaskBoardGroup(models.Model):
name = models.CharField("Название", max_length=120)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="task_board_groups",
verbose_name="Пользователь",
)
position = models.PositiveIntegerField("Позиция", default=0)
created_at = models.DateTimeField("Создано", auto_now_add=True)
updated_at = models.DateTimeField("Обновлено", auto_now=True)
class Meta:
verbose_name = "Группа досок задач"
verbose_name_plural = "Группы досок задач"
ordering = ["position", "name", "id"]
constraints = [
models.UniqueConstraint(
fields=["user", "name"],
name="uniq_task_board_group_user_name",
)
]
def __str__(self):
return f"{self.user}: {self.name}"
class TaskBoardPlacement(models.Model):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="task_board_placements",
verbose_name="Пользователь",
)
board = models.ForeignKey(
TaskBoard,
on_delete=models.CASCADE,
related_name="user_placements",
verbose_name="Доска",
)
group = models.ForeignKey(
TaskBoardGroup,
on_delete=models.CASCADE,
related_name="board_placements",
verbose_name="Группа",
)
created_at = models.DateTimeField("Создано", auto_now_add=True)
updated_at = models.DateTimeField("Обновлено", auto_now=True)
class Meta:
verbose_name = "Размещение доски задач"
verbose_name_plural = "Размещения досок задач"
constraints = [
models.UniqueConstraint(
fields=["user", "board"],
name="uniq_task_board_placement_user_board",
)
]
def __str__(self):
return f"{self.user}: {self.board} -> {self.group}"
class TaskUserSettings(models.Model):
user = models.OneToOneField(
settings.AUTH_USER_MODEL,

View File

@ -25,6 +25,8 @@ from tasks.models import (
Task,
TaskAttachment,
TaskBoard,
TaskBoardGroup,
TaskBoardPlacement,
TaskChecklistItem,
TaskColumn,
TaskExternalLink,
@ -165,6 +167,77 @@ def test_board_summary_list_returns_navigation_counts_without_nested_tasks(
assert "columns" not in result
def test_board_groups_are_personal_and_board_placement_is_independent(
api_client,
user,
):
other_user = make_user("task-groups-other@example.com", "+79994440093")
board = TaskBoard.objects.create(name="Общая доска", created_by=user)
api_client.force_authenticate(user=user)
create_response = api_client.post(
reverse("api:v1:tasks:task-board-group-list"),
{"name": "Проекты"},
format="json",
)
assert create_response.status_code == status.HTTP_201_CREATED
group_id = create_response.data["id"]
move_response = api_client.post(
reverse("api:v1:tasks:task-board-move-to-group", kwargs={"pk": board.id}),
{"group": group_id},
format="json",
)
assert move_response.status_code == status.HTTP_200_OK
assert move_response.data["group_id"] == group_id
assert TaskBoardPlacement.objects.filter(
user=user,
board=board,
group_id=group_id,
).exists()
summary_response = api_client.get(
reverse("api:v1:tasks:task-board-list"),
{"view": "summary"},
)
assert summary_response.status_code == status.HTTP_200_OK
assert summary_response.data["results"][0]["group_id"] == group_id
api_client.force_authenticate(user=other_user)
other_groups_response = api_client.get(
reverse("api:v1:tasks:task-board-group-list")
)
assert other_groups_response.status_code == status.HTTP_200_OK
assert other_groups_response.data["results"] == []
other_summary_response = api_client.get(
reverse("api:v1:tasks:task-board-list"),
{"view": "summary"},
)
assert other_summary_response.status_code == status.HTTP_200_OK
assert other_summary_response.data["results"][0]["group_id"] is None
foreign_move_response = api_client.post(
reverse("api:v1:tasks:task-board-move-to-group", kwargs={"pk": board.id}),
{"group": group_id},
format="json",
)
assert foreign_move_response.status_code == status.HTTP_404_NOT_FOUND
api_client.force_authenticate(user=user)
delete_response = api_client.delete(
reverse("api:v1:tasks:task-board-group-detail", kwargs={"pk": group_id})
)
assert delete_response.status_code == status.HTTP_204_NO_CONTENT
assert not TaskBoardGroup.objects.filter(id=group_id).exists()
assert not TaskBoardPlacement.objects.filter(user=user, board=board).exists()
ungrouped_summary_response = api_client.get(
reverse("api:v1:tasks:task-board-list"),
{"view": "summary"},
)
assert ungrouped_summary_response.data["results"][0]["group_id"] is None
def test_user_cannot_set_inaccessible_board_as_default(api_client):
owner = make_user("task-default-owner@example.com", "+79994440091")
outsider = make_user("task-default-outsider@example.com", "+79994440092")

View File

@ -73,6 +73,7 @@ import type {
TaskActivity,
TaskAttachment,
TaskBoard,
TaskBoardGroup,
TaskBoardSummary,
TaskCard,
TaskChecklistItem,
@ -114,6 +115,11 @@ type BoardFormState = {
department_ids: number[];
};
type BoardGroupFormState = {
id: number | null;
name: string;
};
type ColumnFormState = {
id: number | null;
name: string;
@ -171,6 +177,11 @@ const emptyBoardForm: BoardFormState = {
department_ids: [],
};
const emptyBoardGroupForm: BoardGroupFormState = {
id: null,
name: "",
};
const taskDescriptionPreviewClass =
"app-text-muted mt-1 block max-h-8 w-full max-w-[13rem] overflow-hidden whitespace-normal break-all text-xs leading-4";
@ -2103,6 +2114,7 @@ function TasksPageContent() {
const requestedBoardId = Number(searchParams.get("board") || "");
const requestedTaskId = Number(searchParams.get("task") || "");
const [boards, setBoards] = useState<TaskBoardSummary[]>([]);
const [boardGroups, setBoardGroups] = useState<TaskBoardGroup[]>([]);
const [board, setBoard] = useState<TaskBoard | null>(null);
const [selectedBoardId, setSelectedBoardId] = useState<number | null>(null);
const [employees, setEmployees] = useState<User[]>([]);
@ -2131,10 +2143,12 @@ function TasksPageContent() {
const [cardMenuTaskId, setCardMenuTaskId] = useState<number | null>(null);
const [columnMenuId, setColumnMenuId] = useState<number | null>(null);
const [boardModalOpen, setBoardModalOpen] = useState(false);
const [boardGroupModalOpen, setBoardGroupModalOpen] = useState(false);
const [boardActionsMenuOpen, setBoardActionsMenuOpen] = useState(false);
const [columnModalOpen, setColumnModalOpen] = useState(false);
const [form, setForm] = useState<TaskFormState>(emptyForm);
const [boardForm, setBoardForm] = useState<BoardFormState>(emptyBoardForm);
const [boardGroupForm, setBoardGroupForm] = useState<BoardGroupFormState>(emptyBoardGroupForm);
const [boardMemberSearch, setBoardMemberSearch] = useState("");
const [boardDepartmentSearch, setBoardDepartmentSearch] = useState("");
const [columnForm, setColumnForm] = useState<ColumnFormState>(emptyColumnForm);
@ -2281,6 +2295,13 @@ function TasksPageContent() {
return results as TaskBoardSummary[];
}, []);
const loadBoardGroups = useCallback(async () => {
const response = await apiClient.getTaskBoardGroups();
const results = Array.isArray(response) ? response : response.results || [];
setBoardGroups(results as TaskBoardGroup[]);
return results as TaskBoardGroup[];
}, []);
const loadBoard = useCallback(async (boardId?: number | null) => {
setError(null);
const data = boardId
@ -2639,17 +2660,22 @@ function TasksPageContent() {
if (initialLoad) setLoading(true);
else setBoardLoading(true);
try {
const [boardData, boardsData, employeesData, departmentsData] = await Promise.all([
const [boardData, boardsData, boardGroupsData, employeesData, departmentsData] = await Promise.all([
requestedBoardId > 0
? apiClient.getTaskBoard(requestedBoardId)
: apiClient.getDefaultTaskBoard(),
apiClient.getTaskBoards({ view: "summary" }),
apiClient.getTaskBoardGroups(),
apiClient.getEmployees({ limit: 200, is_active: true, ordering: "last_name" }),
apiClient.getDepartments({ limit: 200 }),
]);
if (!mounted) return;
const boardList = Array.isArray(boardsData) ? boardsData : boardsData.results || [];
const groupList = Array.isArray(boardGroupsData)
? boardGroupsData
: boardGroupsData.results || [];
setBoards(boardList as TaskBoardSummary[]);
setBoardGroups(groupList as TaskBoardGroup[]);
setBoard(boardData);
setSelectedBoardId(boardData.id);
if (window.matchMedia("(max-width: 1023px)").matches) {
@ -3205,6 +3231,22 @@ function TasksPageContent() {
setBoardModalOpen(true);
}, []);
const openCreateBoardGroup = useCallback(() => {
setBoardGroupForm(emptyBoardGroupForm);
setBoardGroupModalOpen(true);
}, []);
const openEditBoardGroup = useCallback((group: TaskBoardGroup) => {
setBoardGroupForm({ id: group.id, name: group.name });
setBoardGroupModalOpen(true);
}, []);
const closeBoardGroupModal = useCallback(() => {
if (saving) return;
setBoardGroupModalOpen(false);
setBoardGroupForm(emptyBoardGroupForm);
}, [saving]);
const openEditBoard = useCallback(() => {
if (!board) return;
const memberIds = board.members || [];
@ -3394,6 +3436,69 @@ function TasksPageContent() {
router,
]);
const saveBoardGroup = useCallback(async () => {
const name = boardGroupForm.name.trim();
if (!name || saving) return;
setSaving(true);
setError(null);
try {
if (boardGroupForm.id) {
await apiClient.updateTaskBoardGroup(boardGroupForm.id, { name });
} else {
await apiClient.createTaskBoardGroup({ name });
}
await loadBoardGroups();
setBoardGroupModalOpen(false);
setBoardGroupForm(emptyBoardGroupForm);
} catch (groupError) {
setError(getTaskError(groupError, "Не удалось сохранить группу досок"));
} finally {
setSaving(false);
}
}, [boardGroupForm.id, boardGroupForm.name, loadBoardGroups, saving]);
const deleteBoardGroup = useCallback(async (group: TaskBoardGroup) => {
if (saving) return;
if (!window.confirm(`Удалить группу «${group.name}»? Доски останутся в разделе «Без группы».`)) {
return;
}
setSaving(true);
setError(null);
try {
await apiClient.deleteTaskBoardGroup(group.id);
await Promise.all([loadBoardGroups(), loadBoards()]);
} catch (groupError) {
setError(getTaskError(groupError, "Не удалось удалить группу досок"));
} finally {
setSaving(false);
}
}, [loadBoardGroups, loadBoards, saving]);
const moveBoardToGroup = useCallback(async (boardId: number, groupId: number | null) => {
const previousGroupId = boards.find((item) => item.id === boardId)?.group_id || null;
if (previousGroupId === groupId || saving) return;
setBoards((current) => current.map((item) => (
item.id === boardId ? { ...item, group_id: groupId } : item
)));
setBoard((current) => current?.id === boardId ? { ...current, group_id: groupId } : current);
setSaving(true);
setError(null);
try {
await apiClient.moveTaskBoardToGroup(boardId, groupId);
await loadBoardGroups();
} catch (groupError) {
setBoards((current) => current.map((item) => (
item.id === boardId ? { ...item, group_id: previousGroupId } : item
)));
setBoard((current) => current?.id === boardId
? { ...current, group_id: previousGroupId }
: current);
setError(getTaskError(groupError, "Не удалось переместить доску"));
} finally {
setSaving(false);
}
}, [boards, loadBoardGroups, saving]);
const saveColumn = useCallback(async () => {
if (!board || !columnForm.name.trim()) return;
setSaving(true);
@ -4474,11 +4579,15 @@ function TasksPageContent() {
<div className={`min-h-0 gap-4 ${desktopWideMode ? "lg:flex lg:flex-1" : ""}`}>
<TaskBoardNavigator
boards={boards}
groups={boardGroups}
selectedBoardId={selectedBoardId}
currentUserId={user?.id}
unreadCounts={unreadTaskBoardCounts}
onSelect={selectBoard}
onCreate={openCreateBoard}
onCreateGroup={openCreateBoardGroup}
onEditGroup={openEditBoardGroup}
onDeleteGroup={deleteBoardGroup}
onMoveBoard={moveBoardToGroup}
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" : ""}`}
@ -4957,6 +5066,52 @@ function TasksPageContent() {
</div>
</Modal>
<Modal
isOpen={boardGroupModalOpen}
onClose={closeBoardGroupModal}
title={boardGroupForm.id ? "Редактировать группу" : "Новая группа досок"}
size="sm"
closeOnClickOutside
footer={(
<div className="flex justify-end gap-2">
<button
type="button"
onClick={closeBoardGroupModal}
className="app-action-secondary rounded-xl px-4 py-2 text-sm font-medium"
disabled={saving}
>
Отмена
</button>
<button
type="button"
onClick={() => void saveBoardGroup()}
disabled={saving || !boardGroupForm.name.trim()}
className="app-action-primary inline-flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-medium disabled:opacity-60"
>
{saving ? <Loader2 size={16} className="animate-spin" /> : null}
{boardGroupForm.id ? "Сохранить" : "Создать"}
</button>
</div>
)}
>
<label className="block">
<span className="app-text-muted mb-1 block text-xs font-medium">Название группы</span>
<input
value={boardGroupForm.name}
onChange={(event) => setBoardGroupForm((current) => ({
...current,
name: event.target.value,
}))}
onKeyDown={(event) => {
if (event.key === "Enter") void saveBoardGroup();
}}
className="app-input w-full rounded-xl px-3 py-2 text-sm"
autoFocus
maxLength={120}
/>
</label>
</Modal>
<Modal
isOpen={columnModalOpen}
onClose={closeColumnModal}

View File

@ -1,24 +1,29 @@
"use client";
import {
Check,
ChevronDown,
Filter,
FolderPlus,
Kanban,
LockKeyhole,
Maximize2,
Minimize2,
Plus,
Pencil,
Search,
Star,
Trash2,
Users,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import type { TaskBoardSummary } from "@/types/api";
import type { TaskBoardGroup, TaskBoardSummary } from "@/types/api";
type BoardSection = {
key: string;
label: string;
groupId: number | null;
boards: TaskBoardSummary[];
};
@ -27,11 +32,15 @@ type BoardAccessFilter = "all" | "private" | "shared";
type TaskBoardNavigatorProps = {
boards: TaskBoardSummary[];
groups: TaskBoardGroup[];
selectedBoardId: number | null;
currentUserId?: number;
unreadCounts: Record<number, number>;
onSelect: (boardId: number) => void;
onCreate: () => void;
onCreateGroup: () => void;
onEditGroup: (group: TaskBoardGroup) => void;
onDeleteGroup: (group: TaskBoardGroup) => void;
onMoveBoard: (boardId: number, groupId: number | null) => void;
wideMode: boolean;
onWideModeChange: (enabled: boolean) => void;
className?: string;
@ -53,24 +62,32 @@ function matchesBoard(board: TaskBoardSummary, query: string) {
function buildSections(
boards: TaskBoardSummary[],
currentUserId: number | undefined,
groups: TaskBoardGroup[],
query: string,
): BoardSection[] {
const filtered = query
? boards.filter((board) => matchesBoard(board, query))
: boards;
if (query) return [{ key: "search", label: "Результаты", boards: filtered }];
const defaultBoards = filtered.filter((board) => board.is_default_for_current_user);
const remaining = filtered.filter((board) => !board.is_default_for_current_user);
const ownBoards = remaining.filter((board) => board.created_by?.id === currentUserId);
const sharedBoards = remaining.filter((board) => board.created_by?.id !== currentUserId);
if (query) {
return filtered.length > 0
? [{ key: "search", label: "Результаты", groupId: null, boards: filtered }]
: [];
}
return [
{ key: "default", label: "По умолчанию", boards: defaultBoards },
{ key: "own", label: "Мои доски", boards: ownBoards },
{ key: "shared", label: "Доступные", boards: sharedBoards },
].filter((section) => section.boards.length > 0);
{
key: "ungrouped",
label: "Без группы",
groupId: null,
boards: filtered.filter((board) => !board.group_id),
},
...groups.map((group) => ({
key: `group-${group.id}`,
label: group.name,
groupId: group.id,
boards: filtered.filter((board) => board.group_id === group.id),
})),
];
}
function BoardRow({
@ -78,62 +95,105 @@ function BoardRow({
active,
unread,
onSelect,
groups,
menuOpen,
onToggleMenu,
onMove,
}: {
board: TaskBoardSummary;
active: boolean;
unread: number;
onSelect: () => void;
groups: TaskBoardGroup[];
menuOpen: boolean;
onToggleMenu: () => void;
onMove: (groupId: number | null) => void;
}) {
return (
<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}
<div className="relative">
<button
type="button"
onClick={onSelect}
className={`group flex w-full items-start gap-3 rounded-lg px-3 py-2.5 pr-10 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.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}
{board.description ? (
<span className="app-text-muted mt-0.5 line-clamp-2 block text-xs">
{board.description}
</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 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>
</span>
</button>
</button>
<button
type="button"
onClick={onToggleMenu}
className="app-action-ghost absolute right-2 top-2 flex h-7 w-7 items-center justify-center rounded-md"
title="Переместить доску"
aria-label={`Переместить доску «${board.name}»`}
aria-expanded={menuOpen}
>
<ChevronDown size={14} className={`transition-transform ${menuOpen ? "rotate-180" : ""}`} />
</button>
{menuOpen ? (
<div className="app-surface-elevated absolute right-2 top-10 z-30 min-w-52 overflow-hidden rounded-lg border border-[var(--border-subtle)] py-1 shadow-xl">
<p className="app-card-caption px-3 py-2">Переместить в</p>
{[{ id: null, name: "Без группы" }, ...groups].map((group) => {
const selected = (board.group_id || null) === group.id;
return (
<button
key={group.id ?? "ungrouped"}
type="button"
onClick={() => onMove(group.id)}
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)]"
>
<Check size={14} className={selected ? "app-accent-text" : "invisible"} />
<span className="min-w-0 flex-1 truncate">{group.name}</span>
</button>
);
})}
</div>
) : null}
</div>
);
}
export default function TaskBoardNavigator({
boards,
groups,
selectedBoardId,
currentUserId,
unreadCounts,
onSelect,
onCreate,
onCreateGroup,
onEditGroup,
onDeleteGroup,
onMoveBoard,
wideMode,
onWideModeChange,
className = "",
@ -143,6 +203,9 @@ export default function TaskBoardNavigator({
const [unreadFilter, setUnreadFilter] = useState<BoardUnreadFilter>("all");
const [accessFilter, setAccessFilter] = useState<BoardAccessFilter>("all");
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const [boardMenuId, setBoardMenuId] = useState<number | null>(null);
const [groupMenuId, setGroupMenuId] = useState<number | null>(null);
const menusRef = useRef<HTMLDivElement>(null);
const normalizedQuery = query.trim().toLowerCase();
const filteredBoards = useMemo(() => boards.filter((board) => {
const unread = unreadCounts[board.id] || 0;
@ -153,8 +216,8 @@ export default function TaskBoardNavigator({
return true;
}), [accessFilter, boards, unreadCounts, unreadFilter]);
const sections = useMemo(
() => buildSections(filteredBoards, currentUserId, normalizedQuery),
[currentUserId, filteredBoards, normalizedQuery],
() => buildSections(filteredBoards, groups, normalizedQuery),
[filteredBoards, groups, normalizedQuery],
);
const activeFiltersCount = [unreadFilter !== "all", accessFilter !== "all"].filter(Boolean).length;
@ -167,6 +230,18 @@ export default function TaskBoardNavigator({
});
};
useEffect(() => {
if (boardMenuId === null && groupMenuId === null) return undefined;
const closeMenus = (event: MouseEvent) => {
if (!menusRef.current?.contains(event.target as Node)) {
setBoardMenuId(null);
setGroupMenuId(null);
}
};
document.addEventListener("mousedown", closeMenus);
return () => document.removeEventListener("mousedown", closeMenus);
}, [boardMenuId, groupMenuId]);
return (
<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">
@ -182,6 +257,15 @@ export default function TaskBoardNavigator({
>
{wideMode ? <Minimize2 size={17} /> : <Maximize2 size={17} />}
</button>
<button
type="button"
onClick={onCreateGroup}
className="app-action-ghost flex h-9 w-9 items-center justify-center rounded-lg"
title="Создать группу"
aria-label="Создать группу досок"
>
<FolderPlus size={17} />
</button>
<button
type="button"
onClick={onCreate}
@ -264,24 +348,66 @@ export default function TaskBoardNavigator({
) : null}
</div>
<div className="tasks-column-scroll min-h-0 flex-1 overflow-y-auto px-4 pb-4">
<div ref={menusRef} 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>
<div className="relative flex items-center gap-1">
<button
type="button"
onClick={() => toggleSection(section.key)}
className="app-action-ghost flex min-w-0 flex-1 items-center gap-2 rounded-lg px-2 py-2 text-left"
aria-expanded={!isCollapsed}
>
<ChevronDown
size={14}
className={`app-text-muted shrink-0 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>
{section.groupId !== null ? (
<button
type="button"
onClick={() => setGroupMenuId((current) => current === section.groupId ? null : section.groupId)}
className="app-action-ghost flex h-8 w-8 shrink-0 items-center justify-center rounded-lg"
title="Управление группой"
aria-label={`Управление группой «${section.label}»`}
aria-expanded={groupMenuId === section.groupId}
>
<ChevronDown size={14} className={`transition-transform ${groupMenuId === section.groupId ? "rotate-180" : ""}`} />
</button>
) : null}
{groupMenuId === section.groupId && section.groupId !== null ? (
<div className="app-surface-elevated absolute right-0 top-9 z-30 min-w-44 overflow-hidden rounded-lg border border-[var(--border-subtle)] py-1 shadow-xl">
<button
type="button"
onClick={() => {
const group = groups.find((item) => item.id === section.groupId);
if (group) onEditGroup(group);
setGroupMenuId(null);
}}
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)]"
>
<Pencil size={14} />
Редактировать
</button>
<button
type="button"
onClick={() => {
const group = groups.find((item) => item.id === section.groupId);
if (group) onDeleteGroup(group);
setGroupMenuId(null);
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--danger-foreground)] transition hover:bg-[var(--danger-soft)]"
>
<Trash2 size={14} />
Удалить
</button>
</div>
) : null}
</div>
{!isCollapsed ? (
<div className="space-y-1">
{section.boards.map((board) => (
@ -291,8 +417,18 @@ export default function TaskBoardNavigator({
active={selectedBoardId === board.id}
unread={unreadCounts[board.id] || 0}
onSelect={() => onSelect(board.id)}
groups={groups}
menuOpen={boardMenuId === board.id}
onToggleMenu={() => setBoardMenuId((current) => current === board.id ? null : board.id)}
onMove={(groupId) => {
onMoveBoard(board.id, groupId);
setBoardMenuId(null);
}}
/>
))}
{section.boards.length === 0 ? (
<p className="app-text-muted px-3 py-3 text-xs">В группе пока нет досок</p>
) : null}
</div>
) : null}
</section>

View File

@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type {
TaskBoard,
TaskBoardGroup,
TaskCard,
TaskColumn,
TaskActivity,
@ -54,8 +55,35 @@ export function createTasksApi(request: RequestFn, requestRaw: RawRequestFn) {
}),
deleteTaskBoard: (id: number): Promise<void> =>
request(`/api/v1/tasks/boards/${id}/`, { method: "DELETE" }),
moveTaskBoardToGroup: (
id: number,
group: number | null,
): Promise<{ board: number; group_id: number | null }> =>
request(`/api/v1/tasks/boards/${id}/move-to-group/`, {
method: "POST",
body: JSON.stringify({ group }),
}),
setDefaultTaskBoard: (id: number): Promise<TaskBoard> =>
request(`/api/v1/tasks/boards/${id}/set-default/`, { method: "POST" }),
getTaskBoardGroups: (): Promise<
TaskBoardGroup[] | { results: TaskBoardGroup[] }
> =>
request("/api/v1/tasks/board-groups/"),
createTaskBoardGroup: (data: { name: string }): Promise<TaskBoardGroup> =>
request("/api/v1/tasks/board-groups/", {
method: "POST",
body: JSON.stringify(data),
}),
updateTaskBoardGroup: (
id: number,
data: { name: string },
): Promise<TaskBoardGroup> =>
request(`/api/v1/tasks/board-groups/${id}/`, {
method: "PATCH",
body: JSON.stringify(data),
}),
deleteTaskBoardGroup: (id: number): Promise<void> =>
request(`/api/v1/tasks/board-groups/${id}/`, { method: "DELETE" }),
markTaskBoardNotificationsRead: (
id: number,
): Promise<{ status: string; count: number; notification_ids: number[] }> =>

View File

@ -246,6 +246,7 @@ export interface TaskBoard {
tasks: TaskCard[];
tasks_count?: number;
is_default_for_current_user?: boolean;
group_id?: number | null;
created_at: string;
updated_at: string;
}
@ -262,6 +263,16 @@ export interface TaskBoardSummary {
open_tasks_count: number;
completed_tasks_count: number;
is_default_for_current_user?: boolean;
group_id?: number | null;
created_at: string;
updated_at: string;
}
export interface TaskBoardGroup {
id: number;
name: string;
position: number;
boards_count: number;
created_at: string;
updated_at: string;
}