feat: optimize boards and extend workflow media

This commit is contained in:
lizerk 2026-08-07 17:26:40 +03:00
parent df1deece23
commit f34f92cd7d
28 changed files with 1195 additions and 246 deletions

View File

@ -339,6 +339,7 @@ class ProcurementItemAttachmentSerializer(serializers.ModelSerializer):
read_only=True,
)
download_url = serializers.SerializerMethodField()
thumbnail_url = serializers.SerializerMethodField()
class Meta:
model = ProcurementItemAttachment
@ -351,6 +352,7 @@ class ProcurementItemAttachmentSerializer(serializers.ModelSerializer):
"uploaded_by_name",
"created_at",
"download_url",
"thumbnail_url",
]
read_only_fields = fields
@ -360,6 +362,16 @@ class ProcurementItemAttachmentSerializer(serializers.ModelSerializer):
f"attachments/{obj.id}/"
)
def get_thumbnail_url(self, obj):
from procurement.media import procurement_attachment_media_kind
if not procurement_attachment_media_kind(obj):
return None
return (
f"/api/v1/procurement/items/{obj.item_id}/"
f"attachments/{obj.id}/thumbnail/"
)
class ProcurementItemSerializer(serializers.ModelSerializer):
"""Сериализатор для позиций заявки."""

View File

