feat: optimize boards and extend workflow media
This commit is contained in:
parent
df1deece23
commit
f34f92cd7d
@ -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):
|
||||
"""Сериализатор для позиций заявки."""
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
43
backend/procurement/media.py
Normal file
43
backend/procurement/media.py
Normal 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
|
||||
@ -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="Миниатюра",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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)
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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="Группы",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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, {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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]">
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
29
frontend/src/hooks/useNearViewport.ts
Normal file
29
frontend/src/hooks/useNearViewport.ts
Normal 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 };
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 }> =>
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user