feat(tasks): add task card covers

This commit is contained in:
lizerk 2026-08-06 10:39:33 +03:00
parent 3643bea2fc
commit bfb175088c
12 changed files with 477 additions and 9 deletions

View File

@ -304,6 +304,7 @@ class TaskSerializer(serializers.ModelSerializer):
linked_objects_count = serializers.SerializerMethodField()
comments_count = serializers.SerializerMethodField()
attachments_count = serializers.SerializerMethodField()
cover_attachment = serializers.SerializerMethodField()
checklist_total = serializers.SerializerMethodField()
checklist_completed = serializers.SerializerMethodField()
@ -346,6 +347,7 @@ class TaskSerializer(serializers.ModelSerializer):
"linked_objects_count",
"comments_count",
"attachments_count",
"cover_attachment",
"checklist_total",
"checklist_completed",
"created_at",
@ -375,6 +377,7 @@ class TaskSerializer(serializers.ModelSerializer):
"linked_objects_count",
"comments_count",
"attachments_count",
"cover_attachment",
"checklist_total",
"checklist_completed",
"created_at",
@ -479,6 +482,29 @@ class TaskSerializer(serializers.ModelSerializer):
return obj.attachments_count
return obj.attachments.count()
@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
@extend_schema_field(serializers.IntegerField())
def get_checklist_total(self, obj):
if hasattr(obj, "checklist_total"):
@ -1002,6 +1028,7 @@ class TaskAttachmentSerializer(serializers.ModelSerializer):
"file_name",
"file_size",
"mime_type",
"is_cover",
"uploaded_by",
"created_at",
"download_url",

View File

@ -935,7 +935,18 @@ class TaskViewSet(viewsets.ModelViewSet):
)
)
.select_related("board", "column", "row", "created_by", "assignee")
.prefetch_related("labels", "participants", "checklist_items")
.prefetch_related(
"labels",
"participants",
"checklist_items",
Prefetch(
"attachments",
queryset=TaskAttachment.objects.filter(
is_cover=True
).select_related("uploaded_by"),
to_attr="cover_attachments",
),
)
.annotate(
linked_posts_count=Count(
"linked_objects",
@ -1422,6 +1433,7 @@ class TaskViewSet(viewsets.ModelViewSet):
)
attachment_id = attachment.id
file_name = attachment.file_name
was_cover = attachment.is_cover
attachment.delete()
create_task_activity(
task,
@ -1435,10 +1447,81 @@ class TaskViewSet(viewsets.ModelViewSet):
"attachments_updated",
"task",
task.id,
extra={"attachments_count": task.attachments.count()},
extra={
"attachments_count": task.attachments.count(),
**({"cover_attachment_id": None} if was_cover else {}),
},
)
return Response(status=status.HTTP_204_NO_CONTENT)
@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()
)
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/"):
return Response(
{"attachment_id": ["Обложкой может быть только изображение."]},
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
)
locked_task.save(update_fields=["updated_at"])
if previous_id != next_id:
create_task_activity(
locked_task,
request.user,
TaskActivityAction.UPDATED,
metadata={
"fields": ["cover_attachment_id"],
"cover_attachment_id": next_id,
},
)
notify_task_updated(
locked_task,
request.user,
{"cover_attachment_id": previous_id},
{"cover_attachment_id": next_id},
)
send_task_board_update(
locked_task.board,
"updated",
"task",
locked_task.id,
extra={"cover_attachment_id": next_id},
)
refreshed_task = self.get_queryset().get(pk=locked_task.pk)
return Response(self.get_serializer(refreshed_task).data)
@action(detail=True, methods=["get", "post"], url_path="checklist")
def checklist(self, request, pk=None):
task = self.get_object()

View File

