diff --git a/backend/api/v1/tasks/serializers/templates.py b/backend/api/v1/tasks/serializers/templates.py new file mode 100644 index 00000000..dcf97d80 --- /dev/null +++ b/backend/api/v1/tasks/serializers/templates.py @@ -0,0 +1,65 @@ +"""Validated board template library input and output.""" + +from rest_framework import serializers + +from tasks.access import task_board_access_q +from tasks.models import TaskBoard, TaskBoardTemplate +from tasks.templates import snapshot_board_structure + + +class TaskBoardTemplateSerializer(serializers.ModelSerializer): + source_board_id = serializers.IntegerField(write_only=True, required=False) + created_by_id = serializers.IntegerField(read_only=True) + + class Meta: + model = TaskBoardTemplate + fields = [ + "id", + "name", + "description", + "is_shared", + "created_by_id", + "source_board_id", + "layout", + "created_at", + "updated_at", + ] + read_only_fields = ["id", "layout", "created_at", "updated_at", "created_by_id"] + + def validate(self, attrs): + attrs = super().validate(attrs) + user = self.context["request"].user + if self.instance is None: + board_id = attrs.pop("source_board_id", None) + if board_id is None: + raise serializers.ValidationError( + {"source_board_id": "Выберите доску."} + ) + board = ( + TaskBoard.objects.filter(id=board_id, is_archived=False) + .filter(task_board_access_q(user)) + .first() + ) + if board is None: + raise serializers.ValidationError( + {"source_board_id": "Доска не найдена."} + ) + self._source_board = board + if attrs.get( + "is_shared", + self.instance.is_shared if self.instance else False, + ) and not (user.is_staff or user.is_superuser): + raise serializers.ValidationError( + {"is_shared": "Публиковать общие шаблоны может только администратор."} + ) + return attrs + + def create(self, validated_data): + return TaskBoardTemplate.objects.create( + layout=snapshot_board_structure(self._source_board), + **validated_data, + ) + + +class TaskBoardTemplateInstantiateSerializer(serializers.Serializer): + name = serializers.CharField(max_length=255, required=False, trim_whitespace=True) diff --git a/backend/api/v1/tasks/urls.py b/backend/api/v1/tasks/urls.py index 0a586803..ce8b4a2a 100644 --- a/backend/api/v1/tasks/urls.py +++ b/backend/api/v1/tasks/urls.py @@ -4,6 +4,7 @@ from rest_framework.routers import DefaultRouter from .viewsets import ( TaskAutomationViewSet, TaskBoardGroupViewSet, + TaskBoardTemplateViewSet, TaskBoardViewSet, TaskColumnRowViewSet, TaskColumnViewSet, @@ -17,6 +18,9 @@ router = DefaultRouter() router.register(r"automations", TaskAutomationViewSet, basename="task-automation") router.register(r"board-groups", TaskBoardGroupViewSet, basename="task-board-group") router.register(r"boards", TaskBoardViewSet, basename="task-board") +router.register( + r"board-templates", TaskBoardTemplateViewSet, basename="task-board-template" +) router.register(r"columns", TaskColumnViewSet, basename="task-column") router.register(r"column-rows", TaskColumnRowViewSet, basename="task-column-row") router.register(r"labels", TaskLabelViewSet, basename="task-label") diff --git a/backend/api/v1/tasks/viewsets/__init__.py b/backend/api/v1/tasks/viewsets/__init__.py index fc412cf9..745014dc 100644 --- a/backend/api/v1/tasks/viewsets/__init__.py +++ b/backend/api/v1/tasks/viewsets/__init__.py @@ -2,10 +2,12 @@ from .automation import TaskAutomationViewSet from .boards import TaskBoardGroupViewSet, TaskBoardViewSet from .structure import TaskColumnRowViewSet, TaskColumnViewSet, TaskLabelViewSet from .tasks import TaskViewSet +from .templates import TaskBoardTemplateViewSet __all__ = [ "TaskAutomationViewSet", "TaskBoardGroupViewSet", + "TaskBoardTemplateViewSet", "TaskBoardViewSet", "TaskColumnRowViewSet", "TaskColumnViewSet", diff --git a/backend/api/v1/tasks/viewsets/templates.py b/backend/api/v1/tasks/viewsets/templates.py new file mode 100644 index 00000000..21549b2a --- /dev/null +++ b/backend/api/v1/tasks/viewsets/templates.py @@ -0,0 +1,66 @@ +"""Private and company-wide reusable task board templates.""" + +from django.db.models import Q +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.exceptions import PermissionDenied +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from tasks.models import TaskBoardTemplate +from tasks.realtime import send_task_board_update +from tasks.templates import create_board_from_template + +from ..serializers.boards import TaskBoardSerializer +from ..serializers.templates import ( + TaskBoardTemplateInstantiateSerializer, + TaskBoardTemplateSerializer, +) + + +class TaskBoardTemplateViewSet(viewsets.ModelViewSet): + permission_classes = [IsAuthenticated] + serializer_class = TaskBoardTemplateSerializer + + def get_queryset(self): + return ( + TaskBoardTemplate.objects.filter( + Q(is_shared=True) | Q(created_by=self.request.user) + ) + .select_related("created_by") + .distinct() + ) + + def get_object(self): + template = super().get_object() + if self.action in {"update", "partial_update", "destroy"}: + self._require_owner(template) + return template + + def perform_create(self, serializer): + serializer.save(created_by=self.request.user) + + def _require_owner(self, template): + if template.created_by_id != self.request.user.pk: + raise PermissionDenied("Изменять шаблон может только его автор.") + + def perform_update(self, serializer): + self._require_owner(serializer.instance) + serializer.save() + + def perform_destroy(self, instance): + self._require_owner(instance) + instance.delete() + + @action(detail=True, methods=["post"], url_path="instantiate") + def instantiate(self, request, pk=None): + template = self.get_object() + serializer = TaskBoardTemplateInstantiateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + name = serializer.validated_data.get("name") or template.name + board = create_board_from_template(template, owner=request.user, name=name) + send_task_board_update(board, "created", "board", board.id) + return Response( + TaskBoardSerializer(board, context={"request": request}).data, + status=status.HTTP_201_CREATED, + ) diff --git a/backend/tasks/migrations/0043_taskboardtemplate.py b/backend/tasks/migrations/0043_taskboardtemplate.py new file mode 100644 index 00000000..8293967c --- /dev/null +++ b/backend/tasks/migrations/0043_taskboardtemplate.py @@ -0,0 +1,59 @@ +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("tasks", "0042_alter_taskactivity_object_kind_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="TaskBoardTemplate", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255, verbose_name="Название")), + ("description", models.TextField(blank=True, verbose_name="Описание")), + ( + "is_shared", + models.BooleanField(default=False, verbose_name="Общий шаблон"), + ), + ( + "layout", + models.JSONField(default=dict, verbose_name="Структура доски"), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Создано"), + ), + ( + "updated_at", + models.DateTimeField(auto_now=True, verbose_name="Обновлено"), + ), + ( + "created_by", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="task_board_templates", + to=settings.AUTH_USER_MODEL, + verbose_name="Автор", + ), + ), + ], + options={ + "verbose_name": "Шаблон доски задач", + "verbose_name_plural": "Шаблоны досок задач", + "ordering": ["name", "id"], + }, + ) + ] diff --git a/backend/tasks/models.py b/backend/tasks/models.py index 40205f6e..086101ed 100644 --- a/backend/tasks/models.py +++ b/backend/tasks/models.py @@ -1016,3 +1016,28 @@ class TaskCover(models.Model): raise ValidationError( "Комментарий обложки должен принадлежать этой задаче." ) + + +class TaskBoardTemplate(models.Model): + """Reusable board structure, independent of the source board and its tasks.""" + + name = models.CharField("Название", max_length=255) + description = models.TextField("Описание", blank=True) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="task_board_templates", + verbose_name="Автор", + ) + is_shared = models.BooleanField("Общий шаблон", default=False) + layout = models.JSONField("Структура доски", default=dict) + created_at = models.DateTimeField("Создано", auto_now_add=True) + updated_at = models.DateTimeField("Обновлено", auto_now=True) + + class Meta: + verbose_name = "Шаблон доски задач" + verbose_name_plural = "Шаблоны досок задач" + ordering = ["name", "id"] + + def __str__(self): + return self.name diff --git a/backend/tasks/templates.py b/backend/tasks/templates.py new file mode 100644 index 00000000..51d9d648 --- /dev/null +++ b/backend/tasks/templates.py @@ -0,0 +1,90 @@ +"""Capture and restore board layouts without copying tasks or access grants.""" + +from django.db import transaction + +from .models import ( + TaskBoard, + TaskBoardAccessScope, + TaskColumn, + TaskColumnRow, +) + + +def snapshot_board_structure(board: TaskBoard) -> dict: + """Capture visible columns, nested columns and swimlanes in stable order.""" + columns = list( + TaskColumn.objects.filter(board=board, is_archived=False).order_by( + "position", "id" + ) + ) + rows = list( + TaskColumnRow.objects.filter( + column__board=board, column__is_archived=False + ).order_by("position", "id") + ) + children_by_parent: dict[int | None, list[TaskColumn]] = {} + for column in columns: + children_by_parent.setdefault(column.parent_id, []).append(column) + rows_by_column: dict[int, list[TaskColumnRow]] = {} + for row in rows: + rows_by_column.setdefault(row.column_id, []).append(row) + + seen: set[int] = set() + + def serialize(column: TaskColumn) -> dict: + if column.id in seen: + return {} + seen.add(column.id) + return { + "name": column.name, + "position": column.position, + "color": column.color, + "is_done": column.is_done, + "rows": [ + {"name": row.name, "position": row.position, "color": row.color} + for row in rows_by_column.get(column.id, []) + ], + "subcolumns": [ + value + for child in children_by_parent.get(column.id, []) + if (value := serialize(child)) + ], + } + + return { + "columns": [serialize(column) for column in children_by_parent.get(None, [])] + } + + +@transaction.atomic +def create_board_from_template(template, *, owner, name: str) -> TaskBoard: + """Instantiate a private board so a shared template cannot leak user data.""" + board = TaskBoard.objects.create( + name=name, + description=template.description, + created_by=owner, + access_scope=TaskBoardAccessScope.PRIVATE, + ) + + def create_column(spec: dict, parent: TaskColumn | None = None) -> None: + column = TaskColumn.objects.create( + board=board, + parent=parent, + name=spec["name"], + position=spec.get("position", 0), + color=spec.get("color", ""), + is_done=bool(spec.get("is_done", False)), + ) + for row in spec.get("rows", []): + TaskColumnRow.objects.create( + column=column, + name=row["name"], + position=row.get("position", 0), + color=row.get("color", ""), + ) + for child in spec.get("subcolumns", []): + create_column(child, column) + + for column_spec in template.layout.get("columns", []): + create_column(column_spec) + return board diff --git a/backend/tests/api/v1/tasks/test_board_templates.py b/backend/tests/api/v1/tasks/test_board_templates.py new file mode 100644 index 00000000..d55131e0 --- /dev/null +++ b/backend/tests/api/v1/tasks/test_board_templates.py @@ -0,0 +1,165 @@ +"""Board template library, permissions and safe board instantiation.""" + +import pytest +from django.contrib.auth import get_user_model +from django.urls import reverse +from rest_framework.test import APIClient + +from tasks.models import ( + Task, + TaskBoard, + TaskBoardAccessScope, + TaskBoardTemplate, + TaskColumn, + TaskColumnRow, +) + +pytestmark = pytest.mark.django_db + + +def make_user(number, *, is_staff=False): + return get_user_model().objects.create_user( + email=f"template-user-{number}@example.com", + phone_number=f"+7999555{number:04d}", + password="testpass123", + first_name="Тест", + last_name="Шаблон", + is_active=True, + is_staff=is_staff, + email_verified=True, + send_activation_email=False, + ) + + +def test_personal_template_preserves_layout_but_not_tasks_or_access(): + owner = make_user(1) + client = APIClient() + client.force_authenticate(user=owner) + source = TaskBoard.objects.create(name="Квартальный план", created_by=owner) + todo = TaskColumn.objects.create( + board=source, name="План", position=1000, color="#445566" + ) + TaskColumnRow.objects.create( + column=todo, name="Срочно", position=1000, color="#aabbcc" + ) + TaskColumn.objects.create(board=source, parent=todo, name="Проверка", position=2000) + TaskColumn.objects.create( + board=source, name="Выполнено", position=2000, is_done=True + ) + Task.objects.create( + board=source, column=todo, title="Секретная задача", created_by=owner + ) + + created = client.post( + reverse("api:v1:tasks:task-board-template-list"), + { + "name": "Шаблон квартала", + "description": "Проверенный процесс", + "source_board_id": source.pk, + }, + format="json", + ) + assert created.status_code == 201, created.data + template_id = created.data["id"] + assert created.data["is_shared"] is False + assert len(created.data["layout"]["columns"]) == 2 + + generated = client.post( + reverse( + "api:v1:tasks:task-board-template-instantiate", + kwargs={"pk": template_id}, + ), + {"name": "Q4"}, + format="json", + ) + assert generated.status_code == 201, generated.data + board = TaskBoard.objects.get(id=generated.data["id"]) + assert board.name == "Q4" + assert board.access_scope == TaskBoardAccessScope.PRIVATE + assert board.created_by == owner + assert not board.tasks.exists() + assert not board.members.exists() + assert not board.departments.exists() + top_columns = list(board.columns.filter(parent__isnull=True).order_by("position")) + assert [column.name for column in top_columns] == ["План", "Выполнено"] + assert top_columns[0].color == "#445566" + assert top_columns[1].is_done + assert list(top_columns[0].subcolumns.values_list("name", flat=True)) == [ + "Проверка" + ] + assert list(top_columns[0].rows.values_list("name", flat=True)) == ["Срочно"] + + +def test_only_owner_sees_private_templates_and_only_staff_can_publish(): + owner, stranger, admin = make_user(2), make_user(3), make_user(4, is_staff=True) + source = TaskBoard.objects.create( + name="Приватный источник", + created_by=owner, + access_scope=TaskBoardAccessScope.PRIVATE, + ) + owner_client = APIClient() + owner_client.force_authenticate(user=owner) + stranger_client = APIClient() + stranger_client.force_authenticate(user=stranger) + admin_client = APIClient() + admin_client.force_authenticate(user=admin) + list_url = reverse("api:v1:tasks:task-board-template-list") + + not_allowed = stranger_client.post( + list_url, {"name": "Чужой", "source_board_id": source.id}, format="json" + ) + assert not_allowed.status_code == 400 + + nonstaff_shared = owner_client.post( + list_url, + {"name": "Всем", "source_board_id": source.id, "is_shared": True}, + format="json", + ) + assert nonstaff_shared.status_code == 400 + + personal = owner_client.post( + list_url, {"name": "Личный", "source_board_id": source.id}, format="json" + ) + assert personal.status_code == 201 + personal_id = personal.data["id"] + assert ( + stranger_client.get( + reverse("api:v1:tasks:task-board-template-detail", args=[personal_id]) + ).status_code + == 404 + ) + + company_source = TaskBoard.objects.create(name="Командный", created_by=admin) + shared = admin_client.post( + list_url, + { + "name": "Командный шаблон", + "source_board_id": company_source.id, + "is_shared": True, + }, + format="json", + ) + assert shared.status_code == 201, shared.data + shared_id = shared.data["id"] + assert ( + stranger_client.get( + reverse("api:v1:tasks:task-board-template-detail", args=[shared_id]) + ).status_code + == 200 + ) + assert ( + stranger_client.patch( + reverse("api:v1:tasks:task-board-template-detail", args=[shared_id]), + {"name": "Подмена"}, + format="json", + ).status_code + == 403 + ) + generated = stranger_client.post( + reverse("api:v1:tasks:task-board-template-instantiate", args=[shared_id]), + {}, + format="json", + ) + assert generated.status_code == 201 + assert generated.data["access_scope"] == TaskBoardAccessScope.PRIVATE + assert TaskBoardTemplate.objects.count() == 2 diff --git a/docs/guides/API.md b/docs/guides/API.md index 102801b4..dc027d26 100644 --- a/docs/guides/API.md +++ b/docs/guides/API.md @@ -199,3 +199,29 @@ curl -fsS http://localhost:8000/api/schema/ -H 'Accept: application/json' - Повторяющиеся refresh-запросы: используйте общий `apiClient`, в нём refresh уже сериализован. - CORS в локальной разработке: browser должен обращаться к Next.js `/api/...`; прямой backend-доступ разрешён только origin-ам из `CORS_ALLOWED_ORIGINS`. - Ошибка загрузки файла: не задавайте multipart `Content-Type` вручную. + +## Task board templates + +Authenticated employees can save the *structure* of an accessible, non-archived +task board as a personal template. Staff and superusers can also publish templates +into the company-wide library. Templates contain active columns, nested columns +and their rows (including names, colors and positions); they do not contain tasks, +members, attachments, comments, automations or board permissions. + +- `GET /api/v1/tasks/board-templates/` — list the current user's personal templates + and all company-wide templates. +- `POST /api/v1/tasks/board-templates/` — create a snapshot from an accessible + board. Example: `{"name":"Sprint","source_board_id":42,"is_shared":false}`. + Setting `is_shared: true` requires staff or superuser privileges. +- `PATCH /api/v1/tasks/board-templates/{id}/` — rename or change the description + of an owned template; only administrators can mark it as shared. +- `DELETE /api/v1/tasks/board-templates/{id}/` — delete an owned template. +- `POST /api/v1/tasks/board-templates/{id}/instantiate/` — create a new board + from any visible template. Optional JSON: `{"name":"Sprint 24"}`. + The new board always belongs to the caller and starts with `access_scope: + "private"`, no tasks, and no members. The caller may subsequently edit its + access using the normal board update endpoint. + +Templates are independent snapshots: changes to a source board do not +retroactively modify stored templates or boards created from them. Changing the +template's metadata does not change its saved structure. diff --git a/frontend/src/app/tasks/page.tsx b/frontend/src/app/tasks/page.tsx index 337e6709..e5b7238a 100644 --- a/frontend/src/app/tasks/page.tsx +++ b/frontend/src/app/tasks/page.tsx @@ -88,6 +88,7 @@ import { AppShell } from "@/components/AppShell"; import { AutoRevealStickyHeader } from "@/components/ui/AutoRevealStickyHeader"; import TaskBoardAvatar from "@/components/tasks/TaskBoardAvatar"; import TaskBoardNavigator from "@/components/tasks/TaskBoardNavigator"; +import TaskBoardTemplatesModal from "@/components/tasks/TaskBoardTemplatesModal"; import TaskCoverPreview from "@/components/tasks/TaskCoverPreview"; import TaskCommentAttachment from "@/components/tasks/TaskCommentAttachment"; import TaskAutomationManager from "@/components/tasks/TaskAutomationManager"; @@ -4037,6 +4038,7 @@ function TasksPageContent() { const [cardMenuTaskId, setCardMenuTaskId] = useState(null); const [columnMenuId, setColumnMenuId] = useState(null); const [boardModalOpen, setBoardModalOpen] = useState(false); + const [boardTemplatesOpen, setBoardTemplatesOpen] = useState(false); const [boardAvatarCropperImage, setBoardAvatarCropperImage] = useState< string | null >(null); @@ -9019,6 +9021,7 @@ function TasksPageContent() { unreadCounts={unreadTaskBoardCounts} onSelect={selectBoard} onCreate={openCreateBoard} + onOpenTemplates={() => setBoardTemplatesOpen(true)} onCreateGroup={openCreateBoardGroup} onEditGroup={openEditBoardGroup} onManageGroupBoards={openManageBoardGroup} @@ -9961,6 +9964,23 @@ function TasksPageContent() { + setBoardTemplatesOpen(false)} + boards={boards} + currentBoardId={board?.id ?? null} + userId={user?.id ?? null} + canPublish={Boolean(user?.auth?.is_staff || user?.auth?.is_superuser)} + onBoardCreated={async (created) => { + await loadBoards(); + await loadBoard(created.id); + router.push(`/tasks?board=${created.id}`, { scroll: false }); + setSearch(""); + setOnlyMine(false); + setSelectedColumnTarget("all"); + }} + /> + setAutomationModalOpen(false)} diff --git a/frontend/src/components/tasks/TaskBoardNavigator.tsx b/frontend/src/components/tasks/TaskBoardNavigator.tsx index 21f32adc..69134814 100644 --- a/frontend/src/components/tasks/TaskBoardNavigator.tsx +++ b/frontend/src/components/tasks/TaskBoardNavigator.tsx @@ -22,6 +22,7 @@ import { Bell, BellOff, Building2, + BookTemplate, Check, ChevronLeft, ChevronRight, @@ -110,6 +111,7 @@ type TaskBoardNavigatorProps = { unreadCounts: Record; onSelect: (boardId: number) => void; onCreate: () => void; + onOpenTemplates: () => void; onCreateGroup: () => void; onEditGroup: (group: TaskBoardGroup) => void; onManageGroupBoards: (group: TaskBoardGroup) => void; @@ -426,6 +428,7 @@ export default function TaskBoardNavigator({ unreadCounts, onSelect, onCreate, + onOpenTemplates, onCreateGroup, onEditGroup, onManageGroupBoards, @@ -854,6 +857,15 @@ export default function TaskBoardNavigator({ > {wideMode ? : } + } diff --git a/frontend/src/components/tasks/TaskBoardTemplatesModal.tsx b/frontend/src/components/tasks/TaskBoardTemplatesModal.tsx new file mode 100644 index 00000000..3eddb142 --- /dev/null +++ b/frontend/src/components/tasks/TaskBoardTemplatesModal.tsx @@ -0,0 +1,258 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { BookTemplate, Loader2, Plus, Trash2 } from "lucide-react"; + +import { Modal } from "@/components/ui"; +import { apiClient } from "@/lib/api"; +import type { + TaskBoard, + TaskBoardSummary, + TaskBoardTemplate, +} from "@/types/api"; + +type Props = { + isOpen: boolean; + onClose: () => void; + boards: TaskBoardSummary[]; + currentBoardId: number | null; + userId: number | null; + canPublish: boolean; + onBoardCreated: (board: TaskBoard) => Promise; +}; + +function errorMessage(error: unknown, fallback: string): string { + if (error instanceof Error && error.message) return error.message; + return fallback; +} + +export default function TaskBoardTemplatesModal({ + isOpen, + onClose, + boards, + currentBoardId, + userId, + canPublish, + onBoardCreated, +}: Props) { + const [templates, setTemplates] = useState([]); + const [templateName, setTemplateName] = useState(""); + const [sourceBoardId, setSourceBoardId] = useState(0); + const [shared, setShared] = useState(false); + const [loading, setLoading] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!isOpen) return; + let active = true; + setSourceBoardId(currentBoardId ?? boards[0]?.id ?? 0); + setLoading(true); + setError(null); + void apiClient.getTaskBoardTemplates().then( + (response) => { + if (!active) return; + setTemplates(Array.isArray(response) ? response : response.results); + setLoading(false); + }, + (loadError: unknown) => { + if (!active) return; + setError(errorMessage(loadError, "Не удалось загрузить шаблоны")); + setLoading(false); + }, + ); + return () => { + active = false; + }; + }, [isOpen, currentBoardId, boards]); + + const saveTemplate = async () => { + if (busy || !templateName.trim() || !sourceBoardId) return; + setBusy(true); + setError(null); + try { + const created = await apiClient.createTaskBoardTemplate({ + name: templateName.trim(), + source_board_id: sourceBoardId, + is_shared: canPublish && shared, + }); + setTemplates((current) => + [...current, created].sort((a, b) => a.name.localeCompare(b.name)), + ); + setTemplateName(""); + setShared(false); + } catch (saveError) { + setError(errorMessage(saveError, "Не удалось сохранить шаблон")); + } finally { + setBusy(false); + } + }; + + const createFromTemplate = async (template: TaskBoardTemplate) => { + if (busy) return; + setBusy(true); + setError(null); + try { + const created = await apiClient.instantiateTaskBoardTemplate(template.id); + await onBoardCreated(created); + onClose(); + } catch (instantiateError) { + setError(errorMessage(instantiateError, "Не удалось создать доску")); + } finally { + setBusy(false); + } + }; + + const deleteTemplate = async (template: TaskBoardTemplate) => { + if (busy || !window.confirm(`Удалить шаблон «${template.name}»?`)) return; + setBusy(true); + setError(null); + try { + await apiClient.deleteTaskBoardTemplate(template.id); + setTemplates((current) => + current.filter((item) => item.id !== template.id), + ); + } catch (deleteError) { + setError(errorMessage(deleteError, "Не удалось удалить шаблон")); + } finally { + setBusy(false); + } + }; + + return ( + +
+
+
+

