From f2d24f70e3ed3d45473a155e129521eb44092dd6 Mon Sep 17 00:00:00 2001
From: lizerk
Date: Thu, 6 Aug 2026 13:35:30 +0300
Subject: [PATCH] feat(tasks): add universal task covers
---
backend/api/v1/tasks/serializers.py | 193 ++++-
backend/api/v1/tasks/views.py | 220 ++++--
backend/tasks/admin.py | 19 +-
.../migrations/0033_universal_task_covers.py | 104 +++
backend/tasks/models.py | 127 ++-
backend/tasks/notifications/config.py | 1 +
backend/tasks/notifications/handlers.py | 1 +
backend/tests/api/v1/tasks/test_tasks_api.py | 144 +++-
frontend/src/app/tasks/page.tsx | 741 ++++++++++++++++--
.../src/components/tasks/TaskCoverImage.tsx | 77 --
.../src/components/tasks/TaskCoverPreview.tsx | 229 ++++++
frontend/src/lib/api/tasks.ts | 8 +-
frontend/src/types/api.ts | 53 +-
13 files changed, 1674 insertions(+), 243 deletions(-)
create mode 100644 backend/tasks/migrations/0033_universal_task_covers.py
delete mode 100644 frontend/src/components/tasks/TaskCoverImage.tsx
create mode 100644 frontend/src/components/tasks/TaskCoverPreview.tsx
diff --git a/backend/api/v1/tasks/serializers.py b/backend/api/v1/tasks/serializers.py
index 5cb780d0..a279ead2 100644
--- a/backend/api/v1/tasks/serializers.py
+++ b/backend/api/v1/tasks/serializers.py
@@ -55,6 +55,8 @@ from tasks.models import (
TaskChecklistItem,
TaskColumn,
TaskColumnRow,
+ TaskCover,
+ TaskCoverType,
TaskExternalLink,
TaskLabel,
TaskLinkedObject,
@@ -434,6 +436,7 @@ class TaskSerializer(serializers.ModelSerializer):
linked_objects_count = serializers.SerializerMethodField()
comments_count = serializers.SerializerMethodField()
attachments_count = serializers.SerializerMethodField()
+ cover = serializers.SerializerMethodField()
cover_attachment = serializers.SerializerMethodField()
checklist_total = serializers.SerializerMethodField()
checklist_completed = serializers.SerializerMethodField()
@@ -477,6 +480,7 @@ class TaskSerializer(serializers.ModelSerializer):
"linked_objects_count",
"comments_count",
"attachments_count",
+ "cover",
"cover_attachment",
"checklist_total",
"checklist_completed",
@@ -507,6 +511,7 @@ class TaskSerializer(serializers.ModelSerializer):
"linked_objects_count",
"comments_count",
"attachments_count",
+ "cover",
"cover_attachment",
"checklist_total",
"checklist_completed",
@@ -612,28 +617,29 @@ class TaskSerializer(serializers.ModelSerializer):
return obj.attachments_count
return obj.attachments.count()
+ def _get_cover_payload(self, obj):
+ if hasattr(obj, "_task_cover_payload"):
+ return obj._task_cover_payload
+ try:
+ cover = obj.cover
+ except TaskCover.DoesNotExist:
+ cover = None
+ payload = (
+ TaskCoverSerializer(cover, context=self.context).data
+ if cover is not None
+ else None
+ )
+ obj._task_cover_payload = payload
+ return payload
+
+ @extend_schema_field(serializers.DictField(allow_null=True))
+ def get_cover(self, obj):
+ return self._get_cover_payload(obj)
+
@extend_schema_field(serializers.DictField(allow_null=True))
def get_cover_attachment(self, obj):
- cover_attachments = getattr(obj, "cover_attachments", None)
- if cover_attachments is not None:
- attachment = cover_attachments[0] if cover_attachments else None
- else:
- prefetched = getattr(obj, "_prefetched_objects_cache", {}).get(
- "attachments"
- )
- if prefetched is not None:
- attachment = next(
- (item for item in prefetched if item.is_cover),
- None,
- )
- else:
- attachment = obj.attachments.filter(is_cover=True).first()
- if attachment is None:
- return None
- return TaskAttachmentSerializer(
- attachment,
- context=self.context,
- ).data
+ payload = self._get_cover_payload(obj)
+ return payload.get("attachment") if payload else None
@extend_schema_field(serializers.IntegerField())
def get_checklist_total(self, obj):
@@ -1157,7 +1163,6 @@ class TaskAttachmentSerializer(serializers.ModelSerializer):
"file_name",
"file_size",
"mime_type",
- "is_cover",
"uploaded_by",
"created_at",
"download_url",
@@ -1845,3 +1850,149 @@ class TaskLinkedAttendanceRecordSerializer(serializers.ModelSerializer):
def get_attendance_record_content_type():
return ContentType.objects.get_for_model(AttendanceRecord)
+
+
+def _linked_object_cover_payload(link, context):
+ source = getattr(link, "content_object", None)
+ kind = link.kind
+ title = link.get_kind_display()
+ description = ""
+ image_url = None
+
+ if source is not None:
+ if kind == TaskLinkedObjectKind.POST:
+ title = source.title or "Новость"
+ description = source.body or ""
+ if source.image:
+ from api.v1.utils import build_media_url
+
+ image_url = build_media_url(source.image, context.get("request"))
+ elif kind == TaskLinkedObjectKind.MESSAGE:
+ title = (
+ source.author.get_full_name()
+ or source.author.email
+ or "Сообщение"
+ )
+ description = source.content or ""
+ elif kind == TaskLinkedObjectKind.CALENDAR_EVENT:
+ title = getattr(source, "title", None) or "Календарное событие"
+ description = getattr(source, "description", "") or ""
+ elif kind == TaskLinkedObjectKind.DOCUMENT:
+ title = source.title or "Документ"
+ description = source.description or ""
+ elif kind == TaskLinkedObjectKind.REQUEST:
+ title = source.display_title or source.title or "Заявление"
+ description = source.comment or ""
+ elif kind == TaskLinkedObjectKind.PROCUREMENT_REQUEST:
+ title = source.title or "Заявка на закупку"
+ description = source.description or ""
+ elif kind == TaskLinkedObjectKind.EMPLOYEE:
+ title = source.get_full_name() or source.email or "Сотрудник"
+ description = getattr(source, "position_name", "") or ""
+ elif kind == TaskLinkedObjectKind.GUEST:
+ title = source.full_name or "Гость"
+ description = source.organization or ""
+ elif kind == TaskLinkedObjectKind.GUEST_VISIT:
+ title = source.guest.full_name or "Гостевой визит"
+ description = source.purpose or ""
+ elif kind == TaskLinkedObjectKind.ATTENDANCE_RECORD:
+ title = source.display_name or "Запись посещаемости"
+ description = str(source.date)
+
+ return {
+ "id": link.id,
+ "kind": kind,
+ "kind_display": link.get_kind_display(),
+ "object_id": link.object_id,
+ "title": str(title),
+ "description": str(description),
+ "image_url": image_url,
+ }
+
+
+class TaskCoverSerializer(serializers.ModelSerializer):
+ kind_display = serializers.CharField(source="get_kind_display", read_only=True)
+ attachment = serializers.SerializerMethodField()
+ checklist = serializers.SerializerMethodField()
+ comment = serializers.SerializerMethodField()
+ external_link = serializers.SerializerMethodField()
+ linked_object = serializers.SerializerMethodField()
+
+ class Meta:
+ model = TaskCover
+ fields = [
+ "id",
+ "kind",
+ "kind_display",
+ "attachment",
+ "checklist",
+ "comment",
+ "external_link",
+ "linked_object",
+ "updated_at",
+ ]
+ read_only_fields = fields
+
+ @extend_schema_field(serializers.DictField(allow_null=True))
+ def get_attachment(self, obj):
+ if obj.kind != TaskCoverType.ATTACHMENT or obj.attachment is None:
+ return None
+ return TaskAttachmentSerializer(
+ obj.attachment,
+ context=self.context,
+ ).data
+
+ @extend_schema_field(serializers.DictField(allow_null=True))
+ def get_checklist(self, obj):
+ if obj.kind != TaskCoverType.CHECKLIST:
+ return None
+ items = list(obj.task.checklist_items.all())
+ return {
+ "total": len(items),
+ "completed": sum(item.is_completed for item in items),
+ "items": [
+ {
+ "id": item.id,
+ "title": item.title,
+ "is_completed": item.is_completed,
+ }
+ for item in items[:6]
+ ],
+ }
+
+ @extend_schema_field(serializers.DictField(allow_null=True))
+ def get_comment(self, obj):
+ message = obj.comment
+ if obj.kind != TaskCoverType.COMMENT or message is None or message.is_deleted:
+ return None
+ attachments = [
+ attachment
+ for attachment in message.task_attachments.all()
+ if attachment.task_id == obj.task_id
+ ]
+ return {
+ "id": message.id,
+ "author": EmployeeBriefSerializer(message.author).data,
+ "text": message.content,
+ "created_at": message.created_at,
+ "attachments": TaskAttachmentSerializer(
+ attachments,
+ many=True,
+ context=self.context,
+ ).data,
+ }
+
+ @extend_schema_field(serializers.DictField(allow_null=True))
+ def get_external_link(self, obj):
+ if obj.kind != TaskCoverType.EXTERNAL_LINK or obj.external_link is None:
+ return None
+ return TaskExternalLinkSerializer(
+ obj.external_link,
+ context=self.context,
+ ).data
+
+ @extend_schema_field(serializers.DictField(allow_null=True))
+ def get_linked_object(self, obj):
+ if obj.kind != TaskCoverType.LINKED_OBJECT or obj.linked_object is None:
+ return None
+ return _linked_object_cover_payload(obj.linked_object, self.context)
diff --git a/backend/api/v1/tasks/views.py b/backend/api/v1/tasks/views.py
index d5d0e0c0..dd7f783a 100644
--- a/backend/api/v1/tasks/views.py
+++ b/backend/api/v1/tasks/views.py
@@ -66,6 +66,8 @@ from tasks.models import (
TaskBoardPlacement,
TaskColumn,
TaskColumnRow,
+ TaskCover,
+ TaskCoverType,
TaskExternalLink,
TaskLabel,
TaskLinkedObject,
@@ -369,14 +371,37 @@ class TaskBoardViewSet(viewsets.ModelViewSet):
),
).order_by("parent_id", "position", "id"),
),
- "tasks__created_by",
- "tasks__assignee",
- "tasks__participants",
- "tasks__labels",
- "tasks__linked_objects",
- "tasks__external_links",
- "tasks__attachments",
- "tasks__checklist_items",
+ Prefetch(
+ "tasks",
+ queryset=Task.objects.select_related(
+ "created_by",
+ "assignee",
+ "column",
+ "row",
+ "cover",
+ "cover__attachment",
+ "cover__attachment__uploaded_by",
+ "cover__comment",
+ "cover__comment__author",
+ "cover__external_link",
+ "cover__external_link__created_by",
+ "cover__linked_object",
+ "cover__linked_object__content_type",
+ ).prefetch_related(
+ "participants",
+ "labels",
+ "linked_objects",
+ "external_links",
+ "attachments",
+ "checklist_items",
+ Prefetch(
+ "cover__comment__task_attachments",
+ queryset=TaskAttachment.objects.select_related(
+ "uploaded_by"
+ ),
+ ),
+ ),
+ ),
)
return queryset
@@ -1041,17 +1066,29 @@ class TaskViewSet(viewsets.ModelViewSet):
task_board_access_q(self.request.user)
)
)
- .select_related("board", "column", "row", "created_by", "assignee")
+ .select_related(
+ "board",
+ "column",
+ "row",
+ "created_by",
+ "assignee",
+ "cover",
+ "cover__attachment",
+ "cover__attachment__uploaded_by",
+ "cover__comment",
+ "cover__comment__author",
+ "cover__external_link",
+ "cover__external_link__created_by",
+ "cover__linked_object",
+ "cover__linked_object__content_type",
+ )
.prefetch_related(
"labels",
"participants",
"checklist_items",
Prefetch(
- "attachments",
- queryset=TaskAttachment.objects.filter(
- is_cover=True
- ).select_related("uploaded_by"),
- to_attr="cover_attachments",
+ "cover__comment__task_attachments",
+ queryset=TaskAttachment.objects.select_related("uploaded_by"),
),
)
.annotate(
@@ -1544,7 +1581,11 @@ class TaskViewSet(viewsets.ModelViewSet):
)
attachment_id = attachment.id
file_name = attachment.file_name
- was_cover = attachment.is_cover
+ was_cover = TaskCover.objects.filter(
+ task=task,
+ kind=TaskCoverType.ATTACHMENT,
+ attachment=attachment,
+ ).exists()
attachment.delete()
create_task_activity(
task,
@@ -1560,7 +1601,7 @@ class TaskViewSet(viewsets.ModelViewSet):
task.id,
extra={
"attachments_count": task.attachments.count(),
- **({"cover_attachment_id": None} if was_cover else {}),
+ **({"cover": None} if was_cover else {}),
},
)
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -1568,66 +1609,140 @@ class TaskViewSet(viewsets.ModelViewSet):
@action(detail=True, methods=["post", "delete"], url_path="cover")
def cover(self, request, pk=None):
task = self.get_object()
- attachment_id = request.data.get("attachment_id")
- if request.method == "POST" and not attachment_id:
- return Response(
- {"attachment_id": ["Это поле обязательно."]},
- status=status.HTTP_400_BAD_REQUEST,
- )
-
with transaction.atomic():
locked_task = Task.objects.select_for_update().get(pk=task.pk)
- previous_id = (
- locked_task.attachments.filter(is_cover=True)
- .values_list("id", flat=True)
- .first()
+ previous_cover = TaskCover.objects.filter(task=locked_task).first()
+ previous = (
+ {
+ "kind": previous_cover.kind,
+ "source_id": (
+ previous_cover.attachment_id
+ or previous_cover.comment_id
+ or previous_cover.external_link_id
+ or previous_cover.linked_object_id
+ ),
+ }
+ if previous_cover
+ else None
)
- next_attachment = None
- if request.method == "POST":
- next_attachment = get_object_or_404(
- locked_task.attachments,
- id=attachment_id,
- )
- content_type = next_attachment.mime_type or (
- mimetypes.guess_type(next_attachment.file_name)[0] or ""
- )
- if not content_type.lower().startswith("image/"):
+
+ if request.method == "DELETE":
+ TaskCover.objects.filter(task=locked_task).delete()
+ next_cover = None
+ else:
+ kind = request.data.get("kind")
+ attachment_id = request.data.get("attachment_id")
+ if not kind and attachment_id:
+ kind = TaskCoverType.ATTACHMENT
+ if kind not in TaskCoverType.values:
return Response(
- {"attachment_id": ["Обложкой может быть только изображение."]},
+ {"kind": ["Выберите допустимый тип обложки."]},
status=status.HTTP_400_BAD_REQUEST,
)
- next_id = next_attachment.id if next_attachment else None
- if previous_id != next_id:
- locked_task.attachments.filter(is_cover=True).update(is_cover=False)
- if next_attachment:
- TaskAttachment.objects.filter(pk=next_attachment.pk).update(
- is_cover=True
+ source_id = request.data.get("source_id")
+ if (
+ kind != TaskCoverType.CHECKLIST
+ and not (attachment_id or source_id)
+ ):
+ return Response(
+ {"source_id": ["Укажите источник обложки."]},
+ status=status.HTTP_400_BAD_REQUEST,
)
+ defaults = {
+ "kind": kind,
+ "attachment": None,
+ "comment": None,
+ "external_link": None,
+ "linked_object": None,
+ "created_by": request.user,
+ }
+ if kind == TaskCoverType.ATTACHMENT:
+ source_id = attachment_id or source_id
+ attachment = get_object_or_404(
+ locked_task.attachments,
+ id=source_id,
+ )
+ content_type = attachment.mime_type or (
+ mimetypes.guess_type(attachment.file_name)[0] or ""
+ )
+ if not content_type.lower().startswith(("image/", "video/")):
+ return Response(
+ {
+ "source_id": [
+ "Обложкой может быть изображение или видео."
+ ]
+ },
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+ defaults["attachment"] = attachment
+ elif kind == TaskCoverType.CHECKLIST:
+ if not locked_task.checklist_items.exists():
+ return Response(
+ {"kind": ["В задаче пока нет пунктов чек-листа."]},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+ elif kind == TaskCoverType.COMMENT:
+ comment = get_object_or_404(
+ comments_helpers.get_comments(locked_task),
+ id=source_id,
+ )
+ defaults["comment"] = comment
+ elif kind == TaskCoverType.EXTERNAL_LINK:
+ defaults["external_link"] = get_object_or_404(
+ locked_task.external_links,
+ id=source_id,
+ )
+ elif kind == TaskCoverType.LINKED_OBJECT:
+ defaults["linked_object"] = get_object_or_404(
+ locked_task.linked_objects,
+ id=source_id,
+ )
+
+ next_cover, _ = TaskCover.objects.update_or_create(
+ task=locked_task,
+ defaults=defaults,
+ )
+ next_cover.full_clean()
+
+ current = (
+ {
+ "kind": next_cover.kind,
+ "source_id": (
+ next_cover.attachment_id
+ or next_cover.comment_id
+ or next_cover.external_link_id
+ or next_cover.linked_object_id
+ ),
+ }
+ if next_cover
+ else None
+ )
+ if previous != current:
locked_task.save(update_fields=["updated_at"])
- if previous_id != next_id:
+ if previous != current:
create_task_activity(
locked_task,
request.user,
TaskActivityAction.UPDATED,
metadata={
- "fields": ["cover_attachment_id"],
- "cover_attachment_id": next_id,
+ "fields": ["cover"],
+ "cover": current,
},
)
notify_task_updated(
locked_task,
request.user,
- {"cover_attachment_id": previous_id},
- {"cover_attachment_id": next_id},
+ {"cover": previous},
+ {"cover": current},
)
send_task_board_update(
locked_task.board,
"updated",
"task",
locked_task.id,
- extra={"cover_attachment_id": next_id},
+ extra={"cover": current},
)
refreshed_task = self.get_queryset().get(pk=locked_task.pk)
@@ -2071,6 +2186,11 @@ class TaskViewSet(viewsets.ModelViewSet):
)
comment_text = message.content
+ TaskCover.objects.filter(
+ task=task,
+ kind=TaskCoverType.COMMENT,
+ comment=message,
+ ).delete()
task.attachments.filter(comment=message).update(comment=None)
comments_helpers.delete_comment(
message=message,
diff --git a/backend/tasks/admin.py b/backend/tasks/admin.py
index 4991afe4..1a2ce054 100644
--- a/backend/tasks/admin.py
+++ b/backend/tasks/admin.py
@@ -12,6 +12,7 @@ from .models import (
TaskChecklistItem,
TaskColumn,
TaskColumnRow,
+ TaskCover,
TaskExternalLink,
TaskLabel,
TaskLinkedObject,
@@ -151,16 +152,30 @@ class TaskAttachmentAdmin(admin.ModelAdmin):
list_display = [
"file_name",
"task",
- "is_cover",
"uploaded_by",
"file_size",
"created_at",
]
- list_filter = ["is_cover", "created_at"]
+ list_filter = ["created_at"]
search_fields = ["file_name", "task__title", "uploaded_by__email"]
autocomplete_fields = ["task", "uploaded_by"]
+@admin.register(TaskCover)
+class TaskCoverAdmin(admin.ModelAdmin):
+ list_display = ["task", "kind", "created_by", "updated_at"]
+ list_filter = ["kind", "updated_at"]
+ search_fields = ["task__title", "created_by__email"]
+ autocomplete_fields = [
+ "task",
+ "attachment",
+ "comment",
+ "external_link",
+ "linked_object",
+ "created_by",
+ ]
+
+
@admin.register(TaskChecklistItem)
class TaskChecklistItemAdmin(admin.ModelAdmin):
list_display = [
diff --git a/backend/tasks/migrations/0033_universal_task_covers.py b/backend/tasks/migrations/0033_universal_task_covers.py
new file mode 100644
index 00000000..513baa66
--- /dev/null
+++ b/backend/tasks/migrations/0033_universal_task_covers.py
@@ -0,0 +1,104 @@
+# Generated by Django 5.2.4 on 2026-08-06 09:58
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+def migrate_attachment_covers(apps, schema_editor):
+ TaskAttachment = apps.get_model("tasks", "TaskAttachment")
+ TaskCover = apps.get_model("tasks", "TaskCover")
+ covers = TaskAttachment.objects.filter(is_cover=True).values(
+ "id",
+ "task_id",
+ "uploaded_by_id",
+ )
+ TaskCover.objects.bulk_create(
+ [
+ TaskCover(
+ task_id=cover["task_id"],
+ kind="attachment",
+ attachment_id=cover["id"],
+ created_by_id=cover["uploaded_by_id"],
+ )
+ for cover in covers
+ ],
+ ignore_conflicts=True,
+ )
+
+
+def restore_attachment_covers(apps, schema_editor):
+ TaskAttachment = apps.get_model("tasks", "TaskAttachment")
+ TaskCover = apps.get_model("tasks", "TaskCover")
+ attachment_ids = TaskCover.objects.filter(
+ kind="attachment",
+ attachment_id__isnull=False,
+ ).values_list("attachment_id", flat=True)
+ TaskAttachment.objects.filter(id__in=attachment_ids).update(is_cover=True)
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('communications', '0039_alter_chat_context_object_id_bigint'),
+ ('tasks', '0032_taskattachment_comment'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='TaskCover',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('kind', models.CharField(choices=[('attachment', 'Файл'), ('checklist', 'Чек-лист'), ('comment', 'Комментарий'), ('external_link', 'Внешняя ссылка'), ('linked_object', 'Связанный объект')], max_length=32, verbose_name='Тип')),
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')),
+ ],
+ options={
+ 'verbose_name': 'Обложка задачи',
+ 'verbose_name_plural': 'Обложки задач',
+ },
+ ),
+ migrations.AddField(
+ model_name='taskcover',
+ name='attachment',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='task_covers', to='tasks.taskattachment', verbose_name='Файл'),
+ ),
+ migrations.AddField(
+ model_name='taskcover',
+ name='comment',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='task_covers', to='communications.message', verbose_name='Комментарий'),
+ ),
+ migrations.AddField(
+ model_name='taskcover',
+ name='created_by',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='created_task_covers', to=settings.AUTH_USER_MODEL, verbose_name='Установил'),
+ ),
+ migrations.AddField(
+ model_name='taskcover',
+ name='external_link',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='task_covers', to='tasks.taskexternallink', verbose_name='Внешняя ссылка'),
+ ),
+ migrations.AddField(
+ model_name='taskcover',
+ name='linked_object',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='task_covers', to='tasks.tasklinkedobject', verbose_name='Связанный объект'),
+ ),
+ migrations.AddField(
+ model_name='taskcover',
+ name='task',
+ field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='cover', to='tasks.task', verbose_name='Задача'),
+ ),
+ migrations.RunPython(
+ migrate_attachment_covers,
+ restore_attachment_covers,
+ ),
+ migrations.RemoveConstraint(
+ model_name='taskattachment',
+ name='uniq_task_cover_attachment',
+ ),
+ migrations.RemoveField(
+ model_name='taskattachment',
+ name='is_cover',
+ ),
+ ]
diff --git a/backend/tasks/models.py b/backend/tasks/models.py
index d8677ac1..f020f33f 100644
--- a/backend/tasks/models.py
+++ b/backend/tasks/models.py
@@ -1,6 +1,7 @@
from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
+from django.core.exceptions import ValidationError
from django.db import models
from django.db.models.functions import Lower
from django.utils import timezone
@@ -14,6 +15,14 @@ class TaskPriority(models.TextChoices):
CRITICAL = "critical", "Критический"
+class TaskCoverType(models.TextChoices):
+ ATTACHMENT = "attachment", "Файл"
+ CHECKLIST = "checklist", "Чек-лист"
+ COMMENT = "comment", "Комментарий"
+ EXTERNAL_LINK = "external_link", "Внешняя ссылка"
+ LINKED_OBJECT = "linked_object", "Связанный объект"
+
+
class TaskAutomationKind(models.TextChoices):
EVENT = "event", "Событие"
SCHEDULE = "schedule", "Срок"
@@ -545,7 +554,6 @@ class TaskAttachment(models.Model):
file_name = models.CharField("Название файла", max_length=255)
file_size = models.PositiveBigIntegerField("Размер файла (байты)")
mime_type = models.CharField("MIME-тип", max_length=255, blank=True)
- is_cover = models.BooleanField("Обложка задачи", default=False)
uploaded_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
@@ -559,13 +567,6 @@ class TaskAttachment(models.Model):
verbose_name_plural = "Вложения задач"
ordering = ["created_at", "id"]
indexes = [models.Index(fields=["task", "created_at"])]
- constraints = [
- models.UniqueConstraint(
- fields=["task"],
- condition=models.Q(is_cover=True),
- name="uniq_task_cover_attachment",
- )
- ]
def __str__(self):
return f"{self.task}: {self.file_name}"
@@ -786,3 +787,113 @@ class TaskExternalLink(models.Model):
def __str__(self):
return f"{self.task} -> {self.title or self.url}"
+
+
+class TaskCover(models.Model):
+ task = models.OneToOneField(
+ Task,
+ on_delete=models.CASCADE,
+ related_name="cover",
+ verbose_name="Задача",
+ )
+ kind = models.CharField(
+ "Тип",
+ max_length=32,
+ choices=TaskCoverType.choices,
+ )
+ attachment = models.ForeignKey(
+ TaskAttachment,
+ on_delete=models.CASCADE,
+ related_name="task_covers",
+ null=True,
+ blank=True,
+ verbose_name="Файл",
+ )
+ comment = models.ForeignKey(
+ "communications.Message",
+ on_delete=models.CASCADE,
+ related_name="task_covers",
+ null=True,
+ blank=True,
+ verbose_name="Комментарий",
+ )
+ external_link = models.ForeignKey(
+ TaskExternalLink,
+ on_delete=models.CASCADE,
+ related_name="task_covers",
+ null=True,
+ blank=True,
+ verbose_name="Внешняя ссылка",
+ )
+ linked_object = models.ForeignKey(
+ TaskLinkedObject,
+ on_delete=models.CASCADE,
+ related_name="task_covers",
+ null=True,
+ blank=True,
+ verbose_name="Связанный объект",
+ )
+ created_by = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ on_delete=models.PROTECT,
+ related_name="created_task_covers",
+ verbose_name="Установил",
+ )
+ created_at = models.DateTimeField("Создано", auto_now_add=True)
+ updated_at = models.DateTimeField("Обновлено", auto_now=True)
+
+ class Meta:
+ verbose_name = "Обложка задачи"
+ verbose_name_plural = "Обложки задач"
+
+ def __str__(self):
+ return f"{self.task}: {self.get_kind_display()}"
+
+ def clean(self):
+ super().clean()
+ sources = {
+ TaskCoverType.ATTACHMENT: self.attachment_id,
+ TaskCoverType.CHECKLIST: True,
+ TaskCoverType.COMMENT: self.comment_id,
+ TaskCoverType.EXTERNAL_LINK: self.external_link_id,
+ TaskCoverType.LINKED_OBJECT: self.linked_object_id,
+ }
+ selected_source = sources.get(self.kind)
+ if not selected_source:
+ raise ValidationError("Для выбранного типа укажите источник обложки.")
+
+ populated = sum(
+ value is not None
+ for value in (
+ self.attachment_id,
+ self.comment_id,
+ self.external_link_id,
+ self.linked_object_id,
+ )
+ )
+ expected = 0 if self.kind == TaskCoverType.CHECKLIST else 1
+ if populated != expected:
+ raise ValidationError("Обложка должна содержать только один источник.")
+
+ task_bound_sources = (
+ self.attachment,
+ self.external_link,
+ self.linked_object,
+ )
+ if any(
+ source is not None and source.task_id != self.task_id
+ for source in task_bound_sources
+ ):
+ raise ValidationError("Источник обложки должен принадлежать этой задаче.")
+
+ if self.comment_id:
+ task_content_type = ContentType.objects.get_for_model(Task)
+ chat = self.comment.chat
+ if not (
+ chat.type == "comments"
+ and chat.context_content_type_id == task_content_type.id
+ and chat.context_object_id == self.task_id
+ ):
+ raise ValidationError(
+ "Комментарий обложки должен принадлежать этой задаче."
+ )
diff --git a/backend/tasks/notifications/config.py b/backend/tasks/notifications/config.py
index 96bbe657..c894b175 100644
--- a/backend/tasks/notifications/config.py
+++ b/backend/tasks/notifications/config.py
@@ -124,6 +124,7 @@ class MessageTemplates:
"label_ids": "метки",
"participant_ids": "участников",
"cover_attachment_id": "обложку",
+ "cover": "обложку",
}
changed = ", ".join(field_names.get(field, field) for field in fields)
return (
diff --git a/backend/tasks/notifications/handlers.py b/backend/tasks/notifications/handlers.py
index 5386dca0..bcb0ec95 100644
--- a/backend/tasks/notifications/handlers.py
+++ b/backend/tasks/notifications/handlers.py
@@ -270,6 +270,7 @@ def notify_task_updated(task: Task, actor, previous: dict, current: dict):
"priority",
"label_ids",
"cover_attachment_id",
+ "cover",
)
if previous.get(field) != current.get(field)
]
diff --git a/backend/tests/api/v1/tasks/test_tasks_api.py b/backend/tests/api/v1/tasks/test_tasks_api.py
index 26e1d353..d03dffeb 100644
--- a/backend/tests/api/v1/tasks/test_tasks_api.py
+++ b/backend/tests/api/v1/tasks/test_tasks_api.py
@@ -32,8 +32,12 @@ from tasks.models import (
TaskChecklistItem,
TaskColumn,
TaskColumnRow,
+ TaskCover,
+ TaskCoverType,
TaskExternalLink,
TaskLabel,
+ TaskLinkedObject,
+ TaskLinkedObjectKind,
TaskUserSettings,
)
from tasks.realtime import get_task_board_recipient_ids
@@ -1606,6 +1610,14 @@ def test_task_cover_uses_image_attachment_and_keeps_file_when_cleared(
mime_type="application/pdf",
uploaded_by=user,
)
+ video = TaskAttachment.objects.create(
+ task=task,
+ file=SimpleUploadedFile("demo.mp4", b"video", content_type="video/mp4"),
+ file_name="demo.mp4",
+ file_size=5,
+ mime_type="video/mp4",
+ uploaded_by=user,
+ )
cover_url = reverse("api:v1:tasks:task-cover", kwargs={"pk": task.id})
api_client.force_authenticate(user=member)
@@ -1616,7 +1628,7 @@ def test_task_cover_uses_image_attachment_and_keeps_file_when_cleared(
)
assert first_response.status_code == status.HTTP_200_OK
assert first_response.data["cover_attachment"]["id"] == first_image.id
- assert first_response.data["cover_attachment"]["is_cover"] is True
+ assert first_response.data["cover"]["kind"] == TaskCoverType.ATTACHMENT
second_response = api_client.post(
cover_url,
@@ -1624,11 +1636,7 @@ def test_task_cover_uses_image_attachment_and_keeps_file_when_cleared(
format="json",
)
assert second_response.status_code == status.HTTP_200_OK
- first_image.refresh_from_db()
- second_image.refresh_from_db()
- assert first_image.is_cover is False
- assert second_image.is_cover is True
- assert TaskAttachment.objects.filter(task=task, is_cover=True).count() == 1
+ assert TaskCover.objects.get(task=task).attachment == second_image
board_response = api_client.get(
reverse("api:v1:tasks:task-board-detail", kwargs={"pk": board.id})
)
@@ -1638,14 +1646,21 @@ def test_task_cover_uses_image_attachment_and_keeps_file_when_cleared(
)
assert serialized_task["cover_attachment"]["id"] == second_image.id
+ video_response = api_client.post(
+ cover_url,
+ {"kind": TaskCoverType.ATTACHMENT, "source_id": video.id},
+ format="json",
+ )
+ assert video_response.status_code == status.HTTP_200_OK
+ assert video_response.data["cover"]["attachment"]["mime_type"] == "video/mp4"
+
invalid_response = api_client.post(
cover_url,
{"attachment_id": document.id},
format="json",
)
assert invalid_response.status_code == status.HTTP_400_BAD_REQUEST
- second_image.refresh_from_db()
- assert second_image.is_cover is True
+ assert TaskCover.objects.get(task=task).attachment == video
api_client.force_authenticate(user=outsider)
assert (
@@ -1661,8 +1676,117 @@ def test_task_cover_uses_image_attachment_and_keeps_file_when_cleared(
clear_response = api_client.delete(cover_url)
assert clear_response.status_code == status.HTTP_200_OK
assert clear_response.data["cover_attachment"] is None
- assert TaskAttachment.objects.filter(task=task).count() == 3
- assert not TaskAttachment.objects.filter(task=task, is_cover=True).exists()
+ assert clear_response.data["cover"] is None
+ assert TaskAttachment.objects.filter(task=task).count() == 4
+ assert not TaskCover.objects.filter(task=task).exists()
+
+
+def test_task_cover_supports_checklist_comment_link_and_linked_object(
+ api_client,
+ user,
+):
+ api_client.force_authenticate(user=user)
+ board = TaskBoard.objects.create(name="Универсальные обложки", created_by=user)
+ column = TaskColumn.objects.create(board=board, name="Новые", position=1000)
+ task = Task.objects.create(
+ board=board,
+ column=column,
+ title="Источники обложки",
+ created_by=user,
+ )
+ checklist_item = TaskChecklistItem.objects.create(
+ task=task,
+ title="Проверить результат",
+ position=1000,
+ created_by=user,
+ )
+ link = TaskExternalLink.objects.create(
+ task=task,
+ url="https://example.com/preview",
+ title="Пример ссылки",
+ created_by=user,
+ )
+ document = Document.objects.create(
+ title="Связанный документ",
+ description="Описание документа",
+ uploaded_by=user,
+ )
+ linked_object = TaskLinkedObject.objects.create(
+ task=task,
+ kind=TaskLinkedObjectKind.DOCUMENT,
+ content_type=ContentType.objects.get_for_model(Document),
+ object_id=document.id,
+ created_by=user,
+ )
+ cover_url = reverse("api:v1:tasks:task-cover", kwargs={"pk": task.id})
+
+ checklist_response = api_client.post(
+ cover_url,
+ {"kind": TaskCoverType.CHECKLIST},
+ format="json",
+ )
+ assert checklist_response.status_code == status.HTTP_200_OK
+ assert checklist_response.data["cover"]["checklist"] == {
+ "total": 1,
+ "completed": 0,
+ "items": [
+ {
+ "id": checklist_item.id,
+ "title": checklist_item.title,
+ "is_completed": False,
+ }
+ ],
+ }
+
+ comment_response = api_client.post(
+ reverse("api:v1:tasks:task-comments", kwargs={"pk": task.id}),
+ {"text": "Комментарий на обложке"},
+ format="json",
+ )
+ assert comment_response.status_code == status.HTTP_201_CREATED
+ comment_cover_response = api_client.post(
+ cover_url,
+ {
+ "kind": TaskCoverType.COMMENT,
+ "source_id": comment_response.data["id"],
+ },
+ format="json",
+ )
+ assert comment_cover_response.status_code == status.HTTP_200_OK
+ assert comment_cover_response.data["cover"]["comment"]["text"] == (
+ "Комментарий на обложке"
+ )
+ delete_comment_response = api_client.delete(
+ reverse(
+ "api:v1:tasks:task-delete-comment",
+ kwargs={"pk": task.id, "comment_id": comment_response.data["id"]},
+ )
+ )
+ assert delete_comment_response.status_code == status.HTTP_204_NO_CONTENT
+ assert not TaskCover.objects.filter(task=task).exists()
+
+ link_response = api_client.post(
+ cover_url,
+ {"kind": TaskCoverType.EXTERNAL_LINK, "source_id": link.id},
+ format="json",
+ )
+ assert link_response.status_code == status.HTTP_200_OK
+ assert link_response.data["cover"]["external_link"]["title"] == (
+ "Пример ссылки"
+ )
+
+ object_response = api_client.post(
+ cover_url,
+ {
+ "kind": TaskCoverType.LINKED_OBJECT,
+ "source_id": linked_object.id,
+ },
+ format="json",
+ )
+ assert object_response.status_code == status.HTTP_200_OK
+ assert object_response.data["cover"]["linked_object"]["title"] == (
+ "Связанный документ"
+ )
def test_task_external_links_follow_board_access_and_support_lifecycle(
diff --git a/frontend/src/app/tasks/page.tsx b/frontend/src/app/tasks/page.tsx
index 3a50ba26..fe204022 100644
--- a/frontend/src/app/tasks/page.tsx
+++ b/frontend/src/app/tasks/page.tsx
@@ -75,7 +75,7 @@ import {
import { AppShell } from "@/components/AppShell";
import TaskBoardAvatar from "@/components/tasks/TaskBoardAvatar";
import TaskBoardNavigator from "@/components/tasks/TaskBoardNavigator";
-import TaskCoverImage from "@/components/tasks/TaskCoverImage";
+import TaskCoverPreview from "@/components/tasks/TaskCoverPreview";
import TaskCommentAttachment from "@/components/tasks/TaskCommentAttachment";
import TaskAutomationManager from "@/components/tasks/TaskAutomationManager";
import { RequestAvatar } from "@/components/requests/RequestAvatar";
@@ -104,6 +104,7 @@ import type {
TaskColumn,
TaskColumnRow,
TaskComment,
+ TaskCoverType,
TaskExternalLink,
TaskLinkedAttendanceRecord,
TaskLinkedDocument,
@@ -170,6 +171,14 @@ type LinkedItemMode = "document" | "calendar_event" | "external_link";
type LinkedItemTarget = "view" | "form";
type TaskAddPanel = "due_date" | "labels" | null;
+type TaskCoverLinkedOption = {
+ id: number;
+ kind: string;
+ kindLabel: string;
+ title: string;
+ description: string;
+};
+
type TaskFormExternalLinkDraft = {
key: string;
url: string;
@@ -434,11 +443,29 @@ function isImageAttachment(attachment: TaskAttachment): boolean {
return /\.(avif|bmp|gif|heic|heif|jpe?g|png|webp)$/i.test(attachment.file_name);
}
+function isVideoAttachment(attachment: TaskAttachment): boolean {
+ if (attachment.mime_type?.toLowerCase().startsWith("video/")) return true;
+ return /\.(m4v|mkv|mov|mp4|mpeg|mpg|ogv|webm)$/i.test(attachment.file_name);
+}
+
+function isMediaAttachment(attachment: TaskAttachment): boolean {
+ return isImageAttachment(attachment) || isVideoAttachment(attachment);
+}
+
function isImageFile(file: File): boolean {
if (file.type.toLowerCase().startsWith("image/")) return true;
return /\.(avif|bmp|gif|heic|heif|jpe?g|png|webp)$/i.test(file.name);
}
+function isVideoFile(file: File): boolean {
+ if (file.type.toLowerCase().startsWith("video/")) return true;
+ return /\.(m4v|mkv|mov|mp4|mpeg|mpg|ogv|webm)$/i.test(file.name);
+}
+
+function isMediaFile(file: File): boolean {
+ return isImageFile(file) || isVideoFile(file);
+}
+
const TASK_COMMENT_URL_PATTERN = /(https?:\/\/[^\s<>"']+)/gi;
const TASK_COMMENT_URL_TRAILING_PUNCTUATION = /[.,;:!?)}\]]+$/;
@@ -521,10 +548,10 @@ function TaskCardView({
{...attributes}
{...listeners}
>
- {task.cover_attachment ? (
-
) : null}
@@ -2594,6 +2621,101 @@ function TaskFormResourceRow({
);
}
+function TaskCoverOption({
+ icon: Icon,
+ title,
+ meta,
+ selected,
+ disabled,
+ onSelect,
+}: {
+ icon: LucideIcon;
+ title: string;
+ meta?: string;
+ selected: boolean;
+ disabled?: boolean;
+ onSelect: () => void;
+}) {
+ return (
+
+ );
+}
+
+function TaskCoverToggleButton({
+ isCover,
+ disabled,
+ onToggle,
+ className = "",
+}: {
+ isCover: boolean;
+ disabled?: boolean;
+ onToggle: () => void;
+ className?: string;
+}) {
+ return (
+
+ );
+}
+
+function CoverableTaskResource({
+ children,
+ isCover,
+ disabled,
+ onToggle,
+}: {
+ children: ReactNode;
+ isCover: boolean;
+ disabled?: boolean;
+ onToggle: () => void;
+}) {
+ return (
+
+ {children}
+
+
+ );
+}
+
function TaskChecklistFormRow({
title,
@@ -2985,6 +3107,7 @@ function TasksPageContent() {
const [taskFormAttachments, setTaskFormAttachments] = useState([]);
const [taskFormCoverAttachmentId, setTaskFormCoverAttachmentId] = useState(null);
const [taskFormCoverPendingFile, setTaskFormCoverPendingFile] = useState(null);
+ const [taskFormCoverChanged, setTaskFormCoverChanged] = useState(false);
const [taskFormDocuments, setTaskFormDocuments] = useState([]);
const [taskFormEvents, setTaskFormEvents] = useState([]);
const [taskFormExternalLinks, setTaskFormExternalLinks] = useState([]);
@@ -3003,6 +3126,8 @@ function TasksPageContent() {
const [taskCommentsLoading, setTaskCommentsLoading] = useState(false);
const [commentDraft, setCommentDraft] = useState("");
const [commentFiles, setCommentFiles] = useState([]);
+ const [coverPickerOpen, setCoverPickerOpen] = useState(false);
+ const [coverPickerLoading, setCoverPickerLoading] = useState(false);
const [attachmentsOpen, setAttachmentsOpen] = useState(false);
const [taskAttachments, setTaskAttachments] = useState([]);
const [taskAttachmentsLoading, setTaskAttachmentsLoading] = useState(false);
@@ -3331,6 +3456,7 @@ function TasksPageContent() {
setTaskFormAttachments([]);
setTaskFormCoverAttachmentId(null);
setTaskFormCoverPendingFile(null);
+ setTaskFormCoverChanged(false);
setTaskFormDocuments([]);
setTaskFormEvents([]);
setTaskFormExternalLinks([]);
@@ -3358,10 +3484,14 @@ function TasksPageContent() {
if (requestId !== taskFormResourcesRequestRef.current) return;
setTaskFormChecklistItems(checklist);
setTaskFormAttachments(attachments);
+ const currentCover = board?.tasks.find((task) => task.id === taskId)?.cover;
setTaskFormCoverAttachmentId(
- attachments.find((attachment) => attachment.is_cover)?.id ?? null,
+ currentCover?.kind === "attachment"
+ ? currentCover.attachment?.id ?? null
+ : null,
);
setTaskFormCoverPendingFile(null);
+ setTaskFormCoverChanged(false);
setTaskFormDocuments(documents);
setTaskFormEvents(events);
setTaskFormExternalLinks(externalLinks);
@@ -3374,7 +3504,7 @@ function TasksPageContent() {
setTaskFormResourcesLoading(false);
}
}
- }, []);
+ }, [board?.tasks]);
const loadTaskComments = useCallback(async (taskId: number) => {
setTaskCommentsLoading(true);
@@ -4005,6 +4135,89 @@ function TasksPageContent() {
));
}, [participantModalSearch, taskParticipantCandidates]);
const linkedObjectsCount = linkedPosts.length + linkedMessages.length + linkedEvents.length + linkedDocuments.length + linkedRequests.length + linkedProcurementRequests.length + linkedEmployees.length + linkedGuests.length + linkedGuestVisits.length + linkedAttendanceRecords.length + taskExternalLinks.length;
+ const taskCoverLinkedOptions = useMemo(() => [
+ ...linkedPosts.map((link) => ({
+ id: link.id,
+ kind: link.kind,
+ kindLabel: "Новость",
+ title: link.post?.title || "Новость",
+ description: link.post?.body || "",
+ })),
+ ...linkedMessages.map((link) => ({
+ id: link.id,
+ kind: link.kind,
+ kindLabel: "Сообщение",
+ title: link.message?.author ? displayUserName(link.message.author) : "Сообщение",
+ description: link.message?.content || "",
+ })),
+ ...linkedEvents.map((link) => ({
+ id: link.id,
+ kind: link.kind,
+ kindLabel: "Календарное событие",
+ title: link.event?.title || "Календарное событие",
+ description: link.event?.description || "",
+ })),
+ ...linkedDocuments.map((link) => ({
+ id: link.id,
+ kind: link.kind,
+ kindLabel: "Документ",
+ title: link.document?.title || "Документ",
+ description: link.document?.description || "",
+ })),
+ ...linkedRequests.map((link) => ({
+ id: link.id,
+ kind: link.kind,
+ kindLabel: "Заявление",
+ title: link.request?.display_title || link.request?.title || "Заявление",
+ description: link.request?.comment || "",
+ })),
+ ...linkedProcurementRequests.map((link) => ({
+ id: link.id,
+ kind: link.kind,
+ kindLabel: "Заявка на закупку",
+ title: link.procurement_request?.title || "Заявка на закупку",
+ description: link.procurement_request?.description || "",
+ })),
+ ...linkedEmployees.map((link) => ({
+ id: link.id,
+ kind: link.kind,
+ kindLabel: "Сотрудник",
+ title: link.employee?.display_name || link.employee?.full_name || "Сотрудник",
+ description: link.employee?.position?.name || "",
+ })),
+ ...linkedGuests.map((link) => ({
+ id: link.id,
+ kind: link.kind,
+ kindLabel: "Гость",
+ title: link.guest?.full_name || "Гость",
+ description: link.guest?.organization || "",
+ })),
+ ...linkedGuestVisits.map((link) => ({
+ id: link.id,
+ kind: link.kind,
+ kindLabel: "Гостевой визит",
+ title: link.guest_visit?.guest?.full_name || "Гостевой визит",
+ description: link.guest_visit?.purpose || "",
+ })),
+ ...linkedAttendanceRecords.map((link) => ({
+ id: link.id,
+ kind: link.kind,
+ kindLabel: "Запись посещаемости",
+ title: link.attendance_record?.display_name || "Запись посещаемости",
+ description: link.attendance_record?.date || "",
+ })),
+ ], [
+ linkedAttendanceRecords,
+ linkedDocuments,
+ linkedEmployees,
+ linkedEvents,
+ linkedGuestVisits,
+ linkedGuests,
+ linkedMessages,
+ linkedPosts,
+ linkedProcurementRequests,
+ linkedRequests,
+ ]);
const linkedObjectsBadgeCount = viewTask?.linked_objects_count ?? linkedObjectsCount;
const commentsBadgeCount = viewTask?.comments_count ?? taskComments.length;
const attachmentsBadgeCount = viewTask?.attachments_count ?? taskAttachments.length;
@@ -4068,6 +4281,8 @@ function TasksPageContent() {
setTaskChecklistDraft("");
setCommentDraft("");
setCommentFiles([]);
+ setCoverPickerOpen(false);
+ setCoverPickerLoading(false);
setLinkedObjectsOpen(false);
setCommentsOpen(false);
setAttachmentsOpen(false);
@@ -4244,6 +4459,8 @@ function TasksPageContent() {
setTaskDetailsOpen(false);
setCommentDraft("");
setCommentFiles([]);
+ setCoverPickerOpen(false);
+ setCoverPickerLoading(false);
setCardMenuTaskId(null);
void loadTaskAutomationButtons(task.id);
}, [loadTaskAutomationButtons]);
@@ -4290,6 +4507,8 @@ function TasksPageContent() {
setActivityOpen(false);
setCommentDraft("");
setCommentFiles([]);
+ setCoverPickerOpen(false);
+ setCoverPickerLoading(false);
setTaskChecklistDraft("");
setParticipantModalOpen(false);
setParticipantModalSearch("");
@@ -5029,13 +5248,19 @@ function TasksPageContent() {
setTaskFormAttachments((current) => [...current, ...uploaded]);
}
- if (selectedCoverAttachmentId) {
- await apiClient.setTaskCover(taskId, selectedCoverAttachmentId);
- } else {
- await apiClient.clearTaskCover(taskId);
+ if (taskFormCoverChanged) {
+ if (selectedCoverAttachmentId) {
+ await apiClient.setTaskCover(taskId, {
+ kind: "attachment",
+ source_id: selectedCoverAttachmentId,
+ });
+ } else {
+ await apiClient.clearTaskCover(taskId);
+ }
}
setTaskFormCoverAttachmentId(selectedCoverAttachmentId);
setTaskFormCoverPendingFile(null);
+ setTaskFormCoverChanged(false);
}, [
taskFormChecklistItems,
taskFormDirtyChecklistItemIds,
@@ -5045,6 +5270,7 @@ function TasksPageContent() {
taskFormPendingFiles,
taskFormPendingChecklistItems,
taskFormCoverAttachmentId,
+ taskFormCoverChanged,
taskFormCoverPendingFile,
taskFormRemovedAttachmentIds,
taskFormRemovedChecklistItemIds,
@@ -5330,14 +5556,18 @@ function TasksPageContent() {
setTaskAttachmentsUploading(true);
setError(null);
try {
- const updatedTask = attachment.is_cover
- ? await apiClient.clearTaskCover(viewTask.id)
- : await apiClient.setTaskCover(viewTask.id, attachment.id);
- const coverId = updatedTask.cover_attachment?.id ?? null;
- setTaskAttachments((current) => current.map((item) => ({
- ...item,
- is_cover: item.id === coverId,
- })));
+ const isCurrentCover = (
+ viewTask.cover?.kind === "attachment"
+ && viewTask.cover.attachment?.id === attachment.id
+ );
+ if (isCurrentCover) {
+ await apiClient.clearTaskCover(viewTask.id);
+ } else {
+ await apiClient.setTaskCover(viewTask.id, {
+ kind: "attachment",
+ source_id: attachment.id,
+ });
+ }
await loadBoard(board.id);
if (activityOpen) await loadTaskActivity(viewTask.id);
} catch (attachmentError) {
@@ -5347,6 +5577,89 @@ function TasksPageContent() {
}
}, [activityOpen, board, loadBoard, loadTaskActivity, taskAttachmentsUploading, viewTask]);
+ const openTaskCoverPicker = useCallback(async () => {
+ if (!viewTask || coverPickerLoading) return;
+ setTaskAddMenuOpen(false);
+ setCoverPickerOpen(true);
+ setCoverPickerLoading(true);
+ setError(null);
+ try {
+ await Promise.all([
+ loadTaskAttachments(viewTask.id),
+ loadTaskChecklist(viewTask.id),
+ loadTaskComments(viewTask.id),
+ loadTaskLinkedObjects(viewTask.id),
+ ]);
+ } finally {
+ setCoverPickerLoading(false);
+ }
+ }, [
+ coverPickerLoading,
+ loadTaskAttachments,
+ loadTaskChecklist,
+ loadTaskComments,
+ loadTaskLinkedObjects,
+ viewTask,
+ ]);
+
+ const selectViewedTaskCover = useCallback(async (
+ kind: TaskCoverType,
+ sourceId?: number,
+ ) => {
+ if (!board || !viewTask || saving) return;
+ setSaving(true);
+ setError(null);
+ try {
+ await apiClient.setTaskCover(viewTask.id, {
+ kind,
+ ...(sourceId ? { source_id: sourceId } : {}),
+ });
+ await loadBoard(board.id);
+ if (activityOpen) await loadTaskActivity(viewTask.id);
+ setCoverPickerOpen(false);
+ } catch (coverError) {
+ setError(getTaskError(coverError, "Не удалось установить обложку задачи"));
+ } finally {
+ setSaving(false);
+ }
+ }, [activityOpen, board, loadBoard, loadTaskActivity, saving, viewTask]);
+
+ const clearViewedTaskCover = useCallback(async () => {
+ if (!board || !viewTask || saving) return;
+ setSaving(true);
+ setError(null);
+ try {
+ await apiClient.clearTaskCover(viewTask.id);
+ await loadBoard(board.id);
+ if (activityOpen) await loadTaskActivity(viewTask.id);
+ setCoverPickerOpen(false);
+ } catch (coverError) {
+ setError(getTaskError(coverError, "Не удалось убрать обложку задачи"));
+ } finally {
+ setSaving(false);
+ }
+ }, [activityOpen, board, loadBoard, loadTaskActivity, saving, viewTask]);
+
+ const toggleViewedTaskResourceCover = useCallback(async (
+ kind: TaskCoverType,
+ sourceId?: number,
+ ) => {
+ if (!viewTask || saving) return;
+ const cover = viewTask.cover;
+ const isCurrent = cover?.kind === kind && (
+ kind === "checklist"
+ || (kind === "comment" && cover.comment?.id === sourceId)
+ || (kind === "external_link" && cover.external_link?.id === sourceId)
+ || (kind === "linked_object" && cover.linked_object?.id === sourceId)
+ || (kind === "attachment" && cover.attachment?.id === sourceId)
+ );
+ if (isCurrent) {
+ await clearViewedTaskCover();
+ return;
+ }
+ await selectViewedTaskCover(kind, sourceId);
+ }, [clearViewedTaskCover, saving, selectViewedTaskCover, viewTask]);
+
const addViewedTaskChecklistItem = useCallback(async () => {
const title = taskChecklistDraft.trim();
if (!board || !viewTask || !title || taskChecklistSaving) return;
@@ -5495,23 +5808,31 @@ function TasksPageContent() {
setTaskFormCoverAttachmentId((current) => (
current === attachment.id ? null : current
));
- }, []);
+ if (taskFormCoverAttachmentId === attachment.id) {
+ setTaskFormCoverChanged(true);
+ }
+ }, [taskFormCoverAttachmentId]);
const removePendingTaskFormFile = useCallback((file: File) => {
setTaskFormPendingFiles((current) => current.filter((item) => item !== file));
setTaskFormCoverPendingFile((current) => (current === file ? null : current));
- }, []);
+ if (taskFormCoverPendingFile === file) {
+ setTaskFormCoverChanged(true);
+ }
+ }, [taskFormCoverPendingFile]);
const toggleExistingTaskFormCover = useCallback((attachment: TaskAttachment) => {
setTaskFormCoverAttachmentId((current) => (
current === attachment.id ? null : attachment.id
));
setTaskFormCoverPendingFile(null);
+ setTaskFormCoverChanged(true);
}, []);
const togglePendingTaskFormCover = useCallback((file: File) => {
setTaskFormCoverPendingFile((current) => (current === file ? null : file));
setTaskFormCoverAttachmentId(null);
+ setTaskFormCoverChanged(true);
}, []);
const removeExistingTaskFormDocument = useCallback((link: TaskLinkedDocument) => {
@@ -7213,6 +7534,14 @@ function TasksPageContent() {
Метки
+
+ {checklistBadgeTotal > 0 ? (
+ void toggleViewedTaskResourceCover("checklist")}
+ />
+ ) : null}
- {isImageAttachment(attachment) ? (
+ {isMediaAttachment(attachment) ? (
@@ -7932,8 +8268,16 @@ function TasksPageContent() {
{comment.is_edited ? " · изменён" : ""}
- {editingCommentId !== comment.id && (canEditComment || canDeleteComment) ? (
+ {editingCommentId !== comment.id ? (
+
void toggleViewedTaskResourceCover("comment", comment.id)}
+ />
{canEditComment ? (