feat(tasks): add universal task covers
This commit is contained in:
parent
6b0643eb75
commit
f2d24f70e3
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 = [
|
||||
|
||||
104
backend/tasks/migrations/0033_universal_task_covers.py
Normal file
104
backend/tasks/migrations/0033_universal_task_covers.py
Normal 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',
|
||||
),
|
||||
]
|
||||
@ -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(
|
||||
"Комментарий обложки должен принадлежать этой задаче."
|
||||
)
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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)
|
||||
]
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
229
frontend/src/components/tasks/TaskCoverPreview.tsx
Normal file
229
frontend/src/components/tasks/TaskCoverPreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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/`, {
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user