Add personal and shared task checklist templates

This commit is contained in:
lizerk 2026-11-10 17:00:05 +03:00
parent de994ade5f
commit 3b00174ad4
13 changed files with 950 additions and 18 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<number | null>(null);
const [columnMenuId, setColumnMenuId] = useState<number | null>(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() {
</div>
</Modal>
<TaskBoardTemplatesModal
isOpen={boardTemplatesOpen}
onClose={() => setBoardTemplatesOpen(false)}
boards={boards}
currentBoardId={board?.id ?? null}
<TaskChecklistTemplatesModal
isOpen={checklistTemplatesOpen}
onClose={() => 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() {
</div>
) : null}
{checklistBadgeTotal > 0 || checklistOpen ? (
{viewTask ? (
<div className="rounded-xl border border-[var(--border-subtle)]">
<div className="flex items-center px-1 py-1">
<button
@ -11327,6 +11364,17 @@ function TasksPageContent() {
{checklistBadgeCompleted}/{checklistBadgeTotal}
</span>
</button>
<button
type="button"
onClick={() => {
setChecklistTemplateTarget("view");
setChecklistTemplatesOpen(true);
}}
className="app-action-secondary inline-flex h-8 shrink-0 items-center gap-1 rounded-lg px-2 text-xs"
title="Шаблоны чек-листов"
>
<BookTemplate size={14} /> Шаблоны
</button>
{checklistBadgeTotal > 0 ? (
<TaskCoverToggleButton
isCover={viewTask.cover?.kind === "checklist"}
@ -13123,6 +13171,17 @@ function TasksPageContent() {
{taskFormChecklistCompleted}/{taskFormChecklistTotal}
</span>
</button>
<button
type="button"
onClick={() => {
setChecklistTemplateTarget("form");
setChecklistTemplatesOpen(true);
}}
className="app-action-secondary inline-flex h-8 shrink-0 items-center gap-1 rounded-lg px-2 text-xs"
title="Шаблоны чек-листов"
>
<BookTemplate size={14} /> Шаблоны
</button>
<button
type="button"
onClick={() => setTaskFormChecklistOpen((current) => !current)}

View File

@ -861,10 +861,10 @@ export default function TaskBoardNavigator({
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="Библиотека шаблонов"
title="Библиотека шаблонов чек-листов"
>
<BookTemplate size={16} />
<span className="hidden sm:inline">Шаблоны</span>
<span className="hidden sm:inline">Чек-листы</span>
</button>
<ModulePrimaryAction
onClick={onCreate}

View File

@ -0,0 +1,366 @@
"use client";
import { useEffect, useState } from "react";
import {
BookTemplate,
Check,
Loader2,
Pencil,
Plus,
Trash2,
} from "lucide-react";
import { Modal } from "@/components/ui";
import { apiClient } from "@/lib/api";
import type { TaskChecklistTemplate } from "@/types/api";
type Props = {
isOpen: boolean;
onClose: () => void;
userId: number | null;
canPublish: boolean;
taskId?: number | null;
draftTarget?: boolean;
sourceItems?: string;
onApplied?: () => Promise<void>;
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<TaskChecklistTemplate[]>([]);
const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [editingId, setEditingId] = useState<number | null>(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 (
<Modal
isOpen={isOpen}
onClose={onClose}
title="Шаблоны чек-листов"
size="lg"
stackLevel={1}
>
<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">
{editingId ? "Редактировать шаблон" : "Новый шаблон чек-листа"}
</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"
aria-label="Название шаблона чек-листа"
maxLength={255}
placeholder="Название, например: Проверка оборудования"
value={name}
onChange={(event) => setName(event.target.value)}
/>
<textarea
className="app-input w-full rounded-lg px-3 py-2 text-sm"
aria-label="Пункты шаблона чек-листа"
placeholder={
"Проверить комплектность\nУбедиться в исправности\nПодписать акт"
}
rows={5}
value={itemsText}
onChange={(event) => setItemsText(event.target.value)}
/>
<p className="app-text-muted text-xs">
{numberOfItems} / 100 пунктов
</p>
<textarea
className="app-input w-full rounded-lg px-3 py-2 text-sm"
aria-label="Описание шаблона"
placeholder="Описание (необязательно)"
value={description}
rows={2}
onChange={(event) => setDescription(event.target.value)}
/>
{canPublish ? (
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={isShared}
onChange={(event) => setIsShared(event.target.checked)}
/>
Общий шаблон для всех сотрудников
</label>
) : (
<p className="app-text-muted text-xs">
Публикация в общей библиотеке доступна администраторам.
</p>
)}
<div className="flex flex-wrap gap-2">
<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"
onClick={() => void save()}
disabled={
busy || !name.trim() || numberOfItems < 1 || numberOfItems > 100
}
>
{busy ? (
<Loader2 size={15} className="animate-spin" />
) : editingId ? (
<Check size={15} />
) : (
<Plus size={15} />
)}
{editingId ? "Сохранить изменения" : "Сохранить шаблон"}
</button>
{editingId ? (
<button
type="button"
onClick={resetEditor}
disabled={busy}
className="app-action-secondary rounded-lg px-3 py-2 text-sm"
>
Отмена
</button>
) : null}
</div>
</section>
<section className="space-y-2">
<h3 className="text-sm font-semibold">Библиотека чек-листов</h3>
{loading ? (
<p className="app-text-muted flex items-center gap-2 py-3 text-sm">
<Loader2 size={16} className="animate-spin" /> Загрузка...
</p>
) : 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="rounded-lg border border-[var(--border-subtle)] p-3"
>
<div className="flex items-center gap-2">
<BookTemplate
className="app-text-muted shrink-0"
size={16}
/>
<span className="min-w-0 flex-1 truncate text-sm font-semibold">
{template.name}
</span>
<span className="app-text-muted shrink-0 text-xs">
{template.is_shared ? "Общий" : "Личный"}
</span>
</div>
<p className="app-text-muted mt-1 text-xs">
{template.items.length} пунктов
{template.description ? ` · ${template.description}` : ""}
</p>
<details className="mt-2">
<summary className="app-text-muted cursor-pointer text-xs">
Показать пункты
</summary>
<ol className="app-text-muted mt-2 list-decimal space-y-1 pl-5 text-xs">
{template.items.map((item, index) => (
<li key={index}>{item}</li>
))}
</ol>
</details>
<div className="mt-3 flex flex-wrap gap-2">
{canApply ? (
<button
type="button"
className="app-action-primary rounded-lg px-3 py-2 text-xs font-medium disabled:opacity-50"
onClick={() => void apply(template)}
disabled={busy}
>
Добавить в чек-лист
</button>
) : null}
{template.created_by_id === userId ? (
<>
<button
type="button"
className="app-action-secondary inline-flex items-center gap-1.5 rounded-lg px-3 py-2 text-xs font-medium"
onClick={() => edit(template)}
disabled={busy}
>
<Pencil size={13} /> Изменить
</button>
<button
type="button"
className="app-action-danger inline-flex items-center gap-1.5 rounded-lg px-3 py-2 text-xs font-medium"
onClick={() => void remove(template)}
disabled={busy}
>
<Trash2 size={13} /> Удалить
</button>
</>
) : null}
</div>
</div>
))}
</div>
)}
{canApply ? (
<p className="app-text-muted text-xs">
Применение добавляет невыполненные пункты в конец списка.
Существующие пункты не удаляются.
</p>
) : (
<p className="app-text-muted text-xs">
Чтобы применить шаблон, откройте задачу и нажмите «Шаблоны» в её
чек-листе.
</p>
)}
</section>
{error ? (
<p role="alert" className="text-sm text-red-500">
{error}
</p>
) : null}
</div>
</Modal>
);
}

View File

@ -13,6 +13,7 @@ import type {
TaskAutomationCatalog,
TaskAutomationRun,
TaskChecklistItem,
TaskChecklistTemplate,
TaskComment,
TaskCoverType,
TaskLabel,
@ -66,6 +67,44 @@ export function createTasksApi(request: RequestFn, requestRaw: RawRequestFn) {
method: "POST",
body: JSON.stringify(name ? { name } : {}),
}),
getTaskChecklistTemplates: (): Promise<
TaskChecklistTemplate[] | { results: TaskChecklistTemplate[] }
> => request("/api/v1/tasks/checklist-templates/"),
createTaskChecklistTemplate: (data: {
name: string;
description?: string;
items: string[];
is_shared?: boolean;
}): Promise<TaskChecklistTemplate> =>
request("/api/v1/tasks/checklist-templates/", {
method: "POST",
body: JSON.stringify(data),
}),
updateTaskChecklistTemplate: (
id: number,
data: {
name?: string;
description?: string;
items?: string[];
is_shared?: boolean;
},
): Promise<TaskChecklistTemplate> =>
request(`/api/v1/tasks/checklist-templates/${id}/`, {
method: "PATCH",
body: JSON.stringify(data),
}),
deleteTaskChecklistTemplate: (id: number): Promise<void> =>
request(`/api/v1/tasks/checklist-templates/${id}/`, {
method: "DELETE",
}),
applyTaskChecklistTemplate: (
id: number,
taskId: number,
): Promise<{ added: number; items: TaskChecklistItem[] }> =>
request(`/api/v1/tasks/checklist-templates/${id}/apply/`, {
method: "POST",
body: JSON.stringify({ task_id: taskId }),
}),
getTaskBoards: (params?: Record<string, string | number>) =>
request(`/api/v1/tasks/boards/${buildQuery(params)}`),
getTaskBoard: (id: number): Promise<TaskBoard> =>

View File

@ -464,6 +464,17 @@ export interface TaskBoardViewState {
collapsed_row_ids: number[];
}
export interface TaskChecklistTemplate {
id: number;
name: string;
description: string;
created_by_id: number;
is_shared: boolean;
items: string[];
created_at: string;
updated_at: string;
}
export interface TaskBoardTemplate {
id: number;
name: string;