Сохранить структуру доски

+

+ Сохраняются колонки, подколонки и дорожки. Задачи, участники и + права доступа не копируются. +

+
+ setTemplateName(event.target.value)} + maxLength={255} + placeholder="Название шаблона" + aria-label="Название шаблона" + /> + + {canPublish ? ( + + ) : ( +

+ Личные шаблоны видны только вам. Общие публикует администратор. +

+ )} + +
+ +
+

Доступные шаблоны

+ {loading ? ( +
+ Загрузка... +
+ ) : templates.length === 0 ? ( +

+ Пока нет шаблонов. Сохраните структуру одной из своих досок. +

+ ) : ( +
+ {templates.map((template) => ( +
+
+
+ + + {template.name} + +
+

+ {template.is_shared ? "Общий" : "Личный"} ·{" "} + {template.layout.columns.length} основных колонок +

+ {template.description ? ( +

+ {template.description} +

+ ) : null} +
+
+ + {template.created_by_id === userId ? ( + + ) : null} +
+
+ ))} +
+ )} +
+ {error ? ( +

+ {error} +

+ ) : null} +
+
+ ); +} diff --git a/frontend/src/lib/api/tasks.ts b/frontend/src/lib/api/tasks.ts index e0709739..9c0363b9 100644 --- a/frontend/src/lib/api/tasks.ts +++ b/frontend/src/lib/api/tasks.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { TaskBoard, + TaskBoardTemplate, TaskBoardViewState, TaskBoardGroup, TaskCard, @@ -42,6 +43,29 @@ function parseDownloadFilename(response: Response, fallback: string): string { export function createTasksApi(request: RequestFn, requestRaw: RawRequestFn) { return { + getTaskBoardTemplates: (): Promise< + TaskBoardTemplate[] | { results: TaskBoardTemplate[] } + > => request("/api/v1/tasks/board-templates/"), + createTaskBoardTemplate: (data: { + name: string; + description?: string; + source_board_id: number; + is_shared?: boolean; + }): Promise => + request("/api/v1/tasks/board-templates/", { + method: "POST", + body: JSON.stringify(data), + }), + deleteTaskBoardTemplate: (id: number): Promise => + request(`/api/v1/tasks/board-templates/${id}/`, { method: "DELETE" }), + instantiateTaskBoardTemplate: ( + id: number, + name?: string, + ): Promise => + request(`/api/v1/tasks/board-templates/${id}/instantiate/`, { + method: "POST", + body: JSON.stringify(name ? { name } : {}), + }), getTaskBoards: (params?: Record) => request(`/api/v1/tasks/boards/${buildQuery(params)}`), getTaskBoard: (id: number): Promise => diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 333fdb06..06e32be3 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -464,6 +464,26 @@ export interface TaskBoardViewState { collapsed_row_ids: number[]; } +export interface TaskBoardTemplate { + id: number; + name: string; + description: string; + is_shared: boolean; + created_by_id: number; + layout: { + columns: Array<{ + name: string; + position: number; + color: string; + is_done: boolean; + rows: Array<{ name: string; position: number; color: string }>; + subcolumns: unknown[]; + }>; + }; + created_at: string; + updated_at: string; +} + export interface TaskBoard { id: number; name: string;