Add private and shared task board template library

This commit is contained in:
lizerk 2026-11-10 16:23:01 +03:00
parent 4ff1657f3b
commit de994ade5f
14 changed files with 836 additions and 0 deletions

View 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)

View File

@ -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")

View File

@ -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",

View 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,
)

View 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"],
},
)
]

View File

@ -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

View 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

View 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

View File

@ -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.

View File

@ -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)}

View File

@ -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} />}

View 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>
);
}

View File

@ -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> =>

View File

@ -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;