diff --git a/backend/api/v1/procurement/views.py b/backend/api/v1/procurement/views.py index 9dc158f9..81f21aac 100644 --- a/backend/api/v1/procurement/views.py +++ b/backend/api/v1/procurement/views.py @@ -7,12 +7,12 @@ from django.contrib.contenttypes.models import ContentType from django.db import transaction from django.http import FileResponse, Http404 from drf_spectacular.utils import OpenApiParameter, extend_schema -from django.db.models import Q +from django.db.models import Prefetch, Q from django.utils import timezone from django_filters.rest_framework import DjangoFilterBackend from rest_framework import filters, permissions, status, viewsets from rest_framework.decorators import action -from rest_framework.parsers import MultiPartParser +from rest_framework.parsers import FormParser, JSONParser, MultiPartParser from rest_framework.response import Response from employees.models import Department, EmployeeDepartment @@ -1508,6 +1508,7 @@ class ProcurementItemViewSet(viewsets.ModelViewSet): "create_equipment", "link_equipment", "comments", + "mark_comment_notifications_read", "delete_comment", "report_issue", "confirm_ordered", @@ -2191,13 +2192,33 @@ class ProcurementItemViewSet(viewsets.ModelViewSet): self._check_item_edit_permission(instance) instance.delete() - @action(detail=True, methods=["get", "post"]) + def _item_comment_payload(self, item, message): + attachments = [ + attachment + for attachment in message.procurement_item_attachments.all() + if attachment.item_id == item.id + ] + return { + "id": message.id, + "item": item.id, + "request": item.request_id, + "author": EmployeeBriefSerializer(message.author).data, + "text": message.content, + "created_at": message.created_at, + "attachments": ProcurementItemAttachmentSerializer( + attachments, + many=True, + context=self.get_serializer_context(), + ).data, + } + + @action( + detail=True, + methods=["get", "post"], + parser_classes=[JSONParser, MultiPartParser, FormParser], + ) def comments(self, request, pk=None): """Список/создание комментариев для позиции заявки.""" - from api.v1.employees.serializers.employee import ( - EmployeeBriefSerializer, - ) - item = self.get_object() if not self._can_access_item_comments(request.user, item.request): return Response( @@ -2206,38 +2227,68 @@ class ProcurementItemViewSet(viewsets.ModelViewSet): ) if request.method in ("GET", "HEAD"): - messages = comments_helpers.get_comments(item) - comments_data = [] - for msg in messages: - author_ser = EmployeeBriefSerializer(msg.author) - comments_data.append( - { - "id": msg.id, - "item": item.id, - "request": item.request_id, - "author": author_ser.data, - "text": msg.content, - "created_at": msg.created_at, - } + messages = comments_helpers.get_comments(item).prefetch_related( + Prefetch( + "procurement_item_attachments", + queryset=ProcurementItemAttachment.objects.filter( + item=item, + ).select_related("uploaded_by"), ) - return Response(comments_data) + ) + return Response([ + self._item_comment_payload(item, message) + for message in messages + ]) text = request.data.get("text", "").strip() - if not text: + files = request.FILES.getlist("files") + if not files and request.FILES.get("file"): + files = [request.FILES["file"]] + if not text and not files: return Response( - {"text": ["Это поле не может быть пустым."]}, + {"detail": "Введите комментарий или добавьте файл."}, status=status.HTTP_400_BAD_REQUEST, ) - message = comments_helpers.create_comment( - obj=item, - author=request.user, - content=text, - ) - item_links, added_links = merge_comment_links(item.links, text) - if added_links: - item.links = item_links - item.save(update_fields=["links"]) + with transaction.atomic(): + message = comments_helpers.create_comment( + obj=item, + author=request.user, + content=text, + ) + created_attachments = [] + for uploaded_file in files: + attachment = ProcurementItemAttachment.objects.create( + item=item, + comment=message, + file=uploaded_file, + file_name=Path(uploaded_file.name.replace("\\", "/")).name, + file_size=uploaded_file.size, + mime_type=uploaded_file.content_type or "", + uploaded_by=request.user, + ) + created_attachments.append(attachment) + create_procurement_activity( + item.request, + request.user, + ProcurementActivityAction.ATTACHMENT_ADDED, + object_kind=ProcurementActivityObjectKind.ATTACHMENT, + object_id=attachment.pk, + metadata={ + "item_id": item.pk, + "item_name": item.name, + "file_name": attachment.file_name, + "comment_id": message.pk, + }, + ) + if created_attachments: + message.has_attachments = True + message.save(update_fields=["has_attachments"]) + + item_links, added_links = merge_comment_links(item.links, text) + if added_links: + item.links = item_links + item.save(update_fields=["links"]) notify_item_comment( item, message, @@ -2256,19 +2307,35 @@ class ProcurementItemViewSet(viewsets.ModelViewSet): "added_links": added_links, }, ) - author_ser = EmployeeBriefSerializer(message.author) + payload = self._item_comment_payload(item, message) + payload.update({ + "item_links": item_links, + "added_links": added_links, + }) + return Response(payload, status=status.HTTP_201_CREATED) + + @extend_schema( + responses=ProcurementNotificationReadResponseSerializer, + summary="Отметить уведомления о комментариях позиции как прочитанные", + ) + @action( + detail=True, + methods=["post"], + url_path="comment-notifications/read", + ) + def mark_comment_notifications_read(self, request, pk=None): + item = self.get_object() + self._check_item_read_permission(item) + result = ProcurementNotificationReadService().mark_item_comment_notifications_as_read( + user=request.user, + item=item, + ) return Response( { - "id": message.id, - "item": item.id, - "request": item.request_id, - "author": author_ser.data, - "text": message.content, - "created_at": message.created_at, - "item_links": item_links, - "added_links": added_links, - }, - status=status.HTTP_201_CREATED, + "status": "success", + "count": result.count, + "notification_ids": result.notification_ids, + } ) @extend_schema( diff --git a/backend/api/v1/requests_app/views.py b/backend/api/v1/requests_app/views.py index 5fa86252..65581df2 100644 --- a/backend/api/v1/requests_app/views.py +++ b/backend/api/v1/requests_app/views.py @@ -13,6 +13,7 @@ from django.utils import timezone from requests_app.enums import DAY_OFF_REQUEST_TYPES, RequestStatus, RequestType from requests_app.models import Request as EmployeeRequest from requests_app.models import RequestSettings +from requests_app.notifications.read_service import RequestNotificationReadService from communications import comments_helpers from drf_spectacular.utils import OpenApiParameter, extend_schema @@ -286,7 +287,11 @@ class RequestViewSet(viewsets.ModelViewSet): from rest_framework.permissions import IsAuthenticated return [IsAuthenticated()] - if self.action in {"retrieve", "employee_statistics"}: + if self.action in { + "retrieve", + "employee_statistics", + "mark_notifications_read", + }: return [CanViewRequest()] if self.action == "destroy": return [IsRequestAuthor(), NotFinalOrStaff()] @@ -339,7 +344,13 @@ class RequestViewSet(viewsets.ModelViewSet): ) & ~Q(status=RequestStatus.DRAFT) addressed_to_me = params.get("addressed_to_me") == "true" - if addressed_to_me: + decision_required = params.get("decision_required") == "true" + if decision_required: + scope = Q( + recipients=user, + status=RequestStatus.PENDING, + ) + elif addressed_to_me: scope = incoming_scope elif want_mine: scope = Q(employee_id=user.id) @@ -415,6 +426,24 @@ class RequestViewSet(viewsets.ModelViewSet): return qs + def _get_scope_counts(self, user) -> dict[str, int]: + """Полные счётчики быстрых областей списка заявлений.""" + incoming_scope = ( + Q(recipients=user) | Q(cc_users=user) + ) & ~Q(status=RequestStatus.DRAFT) + visible_scope = Q(employee_id=user.id) | incoming_scope + base = EmployeeRequest.objects.filter(visible_scope).distinct() + + return { + "all": base.count(), + "mine": base.filter(employee_id=user.id).count(), + "addressed": base.filter(incoming_scope).count(), + "decision": base.filter( + recipients=user, + status=RequestStatus.PENDING, + ).count(), + } + def _attach_linked_task_payloads( self, requests: list[EmployeeRequest], @@ -472,17 +501,20 @@ class RequestViewSet(viewsets.ModelViewSet): def list(self, request, *args, **kwargs): queryset = self.filter_queryset(self.get_queryset()) + scope_counts = self._get_scope_counts(request.user) page = self.paginate_queryset(queryset) if page is not None: self._attach_linked_task_payloads(list(page)) serializer = self.get_serializer(page, many=True) - return self.get_paginated_response(serializer.data) + response = self.get_paginated_response(serializer.data) + response.data["scope_counts"] = scope_counts + return response requests = list(queryset) self._attach_linked_task_payloads(requests) serializer = self.get_serializer(requests, many=True) - return Response(serializer.data) + return Response({"results": serializer.data, "scope_counts": scope_counts}) # --- ВАЖНО: не ловить 404 на detail-экшенах # из-за урезанного get_queryset() --- @@ -509,6 +541,7 @@ class RequestViewSet(viewsets.ModelViewSet): "cancel", "retrieve", "employee_statistics", + "mark_notifications_read", }: lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field lookup_value = self.kwargs[lookup_url_kwarg] @@ -521,6 +554,22 @@ class RequestViewSet(viewsets.ModelViewSet): return super().get_object() + @action(detail=True, methods=["post"], url_path="notifications/read") + def mark_notifications_read(self, request, pk=None): + """Отметить уведомления текущего пользователя по заявлению.""" + employee_request = self.get_object() + result = RequestNotificationReadService().mark_request_notifications_as_read( + user=request.user, + employee_request=employee_request, + ) + return Response( + { + "status": "success", + "count": result.count, + "notification_ids": result.notification_ids, + } + ) + # ---------- CRUD ---------- def perform_create(self, serializer: RequestWriteSerializer) -> None: diff --git a/backend/notifications/api/serializers.py b/backend/notifications/api/serializers.py index 9c2cda11..a7582be5 100644 --- a/backend/notifications/api/serializers.py +++ b/backend/notifications/api/serializers.py @@ -51,6 +51,16 @@ class UnreadSummaryProcurementRequestSerializer(serializers.Serializer): unread = serializers.IntegerField() +class UnreadSummaryEmployeeRequestSerializer(serializers.Serializer): + request_id = serializers.IntegerField() + unread = serializers.IntegerField() + + +class UnreadSummaryProcurementItemCommentSerializer(serializers.Serializer): + item_id = serializers.IntegerField() + unread = serializers.IntegerField() + + class UnreadSummaryTaskBoardSerializer(serializers.Serializer): board_id = serializers.IntegerField() unread = serializers.IntegerField() @@ -64,7 +74,11 @@ class UnreadSummaryRegulationDepartmentSerializer(serializers.Serializer): class UnreadSummaryResponseSerializer(serializers.Serializer): total = serializers.IntegerField() verbs = UnreadSummaryVerbSerializer(many=True) + employee_requests = UnreadSummaryEmployeeRequestSerializer(many=True) procurement_requests = UnreadSummaryProcurementRequestSerializer(many=True) + procurement_item_comments = UnreadSummaryProcurementItemCommentSerializer( + many=True, + ) task_boards = UnreadSummaryTaskBoardSerializer(many=True) regulation_departments = UnreadSummaryRegulationDepartmentSerializer( many=True, diff --git a/backend/notifications/cache.py b/backend/notifications/cache.py index 9a29138a..4dc4c972 100644 --- a/backend/notifications/cache.py +++ b/backend/notifications/cache.py @@ -7,7 +7,7 @@ UNREAD_SUMMARY_TIMEOUT = 60 def unread_summary_cache_key(user_id): - return f"notifications:unread_summary:user:{user_id}" + return f"notifications:unread_summary:v2:user:{user_id}" def _coerce_positive_int(value): @@ -72,6 +72,75 @@ def _build_procurement_request_counts(user_id): ] +def _build_employee_request_counts(user_id): + from django.contrib.contenttypes.models import ContentType + from requests_app.models import Request as EmployeeRequest + + from .models import Notification + + request_content_type_id = ContentType.objects.get_for_model(EmployeeRequest).id + counts = {} + notifications = Notification.objects.filter( + recipient_id=user_id, + unread=True, + deleted=False, + verb__startswith="request_", + ).only( + "data", + "target_content_type_id", + "target_object_id", + "action_object_content_type_id", + "action_object_object_id", + ) + + for notification in notifications: + data = notification.data if isinstance(notification.data, dict) else {} + request_id = _coerce_positive_int(data.get("request_id")) + + if request_id is None and ( + notification.target_content_type_id == request_content_type_id + ): + request_id = _coerce_positive_int(notification.target_object_id) + + if request_id is None and ( + notification.action_object_content_type_id == request_content_type_id + ): + request_id = _coerce_positive_int( + notification.action_object_object_id, + ) + + if request_id is not None: + counts[request_id] = counts.get(request_id, 0) + 1 + + return [ + {"request_id": request_id, "unread": unread} + for request_id, unread in sorted(counts.items()) + ] + + +def _build_procurement_item_comment_counts(user_id): + from .models import Notification + + counts = {} + notifications = Notification.objects.filter( + recipient_id=user_id, + unread=True, + deleted=False, + verb="procurement_item_commented", + ).only("data") + + for notification in notifications: + data = notification.data if isinstance(notification.data, dict) else {} + item_id = _coerce_positive_int(data.get("item_id")) + if item_id is not None: + counts[item_id] = counts.get(item_id, 0) + 1 + + return [ + {"item_id": item_id, "unread": unread} + for item_id, unread in sorted(counts.items()) + ] + + def _build_task_board_counts(user_id): from django.contrib.contenttypes.models import ContentType @@ -208,7 +277,9 @@ def build_unread_summary(user_id): return { "total": sum(item["unread"] for item in verbs), "verbs": verbs, + "employee_requests": _build_employee_request_counts(user_id), "procurement_requests": _build_procurement_request_counts(user_id), + "procurement_item_comments": _build_procurement_item_comment_counts(user_id), "task_boards": _build_task_board_counts(user_id), "regulation_departments": regulation_counts["departments"], "regulation_company_unread": regulation_counts["company_unread"], diff --git a/backend/procurement/migrations/0018_procurementitemattachment_comment.py b/backend/procurement/migrations/0018_procurementitemattachment_comment.py new file mode 100644 index 00000000..d2982816 --- /dev/null +++ b/backend/procurement/migrations/0018_procurementitemattachment_comment.py @@ -0,0 +1,25 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("communications", "0039_alter_chat_context_object_id_bigint"), + ("procurement", "0017_add_item_defective_at"), + ] + + operations = [ + migrations.AddField( + model_name="procurementitemattachment", + name="comment", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="procurement_item_attachments", + to="communications.message", + verbose_name="Комментарий", + ), + ), + ] diff --git a/backend/procurement/models.py b/backend/procurement/models.py index 4f49217e..352a6ee7 100644 --- a/backend/procurement/models.py +++ b/backend/procurement/models.py @@ -680,6 +680,14 @@ class ProcurementItemAttachment(models.Model): related_name="attachments", verbose_name="Позиция", ) + comment = models.ForeignKey( + "communications.Message", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="procurement_item_attachments", + verbose_name="Комментарий", + ) file = models.FileField( "Файл", upload_to=procurement_item_attachment_upload_to, diff --git a/backend/procurement/notifications/read_service.py b/backend/procurement/notifications/read_service.py index 3278c7bc..3844521c 100644 --- a/backend/procurement/notifications/read_service.py +++ b/backend/procurement/notifications/read_service.py @@ -8,7 +8,7 @@ from django.utils import timezone from notifications.cache import invalidate_unread_summary from notifications.models import Notification from notifications.realtime import send_notifications_read_all_event -from procurement.models import ProcurementRequest +from procurement.models import ProcurementItem, ProcurementRequest @dataclass(frozen=True) @@ -34,7 +34,32 @@ class ProcurementNotificationReadService: queryset = self._get_unread_queryset( user_id=user.id, procurement_request=procurement_request, + ).exclude(verb="procurement_item_commented") + return self._mark_queryset_as_read(user_id=user.id, queryset=queryset) + + def mark_item_comment_notifications_as_read( + self, + *, + user, + item: ProcurementItem, + ) -> ProcurementNotificationReadResult: + item_id = item.id + queryset = Notification.objects.filter( + recipient_id=user.id, + unread=True, + deleted=False, + verb="procurement_item_commented", + ).filter( + Q(data__item_id=item_id) | Q(data__item_id=str(item_id)) ) + return self._mark_queryset_as_read(user_id=user.id, queryset=queryset) + + def _mark_queryset_as_read( + self, + *, + user_id: int, + queryset, + ) -> ProcurementNotificationReadResult: notification_ids = list(queryset.values_list("id", flat=True)) if not notification_ids: @@ -51,11 +76,11 @@ class ProcurementNotificationReadService: timestamp_read=read_at, ) - invalidate_unread_summary(user.id) + invalidate_unread_summary(user_id) def notify_after_commit(): - invalidate_unread_summary(user.id) - send_notifications_read_all_event(user.id, notification_ids) + invalidate_unread_summary(user_id) + send_notifications_read_all_event(user_id, notification_ids) transaction.on_commit(notify_after_commit) diff --git a/backend/requests_app/notifications/read_service.py b/backend/requests_app/notifications/read_service.py new file mode 100644 index 00000000..0b8ebbdc --- /dev/null +++ b/backend/requests_app/notifications/read_service.py @@ -0,0 +1,73 @@ +from dataclasses import dataclass + +from django.contrib.contenttypes.models import ContentType +from django.db import transaction +from django.db.models import Q +from django.utils import timezone + +from notifications.cache import invalidate_unread_summary +from notifications.models import Notification +from notifications.realtime import send_notifications_read_all_event +from requests_app.models import Request as EmployeeRequest + + +@dataclass(frozen=True) +class RequestNotificationReadResult: + notification_ids: list[int] + count: int + + +class RequestNotificationReadService: + """Отмечает прочитанными уведомления только выбранного заявления.""" + + def mark_request_notifications_as_read( + self, + *, + user, + employee_request: EmployeeRequest, + ) -> RequestNotificationReadResult: + request_content_type = ContentType.objects.get_for_model(EmployeeRequest) + request_id = employee_request.id + request_id_text = str(request_id) + + queryset = ( + Notification.objects.filter( + recipient_id=user.id, + unread=True, + deleted=False, + verb__startswith="request_", + ) + .filter( + Q(data__request_id=request_id) + | Q(data__request_id=request_id_text) + | Q( + target_content_type=request_content_type, + target_object_id=request_id_text, + ) + | Q( + action_object_content_type=request_content_type, + action_object_object_id=request_id_text, + ) + ) + .distinct() + ) + notification_ids = list(queryset.values_list("id", flat=True)) + + if not notification_ids: + return RequestNotificationReadResult(notification_ids=[], count=0) + + count = Notification.objects.filter(id__in=notification_ids).update( + unread=False, + timestamp_read=timezone.now(), + ) + invalidate_unread_summary(user.id) + + def notify_after_commit(): + invalidate_unread_summary(user.id) + send_notifications_read_all_event(user.id, notification_ids) + + transaction.on_commit(notify_after_commit) + return RequestNotificationReadResult( + notification_ids=notification_ids, + count=count, + ) diff --git a/backend/tests/api/v1/notifications/test_notifications_api.py b/backend/tests/api/v1/notifications/test_notifications_api.py index e6899257..70eaec19 100644 --- a/backend/tests/api/v1/notifications/test_notifications_api.py +++ b/backend/tests/api/v1/notifications/test_notifications_api.py @@ -34,11 +34,29 @@ class TestNotificationUnreadSummary: description="Еще закупка", data={"request_id": "12"}, ) + Notification.objects.create( + recipient=user, + verb="procurement_item_commented", + description="Комментарий к позиции закупки", + data={"request_id": 10, "item_id": 101}, + ) Notification.objects.create( recipient=user, verb="chat_new_message", description="Сообщение", ) + Notification.objects.create( + recipient=user, + verb="request_new", + description="Новое заявление", + data={"request_id": 31}, + ) + Notification.objects.create( + recipient=user, + verb="request_comment", + description="Комментарий к заявлению", + data={"request_id": "31"}, + ) Notification.objects.create( recipient=user, verb="task_assigned", @@ -96,23 +114,32 @@ class TestNotificationUnreadSummary: response = api_client.get("/api/v1/notifications/summary/") assert response.status_code == 200 - assert response.data["total"] == 9 + assert response.data["total"] == 12 assert { item["verb"]: item["unread"] for item in response.data["verbs"] } == { "procurement_department_request": 2, + "procurement_item_commented": 1, "chat_new_message": 1, + "request_new": 1, + "request_comment": 1, "task_assigned": 1, "task_comment": 1, "task_board_member_added": 1, "regulation_ready": 2, "regulation_signed_all": 1, } + assert response.data["employee_requests"] == [ + {"request_id": 31, "unread": 2}, + ] assert response.data["procurement_requests"] == [ - {"request_id": 10, "unread": 1}, + {"request_id": 10, "unread": 2}, {"request_id": 12, "unread": 1}, ] + assert response.data["procurement_item_comments"] == [ + {"item_id": 101, "unread": 1}, + ] assert response.data["task_boards"] == [ {"board_id": 21, "unread": 2}, {"board_id": 22, "unread": 1}, diff --git a/backend/tests/api/v1/procurement/test_procurement_requests.py b/backend/tests/api/v1/procurement/test_procurement_requests.py index 5ff9b3ab..3facc1b0 100644 --- a/backend/tests/api/v1/procurement/test_procurement_requests.py +++ b/backend/tests/api/v1/procurement/test_procurement_requests.py @@ -997,6 +997,63 @@ class TestProcurementItemComments: ] assert comment_activities.first().metadata["item_id"] == procurement_item.id + def test_item_comment_files_are_added_to_item_and_returned_with_comment( + self, + api_client, + user, + procurement_item, + settings, + tmp_path, + ): + settings.MEDIA_ROOT = tmp_path + api_client.force_authenticate(user=user) + comments_url = reverse( + 'api:v1:procurement:procurementitem-comments', + kwargs={'pk': procurement_item.id}, + ) + image_content = b'\x89PNG\r\n\x1a\nprocurement comment image' + + create_response = api_client.post( + comments_url, + { + 'text': '', + 'files': SimpleUploadedFile( + 'position-photo.png', + image_content, + content_type='image/png', + ), + }, + format='multipart', + ) + + assert create_response.status_code == status.HTTP_201_CREATED + assert create_response.data['text'] == '' + assert len(create_response.data['attachments']) == 1 + attachment_data = create_response.data['attachments'][0] + assert attachment_data['file_name'] == 'position-photo.png' + assert attachment_data['mime_type'] == 'image/png' + + attachment = ProcurementItemAttachment.objects.get( + pk=attachment_data['id'], + ) + assert attachment.item_id == procurement_item.id + assert attachment.comment_id == create_response.data['id'] + + comments_response = api_client.get(comments_url) + assert comments_response.status_code == status.HTTP_200_OK + assert comments_response.data[0]['attachments'][0]['id'] == attachment.id + + attachments_response = api_client.get(reverse( + 'api:v1:procurement:procurementitem-attachments', + kwargs={'pk': procurement_item.id}, + )) + assert attachments_response.status_code == status.HTTP_200_OK + assert attachments_response.data[0]['id'] == attachment.id + + download_response = api_client.get(attachment_data['download_url']) + assert download_response.status_code == status.HTTP_200_OK + assert b''.join(download_response.streaming_content) == image_content + def test_comment_links_are_added_to_item_without_duplicates( self, api_client, user, procurement_item ): @@ -6063,7 +6120,8 @@ class TestProcurementRequestNotificationRead: """Тесты прочтения уведомлений, связанных с конкретной закупкой.""" def test_marks_only_current_request_procurement_notifications_read( - self, api_client, user, department, procurement_request + self, api_client, user, department, procurement_request, + procurement_item_factory, ): request_content_type = ContentType.objects.get_for_model( ProcurementRequest, @@ -6088,6 +6146,17 @@ class TestProcurementRequestNotificationRead: target_object_id=str(procurement_request.id), data={}, ) + procurement_item = procurement_item_factory( + request=procurement_request, + ) + item_comment_notification = Notification.objects.create( + recipient=user, + verb="procurement_item_commented", + data={ + "request_id": procurement_request.id, + "item_id": procurement_item.id, + }, + ) other_request_notification = Notification.objects.create( recipient=user, verb="procurement_department_request", @@ -6124,16 +6193,54 @@ class TestProcurementRequestNotificationRead: matching_by_data.refresh_from_db() matching_by_target.refresh_from_db() + item_comment_notification.refresh_from_db() other_request_notification.refresh_from_db() non_procurement_notification.refresh_from_db() already_read_notification.refresh_from_db() assert matching_by_data.unread is False assert matching_by_target.unread is False + assert item_comment_notification.unread is True assert other_request_notification.unread is True assert non_procurement_notification.unread is True assert already_read_notification.unread is False + def test_marks_item_comment_notifications_only_after_opening_item_comments( + self, api_client, user, procurement_request, procurement_item_factory + ): + item = procurement_item_factory(request=procurement_request) + other_item = procurement_item_factory(request=procurement_request) + matching_notification = Notification.objects.create( + recipient=user, + verb="procurement_item_commented", + data={"request_id": procurement_request.id, "item_id": item.id}, + ) + other_item_notification = Notification.objects.create( + recipient=user, + verb="procurement_item_commented", + data={ + "request_id": procurement_request.id, + "item_id": other_item.id, + }, + ) + + api_client.force_authenticate(user=user) + url = reverse( + 'api:v1:procurement:procurementitem-mark-comment-notifications-read', + kwargs={'pk': item.id}, + ) + + response = api_client.post(url) + + assert response.status_code == status.HTTP_200_OK + assert response.data["count"] == 1 + assert response.data["notification_ids"] == [matching_notification.id] + + matching_notification.refresh_from_db() + other_item_notification.refresh_from_db() + assert matching_notification.unread is False + assert other_item_notification.unread is True + # ============================================================================== # ТЕСТЫ УДАЛЕНИЯ diff --git a/backend/tests/api/v1/requests_app/test_requests_api.py b/backend/tests/api/v1/requests_app/test_requests_api.py index 0763a79e..dc4eef31 100644 --- a/backend/tests/api/v1/requests_app/test_requests_api.py +++ b/backend/tests/api/v1/requests_app/test_requests_api.py @@ -47,6 +47,59 @@ def test_list_unauth_401(api_client: APIClient) -> None: assert resp.status_code == 401 +def test_marks_only_opened_request_notifications_read( + auth_client, + regular_user: models.Model, + make_request, +) -> None: + """Открытие заявления читает уведомления только этого заявления.""" + employee_request = make_request(employee=regular_user) + other_request = make_request(employee=regular_user) + request_content_type = ContentType.objects.get_for_model(EmployeeRequest) + + matching_by_data = Notification.objects.create( + recipient=regular_user, + verb="request_new", + data={"request_id": employee_request.id}, + ) + matching_by_action_object = Notification.objects.create( + recipient=regular_user, + verb="request_comment", + action_object_content_type=request_content_type, + action_object_object_id=str(employee_request.id), + data={}, + ) + other_request_notification = Notification.objects.create( + recipient=regular_user, + verb="request_status_changed", + data={"request_id": other_request.id}, + ) + unrelated_notification = Notification.objects.create( + recipient=regular_user, + verb="chat_new_message", + data={"request_id": employee_request.id}, + ) + + response = auth_client(regular_user).post( + f"{API_BASE}{employee_request.id}/notifications/read/", + ) + + assert response.status_code == 200 + assert response.data["count"] == 2 + assert sorted(response.data["notification_ids"]) == sorted( + [matching_by_data.id, matching_by_action_object.id] + ) + + matching_by_data.refresh_from_db() + matching_by_action_object.refresh_from_db() + other_request_notification.refresh_from_db() + unrelated_notification.refresh_from_db() + assert matching_by_data.unread is False + assert matching_by_action_object.unread is False + assert other_request_notification.unread is True + assert unrelated_notification.unread is True + + def test_statistics_requires_privileged_access( auth_client, regular_user: models.Model, make_user ) -> None: @@ -227,6 +280,47 @@ def test_list_addressed_to_me_shows_only_recipient_and_cc_requests( assert cc_request.id in ids +def test_list_returns_scope_counts_and_filters_decisions( + auth_client, regular_user: models.Model, make_user, make_request +) -> None: + foreign_user = make_user(email="scope-counts-owner@example.com") + own_request = make_request(employee=regular_user) + decision_request = make_request( + employee=foreign_user, + status=RequestStatus.PENDING, + ) + copied_request = make_request( + employee=foreign_user, + status=RequestStatus.APPROVED, + approver=foreign_user, + ) + hidden_request = make_request(employee=foreign_user) + + decision_request.recipients.add(regular_user) + copied_request.cc_users.add(regular_user) + + client = auth_client(regular_user) + response = client.get(API_BASE) + + assert response.status_code == 200 + assert response.json()["scope_counts"] == { + "all": 3, + "mine": 1, + "addressed": 2, + "decision": 1, + } + + decision_response = client.get(f"{API_BASE}?decision_required=true") + assert decision_response.status_code == 200 + decision_ids = { + item["id"] for item in _results(decision_response.json()) + } + assert decision_ids == {decision_request.id} + assert own_request.id not in decision_ids + assert copied_request.id not in decision_ids + assert hidden_request.id not in decision_ids + + def test_list_period_filter_matches_overlap_and_excludes_missing_period( auth_client, regular_user: models.Model, make_request ) -> None: diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 64816d3e..918532b1 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -534,6 +534,158 @@ html[data-bs-theme="dark"] .app-input::-webkit-calendar-picker-indicator { container-type: inline-size; } +.payroll-admin-records-responsive { + container-type: inline-size; +} + +.procurement-detail-layout { + container-type: inline-size; +} + +.procurement-detail-layout-grid { + display: grid; + grid-auto-flow: dense; + align-items: start; + gap: 0.75rem; +} + +.procurement-detail-layout-full { + grid-column: 1 / -1; +} + +.procurement-detail-support-grid { + display: grid; + align-items: start; + gap: 0.75rem; +} + +.procurement-items-table-row { + display: grid; + grid-template-columns: + minmax(0, 1.5fr) + minmax(0, 0.7fr) + minmax(0, 0.7fr) + minmax(0, 0.9fr) + minmax(0, 1fr) + minmax(0, 1.15fr) + minmax(0, 0.9fr); +} + +.procurement-items-table-row > * { + min-width: 0; + border-right: 1px solid var(--border-subtle); +} + +.procurement-items-table-row > :last-child { + border-right: 0; +} + +.procurement-item-wide-rowgroup { + display: grid; + grid-template-columns: minmax(0, 1fr); +} + +.procurement-item-wide-main { + grid-row: 1; +} + +.procurement-item-wide-processing { + grid-row: 1; +} + +.procurement-item-wide-details { + display: grid; + grid-row: 2; + grid-template-columns: minmax(0, 1fr); +} + +.procurement-item-wide-description { + grid-row: 2; +} + +.procurement-item-wide-comments { + grid-row: 3; +} + +@container (min-width: 44rem) { + .procurement-detail-layout-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .procurement-detail-support-grid { + grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); + } +} + +@container (min-width: 74rem) { + .procurement-detail-layout-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .procurement-detail-support-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .procurement-detail-support-comments { + grid-column: span 2 / span 2; + } +} + +.payroll-admin-toolbar-responsive { + container-type: inline-size; +} + +.payroll-admin-toolbar-layout { + display: flex; + min-width: 0; + flex-direction: column; + gap: 0.5rem; +} + +.payroll-admin-toolbar-search, +.payroll-admin-toolbar-status, +.payroll-admin-toolbar-actions { + width: 100%; +} + +@container (min-width: 42rem) { + .payroll-admin-toolbar-layout { + flex-direction: row; + } + + .payroll-admin-toolbar-search { + width: auto; + flex: 1 1 0%; + } + + .payroll-admin-toolbar-status { + width: 11rem; + } + + .payroll-admin-toolbar-actions { + width: auto; + flex-shrink: 0; + } + + .payroll-admin-toolbar-actions > .app-action-primary { + flex: none; + } +} + +.payroll-admin-records-table { + display: none; +} + +@container (min-width: 58rem) { + .payroll-admin-records-compact { + display: none; + } + + .payroll-admin-records-table { + display: block; + } +} + .document-card-detail-action { display: none; } diff --git a/frontend/src/app/procurement/page.tsx b/frontend/src/app/procurement/page.tsx index be08ee83..406fa6fb 100644 --- a/frontend/src/app/procurement/page.tsx +++ b/frontend/src/app/procurement/page.tsx @@ -32,7 +32,9 @@ import { Filter, Link2, Loader2, + Maximize2, MessageSquare, + Minimize2, Package, Paperclip, Pencil, @@ -55,6 +57,7 @@ import { useProcurementPage } from "@/hooks/useProcurementPage"; import { Modal } from "@/components/ui"; import { getExpectedDeliveryDateBadgeClass } from "@/lib/procurementDates"; import { getProcurementQuantityUnitLabel, getProcurementUnitOptions } from "@/lib/procurementUnits"; +import { useProcurementDesktopWideMode } from "@/hooks/useProcurementDesktopWideMode"; /* ══════════════════════════════════════════════════════ Constants & helpers @@ -104,6 +107,13 @@ const getReceivedProgressClass = (received: number, total: number) => { return received >= total ? "app-feedback-success" : "app-feedback-warning"; }; +function newestCommentsFirst(comments: T[]): T[] { + return [...comments].sort((left, right) => { + const timeDifference = new Date(right.created_at || 0).getTime() - new Date(left.created_at || 0).getTime(); + return timeDifference || Number(right.id || 0) - Number(left.id || 0); + }); +} + const canConfirmAllReceived = (request: ProcurementRequest, isAuthor: boolean) => { const status = String(request.status || "").toLowerCase(); const itemsCount = Number(request.items_total_count ?? request.items_count ?? request.items?.length ?? 0); @@ -370,12 +380,56 @@ function ProcurementRequestActionButtons({ ); } +function InfiniteRequestLoader({ + hasMore, + loading, + onLoadMore, +}: { + hasMore: boolean; + loading: boolean; + onLoadMore: () => void | Promise; +}) { + const sentinelRef = useRef(null); + const requestedRef = useRef(false); + + useEffect(() => { + if (!loading) requestedRef.current = false; + }, [loading]); + + useEffect(() => { + const sentinel = sentinelRef.current; + if (!sentinel || !hasMore) return; + + const observer = new IntersectionObserver((entries) => { + if (!entries.some((entry) => entry.isIntersecting) || loading || requestedRef.current) return; + requestedRef.current = true; + void onLoadMore(); + }, { rootMargin: "240px 0px" }); + + observer.observe(sentinel); + return () => observer.disconnect(); + }, [hasMore, loading, onLoadMore]); + + if (!hasMore) return null; + + return ( +
+ {loading ? ( + + Загружаем ещё заявки… + + ) : null} +
+ ); +} + /* ══════════════════════════════════════════════════════ Main page component ══════════════════════════════════════════════════════ */ export default function ProcurementPage() { const { user } = useUser(); + const [desktopWideMode, changeDesktopWideMode, desktopWideLayout] = useProcurementDesktopWideMode(user?.id); const { activeFilterCount, activeSection, @@ -400,6 +454,7 @@ export default function ProcurementPage() { expandedItemComments, itemCommentsMap, itemCommentDrafts, + itemCommentFiles, setItemCommentDrafts, expandedIds, expandedComments, @@ -471,9 +526,11 @@ export default function ProcurementPage() { scope, scopeCounts, unreadProcurementRequestCounts, + unreadProcurementItemCommentCounts, handleAddComment, handleDeleteComment, handleAddItemComment, + handleItemCommentFilesChange, handleDeleteItemComment, handleDownloadItemAttachment, handleUploadItemAttachments, @@ -520,8 +577,10 @@ export default function ProcurementPage() { }, [setStatusFilter]); const [detailModalId, setDetailModalId] = useState(null); + const [detailDialogOpen, setDetailDialogOpen] = useState(false); const [detailModalLoading, setDetailModalLoading] = useState(false); const [detailModalError, setDetailModalError] = useState(null); + const initialRequestParamHandledRef = useRef(false); const procurementMenuRef = useRef(null); const statusFilterRef = useRef(null); const [procurementMenuOpenId, setProcurementMenuOpenId] = useState(null); @@ -577,6 +636,7 @@ export default function ProcurementPage() { }, [detailsCache, requests, submitApprovalRequestId]); const closeDetailModal = useCallback(() => { + setDetailDialogOpen(false); setDetailModalId(null); setDetailModalError(null); syncRequestUrl(null); @@ -749,21 +809,24 @@ export default function ProcurementPage() { const openDetailModal = useCallback(async (requestId: number) => { setDetailModalLoading(true); setDetailModalError(null); + setDetailModalId(requestId); + setDetailDialogOpen(!desktopWideLayout); + syncRequestUrl(requestId); try { await ensureRequestDetail(requestId); await markRequestNotificationsRead(requestId); - setDetailModalId(requestId); - syncRequestUrl(requestId); } catch (detailError) { setDetailModalError(getReadableError(detailError, "Не удалось открыть заявку")); } finally { setDetailModalLoading(false); } - }, [ensureRequestDetail, markRequestNotificationsRead, syncRequestUrl]); + }, [desktopWideLayout, ensureRequestDetail, markRequestNotificationsRead, syncRequestUrl]); useEffect(() => { if (typeof window === "undefined") return; + if (initialRequestParamHandledRef.current) return; + initialRequestParamHandledRef.current = true; const requestParam = new URLSearchParams(window.location.search).get("request"); if (!requestParam) return; @@ -847,7 +910,10 @@ export default function ProcurementPage() { ══════════════════════════════════════════════════════ */ return ( - + {loading ? (
@@ -858,7 +924,7 @@ export default function ProcurementPage() {

{error}

) : ( -
+
{/* ── header ── */}
@@ -875,11 +941,23 @@ export default function ProcurementPage() {
*/}
- {activeSection === "requests" && ( - - )} + {activeSection === "requests" && ( + + )} + {/* ── alerts ── */} @@ -1137,8 +1215,244 @@ export default function ProcurementPage() { )} + {/* ══════════ Wide master-detail workspace ══════════ */} +
+ + +
+ {detailModalLoading ? ( +
+ +

Загружаем заявку...

+
+ ) : detailModalError ? ( +
{detailModalError}
+ ) : selectedRequest ? ( +
+
+
+

#{selectedRequest.id} · {selectedRequest.title || "Без названия"}

+ {getStatusMeta(selectedRequest.status).label} + + {(urgencyMeta[selectedRequest.urgency]?.label ?? urgencyMeta.medium.label)} срочность + +
+
+ Отдел-заказчик: {getDeptName(selectedRequest)} + {selectedRequest.processing_department_name ? Отдел-исполнитель: {selectedRequest.processing_department_name} : null} + Заявитель: {displayUserName(selectedRequest.requestor, selectedRequest.requestor_name, selectedRequest.requestor_email)} + Исполнитель: {selectedRequest.executor || selectedRequest.executor_name ? displayUserName(selectedRequest.executor, selectedRequest.executor_name || undefined) : "не назначен"} + {getRequestAmount(selectedRequest) ? Сумма: {money(getRequestAmount(selectedRequest))} : null} +
+

+ Описание: {selectedRequest.description || "—"} +

+
+ Создана: {fmt(selectedRequest.created_at) || "—"} + Отправлена: {fmt(selectedRequest.submitted_at) || "—"} + Взята в работу: {fmt(selectedRequest.started_at) || "—"} + Завершена: {fmt(selectedRequest.completed_at) || "—"} + {selectedRequest.actual_cost ? Фактическая сумма: {money(selectedRequest.actual_cost)} : null} +
+
+ + void openSubmitApprovalDialog(id)} + onEdit={openEdit} + onDelete={(id) => openRequestActionDialog("delete", id)} + onApprove={(id) => openRequestActionDialog("approve", id)} + onReject={(id) => openRequestActionDialog("reject", id)} + onStart={handleStart} + onComplete={handleComplete} + onMarkAllReceived={handleMarkAllReceived} + onNotifyArrival={handleNotifyArrival} + onCancel={(id) => openRequestActionDialog("cancel", id)} + /> +
+
+ + setItemCommentDrafts((previous) => ({ ...previous, [itemId]: value }))} + onItemCommentFilesChange={handleItemCommentFilesChange} + onAddItemComment={handleAddItemComment} + onDeleteItemComment={handleDeleteItemComment} + onDownloadItemAttachment={handleDownloadItemAttachment} + onUploadItemAttachments={handleUploadItemAttachments} + onDeleteItemAttachment={handleDeleteItemAttachment} + wideLayout + footer={( +
+

Комментарии

+
+ setCommentDrafts((previous) => ({ ...previous, [selectedRequest.id]: value }))} + onSubmit={() => handleAddComment(selectedRequest.id)} + disabled={busyKey === `comment-${selectedRequest.id}`} + /> +
+
+ {(commentsMap[selectedRequest.id] || []).length === 0 ? ( +

Комментариев пока нет

+ ) : newestCommentsFirst(commentsMap[selectedRequest.id] || []).map((comment) => { + const canDeleteCommentThis = Boolean(comment.author?.id && (user?.id === comment.author.id || user?.auth?.is_staff || user?.auth?.is_superuser)); + return ( +
+
+ {displayUserName(comment.author)} +
+ {fmt(comment.created_at)} + {canDeleteCommentThis ? handleDeleteComment(selectedRequest.id, comment.id)} /> : null} +
+
+

{comment.text}

+
+ ); + })} +
+
+ )} + /> +
+ ) : ( +
+ +

Выберите заявку

+

Полная информация появится в этой области.

+
+ )} +
+
+ {/* ══════════ Request cards ══════════ */} -
+
{filteredRequests.length === 0 ? (
@@ -1171,7 +1485,7 @@ export default function ProcurementPage() { const commentsOpen = Boolean(expandedComments[req.id]); const commentsTotal = resolvedDetail.comments_count ?? req.comments_count ?? comments.length; const isViewed = Boolean(resolvedDetail.is_viewed ?? req.is_viewed); - const hasUnreadNotifications = (unreadProcurementRequestCounts[req.id] || 0) > 0; + const unreadNotificationCount = unreadProcurementRequestCounts[req.id] || 0; const rowExpanded = !isViewed && expanded; const rowCommentsOpen = !isViewed && commentsOpen; const showInlineRequestActions = ( @@ -1253,12 +1567,14 @@ export default function ProcurementPage() {
- {hasUnreadNotifications ? ( + {unreadNotificationCount > 0 ? ( + className="app-counter inline-flex h-5 min-w-5 shrink-0 items-center justify-center px-1 text-[10px] font-bold text-white" + title={`Новых уведомлений: ${unreadNotificationCount}`} + aria-label={`Новых уведомлений: ${unreadNotificationCount}`} + > + {unreadNotificationCount > 99 ? "99+" : unreadNotificationCount} + ) : null} -
- )} +
+ +
)}
)} setItemCommentDrafts((previous) => ({ ...previous, [itemId]: value }))} + onItemCommentFilesChange={handleItemCommentFilesChange} onAddItemComment={handleAddItemComment} onDeleteItemComment={handleDeleteItemComment} onDownloadItemAttachment={handleDownloadItemAttachment} @@ -1735,11 +2055,19 @@ export default function ProcurementPage() {

Комментарии

+
+ setCommentDrafts((previous) => ({ ...previous, [selectedRequest.id]: value }))} + onSubmit={() => handleAddComment(selectedRequest.id)} + disabled={busyKey === `comment-${selectedRequest.id}`} + /> +
{(commentsMap[selectedRequest.id] || []).length === 0 ? (

Комментариев пока нет

) : ( - (commentsMap[selectedRequest.id] || []).map((comment) => { + newestCommentsFirst(commentsMap[selectedRequest.id] || []).map((comment) => { const canDeleteCommentThis = Boolean(comment.author?.id && (user?.id === comment.author.id || user?.auth?.is_staff || user?.auth?.is_superuser)); return (
@@ -1760,14 +2088,6 @@ export default function ProcurementPage() { }) )}
-
- setCommentDrafts((previous) => ({ ...previous, [selectedRequest.id]: value }))} - onSubmit={() => handleAddComment(selectedRequest.id)} - disabled={busyKey === `comment-${selectedRequest.id}`} - /> -
) : ( diff --git a/frontend/src/app/requests/page.tsx b/frontend/src/app/requests/page.tsx index 6652f8c7..0b5e53a0 100644 --- a/frontend/src/app/requests/page.tsx +++ b/frontend/src/app/requests/page.tsx @@ -4,17 +4,21 @@ import { AppShell } from "../../components/AppShell"; import { RequestAttachmentPreviewModal } from "@/components/requests/RequestAttachmentPreviewModal"; import { RequestComposeModal } from "@/components/requests/RequestComposeModal"; import { RequestDetailModal } from "@/components/requests/RequestDetailModal"; +import { RequestDetailPanel } from "@/components/requests/RequestDetailPanel"; import { RequestListControls } from "@/components/requests/RequestListControls"; import { RequestListSection } from "@/components/requests/RequestListSection"; import { RequestStatisticsPanel } from "@/components/requests/RequestStatisticsPanel"; import { RequestSwipeModePanel } from "@/components/requests/RequestSwipeModePanel"; import { RequestTaskLinks } from "@/components/requests/RequestTaskLinks"; +import { RequestWideListItem } from "@/components/requests/RequestWideListItem"; import { useUser } from "@/contexts/UserContext"; -import { Suspense, useState } from "react"; +import { Suspense, useState, type RefObject } from "react"; import { useRequestsPage } from "@/hooks/useRequestsPage"; import { useRequestsPageScreen } from "@/hooks/useRequestsPageScreen"; +import { useRequestsDesktopWideMode } from "@/hooks/useRequestsDesktopWideMode"; import { canViewRequestStatistics } from "@/lib/permissions"; import type { Request } from "@/types/api"; +import { ChartNoAxesCombined, FileSignature } from "lucide-react"; export default function RequestsPage() { return ( @@ -35,10 +39,26 @@ function RequestsPageFallback() { ); } +function RequestWideListLoader({ + loadMoreRef, + loading, +}: { + loadMoreRef: RefObject; + loading: boolean; +}) { + return ( +
+ {loading ? Загрузка... : null} +
+ ); +} + function RequestsPageContent() { const { user } = useUser(); const canViewStats = canViewRequestStatistics(user); const [taskLinkRequest, setTaskLinkRequest] = useState(null); + const [wideSidebarView, setWideSidebarView] = useState<"requests" | "statistics">("requests"); + const [desktopWideMode, changeDesktopWideMode, desktopWideLayout] = useRequestsDesktopWideMode(user?.id); const h = useRequestsPage(user?.id); const screen = useRequestsPageScreen({ detailsRequestId: h.detailsRequest?.id, @@ -78,13 +98,17 @@ function RequestsPageContent() { periodFromFilter: h.periodFromFilter, periodToFilter: h.periodToFilter, search: h.search, + scopeCounts: h.scopeCounts, statusFilter: h.statusFilter, typeFilter: h.typeFilter, view: h.view, }; return ( - + {h.loading ? (
@@ -94,11 +118,13 @@ function RequestsPageContent() {

{h.error}

) : ( <> - -
+ {!desktopWideLayout ? ( + + ) : null} +
{h.swipeMode && h.pendingDecisionRequests.length > 0 ? ( changeDesktopWideMode(!desktopWideMode)} state={requestControlsState} /> - + {desktopWideLayout ? ( +
+ + +
+ {h.detailsRequest ? ( + { + screen.closeDetailsRequest(); + h.openEdit(request); + }} + onLinkTask={setTaskLinkRequest} + onPreviewAttachment={h.setAttachmentPreview} + onReject={h.handleReject} + onSetCommentDraft={h.setCommentDraft} + request={h.detailsRequest} + /> + ) : ( +
+ +

Выберите заявление

+

Полная информация появится в этой области.

+
+ )} +
+
+ ) : ( + + )} )}
@@ -173,7 +303,7 @@ function RequestsPageContent() { onPreviewAttachment={h.setAttachmentPreview} onReject={h.handleReject} onSetCommentDraft={h.setCommentDraft} - request={h.detailsRequest} + request={desktopWideLayout ? null : h.detailsRequest} /> {h.isModalOpen && ( diff --git a/frontend/src/app/tasks/page.tsx b/frontend/src/app/tasks/page.tsx index 54b63ddc..42c0ba6f 100644 --- a/frontend/src/app/tasks/page.tsx +++ b/frontend/src/app/tasks/page.tsx @@ -1,7 +1,9 @@ "use client"; +import Image from "next/image"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; +import { createPortal } from "react-dom"; import { DndContext, DragOverlay, @@ -44,6 +46,7 @@ import { ListChecks, Loader2, MessageSquare, + Maximize2, Newspaper, Paperclip, Pencil, @@ -79,6 +82,8 @@ import TaskBoardNavigator from "@/components/tasks/TaskBoardNavigator"; import TaskCoverPreview from "@/components/tasks/TaskCoverPreview"; import TaskCommentAttachment from "@/components/tasks/TaskCommentAttachment"; import TaskAutomationManager from "@/components/tasks/TaskAutomationManager"; +import ChatMediaPreviewModal from "@/components/messages/ChatMediaPreviewModal"; +import type { MediaPreview } from "@/components/messages/ChatMessageItem"; import { RequestAvatar } from "@/components/requests/RequestAvatar"; import { CommentComposer, CommentDeleteButton } from "@/components/shared/CommentControls"; import { Modal } from "@/components/ui"; @@ -319,6 +324,7 @@ const priorityOptions: { className: string; textClassName: string; dotClassName: string; + selectedClassName: string; }[] = [ { value: "low", @@ -327,6 +333,7 @@ const priorityOptions: { className: "app-badge", textClassName: "app-text-muted", dotClassName: "bg-slate-400", + selectedClassName: "bg-slate-500", }, { value: "medium", @@ -335,6 +342,7 @@ const priorityOptions: { className: "app-selected", textClassName: "text-[var(--accent-primary-strong)]", dotClassName: "bg-sky-500", + selectedClassName: "bg-sky-500", }, { value: "high", @@ -343,6 +351,7 @@ const priorityOptions: { className: "app-feedback-warning", textClassName: "text-[var(--warning-foreground)]", dotClassName: "bg-amber-500", + selectedClassName: "bg-amber-500", }, { value: "critical", @@ -351,6 +360,7 @@ const priorityOptions: { className: "app-feedback-danger", textClassName: "text-[var(--danger-foreground)]", dotClassName: "bg-red-500", + selectedClassName: "bg-red-500", }, ]; @@ -445,6 +455,116 @@ function isMediaAttachment(attachment: TaskAttachment): boolean { return isImageAttachment(attachment) || isVideoAttachment(attachment); } +function TaskAttachmentThumbnail({ + attachment, + onPreview, +}: { + attachment: TaskAttachment; + onPreview: (preview: MediaPreview) => void; +}) { + const [source, setSource] = useState(null); + const [failed, setFailed] = useState(false); + const image = isImageAttachment(attachment); + const video = isVideoAttachment(attachment); + const attachmentId = attachment.id; + const taskId = attachment.task; + const downloadUrl = attachment.download_url; + const fileName = attachment.file_name; + + useEffect(() => { + if (!image && !video) return; + + let active = true; + let objectUrl: string | null = null; + + void apiClient.downloadTaskAttachment(taskId, { + id: attachmentId, + download_url: downloadUrl, + file_name: fileName, + }) + .then(({ blob }) => { + if (!active) return; + objectUrl = URL.createObjectURL(blob); + setSource(objectUrl); + }) + .catch(() => { + if (active) setFailed(true); + }); + + return () => { + active = false; + if (objectUrl) URL.revokeObjectURL(objectUrl); + }; + }, [attachmentId, downloadUrl, fileName, image, taskId, video]); + + if (!image && !video) { + return ( + + + + ); + } + + return ( + + ); +} + function isImageFile(file: File): boolean { if (file.type.toLowerCase().startsWith("image/")) return true; return /\.(avif|bmp|gif|heic|heif|jpe?g|png|webp)$/i.test(file.name); @@ -837,16 +957,26 @@ function QuickTaskComposer({ type="button" onClick={() => onPriorityChange(option.value)} disabled={saving} - className={`flex h-6 w-6 items-center justify-center rounded-full transition disabled:opacity-50 ${ + className={`flex h-6 min-w-6 items-center overflow-hidden rounded-full transition-all duration-200 ease-out disabled:opacity-50 ${ selected - ? "bg-[var(--surface-tertiary)] ring-2 ring-[var(--accent-primary)]" - : "hover:bg-[var(--surface-tertiary)]" + ? `max-w-28 px-2 shadow-sm ${option.selectedClassName}` + : "w-6 max-w-6 justify-center px-0 hover:bg-[var(--surface-tertiary)]" }`} title={`Приоритет: ${option.label}`} aria-label={`Приоритет: ${option.label}`} aria-pressed={selected} > -
+ {!desktopWideMode ? ( + + ) : null} {taskAddMenuOpen && taskAddMenuPlacement === "bottom" ? ( -
+ {taskAddMenuItems} -
+ ) : null}
) : undefined} @@ -7778,9 +8010,13 @@ function TasksPageContent() { {taskAddMenuOpen && taskAddMenuPlacement === "top" ? ( -
+ {taskAddMenuItems} -
+ ) : null} {!viewTask.assignee && !viewTask.completed_at ? ( @@ -8364,15 +8600,16 @@ function TasksPageContent() { key={attachment.id} className="flex items-center gap-2 border-b border-[var(--border-subtle)] px-3 py-2.5 last:border-b-0" > + + {open ? ( +
+ {children} +
+ ) : null} + + ); +} + +function CompactField({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} + function ListState({ loading, error, empty }: { loading: boolean; error: string | null; empty: boolean }) { if (loading) { return ( @@ -42,7 +94,7 @@ function ActionButton({ tooltip, }: { label: string; - icon: React.ReactNode; + icon: ReactNode; onClick: () => void; tone?: "secondary" | "success"; disabled?: boolean; @@ -127,23 +179,48 @@ export function PayrollRatesTable({ if (loading || error || records.length === 0) return state; return ( -
-
-
- СотрудникОкладЦена сверх нормыДействует сСтатусДействия -
-
+
+
{records.map((record) => ( -
+ +
+ {formatPayrollMoney(record.amount, record.currency)} + + {record.point_rate === null ? "Как в норме" : formatPayrollMoney(record.point_rate, record.currency)} + + {formatPayrollDate(record.effective_from)} +
+
+ onEdit(record)} onApprove={() => onApprove(record)} onRevise={() => onRevise(record)} /> +
+
+ ))} +
+ +
+
+
+ СотрудникОкладЦена сверх нормыДействует сСтатусДействия +
+
+ {records.map((record) => ( +

{record.employee.display_name}

{record.employee.position || record.employee.department || "Сотрудник"}

-
Оклад{formatPayrollMoney(record.amount, record.currency)}
-
Цена сверх нормы{record.point_rate === null ? "Как в норме" : formatPayrollMoney(record.point_rate, record.currency)}
-
С{formatPayrollDate(record.effective_from)}
+
{formatPayrollMoney(record.amount, record.currency)}
+
{record.point_rate === null ? "Как в норме" : formatPayrollMoney(record.point_rate, record.currency)}
+
{formatPayrollDate(record.effective_from)}
onEdit(record)} onApprove={() => onApprove(record)} onRevise={() => onRevise(record)} />
))} -
+
+
); @@ -178,16 +255,51 @@ export function PayrollWorkRecordsTable({ if (loading || error || records.length === 0) return state; return ( -
-
-
- СотрудникНормаФактКонтрольСтатусДействия -
-
+
+
{records.map((record) => { const fromAttendance = isAttendancePayrollWorkRecord(record.source); return ( -
+ + {formatPayrollWorkMetric(record.actual_points, record.source)} / {formatPayrollWorkMetric(record.target_points, record.source)} + {fromAttendance ? ( + + Из посещаемости + + ) : null} + + )} + status={record.status} + > +
+ {formatPayrollWorkMetric(record.target_points, record.source)} + {formatPayrollWorkMetric(record.actual_points, record.source)} + {record.expected_payable ? formatPayrollMoney(record.expected_payable) : "—"} + {fromAttendance ? "Посещаемость" : "Введено вручную"} +
+
+ onEdit(record)} onApprove={() => onApprove(record)} onRevise={() => onRevise(record)} /> +
+
+ ); + })} +
+ +
+
+
+ СотрудникНормаФактКонтрольСтатусДействия +
+
+ {records.map((record) => { + const fromAttendance = isAttendancePayrollWorkRecord(record.source); + return ( +

{record.employee.display_name}

{record.employee.position || record.employee.department || "Сотрудник"}

@@ -197,15 +309,16 @@ export function PayrollWorkRecordsTable({ ) : null}
-
Норма{formatPayrollWorkMetric(record.target_points, record.source)}
-
Факт{formatPayrollWorkMetric(record.actual_points, record.source)}
-
Контроль{record.expected_payable ? formatPayrollMoney(record.expected_payable) : "—"}
+
{formatPayrollWorkMetric(record.target_points, record.source)}
+
{formatPayrollWorkMetric(record.actual_points, record.source)}
+
{record.expected_payable ? formatPayrollMoney(record.expected_payable) : "—"}
onEdit(record)} onApprove={() => onApprove(record)} onRevise={() => onRevise(record)} /> -
- ); - })} -
+
+ ); + })} +
+
); @@ -238,14 +351,46 @@ export function PayrollInputLinesTable({ if (loading || error || records.length === 0) return state; return ( -
-
-
- СотрудникОперацияСуммаСтатусДействия -
-
+
+
{records.map((record) => ( -
+ + {record.component.name} + {formatPayrollMoney(record.amount)} + + )} + status={record.status} + > +
+ {record.component.name} + {formatPayrollMoney(record.amount)} + {record.relates_to_period ? ( + + {record.relates_to_period.name || record.relates_to_period.code} + + ) : null} + {record.reason ? {record.reason} : null} +
+
+ onEdit(record)} onApprove={() => onApprove(record)} /> +
+
+ ))} +
+ +
+
+
+ СотрудникОперацияСуммаСтатусДействия +
+
+ {records.map((record) => ( +

{record.employee.display_name}

{record.employee.position || record.employee.department || "Сотрудник"}

{record.component.name}

@@ -259,12 +404,13 @@ export function PayrollInputLinesTable({ ) : null} {record.reason ?

{record.reason}

: null}
-
Сумма{formatPayrollMoney(record.amount)}
+
{formatPayrollMoney(record.amount)}
onEdit(record)} onApprove={() => onApprove(record)} />
))} -
+
+
); diff --git a/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx b/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx index d2110eea..f383737f 100644 --- a/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx +++ b/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx @@ -264,17 +264,18 @@ function TableToolbar({ }, [addMenuOpen]); return ( -
-