Add private and shared task board template library
This commit is contained in:
parent
4ff1657f3b
commit
de994ade5f
65
backend/api/v1/tasks/serializers/templates.py
Normal file
65
backend/api/v1/tasks/serializers/templates.py
Normal file
@ -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)
|
||||
@ -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")
|
||||
|
||||
@ -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",
|
||||
|
||||
66
backend/api/v1/tasks/viewsets/templates.py
Normal file
66
backend/api/v1/tasks/viewsets/templates.py
Normal file
@ -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,
|
||||
)
|
||||
59
backend/tasks/migrations/0043_taskboardtemplate.py
Normal file
59
backend/tasks/migrations/0043_taskboardtemplate.py
Normal file
@ -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"],
|
||||
},
|
||||
)
|
||||
]
|
||||
@ -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
|
||||
|
||||
90
backend/tasks/templates.py
Normal file
90
backend/tasks/templates.py
Normal file
@ -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
|
||||
165
backend/tests/api/v1/tasks/test_board_templates.py
Normal file
165
backend/tests/api/v1/tasks/test_board_templates.py
Normal file
@ -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
|
||||
@ -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.
|
||||
|
||||
@ -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<number | null>(null);
|
||||
const [columnMenuId, setColumnMenuId] = useState<number | null>(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() {
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<TaskBoardTemplatesModal
|
||||
isOpen={boardTemplatesOpen}
|
||||
onClose={() => 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");
|
||||
}}
|
||||
/>
|
||||
|
||||
<TaskAutomationManager
|
||||
isOpen={automationModalOpen}
|
||||
onClose={() => setAutomationModalOpen(false)}
|
||||
|
||||
@ -22,6 +22,7 @@ import {
|
||||
Bell,
|
||||
BellOff,
|
||||
Building2,
|
||||
BookTemplate,
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
@ -110,6 +111,7 @@ type TaskBoardNavigatorProps = {
|
||||
unreadCounts: Record<number, number>;
|
||||
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 ? <Minimize2 size={17} /> : <Maximize2 size={17} />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenTemplates}
|
||||
className="app-action-secondary flex h-9 items-center gap-1.5 rounded-lg px-2.5 text-xs font-medium"
|
||||
title="Библиотека шаблонов"
|
||||
>
|
||||
<BookTemplate size={16} />
|
||||
<span className="hidden sm:inline">Шаблоны</span>
|
||||
</button>
|
||||
<ModulePrimaryAction
|
||||
onClick={onCreate}
|
||||
icon={<Plus size={14} />}
|
||||
|
||||
258
frontend/src/components/tasks/TaskBoardTemplatesModal.tsx
Normal file
258
frontend/src/components/tasks/TaskBoardTemplatesModal.tsx
Normal file
@ -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<void>;
|
||||
};
|
||||
|
||||
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<TaskBoardTemplate[]>([]);
|
||||
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<string | null>(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 (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
title="Библиотека шаблонов досок"
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<section className="space-y-3 rounded-xl border border-[var(--border-subtle)] p-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Сохранить структуру доски</h3>
|
||||
<p className="app-text-muted mt-1 text-xs">
|
||||
Сохраняются колонки, подколонки и дорожки. Задачи, участники и
|
||||
права доступа не копируются.
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
className="app-input w-full rounded-lg px-3 py-2 text-sm"
|
||||
value={templateName}
|
||||
onChange={(event) => setTemplateName(event.target.value)}
|
||||
maxLength={255}
|
||||
placeholder="Название шаблона"
|
||||
aria-label="Название шаблона"
|
||||
/>
|
||||
<select
|
||||
className="app-input w-full rounded-lg px-3 py-2 text-sm"
|
||||
value={sourceBoardId}
|
||||
onChange={(event) => setSourceBoardId(Number(event.target.value))}
|
||||
aria-label="Исходная доска"
|
||||
>
|
||||
{boards.length === 0 ? (
|
||||
<option value={0}>Нет доступных досок</option>
|
||||
) : null}
|
||||
{boards.map((board) => (
|
||||
<option key={board.id} value={board.id}>
|
||||
{board.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{canPublish ? (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={shared}
|
||||
onChange={(event) => setShared(event.target.checked)}
|
||||
/>
|
||||
Общий шаблон (доступен всем сотрудникам)
|
||||
</label>
|
||||
) : (
|
||||
<p className="app-text-muted text-xs">
|
||||
Личные шаблоны видны только вам. Общие публикует администратор.
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="app-action-primary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium disabled:opacity-50"
|
||||
disabled={busy || !templateName.trim() || !sourceBoardId}
|
||||
onClick={() => void saveTemplate()}
|
||||
>
|
||||
<Plus size={15} />
|
||||
Сохранить шаблон
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">Доступные шаблоны</h3>
|
||||
{loading ? (
|
||||
<div className="app-text-muted flex items-center gap-2 py-4 text-sm">
|
||||
<Loader2 size={16} className="animate-spin" /> Загрузка...
|
||||
</div>
|
||||
) : templates.length === 0 ? (
|
||||
<p className="app-text-muted rounded-lg border border-[var(--border-subtle)] p-4 text-sm">
|
||||
Пока нет шаблонов. Сохраните структуру одной из своих досок.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{templates.map((template) => (
|
||||
<div
|
||||
key={template.id}
|
||||
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-[var(--border-subtle)] p-3"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<BookTemplate
|
||||
size={16}
|
||||
className="app-text-muted shrink-0"
|
||||
/>
|
||||
<span className="truncate text-sm font-semibold">
|
||||
{template.name}
|
||||
</span>
|
||||
</div>
|
||||
<p className="app-text-muted mt-1 text-xs">
|
||||
{template.is_shared ? "Общий" : "Личный"} ·{" "}
|
||||
{template.layout.columns.length} основных колонок
|
||||
</p>
|
||||
{template.description ? (
|
||||
<p className="app-text-muted mt-1 text-xs">
|
||||
{template.description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="app-action-primary rounded-lg px-3 py-2 text-xs font-medium disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onClick={() => void createFromTemplate(template)}
|
||||
>
|
||||
Создать доску
|
||||
</button>
|
||||
{template.created_by_id === userId ? (
|
||||
<button
|
||||
type="button"
|
||||
className="app-action-danger rounded-lg p-2 disabled:opacity-50"
|
||||
title="Удалить шаблон"
|
||||
aria-label={`Удалить шаблон ${template.name}`}
|
||||
disabled={busy}
|
||||
onClick={() => void deleteTemplate(template)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
{error ? (
|
||||
<p className="text-sm text-red-500" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@ -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<TaskBoardTemplate> =>
|
||||
request("/api/v1/tasks/board-templates/", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
deleteTaskBoardTemplate: (id: number): Promise<void> =>
|
||||
request(`/api/v1/tasks/board-templates/${id}/`, { method: "DELETE" }),
|
||||
instantiateTaskBoardTemplate: (
|
||||
id: number,
|
||||
name?: string,
|
||||
): Promise<TaskBoard> =>
|
||||
request(`/api/v1/tasks/board-templates/${id}/instantiate/`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(name ? { name } : {}),
|
||||
}),
|
||||
getTaskBoards: (params?: Record<string, string | number>) =>
|
||||
request(`/api/v1/tasks/boards/${buildQuery(params)}`),
|
||||
getTaskBoard: (id: number): Promise<TaskBoard> =>
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user