@ -40,6 +40,7 @@ from procurement.models import (
ProcurementSettings,
Supplier,
)
from procurement.media import ensure_procurement_attachment_thumbnail
from procurement.activity import create_procurement_activity
from procurement.notifications.handlers import (
notify_executor_reassigned,
@ -1517,6 +1518,7 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
"cancel_issue",
"attachments",
"attachment_detail",
"attachment_thumbnail",
]:
permission_classes = [permissions.IsAuthenticated]
else:
@ -1631,6 +1633,45 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
content_type=attachment.mime_type or "application/octet-stream",
)
@action(
detail=True,
methods=["get"],
url_path=r"attachments/(?P<attachment_id>\d+)/thumbnail",
url_name="attachment-thumbnail",
)
def attachment_thumbnail(self, request, pk=None, attachment_id=None):
item = self.get_object()
self._check_item_read_permission(item)
try:
attachment = item.attachments.get(pk=attachment_id)
except ProcurementItemAttachment.DoesNotExist as exc:
raise Http404 from exc
if not attachment.thumbnail and not ensure_procurement_attachment_thumbnail(
attachment
):
return Response(
{"detail": "Для файла не удалось создать миниатюру."},
status=status.HTTP_404_NOT_FOUND,
)
try:
file_handle = attachment.thumbnail.open("rb")
except (FileNotFoundError, OSError, ValueError):
return Response(
{"detail": "Миниатюра не найдена в хранилище."},
status=status.HTTP_404_NOT_FOUND,
)
response = FileResponse(
file_handle,
as_attachment=False,
filename=f"procurement-attachment-{attachment.id}.webp",
content_type="image/webp",
)
response["Cache-Control"] = "private, max-age=31536000, immutable"
response["X-Content-Type-Options"] = "nosniff"
return response
def _can_use_item_quick_action(self, user, procurement_request):
return (
procurement_request.requestor_id == user.id

View File

@ -807,12 +807,8 @@ class TaskBoardSerializer(serializers.ModelSerializer):
labels = serializers.SerializerMethodField()
tasks_count = serializers.IntegerField(read_only=True, default=0)
is_default_for_current_user = serializers.SerializerMethodField()
group_id = serializers.IntegerField(
source="current_user_group_id",
read_only=True,
allow_null=True,
default=None,
)
group_ids = serializers.SerializerMethodField()
group_id = serializers.SerializerMethodField()
is_pinned = serializers.BooleanField(
source="current_user_is_pinned",
read_only=True,
@ -845,6 +841,7 @@ class TaskBoardSerializer(serializers.ModelSerializer):
"labels",
"tasks_count",
"is_default_for_current_user",
"group_ids",
"group_id",
"is_pinned",
"notifications_enabled",
@ -861,6 +858,30 @@ class TaskBoardSerializer(serializers.ModelSerializer):
self._global_labels_cache = labels
return TaskLabelSerializer(labels, many=True).data
@extend_schema_field(serializers.ListField(child=serializers.IntegerField()))
def get_group_ids(self, obj):
placements = getattr(obj, "current_user_placements", None)
if placements is not None:
placement = placements[0] if placements else None
else:
request = self.context.get("request")
user = getattr(request, "user", None)
if not user or not user.is_authenticated:
return []
placement = (
TaskBoardPlacement.objects.filter(user=user, board=obj)
.prefetch_related("groups")
.first()
)
if not placement:
return []
return sorted(group.id for group in placement.groups.all())
@extend_schema_field(serializers.IntegerField(allow_null=True))
def get_group_id(self, obj):
group_ids = self.get_group_ids(obj)
return group_ids[0] if group_ids else None
def validate(self, attrs):
attrs = super().validate(attrs)
access_scope = attrs.get("access_scope")
@ -970,6 +991,7 @@ class TaskBoardSummarySerializer(TaskBoardSerializer):
"open_tasks_count",
"completed_tasks_count",
"is_default_for_current_user",
"group_ids",
"group_id",
"is_pinned",
"notifications_enabled",

View File

@ -161,16 +161,20 @@ def create_task_activity(
def normalize_column_positions(column, row=None):
task_ids = list(
tasks = list(
column.tasks.filter(row=row)
.order_by("position", "-created_at", "id")
.values_list(
"id",
flat=True,
)
.only("id", "position")
)
for index, task_id in enumerate(task_ids):
Task.objects.filter(id=task_id).update(position=(index + 1) * 1000)
changed_tasks = []
for index, task in enumerate(tasks):
position = (index + 1) * 1000
if task.position == position:
continue
task.position = position
changed_tasks.append(task)
if changed_tasks:
Task.objects.bulk_update(changed_tasks, ["position"], batch_size=500)
def send_task_checklist_update(task, event, item_id, *, cover_cleared=False):
@ -328,12 +332,18 @@ class TaskBoardViewSet(viewsets.ModelViewSet):
TaskBoard.objects.filter(is_archived=False)
.filter(task_board_access_q(self.request.user))
.select_related("created_by")
.prefetch_related("members", "departments")
.annotate(
current_user_group_id=Subquery(
current_placement.values("group_id")[:1],
output_field=IntegerField(),
.prefetch_related(
"members",
"departments",
Prefetch(
"user_placements",
queryset=TaskBoardPlacement.objects.filter(
user=self.request.user,
).prefetch_related("groups"),
to_attr="current_user_placements",
),
)
.annotate(
current_user_is_pinned=Coalesce(
Subquery(
current_placement.values("is_pinned")[:1],
@ -607,21 +617,7 @@ class TaskBoardViewSet(viewsets.ModelViewSet):
board = self.get_object()
group_id = request.data.get("group")
if group_id in (None, ""):
placement = TaskBoardPlacement.objects.filter(
user=request.user,
board=board,
).first()
if placement:
placement.group = None
if (
not placement.is_pinned
and placement.notifications_enabled
and not placement.view_state
):
placement.delete()
else:
placement.save(update_fields=["group", "updated_at"])
return Response({"board": board.id, "group_id": None})
return Response(self._set_board_groups(board, []))
try:
group_id = int(group_id)
@ -635,12 +631,65 @@ class TaskBoardViewSet(viewsets.ModelViewSet):
id=group_id,
user=request.user,
)
TaskBoardPlacement.objects.update_or_create(
user=request.user,
board=board,
defaults={"group": group},
return Response(self._set_board_groups(board, [group]))
@action(detail=True, methods=["post"], url_path="groups")
def set_groups(self, request, pk=None):
board = self.get_object()
raw_group_ids = request.data.get("groups", [])
if not isinstance(raw_group_ids, list):
return Response(
{"groups": ["Передайте список идентификаторов групп."]},
status=status.HTTP_400_BAD_REQUEST,
)
try:
group_ids = sorted({int(group_id) for group_id in raw_group_ids})
except (TypeError, ValueError):
return Response(
{"groups": ["Идентификаторы групп должны быть числами."]},
status=status.HTTP_400_BAD_REQUEST,
)
groups = list(
TaskBoardGroup.objects.filter(
user=request.user,
id__in=group_ids,
).order_by("id")
)
return Response({"board": board.id, "group_id": group.id})
if len(groups) != len(group_ids):
return Response(
{"groups": ["Одна или несколько групп не найдены."]},
status=status.HTTP_400_BAD_REQUEST,
)
return Response(self._set_board_groups(board, groups))
def _set_board_groups(self, board, groups):
with transaction.atomic():
placement = TaskBoardPlacement.objects.select_for_update().filter(
user=self.request.user,
board=board,
).first()
if placement is None and groups:
placement = TaskBoardPlacement.objects.create(
user=self.request.user,
board=board,
)
if placement:
placement.groups.set(groups)
if (
not groups
and not placement.is_pinned
and placement.notifications_enabled
and not placement.view_state
):
placement.delete()
else:
placement.save(update_fields=["updated_at"])
group_ids = sorted(group.id for group in groups)
return {
"board": board.id,
"group_ids": group_ids,
"group_id": group_ids[0] if group_ids else None,
}
@action(detail=True, methods=["post"])
def pin(self, request, pk=None):
@ -648,14 +697,13 @@ class TaskBoardViewSet(viewsets.ModelViewSet):
placement, _ = TaskBoardPlacement.objects.get_or_create(
user=request.user,
board=board,
defaults={"group": None},
)
placement.is_pinned = not placement.is_pinned
placement.pinned_at = timezone.now() if placement.is_pinned else None
is_pinned = placement.is_pinned
if (
not placement.is_pinned
and placement.group_id is None
and not placement.groups.exists()
and placement.notifications_enabled
and not placement.view_state
):
@ -670,13 +718,12 @@ class TaskBoardViewSet(viewsets.ModelViewSet):
placement, _ = TaskBoardPlacement.objects.get_or_create(
user=request.user,
board=board,
defaults={"group": None},
)
placement.notifications_enabled = not placement.notifications_enabled
notifications_enabled = placement.notifications_enabled
if (
placement.notifications_enabled
and placement.group_id is None
and not placement.groups.exists()
and not placement.is_pinned
and not placement.view_state
):
@ -739,7 +786,6 @@ class TaskBoardViewSet(viewsets.ModelViewSet):
placement, _ = TaskBoardPlacement.objects.select_for_update().get_or_create(
user=request.user,
board=board,
defaults={"group": None},
)
state = (
placement.view_state.copy()
@ -757,7 +803,7 @@ class TaskBoardViewSet(viewsets.ModelViewSet):
placement.view_state = state
if (
not state
and placement.group_id is None
and not placement.groups.exists()
and not placement.is_pinned
and placement.notifications_enabled
):
@ -826,7 +872,7 @@ class TaskBoardGroupViewSet(viewsets.ModelViewSet):
instance.delete()
TaskBoardPlacement.objects.filter(
user=user,
group__isnull=True,
groups__isnull=True,
is_pinned=False,
notifications_enabled=True,
view_state={},
@ -1341,6 +1387,7 @@ class TaskViewSet(viewsets.ModelViewSet):
def _move_task(self, task, column, row, position, actor):
old_column = task.column
old_row = task.row
normalize_positions = position not in (None, "")
if position in (None, ""):
max_position = (
Task.objects.filter(column=column, row=row)
@ -1357,9 +1404,14 @@ class TaskViewSet(viewsets.ModelViewSet):
update_fields=["column", "row", "position", "completed_at", "updated_at"]
)
normalize_column_positions(column, row)
if old_column.id != column.id or old_row != row:
normalize_column_positions(old_column, old_row)
# При обычном переносе карточка добавляется в конец с разреженной
# позицией. Перенумерация обеих колонок здесь не нужна и на больших
# досках превращалась в сотни отдельных UPDATE. Нормализуем порядок
# только когда клиент явно передал позицию для сортировки внутри ячейки.
if normalize_positions:
normalize_column_positions(column, row)
if old_column.id != column.id or old_row != row:
normalize_column_positions(old_column, old_row)
task.refresh_from_db()
activity_metadata = {
@ -1390,7 +1442,11 @@ class TaskViewSet(viewsets.ModelViewSet):
"moved",
"task",
task.id,
extra={"column_id": task.column_id, "row_id": task.row_id},
extra={
"column_id": task.column_id,
"row_id": task.row_id,
"position": task.position,
},
)
return task

View File

@ -106,6 +106,10 @@ def get_notifications(request):
queryset = Notification.objects.filter(
recipient=request.user,
deleted=False,
).select_related(
'actor_content_type',
).prefetch_related(
'actor',
).order_by('-timestamp')
# Фильтры
@ -129,7 +133,7 @@ def get_notifications(request):
'total': total,
'page': page,
'page_size': page_size,
'unread_count': queryset.filter(unread=True).count(),
'unread_count': total if unread_only else queryset.filter(unread=True).count(),
'notifications': [
{
'id': n.id,

View File

@ -0,0 +1,43 @@
import logging
from django.core.files.base import ContentFile
from tasks.media import attachment_media_kind, render_attachment_thumbnail
from .models import ProcurementItemAttachment
logger = logging.getLogger(__name__)
def procurement_attachment_media_kind(
attachment: ProcurementItemAttachment,
) -> str | None:
return attachment_media_kind(attachment)
def ensure_procurement_attachment_thumbnail(
attachment: ProcurementItemAttachment,
) -> bool:
if attachment.thumbnail:
return True
if not procurement_attachment_media_kind(attachment):
return False
try:
thumbnail = render_attachment_thumbnail(attachment)
attachment.thumbnail.save(
(
f"procurement-item-{attachment.item_id}-"
f"attachment-{attachment.id}.webp"
),
ContentFile(thumbnail),
save=True,
)
return True
except Exception:
logger.exception(
"Could not generate thumbnail for procurement attachment %s",
attachment.id,
)
return False

View File

@ -0,0 +1,20 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("procurement", "0018_procurementitemattachment_comment"),
]
operations = [
migrations.AddField(
model_name="procurementitemattachment",
name="thumbnail",
field=models.ImageField(
blank=True,
null=True,
upload_to="procurement/item_attachment_thumbnails/%Y/%m/%d/",
verbose_name="Миниатюра",
),
),
]

View File

@ -693,6 +693,12 @@ class ProcurementItemAttachment(models.Model):
upload_to=procurement_item_attachment_upload_to,
max_length=500,
)
thumbnail = models.ImageField(
"Миниатюра",
upload_to="procurement/item_attachment_thumbnails/%Y/%m/%d/",
null=True,
blank=True,
)
file_name = models.TextField("Имя файла")
file_size = models.BigIntegerField("Размер файла", default=0)
mime_type = models.CharField("MIME-тип", max_length=255, blank=True)

View File

@ -52,17 +52,21 @@ class TaskBoardGroupAdmin(admin.ModelAdmin):
class TaskBoardPlacementAdmin(admin.ModelAdmin):
list_display = [
"board",
"group",
"group_names",
"user",
"is_pinned",
"notifications_enabled",
"updated_at",
]
list_filter = ["is_pinned", "notifications_enabled", "group"]
search_fields = ["board__name", "group__name", "user__email"]
autocomplete_fields = ["board", "group", "user"]
list_filter = ["is_pinned", "notifications_enabled", "groups"]
search_fields = ["board__name", "groups__name", "user__email"]
autocomplete_fields = ["board", "groups", "user"]
readonly_fields = ["view_state"]
@admin.display(description="Группы")
def group_names(self, obj):
return ", ".join(obj.groups.values_list("name", flat=True)) or "—"
@admin.register(TaskColumn)
class TaskColumnAdmin(admin.ModelAdmin):

View File

@ -37,7 +37,7 @@ VIDEO_EXTENSIONS = {
}
def task_attachment_media_kind(attachment: TaskAttachment) -> str | None:
def attachment_media_kind(attachment) -> str | None:
mime_type = (attachment.mime_type or "").lower()
suffix = Path(attachment.file_name or "").suffix.lower()
if mime_type.startswith("image/") or suffix in IMAGE_EXTENSIONS:
@ -47,6 +47,10 @@ def task_attachment_media_kind(attachment: TaskAttachment) -> str | None:
return None
def task_attachment_media_kind(attachment: TaskAttachment) -> str | None:
return attachment_media_kind(attachment)
def _render_image_thumbnail(attachment: TaskAttachment) -> bytes:
with attachment.file.open("rb") as source:
image = Image.open(source)
@ -137,19 +141,23 @@ def _render_video_thumbnail(attachment: TaskAttachment) -> bytes:
Path(output_path).unlink(missing_ok=True)
def render_attachment_thumbnail(attachment) -> bytes:
media_kind = attachment_media_kind(attachment)
if media_kind == "image":
return _render_image_thumbnail(attachment)
if media_kind == "video":
return _render_video_thumbnail(attachment)
raise ValueError("Attachment is not supported media")
def ensure_task_attachment_thumbnail(attachment: TaskAttachment) -> bool:
if attachment.thumbnail:
return True
media_kind = task_attachment_media_kind(attachment)
if not media_kind:
if not task_attachment_media_kind(attachment):
return False
try:
thumbnail = (
_render_image_thumbnail(attachment)
if media_kind == "image"
else _render_video_thumbnail(attachment)
)
thumbnail = render_attachment_thumbnail(attachment)
attachment.thumbnail.save(
f"task-{attachment.task_id}-attachment-{attachment.id}.webp",
ContentFile(thumbnail),

View File

@ -0,0 +1,48 @@
from django.db import migrations, models
def copy_group_to_groups(apps, schema_editor):
placement_model = apps.get_model("tasks", "TaskBoardPlacement")
for placement in placement_model.objects.exclude(group_id=None).iterator():
placement.groups.add(placement.group_id)
def copy_first_group_back(apps, schema_editor):
placement_model = apps.get_model("tasks", "TaskBoardPlacement")
for placement in placement_model.objects.iterator():
placement.group_id = placement.groups.values_list("id", flat=True).first()
placement.save(update_fields=["group"])
class Migration(migrations.Migration):
dependencies = [
("tasks", "0035_taskattachment_thumbnail"),
]
operations = [
migrations.AddField(
model_name="taskboardplacement",
name="groups",
field=models.ManyToManyField(
blank=True,
related_name="multi_board_placements",
to="tasks.taskboardgroup",
verbose_name="Группы",
),
),
migrations.RunPython(copy_group_to_groups, copy_first_group_back),
migrations.RemoveField(
model_name="taskboardplacement",
name="group",
),
migrations.AlterField(
model_name="taskboardplacement",
name="groups",
field=models.ManyToManyField(
blank=True,
related_name="board_placements",
to="tasks.taskboardgroup",
verbose_name="Группы",
),
),
]

View File

@ -146,13 +146,11 @@ class TaskBoardPlacement(models.Model):
related_name="user_placements",
verbose_name="Доска",
)
group = models.ForeignKey(
groups = models.ManyToManyField(
TaskBoardGroup,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="board_placements",
verbose_name="Группа",
verbose_name="Группы",
)
is_pinned = models.BooleanField("Закреплена", default=False)
pinned_at = models.DateTimeField("Закреплена в", null=True, blank=True)
@ -180,7 +178,7 @@ class TaskBoardPlacement(models.Model):
]
def __str__(self):
return f"{self.user}: {self.board} -> {self.group}"
return f"{self.user}: {self.board}"
class TaskUserSettings(models.Model):

View File

@ -2,6 +2,9 @@ from unittest.mock import patch
import pytest
from django.core.cache import cache
from django.contrib.contenttypes.models import ContentType
from django.db import connection
from django.test.utils import CaptureQueriesContext
from push_notifications.models import WebPushDevice
from notifications.cache import unread_summary_cache_key
@ -16,6 +19,39 @@ from notifications.models import (
pytestmark = pytest.mark.django_db
class TestNotificationList:
def test_notification_actors_are_prefetched(self, api_client, user_factory):
recipient = user_factory()
actor = user_factory()
actor_content_type = ContentType.objects.get_for_model(actor)
Notification.objects.bulk_create([
Notification(
recipient=recipient,
actor_content_type=actor_content_type,
actor_object_id=str(actor.id),
verb="task_moved",
description=f"Уведомление {index}",
)
for index in range(50)
])
api_client.force_authenticate(user=recipient)
with CaptureQueriesContext(connection) as queries:
response = api_client.get(
"/api/v1/notifications/",
{"page_size": 50, "unread_only": "true"},
)
assert response.status_code == 200
assert len(response.data["notifications"]) == 50
assert response.data["unread_count"] == 50
assert all(
item["actor"]["id"] == actor.id
for item in response.data["notifications"]
)
assert len(queries) <= 5
class TestNotificationUnreadSummary:
def test_unread_summary_groups_unread_notifications_by_verb(
self, api_client, user_factory

View File

@ -2,6 +2,7 @@
Тесты API для заявок на закупку (ProcurementRequest).
"""
import io
from datetime import timedelta
from decimal import Decimal
from unittest.mock import patch
@ -14,6 +15,7 @@ from django.urls import reverse
from django.utils.dateparse import parse_datetime
from django.utils import timezone
from push_notifications.models import WebPushDevice
from PIL import Image
from rest_framework import status
from rest_framework.test import APIClient
@ -1183,6 +1185,63 @@ class TestProcurementItemComments:
class TestProcurementItemAttachments:
def test_image_attachment_has_lazy_thumbnail(
self,
api_client,
user,
procurement_item,
settings,
tmp_path,
):
settings.MEDIA_ROOT = tmp_path
api_client.force_authenticate(user=user)
source = io.BytesIO()
Image.new("RGB", (1200, 800), (32, 142, 210)).save(source, "PNG")
source.seek(0)
upload_response = api_client.post(
reverse(
"api:v1:procurement:procurementitem-attachments",
kwargs={"pk": procurement_item.id},
),
{
"files": SimpleUploadedFile(
"position-photo.png",
source.getvalue(),
content_type="image/png",
),
},
format="multipart",
)
assert upload_response.status_code == status.HTTP_201_CREATED
attachment_id = upload_response.data[0]["id"]
assert upload_response.data[0]["thumbnail_url"].endswith(
f"/{attachment_id}/thumbnail/"
)
attachment = ProcurementItemAttachment.objects.get(pk=attachment_id)
assert not attachment.thumbnail
thumbnail_response = api_client.get(
reverse(
"api:v1:procurement:procurementitem-attachment-thumbnail",
kwargs={
"pk": procurement_item.id,
"attachment_id": attachment_id,
},
)
)
assert thumbnail_response.status_code == status.HTTP_200_OK
assert thumbnail_response["Content-Type"] == "image/webp"
thumbnail = Image.open(
io.BytesIO(b"".join(thumbnail_response.streaming_content))
)
assert thumbnail.format == "WEBP"
assert thumbnail.size == (640, 280)
attachment.refresh_from_db()
assert attachment.thumbnail.name.endswith(".webp")
def test_archive_attachment_lifecycle(
self,
api_client,
@ -1297,6 +1356,16 @@ class TestProcurementItemAttachments:
)
assert response.status_code == status.HTTP_403_FORBIDDEN
thumbnail_response = api_client.get(
reverse(
"api:v1:procurement:procurementitem-attachment-thumbnail",
kwargs={
"pk": procurement_item.id,
"attachment_id": attachment.id,
},
)
)
assert thumbnail_response.status_code == status.HTTP_403_FORBIDDEN
# ==============================================================================

View File

@ -45,6 +45,7 @@ from tasks.models import (
TaskUserSettings,
)
from tasks.realtime import get_task_board_recipient_ids
from api.v1.tasks.views import TaskViewSet
pytestmark = pytest.mark.django_db
@ -316,25 +317,44 @@ def test_board_groups_are_personal_and_board_placement_is_independent(
assert create_response.status_code == status.HTTP_201_CREATED
group_id = create_response.data["id"]
second_group_response = api_client.post(
reverse("api:v1:tasks:task-board-group-list"),
{"name": "Производство"},
format="json",
)
second_group_id = second_group_response.data["id"]
move_response = api_client.post(
reverse("api:v1:tasks:task-board-move-to-group", kwargs={"pk": board.id}),
{"group": group_id},
format="json",
)
assert move_response.status_code == status.HTTP_200_OK
assert move_response.data["group_ids"] == [group_id]
assert move_response.data["group_id"] == group_id
assert TaskBoardPlacement.objects.filter(
user=user,
board=board,
group_id=group_id,
).exists()
groups_response = api_client.post(
reverse("api:v1:tasks:task-board-set-groups", kwargs={"pk": board.id}),
{"groups": [second_group_id, group_id]},
format="json",
)
assert groups_response.status_code == status.HTTP_200_OK
assert groups_response.data["group_ids"] == sorted([group_id, second_group_id])
placement = TaskBoardPlacement.objects.get(user=user, board=board)
assert set(placement.groups.values_list("id", flat=True)) == {
group_id,
second_group_id,
}
summary_response = api_client.get(
reverse("api:v1:tasks:task-board-list"),
{"view": "summary"},
)
assert summary_response.status_code == status.HTTP_200_OK
assert summary_response.data["results"][0]["group_id"] == group_id
assert summary_response.data["results"][0]["group_ids"] == sorted(
[group_id, second_group_id]
)
assert summary_response.data["results"][0]["group_id"] == min(
group_id, second_group_id
)
api_client.force_authenticate(user=other_user)
other_groups_response = api_client.get(
@ -347,8 +367,16 @@ def test_board_groups_are_personal_and_board_placement_is_independent(
{"view": "summary"},
)
assert other_summary_response.status_code == status.HTTP_200_OK
assert other_summary_response.data["results"][0]["group_ids"] == []
assert other_summary_response.data["results"][0]["group_id"] is None
foreign_groups_response = api_client.post(
reverse("api:v1:tasks:task-board-set-groups", kwargs={"pk": board.id}),
{"groups": [group_id]},
format="json",
)
assert foreign_groups_response.status_code == status.HTTP_400_BAD_REQUEST
foreign_move_response = api_client.post(
reverse("api:v1:tasks:task-board-move-to-group", kwargs={"pk": board.id}),
{"group": group_id},
@ -362,13 +390,25 @@ def test_board_groups_are_personal_and_board_placement_is_independent(
)
assert delete_response.status_code == status.HTTP_204_NO_CONTENT
assert not TaskBoardGroup.objects.filter(id=group_id).exists()
assert not TaskBoardPlacement.objects.filter(user=user, board=board).exists()
placement.refresh_from_db()
assert list(placement.groups.values_list("id", flat=True)) == [second_group_id]
ungrouped_summary_response = api_client.get(
grouped_summary_response = api_client.get(
reverse("api:v1:tasks:task-board-list"),
{"view": "summary"},
)
assert ungrouped_summary_response.data["results"][0]["group_id"] is None
assert grouped_summary_response.data["results"][0]["group_ids"] == [
second_group_id
]
second_delete_response = api_client.delete(
reverse(
"api:v1:tasks:task-board-group-detail",
kwargs={"pk": second_group_id},
)
)
assert second_delete_response.status_code == status.HTTP_204_NO_CONTENT
assert not TaskBoardPlacement.objects.filter(user=user, board=board).exists()
def test_user_can_toggle_personal_default_board_group(api_client, user):
@ -456,7 +496,7 @@ def test_board_pin_and_notifications_are_personal_and_keep_ungrouped_settings(
assert notifications_response.status_code == status.HTTP_200_OK
assert notifications_response.data["notifications_enabled"] is False
placement = TaskBoardPlacement.objects.get(user=user, board=pinned_board)
assert placement.group_id is None
assert not placement.groups.exists()
assert placement.is_pinned is True
assert placement.notifications_enabled is False
@ -482,7 +522,7 @@ def test_board_pin_and_notifications_are_personal_and_keep_ungrouped_settings(
format="json",
)
placement.refresh_from_db()
assert placement.group_id is None
assert not placement.groups.exists()
assert placement.is_pinned is True
assert placement.notifications_enabled is False
@ -1056,18 +1096,20 @@ def test_task_moves_between_rows_without_changing_column(api_client, user):
)
api_client.force_authenticate(user=user)
response = api_client.post(
reverse("api:v1:tasks:task-move", kwargs={"pk": task.id}),
{
"column": child.id,
"row": second_row.id,
},
format="json",
)
with patch("api.v1.tasks.views.normalize_column_positions") as normalize:
response = api_client.post(
reverse("api:v1:tasks:task-move", kwargs={"pk": task.id}),
{
"column": child.id,
"row": second_row.id,
},
format="json",
)
assert response.status_code == status.HTTP_200_OK
assert response.data["column"] == child.id
assert response.data["row"] == second_row.id
normalize.assert_not_called()
task.refresh_from_db()
assert task.column_id == child.id
assert task.row_id == second_row.id
@ -1081,6 +1123,41 @@ def test_task_moves_between_rows_without_changing_column(api_client, user):
assert task.row_id is None
def test_appending_task_to_large_column_uses_constant_query_count(user):
board = TaskBoard.objects.create(name="Большая доска", created_by=user)
source = TaskColumn.objects.create(board=board, name="Источник", position=1000)
target = TaskColumn.objects.create(board=board, name="Назначение", position=2000)
task = Task.objects.create(
board=board,
column=source,
title="Перемещаемая задача",
created_by=user,
position=1000,
)
Task.objects.bulk_create([
Task(
board=board,
column=target,
title=f"Задача {index}",
created_by=user,
position=(index + 1) * 1000,
)
for index in range(250)
])
with (
patch("api.v1.tasks.views.create_task_activity"),
patch("api.v1.tasks.views.notify_task_moved"),
patch("api.v1.tasks.views.send_task_board_update"),
CaptureQueriesContext(connection) as queries,
):
moved = TaskViewSet()._move_task(task, target, None, None, user)
assert moved.column_id == target.id
assert moved.position == 251000
assert len(queries) <= 10
def test_reorder_subcolumns_does_not_change_top_level_order(api_client, user):
board = TaskBoard.objects.create(name="Порядок подколонок", created_by=user)
first_parent = TaskColumn.objects.create(
@ -1245,6 +1322,7 @@ def test_create_task_and_move_to_done_column(api_client, user):
assert send_kwargs["extra"] == {
"column_id": columns["Готово"]["id"],
"row_id": None,
"position": move_response.data["position"],
}
task = Task.objects.get(id=task_id)

View File

@ -89,13 +89,25 @@ async function proxyRequest(request: NextRequest, method: string) {
});
}
// arrayBuffer сохраняет бинарные ответы (.xlsx, изображения) и работает
// для JSON/текста без потери заголовков.
// Node fetch может распаковать upstream-ответ, поэтому исходные
// content-encoding/content-length больше не соответствуют телу.
responseHeaders.delete('content-encoding');
responseHeaders.delete('content-length');
responseHeaders.delete('transfer-encoding');
// Возвращаем ответ от backend
// GET-ответы (включая крупные JSON, изображения и скачивания) передаём
// потоком. Раньше arrayBuffer() задерживал первый байт до полной загрузки
// ответа и держал весь файл/ответ доски в памяти процесса Next.js.
if (method === 'GET') {
return new NextResponse(response.body, {
status: response.status,
statusText: response.statusText,
headers: responseHeaders,
});
}
// Для изменяющих запросов пока сохраняем прежнее буферизованное
// поведение: их ответы небольшие, а риск изменения upload-потока выше.
const responseBody = await response.arrayBuffer();
return new NextResponse(responseBody, {

View File

@ -1133,6 +1133,13 @@ html[data-bs-theme="dark"] .tasks-board-group-header-sticky {
-ms-overflow-style: auto;
}
/* Карточка остаётся в DOM с полной высотой для стабильного скролла, но браузер
может не выполнять её layout/paint далеко за пределами видимой области. */
.tasks-task-card {
content-visibility: auto;
contain-intrinsic-size: auto 10rem;
}
.tasks-board-scroll::-webkit-scrollbar,
.tasks-column-scroll::-webkit-scrollbar {
display: block;

View File

@ -70,9 +70,11 @@ import {
memo,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type CSSProperties,
type ReactNode,
type RefObject,
} from "react";
@ -442,6 +444,25 @@ function formatFileSize(size: number): string {
})} МБ`;
}
function nextTaskPosition(
tasks: TaskCard[],
columnId: number,
rowId: number | null,
excludedTaskId?: number,
): number {
let maxPosition = 0;
for (const task of tasks) {
if (
task.id !== excludedTaskId
&& task.column === columnId
&& (task.row || null) === rowId
) {
maxPosition = Math.max(maxPosition, task.position);
}
}
return maxPosition + 1000;
}
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);
@ -613,20 +634,7 @@ function getMessageCreatedLabel(message?: Message | null) {
return message.created || (message.created_at ? formatDate(message.created_at) : "");
}
const TaskCardView = memo(function TaskCardView({
task,
onOpen,
onEdit,
onDelete,
currentUserId,
claiming,
onClaim,
completing,
onComplete,
menuOpen,
menuRef,
onToggleTaskMenu,
}: {
type TaskCardViewProps = {
task: TaskCard;
onOpen: (task: TaskCard) => void;
onEdit: (task: TaskCard) => void;
@ -637,14 +645,43 @@ const TaskCardView = memo(function TaskCardView({
completing: boolean;
onComplete: (task: TaskCard) => void;
menuOpen: boolean;
menuRef: RefObject<HTMLDivElement | null>;
onToggleTaskMenu: (taskId: number) => void;
}) {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
id: `task-${task.id}`,
data: { type: "task", taskId: task.id },
});
const style = transform && !isDragging ? { transform: CSS.Translate.toString(transform) } : undefined;
onToggleTaskMenu: (taskId: number, anchor: HTMLButtonElement) => void;
};
type TaskCardDragBinding = {
attributes?: ReturnType<typeof useDraggable>["attributes"];
listeners?: ReturnType<typeof useDraggable>["listeners"];
setNodeRef?: ReturnType<typeof useDraggable>["setNodeRef"];
style?: CSSProperties;
isDragging?: boolean;
onPointerEnter?: () => void;
onPointerLeave?: () => void;
onPointerDownCapture?: () => void;
onPointerUpCapture?: () => void;
onPointerCancelCapture?: () => void;
};
const TaskCardContent = memo(function TaskCardContent({
task,
onOpen,
currentUserId,
claiming,
onClaim,
completing,
onComplete,
menuOpen,
onToggleTaskMenu,
attributes,
listeners,
setNodeRef,
style,
isDragging = false,
onPointerEnter,
onPointerLeave,
onPointerDownCapture,
onPointerUpCapture,
onPointerCancelCapture,
}: TaskCardViewProps & TaskCardDragBinding) {
const priority = priorityMeta[task.priority] ?? priorityMeta.medium;
const dueDateClass = getTaskDueDateBadgeClass(task);
const canClaim = !task.assignee && !task.completed_at;
@ -654,11 +691,16 @@ const TaskCardView = memo(function TaskCardView({
<article
ref={setNodeRef}
style={style}
className={`app-surface-elevated cursor-grab select-none rounded-xl border border-[var(--border-subtle)] p-3 shadow-sm transition active:cursor-grabbing ${
className={`tasks-task-card app-surface-elevated cursor-grab select-none rounded-xl border border-[var(--border-subtle)] p-3 shadow-sm transition active:cursor-grabbing ${
isDragging ? "opacity-30" : "hover:border-[var(--border-strong)]"
}`}
title="Перетащите задачу в нужную колонку"
onClick={() => onOpen(task)}
onPointerEnter={onPointerEnter}
onPointerLeave={onPointerLeave}
onPointerDownCapture={onPointerDownCapture}
onPointerUpCapture={onPointerUpCapture}
onPointerCancelCapture={onPointerCancelCapture}
{...attributes}
{...listeners}
>
@ -683,14 +725,13 @@ const TaskCardView = memo(function TaskCardView({
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
<div
ref={menuOpen ? menuRef : null}
className="relative"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<button
type="button"
onClick={() => onToggleTaskMenu(task.id)}
onClick={(event) => onToggleTaskMenu(task.id, event.currentTarget)}
className="app-icon-button flex h-7 w-7 items-center justify-center rounded-md"
title="Действия"
aria-label="Действия с задачей"
@ -702,26 +743,6 @@ const TaskCardView = memo(function TaskCardView({
className={`transition-transform ${menuOpen ? "" : "-rotate-90"}`}
/>
</button>
{menuOpen ? (
<div className="app-menu absolute right-0 top-full z-30 mt-2 w-44 rounded-lg p-1.5">
<button
type="button"
onClick={() => onEdit(task)}
className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition hover:bg-[var(--surface-secondary)]"
>
<Pencil size={14} className="app-text-muted" />
Редактировать
</button>
<button
type="button"
onClick={() => onDelete(task)}
className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm text-[var(--danger-foreground)] transition hover:bg-[var(--danger-soft)]"
>
<Trash2 size={14} />
Удалить
</button>
</div>
) : null}
</div>
<span className={`whitespace-nowrap text-right text-[11px] font-medium ${priority.textClassName}`}>
{priority.urgencyLabel} срочность
@ -825,6 +846,86 @@ const TaskCardView = memo(function TaskCardView({
);
});
function TaskCardDraggableBinding({
task,
onBindingChange,
onDraggingChange,
}: {
task: TaskCard;
onBindingChange: (binding: TaskCardDragBinding | null) => void;
onDraggingChange: (isDragging: boolean) => void;
}) {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: `task-${task.id}`,
data: { type: "task", taskId: task.id },
});
const binding = useMemo<TaskCardDragBinding>(() => ({
attributes,
listeners,
setNodeRef,
isDragging,
}), [attributes, isDragging, listeners, setNodeRef]);
useLayoutEffect(() => {
onBindingChange(binding);
return () => onBindingChange(null);
}, [binding, onBindingChange]);
useEffect(() => {
onDraggingChange(isDragging);
}, [isDragging, onDraggingChange]);
return null;
}
const TaskCardView = memo(function TaskCardView(props: TaskCardViewProps) {
const [dragArmed, setDragArmed] = useState(false);
const [dragBinding, setDragBinding] = useState<TaskCardDragBinding | null>(null);
const pointerInsideRef = useRef(false);
const pointerPressedRef = useRef(false);
const draggingRef = useRef(false);
const handleDraggingChange = useCallback((isDragging: boolean) => {
draggingRef.current = isDragging;
if (!isDragging && !pointerInsideRef.current) setDragArmed(false);
}, []);
return (
<>
{dragArmed ? (
<TaskCardDraggableBinding
task={props.task}
onBindingChange={setDragBinding}
onDraggingChange={handleDraggingChange}
/>
) : null}
<TaskCardContent
key={`task-card-content-${props.task.id}`}
{...props}
{...(dragBinding || {})}
onPointerEnter={() => {
pointerInsideRef.current = true;
setDragArmed(true);
}}
onPointerLeave={() => {
pointerInsideRef.current = false;
if (!pointerPressedRef.current && !draggingRef.current) setDragArmed(false);
}}
onPointerDownCapture={() => {
pointerPressedRef.current = true;
}}
onPointerUpCapture={() => {
pointerPressedRef.current = false;
if (!pointerInsideRef.current && !draggingRef.current) setDragArmed(false);
}}
onPointerCancelCapture={() => {
pointerPressedRef.current = false;
if (!pointerInsideRef.current && !draggingRef.current) setDragArmed(false);
}}
/>
</>
);
});
function TaskDragOverlayCard({ task }: { task: TaskCard }) {
const priority = priorityMeta[task.priority] ?? priorityMeta.medium;
const dueDateClass = getTaskDueDateBadgeClass(task);
@ -1092,6 +1193,81 @@ function TaskAddMenuPopover({
);
}
function TaskCardMenuPopover({
anchorRef,
menuRef,
children,
}: {
anchorRef: RefObject<HTMLButtonElement | null>;
menuRef: RefObject<HTMLDivElement | null>;
children: ReactNode;
}) {
const [position, setPosition] = useState<{
left: number;
top?: number;
bottom?: number;
maxHeight: number;
} | null>(null);
useEffect(() => {
const updatePosition = () => {
const anchor = anchorRef.current;
if (!anchor) return;
const rect = anchor.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const menuWidth = 176;
const viewportGap = 8;
const menuGap = 6;
const spaceBelow = viewportHeight - rect.bottom - viewportGap - menuGap;
const spaceAbove = rect.top - viewportGap - menuGap;
const placeBelow = spaceBelow >= 96 || spaceBelow >= spaceAbove;
const left = Math.min(
Math.max(viewportGap, rect.right - menuWidth),
Math.max(viewportGap, viewportWidth - menuWidth - viewportGap),
);
setPosition(placeBelow ? {
left,
top: rect.bottom + menuGap,
maxHeight: Math.max(72, spaceBelow),
} : {
left,
bottom: viewportHeight - rect.top + menuGap,
maxHeight: Math.max(72, spaceAbove),
});
};
updatePosition();
window.addEventListener("resize", updatePosition);
window.addEventListener("scroll", updatePosition, true);
return () => {
window.removeEventListener("resize", updatePosition);
window.removeEventListener("scroll", updatePosition, true);
};
}, [anchorRef]);
if (!position || typeof document === "undefined") return null;
return createPortal(
<div
ref={menuRef}
role="menu"
className="app-menu fixed z-[240] w-44 overflow-y-auto overscroll-contain rounded-lg p-1.5 shadow-xl"
style={{
left: position.left,
top: position.top,
bottom: position.bottom,
maxHeight: position.maxHeight,
}}
>
{children}
</div>,
document.body,
);
}
type TaskColumnCardsProps = {
tasks: TaskCard[];
onOpenTask: (task: TaskCard) => void;
@ -1103,8 +1279,7 @@ type TaskColumnCardsProps = {
completingTaskId: number | null;
onCompleteTask: (task: TaskCard) => void;
openMenuTaskId: number | null;
menuRef: RefObject<HTMLDivElement | null>;
onToggleTaskMenu: (taskId: number) => void;
onToggleTaskMenu: (taskId: number, anchor: HTMLButtonElement) => void;
quickTaskComposer?: ReactNode;
};
@ -1119,7 +1294,6 @@ function TaskColumnCards({
completingTaskId,
onCompleteTask,
openMenuTaskId,
menuRef,
onToggleTaskMenu,
quickTaskComposer,
}: TaskColumnCardsProps) {
@ -1140,7 +1314,6 @@ function TaskColumnCards({
completing={completingTaskId === task.id}
onComplete={onCompleteTask}
menuOpen={openMenuTaskId === task.id}
menuRef={menuRef}
onToggleTaskMenu={onToggleTaskMenu}
/>
))
@ -1167,7 +1340,6 @@ function BoardColumn({
completingTaskId,
onCompleteTask,
openMenuTaskId,
menuRef,
onToggleTaskMenu,
columnMenuOpen,
columnMenuRef,
@ -1198,8 +1370,7 @@ function BoardColumn({
completingTaskId: number | null;
onCompleteTask: (task: TaskCard) => void;
openMenuTaskId: number | null;
menuRef: RefObject<HTMLDivElement | null>;
onToggleTaskMenu: (taskId: number) => void;
onToggleTaskMenu: (taskId: number, anchor: HTMLButtonElement) => void;
columnMenuOpen: boolean;
columnMenuRef: RefObject<HTMLDivElement | null>;
onToggleColumnMenu: () => void;
@ -1393,7 +1564,6 @@ function BoardColumn({
completingTaskId={completingTaskId}
onCompleteTask={onCompleteTask}
openMenuTaskId={openMenuTaskId}
menuRef={menuRef}
onToggleTaskMenu={onToggleTaskMenu}
quickTaskComposer={quickTaskComposer}
/>
@ -3646,6 +3816,7 @@ function TasksPageContent() {
const taskAddMenuBottomRef = useRef<HTMLDivElement | null>(null);
const taskAddMenuPopoverRef = useRef<HTMLDivElement | null>(null);
const viewColumnMenuRef = useRef<HTMLDivElement | null>(null);
const cardMenuAnchorRef = useRef<HTMLButtonElement | null>(null);
const cardMenuRef = useRef<HTMLDivElement | null>(null);
const columnMenuRef = useRef<HTMLDivElement | null>(null);
const selectedBoardIdRef = useRef<number | null>(null);
@ -4159,6 +4330,14 @@ function TasksPageContent() {
onChecklistChange: (taskId) => void loadTaskChecklist(taskId),
onCommentsChange: (taskId) => void loadTaskComments(taskId),
onActivityChange: (taskId) => void loadTaskActivity(taskId),
onTaskMove: (taskId, columnId, rowId, position) => {
updateBoardTask(taskId, (task) => ({
...task,
column: columnId,
row: rowId,
position: position ?? task.position,
}));
},
onBoardChange: (boardId) => void syncTaskBoardState(boardId),
});
@ -4312,8 +4491,8 @@ function TasksPageContent() {
function handlePointerDown(event: MouseEvent) {
if (
cardMenuRef.current &&
!cardMenuRef.current.contains(event.target as Node)
!cardMenuRef.current?.contains(event.target as Node)
&& !cardMenuAnchorRef.current?.contains(event.target as Node)
) {
setCardMenuTaskId(null);
}
@ -4443,6 +4622,11 @@ function TasksPageContent() {
[activeTaskId, board?.tasks],
);
const cardMenuTask = useMemo(
() => (board?.tasks || []).find((task) => task.id === cardMenuTaskId) || null,
[board?.tasks, cardMenuTaskId],
);
const viewTask = useMemo(
() => (board?.tasks || []).find((task) => task.id === viewTaskId) || null,
[board?.tasks, viewTaskId],
@ -4816,8 +5000,15 @@ function TasksPageContent() {
void loadTaskAutomationButtons(task.id);
}, [loadTaskAutomationButtons]);
const toggleCardTaskMenu = useCallback((taskId: number) => {
setCardMenuTaskId((current) => (current === taskId ? null : taskId));
const toggleCardTaskMenu = useCallback((
taskId: number,
anchor: HTMLButtonElement,
) => {
setCardMenuTaskId((current) => {
const next = current === taskId ? null : taskId;
cardMenuAnchorRef.current = next ? anchor : null;
return next;
});
}, []);
const toggleColumnMenu = useCallback((columnId: number) => {
@ -5333,7 +5524,9 @@ function TasksPageContent() {
const deleteBoardGroup = useCallback(async (group: TaskBoardGroup) => {
if (saving) return;
if (!window.confirm(`Удалить группу «${group.name}»? Доски останутся в разделе «Без группы».`)) {
if (!window.confirm(
`Удалить группу «${group.name}»? Она будет убрана у досок, остальные группы сохранятся.`,
)) {
return;
}
setSaving(true);
@ -5367,27 +5560,48 @@ function TasksPageContent() {
}
}, [saving]);
const moveBoardToGroup = useCallback(async (boardId: number, groupId: number | null) => {
const previousGroupId = boards.find((item) => item.id === boardId)?.group_id || null;
if (previousGroupId === groupId || saving) return false;
const setBoardGroupMemberships = useCallback(async (
boardId: number,
groupIds: number[],
) => {
const currentBoard = boards.find((item) => item.id === boardId);
const previousGroupIds = [
...(currentBoard?.group_ids || (currentBoard?.group_id ? [currentBoard.group_id] : [])),
].sort((left, right) => left - right);
const nextGroupIds = [...new Set(groupIds)].sort((left, right) => left - right);
if (
saving
|| (
previousGroupIds.length === nextGroupIds.length
&& previousGroupIds.every((groupId, index) => groupId === nextGroupIds[index])
)
) return false;
const nextGroupId = nextGroupIds[0] || null;
setBoards((current) => current.map((item) => (
item.id === boardId ? { ...item, group_id: groupId } : item
item.id === boardId
? { ...item, group_ids: nextGroupIds, group_id: nextGroupId }
: item
)));
setBoard((current) => current?.id === boardId ? { ...current, group_id: groupId } : current);
setBoard((current) => current?.id === boardId
? { ...current, group_ids: nextGroupIds, group_id: nextGroupId }
: current);
setSaving(true);
setError(null);
try {
await apiClient.moveTaskBoardToGroup(boardId, groupId);
await apiClient.setTaskBoardGroups(boardId, nextGroupIds);
await loadBoardGroups();
return true;
} catch (groupError) {
const previousGroupId = previousGroupIds[0] || null;
setBoards((current) => current.map((item) => (
item.id === boardId ? { ...item, group_id: previousGroupId } : item
item.id === boardId
? { ...item, group_ids: previousGroupIds, group_id: previousGroupId }
: item
)));
setBoard((current) => current?.id === boardId
? { ...current, group_id: previousGroupId }
? { ...current, group_ids: previousGroupIds, group_id: previousGroupId }
: current);
setError(getTaskError(groupError, "Не удалось переместить доску"));
setError(getTaskError(groupError, "Не удалось изменить группы доски"));
return false;
} finally {
setSaving(false);
@ -6728,16 +6942,28 @@ function TasksPageContent() {
&& rowsByColumn.get(targetRootId)?.some((item) => item.id === viewTask.row)
? viewTask.row
: null;
await apiClient.moveTask(viewTask.id, { column: columnId, row });
await loadBoard(board.id);
const optimisticPosition = nextTaskPosition(
board.tasks,
columnId,
row,
viewTask.id,
);
updateBoardTask(viewTask.id, (task) => ({
...task,
column: columnId,
row,
position: optimisticPosition,
}));
const updatedTask = await apiClient.moveTask(viewTask.id, { column: columnId, row });
replaceBoardTask(updatedTask);
if (activityOpen) await loadTaskActivity(viewTask.id);
} catch (moveError) {
setError(getTaskError(moveError, "Не удалось изменить колонку задачи"));
await loadBoard(board.id);
replaceBoardTask(viewTask);
} finally {
setSaving(false);
}
}, [activeColumns, activityOpen, board, loadBoard, loadTaskActivity, rowsByColumn, saving, viewTask]);
}, [activeColumns, activityOpen, board, loadTaskActivity, replaceBoardTask, rowsByColumn, saving, updateBoardTask, viewTask]);
const claimTask = useCallback(async (task: TaskCard) => {
if (
@ -6933,14 +7159,30 @@ function TasksPageContent() {
const task = board.tasks.find((item) => item.id === taskId);
if (!task || (task.column === columnId && (task.row || null) === rowId)) return;
const optimisticPosition = nextTaskPosition(
board.tasks,
columnId,
rowId,
taskId,
);
updateBoardTask(taskId, (current) => ({
...current,
column: columnId,
row: rowId,
position: optimisticPosition,
}));
try {
await apiClient.moveTask(taskId, { column: columnId, row: rowId });
await loadBoard(board.id);
const updatedTask = await apiClient.moveTask(taskId, {
column: columnId,
row: rowId,
});
replaceBoardTask(updatedTask);
} catch (moveError) {
setError(getTaskError(moveError, "Не удалось переместить задачу"));
await loadBoard(board.id);
replaceBoardTask(task);
}
}, [activeColumns, board, loadBoard]);
}, [activeColumns, board, loadBoard, replaceBoardTask, updateBoardTask]);
const taskAddMenuItems = viewTask ? (
<>
@ -7101,7 +7343,7 @@ function TasksPageContent() {
onSetDefaultBoard={(targetBoard) => void setDefaultBoard(targetBoard)}
onToggleBoardPin={(targetBoard) => void toggleBoardPin(targetBoard)}
onToggleBoardNotifications={(targetBoard) => void toggleBoardNotifications(targetBoard)}
onMoveBoard={moveBoardToGroup}
onMoveBoard={setBoardGroupMemberships}
wideMode={desktopWideMode}
onWideModeChange={changeDesktopWideMode}
userId={user?.id ?? null}
@ -7400,7 +7642,6 @@ function TasksPageContent() {
completingTaskId={completingTaskId}
onCompleteTask={completeTask}
openMenuTaskId={cardMenuTaskId}
menuRef={cardMenuRef}
onToggleTaskMenu={toggleCardTaskMenu}
columnMenuOpen={columnMenuId === column.id}
columnMenuRef={columnMenuRef}
@ -7460,7 +7701,6 @@ function TasksPageContent() {
completingTaskId={completingTaskId}
onCompleteTask={completeTask}
openMenuTaskId={cardMenuTaskId}
menuRef={cardMenuRef}
onToggleTaskMenu={toggleCardTaskMenu}
linkedColumnHighlight={activeColumnDragId === cellColumn.id}
synchronizedColumnDrag={activeColumnDragId !== null}
@ -7574,6 +7814,32 @@ function TasksPageContent() {
{activeDragTask ? <TaskDragOverlayCard task={activeDragTask} /> : null}
</DragOverlay>
</DndContext>
{cardMenuTask ? (
<TaskCardMenuPopover
anchorRef={cardMenuAnchorRef}
menuRef={cardMenuRef}
>
<button
type="button"
role="menuitem"
onClick={() => openEditTask(cardMenuTask)}
className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition hover:bg-[var(--surface-secondary)]"
>
<Pencil size={14} className="app-text-muted" />
Редактировать
</button>
<button
type="button"
role="menuitem"
onClick={() => void deleteTask(cardMenuTask)}
className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm text-[var(--danger-foreground)] transition hover:bg-[var(--danger-soft)]"
>
<Trash2 size={14} />
Удалить
</button>
</TaskCardMenuPopover>
) : null}
</div>
</div>
</div>

View File

@ -1,9 +1,12 @@
"use client";
import Image from "next/image";
import { Download, FileText, Loader2, Play } from "lucide-react";
import { useEffect, useState } from "react";
import { Download, Eye, FileText, Loader2, Play } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import ChatMediaPreviewModal from "@/components/messages/ChatMediaPreviewModal";
import type { MediaPreview } from "@/components/messages/ChatMessageItem";
import { useNearViewport } from "@/hooks/useNearViewport";
import { apiClient } from "@/lib/api";
import type { ProcurementItemAttachment } from "@/types/api";
@ -36,58 +39,95 @@ export function ProcurementCommentAttachment({
}) {
const image = isImage(attachment);
const video = isVideo(attachment);
const [source, setSource] = useState<string | null>(null);
const [failed, setFailed] = useState(false);
const media = image || video;
const [thumbnailSource, setThumbnailSource] = useState<string | null>(null);
const [thumbnailFailed, setThumbnailFailed] = useState(false);
const [preview, setPreview] = useState<MediaPreview | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const previewUrlRef = useRef<string | null>(null);
const { elementRef, isNearViewport } = useNearViewport();
useEffect(() => {
if (!image && !video) return;
if (!media || !isNearViewport) return;
let active = true;
let objectUrl: string | null = null;
void apiClient
.downloadProcurementItemAttachment(itemId, attachment)
.then(({ blob }) => {
void apiClient.downloadProcurementItemAttachmentThumbnail(itemId, attachment)
.then((blob) => {
if (!active) return;
objectUrl = URL.createObjectURL(blob);
setSource(objectUrl);
setThumbnailSource(objectUrl);
})
.catch(() => {
if (active) setFailed(true);
if (active) setThumbnailFailed(true);
});
return () => {
active = false;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [attachment, image, itemId, video]);
}, [attachment, isNearViewport, itemId, media]);
if (image || video) {
useEffect(() => () => {
if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current);
}, []);
const closePreview = useCallback(() => {
setPreview(null);
if (previewUrlRef.current) {
URL.revokeObjectURL(previewUrlRef.current);
previewUrlRef.current = null;
}
}, []);
const openPreview = useCallback(async () => {
if (!media || previewLoading) return;
setPreviewLoading(true);
try {
const { blob } = await apiClient.downloadProcurementItemAttachment(itemId, attachment);
if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current);
const source = URL.createObjectURL(blob);
previewUrlRef.current = source;
setPreview({
type: video ? "video" : "image",
src: source,
name: attachment.file_name,
});
} finally {
setPreviewLoading(false);
}
}, [attachment, itemId, media, previewLoading, video]);
if (media) {
return (
<div className="app-surface-elevated overflow-hidden rounded-lg border border-[var(--border-subtle)]">
<div className="relative aspect-video min-h-32 bg-[var(--surface-secondary)]">
{source && image ? (
<div ref={elementRef} className="app-surface-elevated overflow-hidden rounded-lg border border-[var(--border-subtle)]">
<button
type="button"
onClick={() => void openPreview()}
disabled={!thumbnailSource || thumbnailFailed || previewLoading}
className="group relative block aspect-video min-h-32 w-full overflow-hidden bg-[var(--surface-secondary)] disabled:cursor-default"
title={`Просмотреть ${attachment.file_name}`}
>
{thumbnailSource ? (
<Image
src={source}
src={thumbnailSource}
alt={attachment.file_name}
fill
unoptimized
sizes="(max-width: 640px) 100vw, 320px"
className="object-contain"
/>
) : source && video ? (
<video
src={source}
controls
preload="metadata"
className="h-full w-full object-contain"
className="object-cover"
/>
) : (
<span className="app-text-muted absolute inset-0 flex items-center justify-center">
{failed ? <Play size={20} /> : <Loader2 size={18} className="animate-spin" />}
{thumbnailFailed ? <FileText size={20} /> : <Loader2 size={18} className="animate-spin" />}
</span>
)}
</div>
{thumbnailSource ? (
<span className="pointer-events-none absolute inset-0 flex items-center justify-center bg-black/35 text-white opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100">
{previewLoading ? <Loader2 size={22} className="animate-spin" /> : video ? <Play size={24} fill="currentColor" /> : <Eye size={24} />}
</span>
) : null}
</button>
<button
type="button"
onClick={() => void onDownload(attachment)}
@ -99,6 +139,7 @@ export function ProcurementCommentAttachment({
</span>
<Download size={13} className="app-text-muted shrink-0" />
</button>
{preview ? <ChatMediaPreviewModal preview={preview} onClose={closePreview} /> : null}
</div>
);
}

View File

@ -35,6 +35,17 @@ type BoardAccessFilter = "all" | "private" | "shared";
type BoardGroupFilter = "all" | "ungrouped" | number;
const NAVIGATOR_COLLAPSED_STORAGE_PREFIX = "tasks.board-navigator-collapsed";
function boardGroupIds(board: TaskBoardSummary): number[] {
return board.group_ids || (board.group_id ? [board.group_id] : []);
}
function sameGroupIds(left: number[], right: number[]): boolean {
const normalizedLeft = [...left].sort((first, second) => first - second);
const normalizedRight = [...right].sort((first, second) => first - second);
return normalizedLeft.length === normalizedRight.length
&& normalizedLeft.every((groupId, index) => groupId === normalizedRight[index]);
}
type TaskBoardNavigatorProps = {
boards: TaskBoardSummary[];
groups: TaskBoardGroup[];
@ -53,7 +64,7 @@ type TaskBoardNavigatorProps = {
onToggleBoardNotifications: (board: TaskBoardSummary) => void;
onMoveBoard: (
boardId: number,
groupId: number | null,
groupIds: number[],
) => boolean | void | Promise<boolean | void>;
wideMode: boolean;
onWideModeChange: (enabled: boolean) => void;
@ -183,7 +194,7 @@ function BoardRow({
{board.access_scope === "private" ? "Личная" : "Общая"}
</span>
{groupName ? (
<span className="inline-flex min-w-0 items-center gap-1" title={`Группа: ${groupName}`}>
<span className="inline-flex min-w-0 items-center gap-1" title={`Группы: ${groupName}`}>
<Folder size={11} className="shrink-0" />
<span className="max-w-32 truncate">{groupName}</span>
</span>
@ -265,7 +276,7 @@ function BoardRow({
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<FolderInput size={14} className="app-text-muted" />
Переместить в группу
Настроить группы
</button>
<div className="my-1 border-t border-[var(--border-subtle)]" />
<button
@ -313,7 +324,7 @@ export default function TaskBoardNavigator({
const [groupMenuId, setGroupMenuId] = useState<number | null>(null);
const [navigatorCollapsed, setNavigatorCollapsed] = useState(false);
const [moveBoardTarget, setMoveBoardTarget] = useState<TaskBoardSummary | null>(null);
const [moveGroupId, setMoveGroupId] = useState<number | null>(null);
const [moveGroupIds, setMoveGroupIds] = useState<number[]>([]);
const [moveGroupQuery, setMoveGroupQuery] = useState("");
const [moveSubmitting, setMoveSubmitting] = useState(false);
const [participantsBoard, setParticipantsBoard] = useState<TaskBoardSummary | null>(null);
@ -332,8 +343,9 @@ export default function TaskBoardNavigator({
return true;
}), [accessFilter, boards, unreadCounts, unreadFilter]);
const visibleBoards = useMemo(() => filteredBoards.filter((board) => {
if (effectiveGroupFilter === "ungrouped" && board.group_id) return false;
if (typeof effectiveGroupFilter === "number" && board.group_id !== effectiveGroupFilter) return false;
const groupIds = boardGroupIds(board);
if (effectiveGroupFilter === "ungrouped" && groupIds.length > 0) return false;
if (typeof effectiveGroupFilter === "number" && !groupIds.includes(effectiveGroupFilter)) return false;
return !normalizedQuery || matchesBoard(board, normalizedQuery);
}), [effectiveGroupFilter, filteredBoards, normalizedQuery]);
const groupStats = useMemo(() => {
@ -346,10 +358,10 @@ export default function TaskBoardNavigator({
});
return {
all: buildStats(filteredBoards),
ungrouped: buildStats(filteredBoards.filter((board) => !board.group_id)),
ungrouped: buildStats(filteredBoards.filter((board) => boardGroupIds(board).length === 0)),
byId: new Map(groups.map((group) => [
group.id,
buildStats(filteredBoards.filter((board) => board.group_id === group.id)),
buildStats(filteredBoards.filter((board) => boardGroupIds(board).includes(group.id))),
])),
};
}, [filteredBoards, groups, unreadCounts]);
@ -403,22 +415,22 @@ export default function TaskBoardNavigator({
const closeMoveModal = () => {
if (moveSubmitting) return;
setMoveBoardTarget(null);
setMoveGroupId(null);
setMoveGroupIds([]);
setMoveGroupQuery("");
};
const confirmMove = async () => {
if (!moveBoardTarget || moveSubmitting) return;
if ((moveBoardTarget.group_id || null) === moveGroupId) {
if (sameGroupIds(boardGroupIds(moveBoardTarget), moveGroupIds)) {
closeMoveModal();
return;
}
setMoveSubmitting(true);
try {
const moved = await onMoveBoard(moveBoardTarget.id, moveGroupId);
const moved = await onMoveBoard(moveBoardTarget.id, moveGroupIds);
if (moved === false) return;
setMoveBoardTarget(null);
setMoveGroupId(null);
setMoveGroupIds([]);
setMoveGroupQuery("");
} finally {
setMoveSubmitting(false);
@ -704,7 +716,10 @@ export default function TaskBoardNavigator({
<BoardRow
key={board.id}
board={board}
groupName={board.group_id ? groupNames.get(board.group_id) : undefined}
groupName={boardGroupIds(board)
.map((groupId) => groupNames.get(groupId))
.filter((groupName): groupName is string => Boolean(groupName))
.join(", ") || undefined}
active={selectedBoardId === board.id}
unread={unreadCounts[board.id] || 0}
compact={compactNavigator}
@ -733,7 +748,7 @@ export default function TaskBoardNavigator({
}}
onMove={() => {
setMoveBoardTarget(board);
setMoveGroupId(board.group_id || null);
setMoveGroupIds(boardGroupIds(board));
setMoveGroupQuery("");
setBoardMenuId(null);
}}
@ -903,7 +918,7 @@ export default function TaskBoardNavigator({
<Modal
isOpen={Boolean(moveBoardTarget)}
onClose={closeMoveModal}
title="Переместить доску"
title="Группы доски"
size="sm"
closeOnClickOutside
closeOnEsc={!moveSubmitting}
@ -920,10 +935,13 @@ export default function TaskBoardNavigator({
<button
type="button"
onClick={() => void confirmMove()}
disabled={moveSubmitting || (moveBoardTarget?.group_id || null) === moveGroupId}
disabled={moveSubmitting || Boolean(
moveBoardTarget
&& sameGroupIds(boardGroupIds(moveBoardTarget), moveGroupIds)
)}
className="app-action-primary rounded-lg px-4 py-2 text-sm font-medium disabled:opacity-60"
>
{moveSubmitting ? "Перемещение..." : "Переместить"}
{moveSubmitting ? "Сохранение..." : "Сохранить"}
</button>
</div>
)}
@ -940,7 +958,7 @@ export default function TaskBoardNavigator({
<p className="truncate text-sm font-semibold text-[var(--foreground)]">
{moveBoardTarget.name}
</p>
<p className="app-text-muted mt-0.5 text-xs">Выберите новую группу</p>
<p className="app-text-muted mt-0.5 text-xs">Выберите одну или несколько групп</p>
</div>
</div>
) : null}
@ -964,26 +982,30 @@ export default function TaskBoardNavigator({
<div className="tasks-column-scroll max-h-[45vh] space-y-1 overflow-y-auto pr-1">
<button
type="button"
onClick={() => setMoveGroupId(null)}
onClick={() => setMoveGroupIds([])}
className={`flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm transition ${
moveGroupId === null ? "app-selected" : "hover:bg-[var(--surface-secondary)]"
moveGroupIds.length === 0 ? "app-selected" : "hover:bg-[var(--surface-secondary)]"
}`}
>
<Check size={15} className={moveGroupId === null ? "app-accent-text" : "invisible"} />
<Check size={15} className={moveGroupIds.length === 0 ? "app-accent-text" : "invisible"} />
<span className="min-w-0 flex-1 truncate">Без группы</span>
</button>
{filteredMoveGroups.map((group) => (
<button
key={group.id}
type="button"
onClick={() => setMoveGroupId(group.id)}
onClick={() => setMoveGroupIds((current) => (
current.includes(group.id)
? current.filter((groupId) => groupId !== group.id)
: [...current, group.id]
))}
className={`flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm transition ${
moveGroupId === group.id ? "app-selected" : "hover:bg-[var(--surface-secondary)]"
moveGroupIds.includes(group.id) ? "app-selected" : "hover:bg-[var(--surface-secondary)]"
}`}
>
<Check
size={15}
className={moveGroupId === group.id ? "app-accent-text" : "invisible"}
className={moveGroupIds.includes(group.id) ? "app-accent-text" : "invisible"}
/>
<span className="min-w-0 flex-1 truncate">{group.name}</span>
<span className="app-badge rounded-full px-2 py-0.5 text-[10px]">

View File

@ -1,9 +1,12 @@
"use client";
import Image from "next/image";
import { Download, FileText, Loader2, Play } from "lucide-react";
import { useEffect, useState } from "react";
import { Download, Eye, FileText, Loader2, Play } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import ChatMediaPreviewModal from "@/components/messages/ChatMediaPreviewModal";
import type { MediaPreview } from "@/components/messages/ChatMessageItem";
import { useNearViewport } from "@/hooks/useNearViewport";
import { apiClient } from "@/lib/api";
import type { TaskAttachment } from "@/types/api";
@ -36,58 +39,95 @@ export default function TaskCommentAttachment({
}) {
const image = isImage(attachment);
const video = isVideo(attachment);
const [source, setSource] = useState<string | null>(null);
const [failed, setFailed] = useState(false);
const media = image || video;
const [thumbnailSource, setThumbnailSource] = useState<string | null>(null);
const [thumbnailFailed, setThumbnailFailed] = useState(false);
const [preview, setPreview] = useState<MediaPreview | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const previewUrlRef = useRef<string | null>(null);
const { elementRef, isNearViewport } = useNearViewport();
useEffect(() => {
if (!image && !video) return;
if (!media || !isNearViewport) return;
let active = true;
let objectUrl: string | null = null;
void apiClient
.downloadTaskAttachment(taskId, attachment)
.then(({ blob }) => {
void apiClient.downloadTaskAttachmentThumbnail(taskId, attachment)
.then((blob) => {
if (!active) return;
objectUrl = URL.createObjectURL(blob);
setSource(objectUrl);
setThumbnailSource(objectUrl);
})
.catch(() => {
if (active) setFailed(true);
if (active) setThumbnailFailed(true);
});
return () => {
active = false;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [attachment, image, taskId, video]);
}, [attachment, isNearViewport, media, taskId]);
if (image || video) {
useEffect(() => () => {
if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current);
}, []);
const closePreview = useCallback(() => {
setPreview(null);
if (previewUrlRef.current) {
URL.revokeObjectURL(previewUrlRef.current);
previewUrlRef.current = null;
}
}, []);
const openPreview = useCallback(async () => {
if (!media || previewLoading) return;
setPreviewLoading(true);
try {
const { blob } = await apiClient.downloadTaskAttachment(taskId, attachment);
if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current);
const source = URL.createObjectURL(blob);
previewUrlRef.current = source;
setPreview({
type: video ? "video" : "image",
src: source,
name: attachment.file_name,
});
} finally {
setPreviewLoading(false);
}
}, [attachment, media, previewLoading, taskId, video]);
if (media) {
return (
<div className="app-surface-elevated overflow-hidden rounded-lg border border-[var(--border-subtle)]">
<div className="relative aspect-video min-h-32 bg-[var(--surface-secondary)]">
{source && image ? (
<div ref={elementRef} className="app-surface-elevated overflow-hidden rounded-lg border border-[var(--border-subtle)]">
<button
type="button"
onClick={() => void openPreview()}
disabled={!thumbnailSource || thumbnailFailed || previewLoading}
className="group relative block aspect-video min-h-32 w-full overflow-hidden bg-[var(--surface-secondary)] disabled:cursor-default"
title={`Просмотреть ${attachment.file_name}`}
>
{thumbnailSource ? (
<Image
src={source}
src={thumbnailSource}
alt={attachment.file_name}
fill
unoptimized
sizes="(max-width: 640px) 100vw, 320px"
className="object-contain"
/>
) : source && video ? (
<video
src={source}
controls
preload="metadata"
className="h-full w-full object-contain"
className="object-cover"
/>
) : (
<span className="app-text-muted absolute inset-0 flex items-center justify-center">
{failed ? <Play size={20} /> : <Loader2 size={18} className="animate-spin" />}
{thumbnailFailed ? <FileText size={20} /> : <Loader2 size={18} className="animate-spin" />}
</span>
)}
</div>
{thumbnailSource ? (
<span className="pointer-events-none absolute inset-0 flex items-center justify-center bg-black/35 text-white opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100">
{previewLoading ? <Loader2 size={22} className="animate-spin" /> : video ? <Play size={24} fill="currentColor" /> : <Eye size={24} />}
</span>
) : null}
</button>
<button
type="button"
onClick={() => void onDownload(attachment)}
@ -99,6 +139,7 @@ export default function TaskCommentAttachment({
</span>
<Download size={13} className="app-text-muted shrink-0" />
</button>
{preview ? <ChatMediaPreviewModal preview={preview} onClose={closePreview} /> : null}
</div>
);
}

View File

@ -0,0 +1,29 @@
"use client";
import { useEffect, useRef, useState } from "react";
export function useNearViewport(rootMargin = "240px") {
const elementRef = useRef<HTMLDivElement | null>(null);
const [isNearViewport, setIsNearViewport] = useState(false);
useEffect(() => {
if (isNearViewport) return;
const element = elementRef.current;
if (!element) return;
if (typeof IntersectionObserver === "undefined") {
const fallbackId = window.setTimeout(() => setIsNearViewport(true), 0);
return () => window.clearTimeout(fallbackId);
}
const observer = new IntersectionObserver((entries) => {
if (!entries.some((entry) => entry.isIntersecting)) return;
setIsNearViewport(true);
observer.disconnect();
}, { rootMargin });
observer.observe(element);
return () => observer.disconnect();
}, [isNearViewport, rootMargin]);
return { elementRef, isNearViewport };
}

View File

@ -11,6 +11,12 @@ type TaskBoardRealtimeOptions = {
onChecklistChange: (taskId: number) => void;
onCommentsChange: (taskId: number) => void;
onActivityChange: (taskId: number) => void;
onTaskMove: (
taskId: number,
columnId: number,
rowId: number | null,
position: number | null,
) => void;
onBoardChange: (boardId: number) => void;
debounceMs?: number;
};
@ -47,6 +53,21 @@ export function useTaskBoardRealtime(options: TaskBoardRealtimeOptions): void {
}
}
if (
update.model === "task"
&& update.event === "moved"
&& update.taskId
&& update.columnId
) {
current.onTaskMove(
update.taskId,
update.columnId,
update.rowId,
update.position,
);
return;
}
if (syncTimerRef.current) clearTimeout(syncTimerRef.current);
syncTimerRef.current = setTimeout(() => {
syncTimerRef.current = null;

View File

@ -118,6 +118,15 @@ export function createProcurementApi(request: RequestFn, requestRaw: RawRequestF
filename: parseDownloadFilename(response, attachment.file_name),
};
},
downloadProcurementItemAttachmentThumbnail: async (
itemId: number,
attachment: ProcurementItemAttachment,
): Promise<Blob> => {
const response = await requestRaw(
attachment.thumbnail_url || `/api/v1/procurement/items/${itemId}/attachments/${attachment.id}/thumbnail/`,
);
return response.blob();
},
deleteProcurementItemAttachment: (itemId: number, attachmentId: number): Promise<void> =>
request(`/api/v1/procurement/items/${itemId}/attachments/${attachmentId}/`, { method: 'DELETE' }),
// Suppliers

View File

@ -72,11 +72,19 @@ export function createTasksApi(request: RequestFn, requestRaw: RawRequestFn) {
moveTaskBoardToGroup: (
id: number,
group: number | null,
): Promise<{ board: number; group_id: number | null }> =>
): Promise<{ board: number; group_ids: number[]; group_id: number | null }> =>
request(`/api/v1/tasks/boards/${id}/move-to-group/`, {
method: "POST",
body: JSON.stringify({ group }),
}),
setTaskBoardGroups: (
id: number,
groups: number[],
): Promise<{ board: number; group_ids: number[]; group_id: number | null }> =>
request(`/api/v1/tasks/boards/${id}/groups/`, {
method: "POST",
body: JSON.stringify({ groups }),
}),
setDefaultTaskBoard: (id: number): Promise<TaskBoard> =>
request(`/api/v1/tasks/boards/${id}/set-default/`, { method: "POST" }),
toggleTaskBoardPin: (id: number): Promise<{ ok: boolean; is_pinned: boolean }> =>

View File

@ -15,6 +15,9 @@ test("task board event parser normalizes task updates", () => {
}), {
boardId: 7,
taskId: 42,
columnId: null,
rowId: null,
position: null,
model: "task",
event: "updated",
});
@ -33,11 +36,37 @@ test("task board event parser keeps explicit parent task ids", () => {
}), {
boardId: 3,
taskId: 12,
columnId: null,
rowId: null,
position: null,
model: "checklist_item",
event: "updated",
});
});
test("task board event parser keeps lightweight move coordinates", () => {
assert.deepEqual(parseTaskBoardSocketUpdate({
type: "task_board_update",
data: {
board_id: 3,
model: "task",
object_id: 12,
event: "moved",
column_id: "8",
row_id: null,
position: "4000",
},
}), {
boardId: 3,
taskId: 12,
columnId: 8,
rowId: null,
position: 4000,
model: "task",
event: "moved",
});
});
test("task board event parser rejects unrelated and invalid events", () => {
assert.equal(parseTaskBoardSocketUpdate({ type: "notification" }), null);
assert.equal(parseTaskBoardSocketUpdate({

View File

@ -1,6 +1,9 @@
export type TaskBoardSocketUpdate = {
boardId: number;
taskId: number | null;
columnId: number | null;
rowId: number | null;
position: number | null;
event: string;
model: string;
};
@ -13,6 +16,9 @@ type TaskBoardSocketEvent = {
model?: unknown;
object_id?: unknown;
task_id?: unknown;
column_id?: unknown;
row_id?: unknown;
position?: unknown;
};
};
@ -34,10 +40,20 @@ export function parseTaskBoardSocketUpdate(
const rawTaskId = socketEvent.data?.task_id
?? (model === "task" ? socketEvent.data?.object_id : undefined);
const taskId = Number(rawTaskId);
const columnId = Number(socketEvent.data?.column_id);
const rawRowId = socketEvent.data?.row_id;
const rowId = Number(rawRowId);
const position = Number(socketEvent.data?.position);
return {
boardId,
taskId: Number.isFinite(taskId) && taskId > 0 ? taskId : null,
columnId: Number.isFinite(columnId) && columnId > 0 ? columnId : null,
rowId: rawRowId !== null && rawRowId !== undefined
&& Number.isFinite(rowId) && rowId > 0
? rowId
: null,
position: Number.isFinite(position) ? position : null,
event: eventName,
model,
};

View File

@ -366,6 +366,7 @@ export interface TaskBoard {
labels: TaskLabel[];
tasks_count?: number;
is_default_for_current_user?: boolean;
group_ids?: number[];
group_id?: number | null;
is_pinned?: boolean;
notifications_enabled?: boolean;
@ -387,6 +388,7 @@ export interface TaskBoardSummary {
open_tasks_count: number;
completed_tasks_count: number;
is_default_for_current_user?: boolean;
group_ids?: number[];
group_id?: number | null;
is_pinned?: boolean;
notifications_enabled?: boolean;
@ -1280,6 +1282,7 @@ export interface ProcurementItemAttachment {
uploaded_by_name?: string;
created_at: string;
download_url?: string;
thumbnail_url?: string | null;
}
export interface ProcurementItem {