add personal task board groups
This commit is contained in:
parent
0dad6b84de
commit
60c82b6d5a
@ -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()
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -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,
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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[] }> =>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user