Add personal and shared task checklist templates
This commit is contained in:
parent
de994ade5f
commit
3b00174ad4
76
backend/api/v1/tasks/serializers/checklist_templates.py
Normal file
76
backend/api/v1/tasks/serializers/checklist_templates.py
Normal 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
|
||||
@ -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")
|
||||
|
||||
@ -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",
|
||||
|
||||
103
backend/api/v1/tasks/viewsets/checklist_templates.py
Normal file
103
backend/api/v1/tasks/viewsets/checklist_templates.py
Normal 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,
|
||||
)
|
||||
53
backend/tasks/migrations/0044_taskchecklisttemplate.py
Normal file
53
backend/tasks/migrations/0044_taskchecklisttemplate.py
Normal 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"],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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
|
||||
|
||||
168
backend/tests/api/v1/tasks/test_checklist_templates.py
Normal file
168
backend/tests/api/v1/tasks/test_checklist_templates.py
Normal 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
|
||||
)
|
||||
@ -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.
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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}
|
||||
|
||||
366
frontend/src/components/tasks/TaskChecklistTemplatesModal.tsx
Normal file
366
frontend/src/components/tasks/TaskChecklistTemplatesModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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> =>
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user