From 3b00174ad4f767272f392a852fe0d5563906010b Mon Sep 17 00:00:00 2001 From: lizerk Date: Tue, 10 Nov 2026 17:00:05 +0300 Subject: [PATCH] Add personal and shared task checklist templates --- .../tasks/serializers/checklist_templates.py | 76 ++++ backend/api/v1/tasks/urls.py | 6 + backend/api/v1/tasks/viewsets/__init__.py | 2 + .../v1/tasks/viewsets/checklist_templates.py | 103 +++++ .../migrations/0044_taskchecklisttemplate.py | 53 +++ backend/tasks/models.py | 25 ++ .../api/v1/tasks/test_checklist_templates.py | 168 ++++++++ docs/guides/API.md | 24 ++ frontend/src/app/tasks/page.tsx | 91 ++++- .../components/tasks/TaskBoardNavigator.tsx | 4 +- .../tasks/TaskChecklistTemplatesModal.tsx | 366 ++++++++++++++++++ frontend/src/lib/api/tasks.ts | 39 ++ frontend/src/types/api.ts | 11 + 13 files changed, 950 insertions(+), 18 deletions(-) create mode 100644 backend/api/v1/tasks/serializers/checklist_templates.py create mode 100644 backend/api/v1/tasks/viewsets/checklist_templates.py create mode 100644 backend/tasks/migrations/0044_taskchecklisttemplate.py create mode 100644 backend/tests/api/v1/tasks/test_checklist_templates.py create mode 100644 frontend/src/components/tasks/TaskChecklistTemplatesModal.tsx diff --git a/backend/api/v1/tasks/serializers/checklist_templates.py b/backend/api/v1/tasks/serializers/checklist_templates.py new file mode 100644 index 00000000..0025564c --- /dev/null +++ b/backend/api/v1/tasks/serializers/checklist_templates.py @@ -0,0 +1,76 @@ +"""Validated checklist templates (personal and shared).""" + +from rest_framework import serializers + +from tasks.access import task_board_access_q +from tasks.models import Task, TaskBoard, TaskChecklistTemplate + + +class TaskChecklistTemplateSerializer(serializers.ModelSerializer): + created_by_id = serializers.IntegerField(read_only=True) + source_task_id = serializers.IntegerField(write_only=True, required=False) + items = serializers.ListField( + child=serializers.CharField(max_length=500, trim_whitespace=True), + min_length=1, + max_length=100, + required=False, + ) + + class Meta: + model = TaskChecklistTemplate + fields = [ + "id", + "name", + "description", + "created_by_id", + "is_shared", + "items", + "source_task_id", + "created_at", + "updated_at", + ] + read_only_fields = ["id", "created_by_id", "created_at", "updated_at"] + + def validate(self, attrs): + attrs = super().validate(attrs) + user = self.context["request"].user + source_task_id = attrs.pop("source_task_id", None) + if source_task_id is not None: + if "items" in attrs: + raise serializers.ValidationError( + {"items": "Укажите либо задачу, либо готовые пункты."} + ) + task = ( + Task.objects.filter(pk=source_task_id, board__is_archived=False) + .filter(board__in=TaskBoard.objects.filter(task_board_access_q(user))) + .first() + ) + if task is None: + raise serializers.ValidationError( + {"source_task_id": "Задача не найдена или недоступна."} + ) + attrs["items"] = list( + task.checklist_items.order_by("position", "id").values_list( + "title", flat=True + ) + ) + if self.instance is None and not attrs.get("items"): + raise serializers.ValidationError( + {"items": "Добавьте хотя бы один пункт чек-листа."} + ) + if "items" in attrs and not attrs["items"]: + raise serializers.ValidationError( + {"items": "Добавьте хотя бы один пункт чек-листа."} + ) + if len(attrs.get("items", [])) > 100: + raise serializers.ValidationError( + {"items": "В одном шаблоне не больше 100 пунктов."} + ) + 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 diff --git a/backend/api/v1/tasks/urls.py b/backend/api/v1/tasks/urls.py index ce8b4a2a..1d285e93 100644 --- a/backend/api/v1/tasks/urls.py +++ b/backend/api/v1/tasks/urls.py @@ -6,6 +6,7 @@ from .viewsets import ( TaskBoardGroupViewSet, TaskBoardTemplateViewSet, TaskBoardViewSet, + TaskChecklistTemplateViewSet, TaskColumnRowViewSet, TaskColumnViewSet, TaskLabelViewSet, @@ -21,6 +22,11 @@ router.register(r"boards", TaskBoardViewSet, basename="task-board") router.register( r"board-templates", TaskBoardTemplateViewSet, basename="task-board-template" ) +router.register( + r"checklist-templates", + TaskChecklistTemplateViewSet, + basename="task-checklist-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 745014dc..fb0783b0 100644 --- a/backend/api/v1/tasks/viewsets/__init__.py +++ b/backend/api/v1/tasks/viewsets/__init__.py @@ -1,5 +1,6 @@ from .automation import TaskAutomationViewSet from .boards import TaskBoardGroupViewSet, TaskBoardViewSet +from .checklist_templates import TaskChecklistTemplateViewSet from .structure import TaskColumnRowViewSet, TaskColumnViewSet, TaskLabelViewSet from .tasks import TaskViewSet from .templates import TaskBoardTemplateViewSet @@ -7,6 +8,7 @@ from .templates import TaskBoardTemplateViewSet __all__ = [ "TaskAutomationViewSet", "TaskBoardGroupViewSet", + "TaskChecklistTemplateViewSet", "TaskBoardTemplateViewSet", "TaskBoardViewSet", "TaskColumnRowViewSet", diff --git a/backend/api/v1/tasks/viewsets/checklist_templates.py b/backend/api/v1/tasks/viewsets/checklist_templates.py new file mode 100644 index 00000000..be23cd88 --- /dev/null +++ b/backend/api/v1/tasks/viewsets/checklist_templates.py @@ -0,0 +1,103 @@ +"""Checklist template library and atomic insertion into an existing task.""" + +from django.db import transaction +from django.db.models import Max, Q +from django.shortcuts import get_object_or_404 +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.access import task_board_access_q +from tasks.activity import create_task_activity +from tasks.models import ( + Task, + TaskActivityAction, + TaskBoard, + TaskChecklistItem, + TaskChecklistTemplate, +) +from tasks.realtime import send_task_checklist_update + +from ..serializers.checklist_templates import TaskChecklistTemplateSerializer +from ..serializers.resources import TaskChecklistItemSerializer + + +class TaskChecklistTemplateViewSet(viewsets.ModelViewSet): + permission_classes = [IsAuthenticated] + serializer_class = TaskChecklistTemplateSerializer + + def get_queryset(self): + return ( + TaskChecklistTemplate.objects.filter( + Q(created_by=self.request.user) | Q(is_shared=True) + ) + .select_related("created_by") + .distinct() + ) + + def get_object(self): + template = super().get_object() + if self.action in {"update", "partial_update", "destroy"}: + if template.created_by_id != self.request.user.pk: + raise PermissionDenied("Изменить шаблон может только автор.") + return template + + def perform_create(self, serializer): + serializer.save(created_by=self.request.user) + + @action(detail=True, methods=["post"], url_path="apply") + def apply(self, request, pk=None): + template = self.get_object() + raw_task_id = request.data.get("task_id") + try: + task_id = int(raw_task_id) + except (ValueError, TypeError): + return Response( + {"task_id": ["Укажите задачу для применения шаблона."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + allowed_tasks = Task.objects.filter(board__is_archived=False).filter( + board__in=TaskBoard.objects.filter(task_board_access_q(request.user)) + ) + with transaction.atomic(): + task = get_object_or_404(allowed_tasks.select_for_update(), pk=task_id) + start = ( + TaskChecklistItem.objects.filter(task=task).aggregate(Max("position"))[ + "position__max" + ] + or 0 + ) + created = TaskChecklistItem.objects.bulk_create( + [ + TaskChecklistItem( + task=task, + title=title, + position=start + index * 1000, + created_by=request.user, + is_completed=False, + ) + for index, title in enumerate(template.items, start=1) + ] + ) + create_task_activity( + task, + request.user, + TaskActivityAction.UPDATED, + metadata={ + "fields": ["checklist"], + "template_id": template.id, + "template_name": template.name, + "added_items": len(created), + }, + ) + if created: + send_task_checklist_update(task, "checklist_updated", created[-1].pk) + return Response( + { + "added": len(created), + "items": TaskChecklistItemSerializer(created, many=True).data, + }, + status=status.HTTP_201_CREATED, + ) diff --git a/backend/tasks/migrations/0044_taskchecklisttemplate.py b/backend/tasks/migrations/0044_taskchecklisttemplate.py new file mode 100644 index 00000000..42b867d6 --- /dev/null +++ b/backend/tasks/migrations/0044_taskchecklisttemplate.py @@ -0,0 +1,53 @@ +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("tasks", "0043_taskboardtemplate"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="TaskChecklistTemplate", + 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="Общий")), + ("items", models.JSONField(default=list, 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_checklist_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 086101ed..35a10e6b 100644 --- a/backend/tasks/models.py +++ b/backend/tasks/models.py @@ -1041,3 +1041,28 @@ class TaskBoardTemplate(models.Model): def __str__(self): return self.name + + +class TaskChecklistTemplate(models.Model): + """A reusable ordered checklist with no task-specific completion data.""" + + 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_checklist_templates", + verbose_name="Автор", + ) + is_shared = models.BooleanField("Общий", default=False) + items = models.JSONField("Пункты", default=list) + 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/tests/api/v1/tasks/test_checklist_templates.py b/backend/tests/api/v1/tasks/test_checklist_templates.py new file mode 100644 index 00000000..0caa8684 --- /dev/null +++ b/backend/tests/api/v1/tasks/test_checklist_templates.py @@ -0,0 +1,168 @@ +"""Checklist templates: sharing, author rights, snapshot and safe application.""" + +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, + TaskChecklistItem, + TaskChecklistTemplate, + TaskColumn, +) + +pytestmark = pytest.mark.django_db + + +def make_user(i, staff=False): + return get_user_model().objects.create_user( + email=f"checklist-template-{i}@example.com", + password="testpass123", + phone_number=f"+7999666{i:04d}", + first_name="Тест", + last_name="Шаблон", + is_active=True, + email_verified=True, + is_staff=staff, + send_activation_email=False, + ) + + +def make_task(user, name="Task"): + board = TaskBoard.objects.create( + name=f"Board-{name}", created_by=user, access_scope="private" + ) + column = TaskColumn.objects.create(board=board, name="Новые", position=1000) + return Task.objects.create(board=board, column=column, title=name, created_by=user) + + +def client_for(user): + client = APIClient() + client.force_authenticate(user=user) + return client + + +def test_create_from_task_snapshot_and_apply_keeps_existing_items(): + user = make_user(1) + task = make_task(user) + TaskChecklistItem.objects.create( + task=task, title="Шаг первый", position=1000, created_by=user, is_completed=True + ) + TaskChecklistItem.objects.create( + task=task, title="Шаг второй", position=2000, created_by=user + ) + client = client_for(user) + created = client.post( + reverse("api:v1:tasks:task-checklist-template-list"), + {"name": "Инструкция", "source_task_id": task.pk}, + format="json", + ) + assert created.status_code == 201, created.data + assert created.data["items"] == ["Шаг первый", "Шаг второй"] + template = TaskChecklistTemplate.objects.get(pk=created.data["id"]) + other = make_task(user, "New") + TaskChecklistItem.objects.create( + task=other, title="Уже есть", position=1000, created_by=user + ) + applied = client.post( + reverse("api:v1:tasks:task-checklist-template-apply", args=[template.pk]), + {"task_id": other.pk}, + format="json", + ) + assert applied.status_code == 201, applied.data + assert applied.data["added"] == 2 + checklist = list(other.checklist_items.order_by("position", "id")) + assert [item.title for item in checklist] == [ + "Уже есть", + "Шаг первый", + "Шаг второй", + ] + assert all(not item.is_completed for item in checklist) + assert all(item.completed_by_id is None for item in checklist) + assert all(item.created_by_id == user.pk for item in checklist) + + +def test_private_template_permissions_admin_sharing_and_editing(): + owner, outsider, admin = make_user(2), make_user(3), make_user(4, staff=True) + own_task = make_task(owner) + owner_api, outsider_api, admin_api = ( + client_for(owner), + client_for(outsider), + client_for(admin), + ) + list_url = reverse("api:v1:tasks:task-checklist-template-list") + denied = outsider_api.post( + list_url, {"name": "Чужая", "source_task_id": own_task.pk}, format="json" + ) + assert denied.status_code == 400 + denied_shared = owner_api.post( + list_url, {"name": "Общая", "items": ["Шаг"], "is_shared": True}, format="json" + ) + assert denied_shared.status_code == 400 + personal = owner_api.post( + list_url, {"name": "Личная", "items": ["Первый", "Второй"]}, format="json" + ) + assert personal.status_code == 201 + assert len(outsider_api.get(list_url).data["results"]) == 0 + assert ( + outsider_api.get( + reverse( + "api:v1:tasks:task-checklist-template-detail", + args=[personal.data["id"]], + ) + ).status_code + == 404 + ) + shared = admin_api.post( + list_url, + {"name": "Для всех", "items": ["Один"], "is_shared": True}, + format="json", + ) + assert shared.status_code == 201, shared.data + detail = reverse( + "api:v1:tasks:task-checklist-template-detail", args=[shared.data["id"]] + ) + assert ( + outsider_api.patch(detail, {"name": "Подмена"}, format="json").status_code + == 403 + ) + assert outsider_api.delete(detail).status_code == 403 + assert ( + outsider_api.post( + reverse( + "api:v1:tasks:task-checklist-template-apply", args=[shared.data["id"]] + ), + {"task_id": own_task.pk}, + format="json", + ).status_code + == 404 + ) + updated = admin_api.patch( + detail, {"items": ["Перепроверить", "Сдать"]}, format="json" + ) + assert updated.status_code == 200 + assert updated.data["items"] == ["Перепроверить", "Сдать"] + + +def test_empty_and_too_large_lists_rejected(): + user = make_user(5) + client = client_for(user) + url = reverse("api:v1:tasks:task-checklist-template-list") + assert ( + client.post(url, {"name": "Empty", "items": []}, format="json").status_code + == 400 + ) + assert ( + client.post( + url, {"name": "Too large", "items": ["x"] * 101}, format="json" + ).status_code + == 400 + ) + assert ( + client.post( + url, {"name": "Whitespace", "items": [" "]}, format="json" + ).status_code + == 400 + ) diff --git a/docs/guides/API.md b/docs/guides/API.md index dc027d26..3b831672 100644 --- a/docs/guides/API.md +++ b/docs/guides/API.md @@ -225,3 +225,27 @@ members, attachments, comments, automations or board permissions. 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. + +## Task checklist templates + +The checklist template library is separate from board layout templates. An +authenticated employee can create personal templates from one or more checklist +item titles. Staff and superusers can publish shared templates visible to every +employee. Only the author can update or delete a template. + +- `GET /api/v1/tasks/checklist-templates/` — list personal and shared templates + (standard paginated DRF response). +- `POST /api/v1/tasks/checklist-templates/` — create a template from + `{"name":"Quality check","items":["Inspect","Sign off"],"is_shared":false}`; + alternatively supply `source_task_id` instead of `items` to save a + snapshot of an accessible task's checklist. +- `PATCH /api/v1/tasks/checklist-templates/{id}/` — edit an owned template. +- `DELETE /api/v1/tasks/checklist-templates/{id}/` — delete an owned template. +- `POST /api/v1/tasks/checklist-templates/{id}/apply/` with + `{"task_id":42}` — append a template's items to an accessible task. + +Titles are normalized and limited to 500 characters each. A template has +1–100 items; its snapshot does **not** include completion state or actors. +Applying it adds uncompleted items to the end of the task checklist, +transactionally, leaving existing items untouched. Applying it to a draft task +from the frontend form adds draft items; persistence happens when the task is saved. diff --git a/frontend/src/app/tasks/page.tsx b/frontend/src/app/tasks/page.tsx index e5b7238a..7980eea2 100644 --- a/frontend/src/app/tasks/page.tsx +++ b/frontend/src/app/tasks/page.tsx @@ -31,6 +31,7 @@ import { import { CSS } from "@dnd-kit/utilities"; import { CalendarDays, + BookTemplate, ArrowLeft, Check, ChevronDown, @@ -88,7 +89,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 TaskChecklistTemplatesModal from "@/components/tasks/TaskChecklistTemplatesModal"; import TaskCoverPreview from "@/components/tasks/TaskCoverPreview"; import TaskCommentAttachment from "@/components/tasks/TaskCommentAttachment"; import TaskAutomationManager from "@/components/tasks/TaskAutomationManager"; @@ -4038,7 +4039,10 @@ function TasksPageContent() { const [cardMenuTaskId, setCardMenuTaskId] = useState(null); const [columnMenuId, setColumnMenuId] = useState(null); const [boardModalOpen, setBoardModalOpen] = useState(false); - const [boardTemplatesOpen, setBoardTemplatesOpen] = useState(false); + const [checklistTemplatesOpen, setChecklistTemplatesOpen] = useState(false); + const [checklistTemplateTarget, setChecklistTemplateTarget] = useState< + "view" | "form" | null + >(null); const [boardAvatarCropperImage, setBoardAvatarCropperImage] = useState< string | null >(null); @@ -9021,7 +9025,10 @@ function TasksPageContent() { unreadCounts={unreadTaskBoardCounts} onSelect={selectBoard} onCreate={openCreateBoard} - onOpenTemplates={() => setBoardTemplatesOpen(true)} + onOpenTemplates={() => { + setChecklistTemplateTarget(null); + setChecklistTemplatesOpen(true); + }} onCreateGroup={openCreateBoardGroup} onEditGroup={openEditBoardGroup} onManageGroupBoards={openManageBoardGroup} @@ -9964,20 +9971,50 @@ function TasksPageContent() { - setBoardTemplatesOpen(false)} - boards={boards} - currentBoardId={board?.id ?? null} + setChecklistTemplatesOpen(false)} 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"); + taskId={checklistTemplateTarget === "view" ? viewTask?.id : null} + draftTarget={checklistTemplateTarget === "form"} + sourceItems={ + checklistTemplateTarget === "view" + ? taskChecklist.map((item) => item.title).join("\n") + : checklistTemplateTarget === "form" + ? [ + ...taskFormChecklistItems.map((item) => item.title), + ...taskFormPendingChecklistItems.map((item) => item.title), + ].join("\n") + : "" + } + onApplied={async () => { + if (!viewTask || !board) return; + await Promise.all([ + loadTaskChecklist(viewTask.id), + loadBoard(board.id), + ]); + if (activityOpen) await loadTaskActivity(viewTask.id); + setChecklistOpen(true); + }} + onApplyDraft={(items) => { + setTaskFormPendingChecklistItems((current) => { + const maxPosition = Math.max( + 0, + ...taskFormChecklistItems.map((item) => item.position), + ...current.map((item) => item.position), + ); + return [ + ...current, + ...items.map((title, index) => ({ + key: `checklist-template-${Date.now()}-${index}`, + title, + position: maxPosition + (index + 1) * 1000, + is_completed: false, + })), + ]; + }); + setTaskFormChecklistOpen(true); }} /> @@ -11310,7 +11347,7 @@ function TasksPageContent() { ) : null} - {checklistBadgeTotal > 0 || checklistOpen ? ( + {viewTask ? (
+ {checklistBadgeTotal > 0 ? ( + void; + userId: number | null; + canPublish: boolean; + taskId?: number | null; + draftTarget?: boolean; + sourceItems?: string; + onApplied?: () => Promise; + onApplyDraft?: (items: string[]) => void; +}; + +function message(error: unknown, fallback: string): string { + return error instanceof Error && error.message ? error.message : fallback; +} + +export default function TaskChecklistTemplatesModal({ + isOpen, + onClose, + userId, + canPublish, + taskId = null, + draftTarget = false, + sourceItems = "", + onApplied, + onApplyDraft, +}: Props) { + const [templates, setTemplates] = useState([]); + const [loading, setLoading] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [editingId, setEditingId] = useState(null); + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [itemsText, setItemsText] = useState(""); + const [isShared, setIsShared] = useState(false); + + useEffect(() => { + if (!isOpen) return; + let active = true; + setName(""); + setDescription(""); + setItemsText(sourceItems); + setIsShared(false); + setEditingId(null); + setError(null); + setLoading(true); + void apiClient.getTaskChecklistTemplates().then( + (result) => { + if (!active) return; + setTemplates(Array.isArray(result) ? result : result.results); + setLoading(false); + }, + (cause: unknown) => { + if (!active) return; + setError(message(cause, "Не удалось загрузить библиотеку чек-листов")); + setLoading(false); + }, + ); + return () => { + active = false; + }; + }, [isOpen, sourceItems]); + + const resetEditor = () => { + setEditingId(null); + setName(""); + setDescription(""); + setItemsText(sourceItems); + setIsShared(false); + }; + + const save = async () => { + const titles = itemsText + .split("\n") + .map((title) => title.trim()) + .filter(Boolean); + if (busy || !name.trim() || !titles.length || titles.length > 100) return; + setError(null); + setBusy(true); + try { + const payload = { + name: name.trim(), + description: description.trim(), + items: titles, + is_shared: canPublish && isShared, + }; + const saved = editingId + ? await apiClient.updateTaskChecklistTemplate(editingId, payload) + : await apiClient.createTaskChecklistTemplate(payload); + setTemplates((current) => + [...current.filter((item) => item.id !== saved.id), saved].sort( + (a, b) => a.name.localeCompare(b.name), + ), + ); + resetEditor(); + } catch (cause) { + setError(message(cause, "Не удалось сохранить шаблон")); + } finally { + setBusy(false); + } + }; + + const edit = (template: TaskChecklistTemplate) => { + setEditingId(template.id); + setName(template.name); + setDescription(template.description); + setItemsText(template.items.join("\n")); + setIsShared(template.is_shared); + setError(null); + }; + + const remove = async (template: TaskChecklistTemplate) => { + if (busy || !window.confirm(`Удалить шаблон «${template.name}»?`)) return; + setBusy(true); + setError(null); + try { + await apiClient.deleteTaskChecklistTemplate(template.id); + setTemplates((current) => + current.filter((item) => item.id !== template.id), + ); + if (editingId === template.id) resetEditor(); + } catch (cause) { + setError(message(cause, "Не удалось удалить шаблон")); + } finally { + setBusy(false); + } + }; + + const apply = async (template: TaskChecklistTemplate) => { + if (busy) return; + setBusy(true); + setError(null); + try { + if (draftTarget && onApplyDraft) { + onApplyDraft(template.items); + } else if (taskId !== null && onApplied) { + await apiClient.applyTaskChecklistTemplate(template.id, taskId); + await onApplied(); + } else { + return; + } + onClose(); + } catch (cause) { + setError(message(cause, "Не удалось добавить пункты чек-листа")); + } finally { + setBusy(false); + } + }; + + const numberOfItems = itemsText + .split("\n") + .map((line) => line.trim()) + .filter(Boolean).length; + const canApply = draftTarget || taskId !== null; + + return ( + +
+
+
+

+ {editingId ? "Редактировать шаблон" : "Новый шаблон чек-листа"} +

+

+ Один пункт в каждой строке. Сохраняются только названия, без + отметок выполнения. Личные шаблоны видны только вам, общие + доступны всем. +

+
+ setName(event.target.value)} + /> +