feat(tasks): add universal task covers

This commit is contained in:
lizerk 2026-08-06 13:35:30 +03:00
parent 6b0643eb75
commit f2d24f70e3
13 changed files with 1674 additions and 243 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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(
"Комментарий обложки должен принадлежать этой задаче."
)

View File

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

View File

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

View File

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

View File

@ -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 ? (
<TaskCoverImage
{task.cover ? (
<TaskCoverPreview
taskId={task.id}
attachment={task.cover_attachment}
cover={task.cover}
className="-mx-3 -mt-3 mb-3 aspect-[16/7] rounded-t-[11px] border-b border-[var(--border-subtle)]"
/>
) : 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 (
<button
type="button"
onClick={onSelect}
disabled={disabled}
className={`${selected ? "app-selected" : "app-surface-muted"} flex min-w-0 items-center gap-3 rounded-lg border border-[var(--border-subtle)] px-3 py-2.5 text-left transition hover:border-[var(--border-strong)] disabled:opacity-50`}
aria-pressed={selected}
>
<span className="app-surface-elevated flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-[var(--border-subtle)]">
<Icon size={16} />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-[var(--foreground)]">
{title}
</span>
{meta ? (
<span className="app-text-muted mt-0.5 block truncate text-[11px]">
{meta}
</span>
) : null}
</span>
{selected ? <Check size={15} className="shrink-0 text-sky-400" /> : null}
</button>
);
}
function TaskCoverToggleButton({
isCover,
disabled,
onToggle,
className = "",
}: {
isCover: boolean;
disabled?: boolean;
onToggle: () => void;
className?: string;
}) {
return (
<button
type="button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onToggle();
}}
disabled={disabled}
className={`${isCover ? "app-action-primary" : "app-icon-button"} ${className} flex h-8 w-8 shrink-0 items-center justify-center rounded-lg disabled:opacity-50`}
title={isCover ? "Убрать обложку" : "Сделать обложкой"}
aria-label={isCover ? "Убрать обложку" : "Сделать обложкой"}
aria-pressed={isCover}
>
<ImagePlus size={14} />
</button>
);
}
function CoverableTaskResource({
children,
isCover,
disabled,
onToggle,
}: {
children: ReactNode;
isCover: boolean;
disabled?: boolean;
onToggle: () => void;
}) {
return (
<div className="relative">
{children}
<TaskCoverToggleButton
isCover={isCover}
disabled={disabled}
onToggle={onToggle}
className="absolute right-3 top-12 z-20"
/>
</div>
);
}
function TaskChecklistFormRow({
title,
@ -2985,6 +3107,7 @@ function TasksPageContent() {
const [taskFormAttachments, setTaskFormAttachments] = useState<TaskAttachment[]>([]);
const [taskFormCoverAttachmentId, setTaskFormCoverAttachmentId] = useState<number | null>(null);
const [taskFormCoverPendingFile, setTaskFormCoverPendingFile] = useState<File | null>(null);
const [taskFormCoverChanged, setTaskFormCoverChanged] = useState(false);
const [taskFormDocuments, setTaskFormDocuments] = useState<TaskLinkedDocument[]>([]);
const [taskFormEvents, setTaskFormEvents] = useState<TaskLinkedCalendarEvent[]>([]);
const [taskFormExternalLinks, setTaskFormExternalLinks] = useState<TaskExternalLink[]>([]);
@ -3003,6 +3126,8 @@ function TasksPageContent() {
const [taskCommentsLoading, setTaskCommentsLoading] = useState(false);
const [commentDraft, setCommentDraft] = useState("");
const [commentFiles, setCommentFiles] = useState<File[]>([]);
const [coverPickerOpen, setCoverPickerOpen] = useState(false);
const [coverPickerLoading, setCoverPickerLoading] = useState(false);
const [attachmentsOpen, setAttachmentsOpen] = useState(false);
const [taskAttachments, setTaskAttachments] = useState<TaskAttachment[]>([]);
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<TaskCoverLinkedOption[]>(() => [
...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() {
<Tag size={15} className="app-text-muted" />
Метки
</button>
<button
type="button"
onClick={() => void openTaskCoverPicker()}
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition hover:bg-[var(--surface-secondary)]"
>
<ImagePlus size={15} className="app-text-muted" />
Обложка
</button>
<button
type="button"
onClick={() => {
@ -7643,6 +7972,13 @@ function TasksPageContent() {
{checklistBadgeCompleted}/{checklistBadgeTotal}
</span>
</button>
{checklistBadgeTotal > 0 ? (
<TaskCoverToggleButton
isCover={viewTask.cover?.kind === "checklist"}
disabled={saving}
onToggle={() => void toggleViewedTaskResourceCover("checklist")}
/>
) : null}
<button
type="button"
onClick={() => setChecklistOpen((current) => !current)}
@ -7841,17 +8177,17 @@ function TasksPageContent() {
</span>
<Download size={14} className="app-text-muted shrink-0" />
</button>
{isImageAttachment(attachment) ? (
{isMediaAttachment(attachment) ? (
<button
type="button"
onClick={() => void toggleViewedTaskCover(attachment)}
disabled={taskAttachmentsUploading}
className={`${attachment.is_cover ? "app-action-primary" : "app-icon-button"} flex h-8 w-8 shrink-0 items-center justify-center rounded-lg disabled:opacity-50`}
title={attachment.is_cover ? "Убрать обложку" : "Сделать обложкой"}
aria-label={attachment.is_cover
className={`${viewTask.cover?.kind === "attachment" && viewTask.cover.attachment?.id === attachment.id ? "app-action-primary" : "app-icon-button"} flex h-8 w-8 shrink-0 items-center justify-center rounded-lg disabled:opacity-50`}
title={viewTask.cover?.kind === "attachment" && viewTask.cover.attachment?.id === attachment.id ? "Убрать обложку" : "Сделать обложкой"}
aria-label={viewTask.cover?.kind === "attachment" && viewTask.cover.attachment?.id === attachment.id
? `Убрать обложку ${attachment.file_name}`
: `Сделать обложкой ${attachment.file_name}`}
aria-pressed={attachment.is_cover}
aria-pressed={viewTask.cover?.kind === "attachment" && viewTask.cover.attachment?.id === attachment.id}
>
<ImagePlus size={14} />
</button>
@ -7932,8 +8268,16 @@ function TasksPageContent() {
{comment.is_edited ? " · изменён" : ""}
</p>
</div>
{editingCommentId !== comment.id && (canEditComment || canDeleteComment) ? (
{editingCommentId !== comment.id ? (
<div className="flex shrink-0 items-center gap-1">
<TaskCoverToggleButton
isCover={
viewTask.cover?.kind === "comment"
&& viewTask.cover.comment?.id === comment.id
}
disabled={saving}
onToggle={() => void toggleViewedTaskResourceCover("comment", comment.id)}
/>
{canEditComment ? (
<button
type="button"
@ -8092,92 +8436,191 @@ function TasksPageContent() {
) : linkedObjectsCount > 0 ? (
<>
{linkedPosts.map((link) => (
<LinkedPostCard
<CoverableTaskResource
key={link.id}
link={link}
onUnlink={unlinkPostFromViewedTask}
isCover={
viewTask.cover?.kind === "linked_object"
&& viewTask.cover.linked_object?.id === link.id
}
disabled={saving}
/>
onToggle={() => void toggleViewedTaskResourceCover("linked_object", link.id)}
>
<LinkedPostCard
link={link}
onUnlink={unlinkPostFromViewedTask}
disabled={saving}
/>
</CoverableTaskResource>
))}
{linkedMessages.map((link) => (
<LinkedMessageCard
<CoverableTaskResource
key={link.id}
link={link}
onUnlink={unlinkMessageFromViewedTask}
isCover={
viewTask.cover?.kind === "linked_object"
&& viewTask.cover.linked_object?.id === link.id
}
disabled={saving}
/>
onToggle={() => void toggleViewedTaskResourceCover("linked_object", link.id)}
>
<LinkedMessageCard
link={link}
onUnlink={unlinkMessageFromViewedTask}
disabled={saving}
/>
</CoverableTaskResource>
))}
{linkedEvents.map((link) => (
<LinkedCalendarEventCard
<CoverableTaskResource
key={link.id}
link={link}
onUnlink={unlinkEventFromViewedTask}
isCover={
viewTask.cover?.kind === "linked_object"
&& viewTask.cover.linked_object?.id === link.id
}
disabled={saving}
/>
onToggle={() => void toggleViewedTaskResourceCover("linked_object", link.id)}
>
<LinkedCalendarEventCard
link={link}
onUnlink={unlinkEventFromViewedTask}
disabled={saving}
/>
</CoverableTaskResource>
))}
{linkedDocuments.map((link) => (
<LinkedDocumentCard
<CoverableTaskResource
key={link.id}
link={link}
onUnlink={unlinkDocumentFromViewedTask}
isCover={
viewTask.cover?.kind === "linked_object"
&& viewTask.cover.linked_object?.id === link.id
}
disabled={saving}
/>
onToggle={() => void toggleViewedTaskResourceCover("linked_object", link.id)}
>
<LinkedDocumentCard
link={link}
onUnlink={unlinkDocumentFromViewedTask}
disabled={saving}
/>
</CoverableTaskResource>
))}
{linkedRequests.map((link) => (
<LinkedRequestCard
<CoverableTaskResource
key={link.id}
link={link}
onUnlink={unlinkRequestFromViewedTask}
isCover={
viewTask.cover?.kind === "linked_object"
&& viewTask.cover.linked_object?.id === link.id
}
disabled={saving}
/>
onToggle={() => void toggleViewedTaskResourceCover("linked_object", link.id)}
>
<LinkedRequestCard
link={link}
onUnlink={unlinkRequestFromViewedTask}
disabled={saving}
/>
</CoverableTaskResource>
))}
{linkedProcurementRequests.map((link) => (
<LinkedProcurementRequestCard
<CoverableTaskResource
key={link.id}
link={link}
onUnlink={unlinkProcurementRequestFromViewedTask}
isCover={
viewTask.cover?.kind === "linked_object"
&& viewTask.cover.linked_object?.id === link.id
}
disabled={saving}
/>
onToggle={() => void toggleViewedTaskResourceCover("linked_object", link.id)}
>
<LinkedProcurementRequestCard
link={link}
onUnlink={unlinkProcurementRequestFromViewedTask}
disabled={saving}
/>
</CoverableTaskResource>
))}
{linkedEmployees.map((link) => (
<LinkedEmployeeCard
<CoverableTaskResource
key={link.id}
link={link}
onUnlink={unlinkEmployeeFromViewedTask}
isCover={
viewTask.cover?.kind === "linked_object"
&& viewTask.cover.linked_object?.id === link.id
}
disabled={saving}
/>
onToggle={() => void toggleViewedTaskResourceCover("linked_object", link.id)}
>
<LinkedEmployeeCard
link={link}
onUnlink={unlinkEmployeeFromViewedTask}
disabled={saving}
/>
</CoverableTaskResource>
))}
{linkedGuests.map((link) => (
<LinkedGuestCard
<CoverableTaskResource
key={link.id}
link={link}
onUnlink={unlinkGuestFromViewedTask}
isCover={
viewTask.cover?.kind === "linked_object"
&& viewTask.cover.linked_object?.id === link.id
}
disabled={saving}
/>
onToggle={() => void toggleViewedTaskResourceCover("linked_object", link.id)}
>
<LinkedGuestCard
link={link}
onUnlink={unlinkGuestFromViewedTask}
disabled={saving}
/>
</CoverableTaskResource>
))}
{linkedGuestVisits.map((link) => (
<LinkedGuestVisitCard
<CoverableTaskResource
key={link.id}
link={link}
onUnlink={unlinkGuestVisitFromViewedTask}
isCover={
viewTask.cover?.kind === "linked_object"
&& viewTask.cover.linked_object?.id === link.id
}
disabled={saving}
/>
onToggle={() => void toggleViewedTaskResourceCover("linked_object", link.id)}
>
<LinkedGuestVisitCard
link={link}
onUnlink={unlinkGuestVisitFromViewedTask}
disabled={saving}
/>
</CoverableTaskResource>
))}
{linkedAttendanceRecords.map((link) => (
<LinkedAttendanceRecordCard
<CoverableTaskResource
key={link.id}
link={link}
onUnlink={unlinkAttendanceRecordFromViewedTask}
isCover={
viewTask.cover?.kind === "linked_object"
&& viewTask.cover.linked_object?.id === link.id
}
disabled={saving}
/>
onToggle={() => void toggleViewedTaskResourceCover("linked_object", link.id)}
>
<LinkedAttendanceRecordCard
link={link}
onUnlink={unlinkAttendanceRecordFromViewedTask}
disabled={saving}
/>
</CoverableTaskResource>
))}
{taskExternalLinks.map((link) => (
<TaskExternalLinkCard
<CoverableTaskResource
key={link.id}
link={link}
onDelete={deleteExternalLinkFromViewedTask}
isCover={
viewTask.cover?.kind === "external_link"
&& viewTask.cover.external_link?.id === link.id
}
disabled={saving}
/>
onToggle={() => void toggleViewedTaskResourceCover("external_link", link.id)}
>
<TaskExternalLinkCard
link={link}
onDelete={deleteExternalLinkFromViewedTask}
disabled={saving}
/>
</CoverableTaskResource>
))}
</>
) : (
@ -8297,6 +8740,160 @@ function TasksPageContent() {
) : null}
</Modal>
<Modal
isOpen={coverPickerOpen}
onClose={() => {
if (!saving) setCoverPickerOpen(false);
}}
title="Обложка задачи"
size="md"
stackLevel={1}
closeOnClickOutside
footer={(
<div className="flex justify-between gap-2">
<button
type="button"
onClick={() => void clearViewedTaskCover()}
disabled={saving || !viewTask?.cover}
className="app-action-danger rounded-lg px-3 py-2 text-sm disabled:opacity-50"
>
Убрать обложку
</button>
<button
type="button"
onClick={() => setCoverPickerOpen(false)}
disabled={saving}
className="app-action-secondary rounded-lg px-3 py-2 text-sm disabled:opacity-50"
>
Закрыть
</button>
</div>
)}
>
{coverPickerLoading ? (
<div className="app-surface-muted flex min-h-44 items-center justify-center rounded-xl border border-[var(--border-subtle)]">
<Loader2 size={20} className="animate-spin text-sky-500" />
</div>
) : viewTask ? (
<div className="max-h-[min(62vh,38rem)] space-y-4 overflow-y-auto pr-1 app-scrollbar">
{taskAttachments.some(isMediaAttachment) ? (
<section>
<h3 className="app-card-caption mb-2">Фото и видео</h3>
<div className="grid gap-2 sm:grid-cols-2">
{taskAttachments.filter(isMediaAttachment).map((attachment) => (
<TaskCoverOption
key={`cover-attachment-${attachment.id}`}
icon={isVideoAttachment(attachment) ? Play : ImagePlus}
title={attachment.file_name}
meta={`${isVideoAttachment(attachment) ? "Видео" : "Изображение"} · ${formatFileSize(attachment.file_size)}`}
selected={
viewTask.cover?.kind === "attachment"
&& viewTask.cover.attachment?.id === attachment.id
}
disabled={saving}
onSelect={() => void selectViewedTaskCover("attachment", attachment.id)}
/>
))}
</div>
</section>
) : null}
{taskChecklist.length > 0 ? (
<section>
<h3 className="app-card-caption mb-2">Чек-лист</h3>
<TaskCoverOption
icon={ListChecks}
title="Прогресс чек-листа"
meta={`${taskChecklist.filter((item) => item.is_completed).length}/${taskChecklist.length} выполнено`}
selected={viewTask.cover?.kind === "checklist"}
disabled={saving}
onSelect={() => void selectViewedTaskCover("checklist")}
/>
</section>
) : null}
{taskComments.length > 0 ? (
<section>
<h3 className="app-card-caption mb-2">Комментарии</h3>
<div className="grid gap-2 sm:grid-cols-2">
{taskComments.map((comment) => (
<TaskCoverOption
key={`cover-comment-${comment.id}`}
icon={MessageSquare}
title={comment.author ? displayUserName(comment.author) : "Комментарий"}
meta={comment.text || comment.attachments?.[0]?.file_name || "Вложение"}
selected={
viewTask.cover?.kind === "comment"
&& viewTask.cover.comment?.id === comment.id
}
disabled={saving}
onSelect={() => void selectViewedTaskCover("comment", comment.id)}
/>
))}
</div>
</section>
) : null}
{taskExternalLinks.length > 0 ? (
<section>
<h3 className="app-card-caption mb-2">Ссылки</h3>
<div className="grid gap-2 sm:grid-cols-2">
{taskExternalLinks.map((link) => (
<TaskCoverOption
key={`cover-link-${link.id}`}
icon={ExternalLinkIcon}
title={link.title || link.url}
meta={link.url}
selected={
viewTask.cover?.kind === "external_link"
&& viewTask.cover.external_link?.id === link.id
}
disabled={saving}
onSelect={() => void selectViewedTaskCover("external_link", link.id)}
/>
))}
</div>
</section>
) : null}
{taskCoverLinkedOptions.length > 0 ? (
<section>
<h3 className="app-card-caption mb-2">Связанные объекты</h3>
<div className="grid gap-2 sm:grid-cols-2">
{taskCoverLinkedOptions.map((option) => (
<TaskCoverOption
key={`cover-object-${option.id}`}
icon={Link2}
title={option.title}
meta={`${option.kindLabel}${option.description ? ` · ${option.description}` : ""}`}
selected={
viewTask.cover?.kind === "linked_object"
&& viewTask.cover.linked_object?.id === option.id
}
disabled={saving}
onSelect={() => void selectViewedTaskCover("linked_object", option.id)}
/>
))}
</div>
</section>
) : null}
{!taskAttachments.some(isMediaAttachment)
&& taskChecklist.length === 0
&& taskComments.length === 0
&& taskExternalLinks.length === 0
&& taskCoverLinkedOptions.length === 0 ? (
<div className="app-surface-muted rounded-xl border border-dashed border-[var(--border-subtle)] px-4 py-8 text-center">
<ImagePlus size={22} className="app-text-muted mx-auto" />
<p className="app-text-muted mt-2 text-sm">
Добавьте фото, видео, чек-лист, комментарий, ссылку или связанный объект.
</p>
</div>
) : null}
</div>
) : null}
</Modal>
<Modal
isOpen={participantModalOpen}
onClose={closeParticipantModal}
@ -9095,7 +9692,7 @@ function TasksPageContent() {
icon={FileText}
title={attachment.file_name}
meta={formatFileSize(attachment.file_size)}
canUseAsCover={isImageAttachment(attachment)}
canUseAsCover={isMediaAttachment(attachment)}
isCover={taskFormCoverAttachmentId === attachment.id}
onToggleCover={() => toggleExistingTaskFormCover(attachment)}
onRemove={() => removeExistingTaskFormAttachment(attachment)}
@ -9108,7 +9705,7 @@ function TasksPageContent() {
title={file.name}
meta={formatFileSize(file.size)}
pending
canUseAsCover={isImageFile(file)}
canUseAsCover={isMediaFile(file)}
isCover={taskFormCoverPendingFile === file}
onToggleCover={() => togglePendingTaskFormCover(file)}
onRemove={() => removePendingTaskFormFile(file)}

View File

@ -1,77 +0,0 @@
"use client";
import Image from "next/image";
import { ImagePlus, Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import { apiClient } from "@/lib/api";
import type { TaskAttachment } from "@/types/api";
export default function TaskCoverImage({
taskId,
attachment,
className = "",
}: {
taskId: number;
attachment: TaskAttachment;
className?: string;
}) {
const [loadedImage, setLoadedImage] = useState<{
attachmentId: number;
source: string;
} | null>(null);
const [failedAttachmentId, setFailedAttachmentId] = useState<number | null>(null);
const attachmentId = attachment.id;
const downloadUrl = attachment.download_url;
const fileName = attachment.file_name;
useEffect(() => {
let active = true;
let objectUrl: string | null = null;
void apiClient
.downloadTaskAttachment(taskId, {
id: attachmentId,
download_url: downloadUrl,
file_name: fileName,
})
.then(({ blob }) => {
if (!active) return;
objectUrl = URL.createObjectURL(blob);
setLoadedImage({ attachmentId, source: objectUrl });
})
.catch(() => {
if (active) setFailedAttachmentId(attachmentId);
});
return () => {
active = false;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [attachmentId, downloadUrl, fileName, taskId]);
const source = loadedImage?.attachmentId === attachment.id
? loadedImage.source
: null;
const failed = failedAttachmentId === attachment.id;
return (
<div className={`relative overflow-hidden bg-[var(--surface-secondary)] ${className}`}>
{source ? (
<Image
src={source}
alt=""
fill
unoptimized
sizes="272px"
className="object-cover"
draggable={false}
/>
) : (
<span className="app-text-muted absolute inset-0 flex items-center justify-center">
{failed ? <ImagePlus size={20} /> : <Loader2 size={18} className="animate-spin" />}
</span>
)}
</div>
);
}

View File

@ -0,0 +1,229 @@
"use client";
import Image from "next/image";
import {
Check,
ExternalLink,
FileText,
ImagePlus,
Link2,
Loader2,
MessageSquare,
Play,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { apiClient } from "@/lib/api";
import { displayUserName } from "@/lib/shared";
import type { TaskAttachment, TaskCover } from "@/types/api";
function isImage(attachment: TaskAttachment): boolean {
if (attachment.mime_type?.toLowerCase().startsWith("image/")) return true;
return /\.(avif|bmp|gif|heic|heif|jpe?g|png|webp)$/i.test(attachment.file_name);
}
function isVideo(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 AuthenticatedCoverMedia({
taskId,
attachment,
}: {
taskId: number;
attachment: TaskAttachment;
}) {
const [source, setSource] = useState<string | null>(null);
const [failed, setFailed] = useState(false);
const video = isVideo(attachment);
const attachmentId = attachment.id;
const downloadUrl = attachment.download_url;
const fileName = attachment.file_name;
useEffect(() => {
let active = true;
let objectUrl: string | null = null;
void apiClient
.downloadTaskAttachment(taskId, {
id: attachmentId,
download_url: downloadUrl,
file_name: fileName,
})
.then(({ blob }) => {
if (!active) return;
objectUrl = URL.createObjectURL(blob);
setSource(objectUrl);
})
.catch(() => {
if (active) setFailed(true);
});
return () => {
active = false;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [attachmentId, downloadUrl, fileName, taskId]);
return (
<div className="absolute inset-0 overflow-hidden bg-[var(--surface-secondary)]">
{source && video ? (
<>
<video
src={source}
muted
playsInline
preload="metadata"
className="h-full w-full object-cover"
onLoadedMetadata={(event) => {
const media = event.currentTarget;
if (Number.isFinite(media.duration) && media.duration > 0.2) {
media.currentTime = Math.min(0.25, media.duration / 10);
}
}}
/>
<span className="app-surface-elevated pointer-events-none absolute bottom-2 right-2 flex h-7 w-7 items-center justify-center rounded-full border border-[var(--border-subtle)]">
<Play size={13} className="translate-x-px" />
</span>
</>
) : source ? (
<Image
src={source}
alt=""
fill
unoptimized
sizes="272px"
className="object-cover"
draggable={false}
/>
) : (
<span className="app-text-muted absolute inset-0 flex items-center justify-center">
{failed ? <ImagePlus size={20} /> : <Loader2 size={18} className="animate-spin" />}
</span>
)}
</div>
);
}
export default function TaskCoverPreview({
taskId,
cover,
className = "",
}: {
taskId: number;
cover: TaskCover;
className?: string;
}) {
const commentMedia = useMemo(
() => cover.comment?.attachments.find((attachment) => (
isImage(attachment) || isVideo(attachment)
)),
[cover.comment?.attachments],
);
const media = cover.kind === "attachment" ? cover.attachment : commentMedia;
if (media) {
return (
<div className={`relative overflow-hidden bg-[var(--surface-secondary)] ${className}`}>
<AuthenticatedCoverMedia taskId={taskId} attachment={media} />
</div>
);
}
if (cover.kind === "checklist" && cover.checklist) {
const { completed, items, total } = cover.checklist;
const progress = total > 0 ? Math.round((completed / total) * 100) : 0;
return (
<div className={`app-surface-muted flex flex-col justify-center overflow-hidden p-2 ${className}`}>
<div className="flex items-center gap-2">
<div className="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-[var(--surface-secondary)]">
<div
className="h-full rounded-full bg-emerald-500 transition-[width]"
style={{ width: `${progress}%` }}
/>
</div>
<span className="app-text-muted shrink-0 text-[9px] tabular-nums">
{completed}/{total}
</span>
</div>
<div className="mt-1.5 space-y-0.5 overflow-hidden">
{items.map((item) => (
<div key={item.id} className="flex min-w-0 items-center gap-1.5 text-[10px] leading-3.5">
<span className={`flex h-3 w-3 shrink-0 items-center justify-center rounded-sm border ${
item.is_completed
? "border-emerald-500 bg-emerald-500 text-white"
: "border-[var(--border-strong)]"
}`}>
{item.is_completed ? <Check size={8} /> : null}
</span>
<span className={`truncate ${item.is_completed ? "app-text-muted line-through" : "text-[var(--foreground)]"}`}>
{item.title}
</span>
</div>
))}
</div>
</div>
);
}
if (cover.kind === "comment" && cover.comment) {
return (
<div className={`app-surface-muted flex flex-col justify-center overflow-hidden p-3 ${className}`}>
<div className="flex items-center gap-2 text-xs font-semibold text-[var(--foreground)]">
<MessageSquare size={14} className="shrink-0 text-sky-400" />
<span className="truncate">{displayUserName(cover.comment.author)}</span>
</div>
<p className="app-text-wrap mt-2 line-clamp-3 text-xs leading-4 text-[var(--foreground)]">
{cover.comment.text || "Комментарий с вложением"}
</p>
</div>
);
}
if (cover.kind === "external_link" && cover.external_link) {
let hostname = cover.external_link.url;
try {
hostname = new URL(cover.external_link.url).hostname;
} catch {
// Keep the original URL when it cannot be parsed by the browser.
}
return (
<div className={`app-surface-muted flex flex-col justify-center overflow-hidden p-3 ${className}`}>
<div className="flex items-center gap-2 text-xs font-semibold text-[var(--foreground)]">
<ExternalLink size={14} className="shrink-0 text-cyan-400" />
<span className="truncate">{cover.external_link.title || hostname}</span>
</div>
<p className="app-text-muted mt-2 truncate text-[10px]">{hostname}</p>
<p className="app-text-muted mt-1 line-clamp-2 break-all text-[10px]">
{cover.external_link.url}
</p>
</div>
);
}
if (cover.kind === "linked_object" && cover.linked_object) {
return (
<div className={`app-surface-muted flex flex-col justify-center overflow-hidden p-3 ${className}`}>
<div className="flex items-center gap-2 text-[10px] font-medium uppercase text-sky-400">
<Link2 size={13} className="shrink-0" />
<span className="truncate">{cover.linked_object.kind_display}</span>
</div>
<p className="mt-2 truncate text-xs font-semibold text-[var(--foreground)]">
{cover.linked_object.title}
</p>
{cover.linked_object.description ? (
<p className="app-text-muted mt-1 line-clamp-2 text-[10px] leading-4">
{cover.linked_object.description}
</p>
) : null}
</div>
);
}
return (
<div className={`app-surface-muted flex items-center justify-center ${className}`}>
<FileText size={20} className="app-text-muted" />
</div>
);
}

View File

@ -13,6 +13,7 @@ import type {
TaskAutomationRun,
TaskChecklistItem,
TaskComment,
TaskCoverType,
TaskLabel,
TaskLinkedAttendanceRecord,
TaskLinkedCalendarEvent,
@ -263,10 +264,13 @@ export function createTasksApi(request: RequestFn, requestRaw: RawRequestFn) {
request(`/api/v1/tasks/${taskId}/attachments/${attachmentId}/`, {
method: "DELETE",
}),
setTaskCover: (taskId: number, attachmentId: number): Promise<TaskCard> =>
setTaskCover: (
taskId: number,
data: { kind: TaskCoverType; source_id?: number },
): Promise<TaskCard> =>
request(`/api/v1/tasks/${taskId}/cover/`, {
method: "POST",
body: JSON.stringify({ attachment_id: attachmentId }),
body: JSON.stringify(data),
}),
clearTaskCover: (taskId: number): Promise<TaskCard> =>
request(`/api/v1/tasks/${taskId}/cover/`, {

View File

@ -334,6 +334,7 @@ export interface TaskCard {
linked_objects_count?: number;
comments_count?: number;
attachments_count?: number;
cover?: TaskCover | null;
cover_attachment?: TaskAttachment | null;
checklist_total?: number;
checklist_completed?: number;
@ -636,7 +637,6 @@ export interface TaskAttachment {
file_name: string;
file_size: number;
mime_type?: string;
is_cover: boolean;
uploaded_by?: User;
created_at: string;
download_url: string;
@ -666,6 +666,57 @@ export interface TaskComment {
attachments?: TaskAttachment[];
}
export type TaskCoverType =
| "attachment"
| "checklist"
| "comment"
| "external_link"
| "linked_object";
export interface TaskCover {
id: number;
kind: TaskCoverType;
kind_display: string;
attachment?: TaskAttachment | null;
checklist?: {
total: number;
completed: number;
items: Array<{
id: number;
title: string;
is_completed: boolean;
}>;
} | null;
comment?: {
id: number;
author: User;
text: string;
created_at: string;
attachments: TaskAttachment[];
} | null;
external_link?: TaskExternalLink | null;
linked_object?: {
id: number;
kind:
| "post"
| "message"
| "calendar_event"
| "document"
| "request"
| "procurement_request"
| "employee"
| "guest"
| "guest_visit"
| "attendance_record";
kind_display: string;
object_id: number;
title: string;
description: string;
image_url?: string | null;
} | null;
updated_at: string;
}
export interface MessageLinkedTask {
link_id: number;
id: number;