@ -114,8 +114,15 @@ class TaskAdmin(admin.ModelAdmin):
@admin.register(TaskAttachment)
class TaskAttachmentAdmin(admin.ModelAdmin):
list_display = ["file_name", "task", "uploaded_by", "file_size", "created_at"]
list_filter = ["created_at"]
list_display = [
"file_name",
"task",
"is_cover",
"uploaded_by",
"file_size",
"created_at",
]
list_filter = ["is_cover", "created_at"]
search_fields = ["file_name", "task__title", "uploaded_by__email"]
autocomplete_fields = ["task", "uploaded_by"]

View File

@ -0,0 +1,23 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("tasks", "0028_unique_done_column_per_board"),
]
operations = [
migrations.AddField(
model_name="taskattachment",
name="is_cover",
field=models.BooleanField(default=False, verbose_name="Обложка задачи"),
),
migrations.AddConstraint(
model_name="taskattachment",
constraint=models.UniqueConstraint(
condition=models.Q(is_cover=True),
fields=("task",),
name="uniq_task_cover_attachment",
),
),
]

View File

@ -381,6 +381,7 @@ 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,
@ -394,6 +395,13 @@ 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}"

View File

@ -123,6 +123,7 @@ class MessageTemplates:
"priority": "приоритет",
"label_ids": "метки",
"participant_ids": "участников",
"cover_attachment_id": "обложку",
}
changed = ", ".join(field_names.get(field, field) for field in fields)
return (

View File

@ -264,7 +264,13 @@ def notify_task_updated(task: Task, actor, previous: dict, current: dict):
generic_fields = [
field
for field in ("title", "description", "priority", "label_ids")
for field in (
"title",
"description",
"priority",
"label_ids",
"cover_attachment_id",
)
if previous.get(field) != current.get(field)
]
if participant_ids_changed:

View File

@ -1498,6 +1498,107 @@ def test_task_attachment_has_no_size_limit(
assert TaskAttachment.objects.filter(task=task, file_name="large.txt").exists()
def test_task_cover_uses_image_attachment_and_keeps_file_when_cleared(
api_client,
user,
tmp_path,
settings,
):
settings.MEDIA_ROOT = tmp_path
member = make_user("task-cover-member@example.com", "+79994440105")
outsider = make_user("task-cover-outsider@example.com", "+79994440106")
board = TaskBoard.objects.create(name="Доска с обложкой", created_by=user)
board.members.add(member)
column = TaskColumn.objects.create(board=board, name="Новые", position=1000)
task = Task.objects.create(
board=board,
column=column,
title="Задача с обложкой",
created_by=user,
)
first_image = TaskAttachment.objects.create(
task=task,
file=SimpleUploadedFile("first.png", b"first image", content_type="image/png"),
file_name="first.png",
file_size=11,
mime_type="image/png",
uploaded_by=user,
)
second_image = TaskAttachment.objects.create(
task=task,
file=SimpleUploadedFile("second.jpg", b"second image", content_type="image/jpeg"),
file_name="second.jpg",
file_size=12,
mime_type="image/jpeg",
uploaded_by=user,
)
document = TaskAttachment.objects.create(
task=task,
file=SimpleUploadedFile("brief.pdf", b"pdf", content_type="application/pdf"),
file_name="brief.pdf",
file_size=3,
mime_type="application/pdf",
uploaded_by=user,
)
cover_url = reverse("api:v1:tasks:task-cover", kwargs={"pk": task.id})
api_client.force_authenticate(user=member)
first_response = api_client.post(
cover_url,
{"attachment_id": first_image.id},
format="json",
)
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
second_response = api_client.post(
cover_url,
{"attachment_id": second_image.id},
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
board_response = api_client.get(
reverse("api:v1:tasks:task-board-detail", kwargs={"pk": board.id})
)
assert board_response.status_code == status.HTTP_200_OK
serialized_task = next(
item for item in board_response.data["tasks"] if item["id"] == task.id
)
assert serialized_task["cover_attachment"]["id"] == second_image.id
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
api_client.force_authenticate(user=outsider)
assert (
api_client.post(
cover_url,
{"attachment_id": first_image.id},
format="json",
).status_code
== status.HTTP_404_NOT_FOUND
)
api_client.force_authenticate(user=member)
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()
def test_task_external_links_follow_board_access_and_support_lifecycle(
api_client,
user,

View File

@ -74,6 +74,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 { RequestAvatar } from "@/components/requests/RequestAvatar";
import { CommentComposer, CommentDeleteButton } from "@/components/shared/CommentControls";
import { Modal } from "@/components/ui";
@ -417,6 +418,16 @@ function formatFileSize(size: number): string {
})} МБ`;
}
function isImageAttachment(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 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 getMessageCreatedLabel(message?: Message | null) {
if (!message) return "";
return message.created || (message.created_at ? formatDate(message.created_at) : "");
@ -471,6 +482,13 @@ function TaskCardView({
{...attributes}
{...listeners}
>
{task.cover_attachment ? (
<TaskCoverImage
taskId={task.id}
attachment={task.cover_attachment}
className="-mx-3 -mt-3 mb-3 aspect-[16/7] rounded-t-[11px] border-b border-[var(--border-subtle)]"
/>
) : null}
<div className="mb-2 flex items-start gap-2">
<div className="min-w-0 flex-1 overflow-hidden whitespace-normal text-left">
<div className="flex min-w-0 items-center gap-2">
@ -2410,12 +2428,18 @@ function TaskFormResourceRow({
title,
meta,
pending = false,
canUseAsCover = false,
isCover = false,
onToggleCover,
onRemove,
}: {
icon: LucideIcon;
title: string;
meta?: string;
pending?: boolean;
canUseAsCover?: boolean;
isCover?: boolean;
onToggleCover?: () => void;
onRemove: () => void;
}) {
return (
@ -2438,6 +2462,18 @@ function TaskFormResourceRow({
новое
</span>
) : null}
{canUseAsCover && onToggleCover ? (
<button
type="button"
onClick={onToggleCover}
className={`${isCover ? "app-action-primary" : "app-icon-button"} flex h-8 w-8 shrink-0 items-center justify-center rounded-lg`}
title={isCover ? "Убрать обложку" : "Сделать обложкой"}
aria-label={isCover ? `Убрать обложку ${title}` : `Сделать обложкой ${title}`}
aria-pressed={isCover}
>
<ImagePlus size={14} />
</button>
) : null}
<button
type="button"
onClick={onRemove}
@ -2696,6 +2732,8 @@ function TasksPageContent() {
const [taskFormRemovedChecklistItemIds, setTaskFormRemovedChecklistItemIds] = useState<number[]>([]);
const [taskFormDirtyChecklistItemIds, setTaskFormDirtyChecklistItemIds] = useState<number[]>([]);
const [taskFormAttachments, setTaskFormAttachments] = useState<TaskAttachment[]>([]);
const [taskFormCoverAttachmentId, setTaskFormCoverAttachmentId] = useState<number | null>(null);
const [taskFormCoverPendingFile, setTaskFormCoverPendingFile] = useState<File | null>(null);
const [taskFormDocuments, setTaskFormDocuments] = useState<TaskLinkedDocument[]>([]);
const [taskFormEvents, setTaskFormEvents] = useState<TaskLinkedCalendarEvent[]>([]);
const [taskFormExternalLinks, setTaskFormExternalLinks] = useState<TaskExternalLink[]>([]);
@ -3002,6 +3040,8 @@ function TasksPageContent() {
setTaskFormRemovedChecklistItemIds([]);
setTaskFormDirtyChecklistItemIds([]);
setTaskFormAttachments([]);
setTaskFormCoverAttachmentId(null);
setTaskFormCoverPendingFile(null);
setTaskFormDocuments([]);
setTaskFormEvents([]);
setTaskFormExternalLinks([]);
@ -3029,6 +3069,10 @@ function TasksPageContent() {
if (requestId !== taskFormResourcesRequestRef.current) return;
setTaskFormChecklistItems(checklist);
setTaskFormAttachments(attachments);
setTaskFormCoverAttachmentId(
attachments.find((attachment) => attachment.is_cover)?.id ?? null,
);
setTaskFormCoverPendingFile(null);
setTaskFormDocuments(documents);
setTaskFormEvents(events);
setTaskFormExternalLinks(externalLinks);
@ -4472,6 +4516,8 @@ function TasksPageContent() {
}, [board, loadBoard, saving]);
const persistTaskFormResources = useCallback(async (taskId: number) => {
let selectedCoverAttachmentId = taskFormCoverAttachmentId;
for (const itemId of [...taskFormRemovedChecklistItemIds]) {
await apiClient.deleteTaskChecklistItem(taskId, itemId);
setTaskFormRemovedChecklistItemIds((current) => current.filter((id) => id !== itemId));
@ -4504,6 +4550,9 @@ function TasksPageContent() {
for (const attachmentId of [...taskFormRemovedAttachmentIds]) {
await apiClient.deleteTaskAttachment(taskId, attachmentId);
if (selectedCoverAttachmentId === attachmentId) {
selectedCoverAttachmentId = null;
}
setTaskFormRemovedAttachmentIds((current) => (
current.filter((id) => id !== attachmentId)
));
@ -4555,9 +4604,20 @@ function TasksPageContent() {
for (const file of [...taskFormPendingFiles]) {
const uploaded = await apiClient.uploadTaskAttachments(taskId, [file]);
if (file === taskFormCoverPendingFile && uploaded[0]) {
selectedCoverAttachmentId = uploaded[0].id;
}
setTaskFormPendingFiles((current) => current.filter((item) => item !== file));
setTaskFormAttachments((current) => [...current, ...uploaded]);
}
if (selectedCoverAttachmentId) {
await apiClient.setTaskCover(taskId, selectedCoverAttachmentId);
} else {
await apiClient.clearTaskCover(taskId);
}
setTaskFormCoverAttachmentId(selectedCoverAttachmentId);
setTaskFormCoverPendingFile(null);
}, [
taskFormChecklistItems,
taskFormDirtyChecklistItemIds,
@ -4566,6 +4626,8 @@ function TasksPageContent() {
taskFormPendingExternalLinks,
taskFormPendingFiles,
taskFormPendingChecklistItems,
taskFormCoverAttachmentId,
taskFormCoverPendingFile,
taskFormRemovedAttachmentIds,
taskFormRemovedChecklistItemIds,
taskFormRemovedDocumentLinkIds,
@ -4760,6 +4822,29 @@ function TasksPageContent() {
}
}, [activityOpen, board, loadBoard, loadTaskActivity, taskAttachmentsUploading, viewTask]);
const toggleViewedTaskCover = useCallback(async (attachment: TaskAttachment) => {
if (!board || !viewTask || taskAttachmentsUploading) return;
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,
})));
await loadBoard(board.id);
if (activityOpen) await loadTaskActivity(viewTask.id);
} catch (attachmentError) {
setError(getTaskError(attachmentError, "Не удалось изменить обложку задачи"));
} finally {
setTaskAttachmentsUploading(false);
}
}, [activityOpen, board, loadBoard, loadTaskActivity, taskAttachmentsUploading, viewTask]);
const addViewedTaskChecklistItem = useCallback(async () => {
const title = taskChecklistDraft.trim();
if (!board || !viewTask || !title || taskChecklistSaving) return;
@ -4905,6 +4990,26 @@ function TasksPageContent() {
const removeExistingTaskFormAttachment = useCallback((attachment: TaskAttachment) => {
setTaskFormAttachments((current) => current.filter((item) => item.id !== attachment.id));
setTaskFormRemovedAttachmentIds((current) => [...current, attachment.id]);
setTaskFormCoverAttachmentId((current) => (
current === attachment.id ? null : current
));
}, []);
const removePendingTaskFormFile = useCallback((file: File) => {
setTaskFormPendingFiles((current) => current.filter((item) => item !== file));
setTaskFormCoverPendingFile((current) => (current === file ? null : current));
}, []);
const toggleExistingTaskFormCover = useCallback((attachment: TaskAttachment) => {
setTaskFormCoverAttachmentId((current) => (
current === attachment.id ? null : attachment.id
));
setTaskFormCoverPendingFile(null);
}, []);
const togglePendingTaskFormCover = useCallback((file: File) => {
setTaskFormCoverPendingFile((current) => (current === file ? null : file));
setTaskFormCoverAttachmentId(null);
}, []);
const removeExistingTaskFormDocument = useCallback((link: TaskLinkedDocument) => {
@ -6929,6 +7034,21 @@ function TasksPageContent() {
</span>
<Download size={14} className="app-text-muted shrink-0" />
</button>
{isImageAttachment(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
? `Убрать обложку ${attachment.file_name}`
: `Сделать обложкой ${attachment.file_name}`}
aria-pressed={attachment.is_cover}
>
<ImagePlus size={14} />
</button>
) : null}
<button
type="button"
onClick={() => void deleteViewedTaskAttachment(attachment)}
@ -8140,6 +8260,9 @@ function TasksPageContent() {
icon={FileText}
title={attachment.file_name}
meta={formatFileSize(attachment.file_size)}
canUseAsCover={isImageAttachment(attachment)}
isCover={taskFormCoverAttachmentId === attachment.id}
onToggleCover={() => toggleExistingTaskFormCover(attachment)}
onRemove={() => removeExistingTaskFormAttachment(attachment)}
/>
))}
@ -8150,9 +8273,10 @@ function TasksPageContent() {
title={file.name}
meta={formatFileSize(file.size)}
pending
onRemove={() => setTaskFormPendingFiles((current) => (
current.filter((item) => item !== file)
))}
canUseAsCover={isImageFile(file)}
isCover={taskFormCoverPendingFile === file}
onToggleCover={() => togglePendingTaskFormCover(file)}
onRemove={() => removePendingTaskFormFile(file)}
/>
))}
</div>

View File

@ -0,0 +1,77 @@
"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

@ -211,7 +211,7 @@ export function createTasksApi(request: RequestFn, requestRaw: RawRequestFn) {
},
downloadTaskAttachment: async (
taskId: number,
attachment: TaskAttachment,
attachment: Pick<TaskAttachment, "id" | "download_url" | "file_name">,
): Promise<{ blob: Blob; filename: string }> => {
const response = await requestRaw(
attachment.download_url || `/api/v1/tasks/${taskId}/attachments/${attachment.id}/download/`,
@ -225,6 +225,15 @@ export function createTasksApi(request: RequestFn, requestRaw: RawRequestFn) {
request(`/api/v1/tasks/${taskId}/attachments/${attachmentId}/`, {
method: "DELETE",
}),
setTaskCover: (taskId: number, attachmentId: number): Promise<TaskCard> =>
request(`/api/v1/tasks/${taskId}/cover/`, {
method: "POST",
body: JSON.stringify({ attachment_id: attachmentId }),
}),
clearTaskCover: (taskId: number): Promise<TaskCard> =>
request(`/api/v1/tasks/${taskId}/cover/`, {
method: "DELETE",
}),
getTaskChecklist: (id: number): Promise<TaskChecklistItem[]> =>
request(`/api/v1/tasks/${id}/checklist/`),
addTaskChecklistItem: (

View File

@ -240,6 +240,7 @@ export interface TaskCard {
linked_objects_count?: number;
comments_count?: number;
attachments_count?: number;
cover_attachment?: TaskAttachment | null;
checklist_total?: number;
checklist_completed?: number;
created_at: string;
@ -540,6 +541,7 @@ export interface TaskAttachment {
file_name: string;
file_size: number;
mime_type?: string;
is_cover: boolean;
uploaded_by?: User;
created_at: string;
download_url: string;