Merge branch 'develop'
This commit is contained in:
commit
17bd90b02d
@ -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(
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"],
|
||||
|
||||
@ -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="Комментарий",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
73
backend/requests_app/notifications/read_service.py
Normal file
73
backend/requests_app/notifications/read_service.py
Normal file
@ -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,
|
||||
)
|
||||
@ -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},
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# ТЕСТЫ УДАЛЕНИЯ
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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<T extends { created_at?: string | null; id?: number }>(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<unknown>;
|
||||
}) {
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(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 (
|
||||
<div ref={sentinelRef} className="flex min-h-10 items-center justify-center py-2" aria-live="polite">
|
||||
{loading ? (
|
||||
<span className="app-text-muted inline-flex items-center gap-2 text-xs">
|
||||
<Loader2 size={14} className="animate-spin" /> Загружаем ещё заявки…
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════
|
||||
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<number | null>(null);
|
||||
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
|
||||
const [detailModalLoading, setDetailModalLoading] = useState(false);
|
||||
const [detailModalError, setDetailModalError] = useState<string | null>(null);
|
||||
const initialRequestParamHandledRef = useRef(false);
|
||||
const procurementMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const statusFilterRef = useRef<HTMLDivElement | null>(null);
|
||||
const [procurementMenuOpenId, setProcurementMenuOpenId] = useState<number | null>(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 (
|
||||
<AppShell>
|
||||
<AppShell
|
||||
desktopWideMode={desktopWideMode}
|
||||
onDesktopWideModeChange={changeDesktopWideMode}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="app-surface rounded-2xl p-8 text-center">
|
||||
<Loader2 size={28} className="mx-auto mb-3 animate-spin text-sky-500" />
|
||||
@ -858,7 +924,7 @@ export default function ProcurementPage() {
|
||||
<p className="text-sm text-red-800">{error}</p>
|
||||
</div>
|
||||
) : (
|
||||
<section className="app-surface rounded-2xl p-4">
|
||||
<section className={`app-surface rounded-2xl p-4 ${desktopWideMode ? "lg:min-h-0 lg:flex-1 lg:flex lg:flex-col lg:overflow-hidden" : ""}`}>
|
||||
{/* ── header ── */}
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
@ -875,11 +941,23 @@ export default function ProcurementPage() {
|
||||
</button>
|
||||
</div> */}
|
||||
</div>
|
||||
{activeSection === "requests" && (
|
||||
<button type="button" onClick={openCreate} className="app-action-primary inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm font-medium transition">
|
||||
<Plus size={14} /> Создать заявку
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changeDesktopWideMode(!desktopWideMode)}
|
||||
className="app-action-ghost hidden h-9 w-9 items-center justify-center rounded-lg lg:inline-flex"
|
||||
title={desktopWideMode ? "Вернуть обычный вид" : "Развернуть закупки"}
|
||||
aria-label={desktopWideMode ? "Вернуть обычный вид" : "Развернуть закупки"}
|
||||
aria-pressed={desktopWideMode}
|
||||
>
|
||||
{desktopWideMode ? <Minimize2 size={17} /> : <Maximize2 size={17} />}
|
||||
</button>
|
||||
)}
|
||||
{activeSection === "requests" && (
|
||||
<button type="button" onClick={openCreate} className="app-action-primary inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm font-medium transition">
|
||||
<Plus size={14} /> Создать заявку
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── alerts ── */}
|
||||
@ -1137,8 +1215,244 @@ export default function ProcurementPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ══════════ Wide master-detail workspace ══════════ */}
|
||||
<div className={`${desktopWideMode ? "hidden lg:grid" : "hidden"} min-h-0 flex-1 grid-cols-[minmax(300px,380px)_minmax(0,1fr)] gap-3`}>
|
||||
<aside className="app-surface-muted min-h-0 overflow-y-auto rounded-xl border border-[var(--border-subtle)]" aria-label="Список заявок на закупку">
|
||||
<div className="sticky top-0 z-10 flex items-center justify-between border-b border-[var(--border-subtle)] bg-[var(--surface-primary)] px-3 py-2.5">
|
||||
<span className="app-card-caption">Заявки</span>
|
||||
<span className="app-counter inline-flex h-5 min-w-5 items-center justify-center px-1 text-[10px] font-bold">{filteredRequests.length}</span>
|
||||
</div>
|
||||
<div className="space-y-2 p-2">
|
||||
{filteredRequests.length === 0 ? (
|
||||
<div className="px-3 py-10 text-center">
|
||||
<ShoppingCart size={22} className="app-text-muted mx-auto mb-2" />
|
||||
<p className="app-text-muted text-sm">Заявок на закупку не найдено</p>
|
||||
</div>
|
||||
) : filteredRequests.map((req) => {
|
||||
const status = getStatusMeta(req.status);
|
||||
const urgency = urgencyMeta[req.urgency] ?? urgencyMeta.medium;
|
||||
const active = detailModalId === req.id;
|
||||
const amount = getRequestAmount(req);
|
||||
const resolvedDetail = detailsCache[req.id] || req;
|
||||
const detailItems = resolvedDetail.items || req.items || [];
|
||||
const itemsTotalCount = resolvedDetail.items_total_count ?? req.items_total_count ?? resolvedDetail.items_count ?? req.items_count ?? detailItems.length;
|
||||
const itemsReceivedCount = resolvedDetail.items_received_count ?? req.items_received_count ?? detailItems.filter((item) => item.execution_status === "received").length;
|
||||
const itemsProblemCount = resolvedDetail.items_problem_count ?? req.items_problem_count ?? detailItems.filter((item) => ["rejected", "completed_with_issue", "edited", "defective"].includes(String(item.execution_status || ""))).length;
|
||||
const totalRequestedQuantity = resolvedDetail.total_requested_quantity ?? req.total_requested_quantity ?? detailItems.reduce((sum, item) => sum + Number(item.quantity || 0), 0);
|
||||
const totalOrderedQuantity = resolvedDetail.total_ordered_quantity ?? req.total_ordered_quantity ?? detailItems.reduce((sum, item) => sum + Number(item.ordered_quantity || 0), 0);
|
||||
const totalReceivedQuantity = resolvedDetail.total_received_quantity ?? req.total_received_quantity ?? detailItems.reduce((sum, item) => sum + Number(item.received_quantity || 0), 0);
|
||||
const quantityUnitLabel = getProcurementQuantityUnitLabel(detailItems, resolvedDetail.quantity_unit_label ?? req.quantity_unit_label);
|
||||
const fulfillmentStatus = resolvedDetail.fulfillment_status ?? req.fulfillment_status;
|
||||
const fulfillmentStatusDisplay = resolvedDetail.fulfillment_status_display ?? req.fulfillment_status_display;
|
||||
const nextExpectedDeliveryDate = resolvedDetail.next_expected_delivery_date ?? req.next_expected_delivery_date ?? null;
|
||||
const isRequestFullyReceived = totalRequestedQuantity > 0 && totalReceivedQuantity >= totalRequestedQuantity;
|
||||
const requestorName = displayUserName(req.requestor, req.requestor_name, req.requestor_email);
|
||||
const executorName = req.executor || req.executor_name ? displayUserName(req.executor, req.executor_name || undefined) : "не назначен";
|
||||
const isViewed = Boolean(resolvedDetail.is_viewed ?? req.is_viewed);
|
||||
const unreadNotificationCount = unreadProcurementRequestCounts[req.id] || 0;
|
||||
return (
|
||||
<article
|
||||
key={req.id}
|
||||
className={`${active ? "app-selected border-[var(--accent-primary)]" : "app-surface hover:border-[var(--border-strong)]"} relative rounded-xl border ${isViewed ? "p-2.5" : "p-3"} transition`}
|
||||
aria-current={active ? "true" : undefined}
|
||||
>
|
||||
<button type="button" onClick={() => void openDetailModal(req.id)} className="block w-full text-left">
|
||||
<span className="flex min-w-0 items-start justify-between gap-2">
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
{unreadNotificationCount > 0 ? (
|
||||
<span 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}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="truncate text-sm font-semibold text-[var(--foreground)]">#{req.id} · {req.title || "Без названия"}</span>
|
||||
</span>
|
||||
<span className="app-text-muted mt-1 block truncate text-[11px]">Отдел-заказчик: <span className="text-[var(--foreground)]">{getDeptName(req)}</span></span>
|
||||
</span>
|
||||
<span className="flex shrink-0 flex-col items-end gap-1">
|
||||
<span className={`app-status-pill ${status.cls}`}>{status.label}</span>
|
||||
{!isViewed ? <span className={`text-[10px] font-medium ${urgency.cls}`}>{urgency.label} срочность</span> : null}
|
||||
</span>
|
||||
</span>
|
||||
<span className="app-text-muted mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px]">
|
||||
<span>{getRequestListDateLabel(resolvedDetail, String(req.status || "").toLowerCase())}</span>
|
||||
<span className="font-semibold text-[var(--foreground)] tabular-nums">{amount ? money(amount) : "—"}</span>
|
||||
</span>
|
||||
{!isViewed ? (
|
||||
<>
|
||||
<span className="app-text-muted mt-2 grid grid-cols-2 gap-x-2 gap-y-1 text-[11px]">
|
||||
<span className="min-w-0 truncate">Заявитель: <span className="text-[var(--foreground)]">{requestorName}</span></span>
|
||||
<span className="min-w-0 truncate">Исполнитель: <span className="text-[var(--foreground)]">{executorName}</span></span>
|
||||
</span>
|
||||
<span className="mt-2 flex flex-wrap gap-1">
|
||||
{fulfillmentStatusDisplay ? <span className={`${getFulfillmentStatusClass(fulfillmentStatus)} inline-flex rounded-full px-2 py-0.5 text-[10px] font-medium`}>{fulfillmentStatusDisplay}</span> : null}
|
||||
{nextExpectedDeliveryDate ? <span className={`${getExpectedDeliveryDateBadgeClass(nextExpectedDeliveryDate, isRequestFullyReceived)} inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium`}><CalendarDays size={10} /> {fmt(nextExpectedDeliveryDate)}</span> : null}
|
||||
{itemsTotalCount > 0 ? <span className="app-badge inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium"><Package size={10} /> {itemsTotalCount} поз.</span> : null}
|
||||
{itemsProblemCount > 0 ? <span className="app-feedback-warning inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium"><AlertTriangle size={10} /> Проблемы {itemsProblemCount}/{itemsTotalCount}</span> : null}
|
||||
{itemsTotalCount > 0 ? <span className={`${getReceivedProgressClass(itemsReceivedCount, itemsTotalCount)} inline-flex rounded-full px-2 py-0.5 text-[10px] font-medium`}>Получено {itemsReceivedCount}/{itemsTotalCount} поз.</span> : null}
|
||||
{totalRequestedQuantity > 0 ? <span className={`${getOrderedProgressClass(totalOrderedQuantity, totalRequestedQuantity)} inline-flex rounded-full px-2 py-0.5 text-[10px] font-medium`}>Заказано {totalOrderedQuantity}/{totalRequestedQuantity} {quantityUnitLabel}</span> : null}
|
||||
{totalRequestedQuantity > 0 ? <span className={`${getReceivedProgressClass(totalReceivedQuantity, totalRequestedQuantity)} inline-flex rounded-full px-2 py-0.5 text-[10px] font-medium`}>Получено {totalReceivedQuantity}/{totalRequestedQuantity} {quantityUnitLabel}</span> : null}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={isViewed ? "Отметить непросмотренной" : "Отметить просмотренной"}
|
||||
aria-label={isViewed ? "Отметить заявку непросмотренной" : "Отметить заявку просмотренной"}
|
||||
disabled={busyKey === `viewed-${req.id}`}
|
||||
onClick={() => void handleToggleViewed(req.id, !isViewed)}
|
||||
className={`${isViewed ? "app-selected app-accent-text" : "app-action-secondary"} absolute bottom-2 right-2 z-[1] inline-flex h-8 w-8 items-center justify-center rounded-lg shadow-sm disabled:cursor-not-allowed disabled:opacity-60`}
|
||||
>
|
||||
{isViewed ? <Eye size={15} /> : <EyeOff size={15} />}
|
||||
</button>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<InfiniteRequestLoader
|
||||
hasMore={Boolean(nextPage)}
|
||||
loading={loadingMore}
|
||||
onLoadMore={handleLoadMore}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<section className="app-surface-muted min-h-0 overflow-y-auto rounded-xl border border-[var(--border-subtle)] p-4" aria-label="Просмотр выбранной заявки">
|
||||
{detailModalLoading ? (
|
||||
<div className="flex h-full min-h-64 flex-col items-center justify-center text-center">
|
||||
<Loader2 size={24} className="mb-3 animate-spin text-sky-500" />
|
||||
<p className="app-text-muted text-sm">Загружаем заявку...</p>
|
||||
</div>
|
||||
) : detailModalError ? (
|
||||
<div className="app-feedback-danger rounded-xl px-4 py-3 text-sm">{detailModalError}</div>
|
||||
) : selectedRequest ? (
|
||||
<div className="space-y-4">
|
||||
<div className="app-surface rounded-xl p-4">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h2 className="mr-auto min-w-0 text-lg font-semibold text-[var(--foreground)]">#{selectedRequest.id} · {selectedRequest.title || "Без названия"}</h2>
|
||||
<span className={`app-status-pill shrink-0 ${getStatusMeta(selectedRequest.status).cls}`}>{getStatusMeta(selectedRequest.status).label}</span>
|
||||
<span className={`shrink-0 text-[11px] font-medium ${urgencyMeta[selectedRequest.urgency]?.cls ?? urgencyMeta.medium.cls}`}>
|
||||
{(urgencyMeta[selectedRequest.urgency]?.label ?? urgencyMeta.medium.label)} срочность
|
||||
</span>
|
||||
</div>
|
||||
<div className="app-text-muted mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs">
|
||||
<span>Отдел-заказчик: <span className="font-medium text-[var(--foreground)]">{getDeptName(selectedRequest)}</span></span>
|
||||
{selectedRequest.processing_department_name ? <span>Отдел-исполнитель: <span className="font-medium text-[var(--foreground)]">{selectedRequest.processing_department_name}</span></span> : null}
|
||||
<span>Заявитель: <span className="font-medium text-[var(--foreground)]">{displayUserName(selectedRequest.requestor, selectedRequest.requestor_name, selectedRequest.requestor_email)}</span></span>
|
||||
<span>Исполнитель: <span className="font-medium text-[var(--foreground)]">{selectedRequest.executor || selectedRequest.executor_name ? displayUserName(selectedRequest.executor, selectedRequest.executor_name || undefined) : "не назначен"}</span></span>
|
||||
{getRequestAmount(selectedRequest) ? <span>Сумма: <span className="font-semibold text-[var(--foreground)]">{money(getRequestAmount(selectedRequest))}</span></span> : null}
|
||||
</div>
|
||||
<p className="app-text-wrap mt-2 text-sm text-[var(--foreground)]">
|
||||
<span className="app-text-muted">Описание: </span>{selectedRequest.description || "—"}
|
||||
</p>
|
||||
<div className="app-text-muted mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
||||
<span>Создана: <span className="text-[var(--foreground)]">{fmt(selectedRequest.created_at) || "—"}</span></span>
|
||||
<span>Отправлена: <span className="text-[var(--foreground)]">{fmt(selectedRequest.submitted_at) || "—"}</span></span>
|
||||
<span>Взята в работу: <span className="text-[var(--foreground)]">{fmt(selectedRequest.started_at) || "—"}</span></span>
|
||||
<span>Завершена: <span className="text-[var(--foreground)]">{fmt(selectedRequest.completed_at) || "—"}</span></span>
|
||||
{selectedRequest.actual_cost ? <span>Фактическая сумма: <span className="font-medium text-[var(--foreground)]">{money(selectedRequest.actual_cost)}</span></span> : null}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center justify-end gap-2 border-t border-[var(--border-subtle)] pt-3">
|
||||
<button type="button" onClick={() => setTaskLinkRequest(selectedRequest)} className="app-action-secondary inline-flex h-9 items-center gap-1.5 rounded-lg px-3 text-xs font-medium">
|
||||
<Link2 size={14} /> Связать с задачей
|
||||
</button>
|
||||
<ProcurementRequestActionButtons
|
||||
request={selectedRequest}
|
||||
busyKey={busyKey}
|
||||
canManage={canManage}
|
||||
canSubmitForApproval={canCurrentUserSubmitRequest(selectedRequest)}
|
||||
isAuthor={Boolean(resolveUserId(selectedRequest.requestor) && user?.id && resolveUserId(selectedRequest.requestor) === user.id)}
|
||||
isExecutor={Boolean(resolveUserId(selectedRequest.executor) && user?.id && resolveUserId(selectedRequest.executor) === user.id)}
|
||||
isFinal={isFinal}
|
||||
showLabels
|
||||
onSubmit={(id) => 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)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProcurementRequestDetailContent
|
||||
currentUserId={user?.id}
|
||||
request={selectedRequest}
|
||||
displayUserName={displayUserName}
|
||||
canProcessItems={Boolean(selectedRequest.can_current_user_process_items)}
|
||||
busyKey={busyKey}
|
||||
canDeleteAnyComment={Boolean(user?.auth?.is_staff || user?.auth?.is_superuser)}
|
||||
onUpdateItem={handleUpdateItem}
|
||||
onReportItemIssue={handleReportItemIssue}
|
||||
onCancelItemIssue={handleCancelItemIssue}
|
||||
onConfirmItemOrdered={handleConfirmItemOrdered}
|
||||
onConfirmItemReceived={handleConfirmItemReceived}
|
||||
onCancelItemReceived={handleCancelItemReceived}
|
||||
onMarkAllReceived={handleMarkAllReceived}
|
||||
itemCommentsMap={itemCommentsMap}
|
||||
itemCommentDrafts={itemCommentDrafts}
|
||||
itemCommentFiles={itemCommentFiles}
|
||||
expandedItemComments={expandedItemComments}
|
||||
unreadItemCommentCounts={unreadProcurementItemCommentCounts}
|
||||
onToggleItemComments={toggleItemComments}
|
||||
onItemCommentDraftChange={(itemId, value) => setItemCommentDrafts((previous) => ({ ...previous, [itemId]: value }))}
|
||||
onItemCommentFilesChange={handleItemCommentFilesChange}
|
||||
onAddItemComment={handleAddItemComment}
|
||||
onDeleteItemComment={handleDeleteItemComment}
|
||||
onDownloadItemAttachment={handleDownloadItemAttachment}
|
||||
onUploadItemAttachments={handleUploadItemAttachments}
|
||||
onDeleteItemAttachment={handleDeleteItemAttachment}
|
||||
wideLayout
|
||||
footer={(
|
||||
<div className="app-surface rounded-xl p-4">
|
||||
<p className="app-card-caption">Комментарии</p>
|
||||
<div className="mt-3">
|
||||
<CommentComposer
|
||||
value={commentDrafts[selectedRequest.id] || ""}
|
||||
onChange={(value) => setCommentDrafts((previous) => ({ ...previous, [selectedRequest.id]: value }))}
|
||||
onSubmit={() => handleAddComment(selectedRequest.id)}
|
||||
disabled={busyKey === `comment-${selectedRequest.id}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{(commentsMap[selectedRequest.id] || []).length === 0 ? (
|
||||
<p className="app-text-muted text-xs">Комментариев пока нет</p>
|
||||
) : 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 (
|
||||
<div key={comment.id} className="app-surface-muted rounded-lg px-3 py-2 text-xs text-[var(--foreground)]">
|
||||
<div className="mb-1 flex items-center justify-between gap-2">
|
||||
<span className="font-medium">{displayUserName(comment.author)}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="app-text-muted">{fmt(comment.created_at)}</span>
|
||||
{canDeleteCommentThis ? <CommentDeleteButton onClick={() => handleDeleteComment(selectedRequest.id, comment.id)} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
<p className="app-text-wrap text-[var(--foreground)]">{comment.text}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full min-h-64 flex-col items-center justify-center text-center">
|
||||
<ShoppingCart size={28} className="app-text-muted mb-3" />
|
||||
<p className="text-sm font-medium text-[var(--foreground)]">Выберите заявку</p>
|
||||
<p className="app-text-muted mt-1 text-xs">Полная информация появится в этой области.</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* ══════════ Request cards ══════════ */}
|
||||
<div className="space-y-3">
|
||||
<div className={`space-y-3 ${desktopWideMode ? "lg:hidden" : ""}`}>
|
||||
{filteredRequests.length === 0 ? (
|
||||
<div className="app-surface-muted rounded-xl p-8 text-center">
|
||||
<ShoppingCart size={22} className="app-text-muted mx-auto mb-2" />
|
||||
@ -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() {
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{hasUnreadNotifications ? (
|
||||
{unreadNotificationCount > 0 ? (
|
||||
<span
|
||||
className="app-dot-accent h-2 w-2 shrink-0 rounded-full"
|
||||
title="Есть новые уведомления"
|
||||
aria-label="Есть новые уведомления"
|
||||
/>
|
||||
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}
|
||||
</span>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
@ -1487,11 +1803,17 @@ export default function ProcurementPage() {
|
||||
<div className="mt-4 space-y-3 px-4 pb-4">
|
||||
{rowCommentsOpen ? (
|
||||
<div className={rowExpanded ? "app-surface rounded-xl p-3" : "app-surface-elevated rounded-xl p-3"}>
|
||||
<div className="space-y-2">
|
||||
<CommentComposer
|
||||
value={commentDrafts[req.id] || ""}
|
||||
onChange={(value) => setCommentDrafts((previous) => ({ ...previous, [req.id]: value }))}
|
||||
onSubmit={() => handleAddComment(req.id)}
|
||||
disabled={busyKey === `comment-${req.id}`}
|
||||
/>
|
||||
<div className="mt-2 space-y-2">
|
||||
{comments.length === 0 ? (
|
||||
<p className="app-text-muted text-xs">Комментариев пока нет</p>
|
||||
) : (
|
||||
comments.map((comment) => {
|
||||
newestCommentsFirst(comments).map((comment) => {
|
||||
const canDeleteCommentThis = Boolean(comment.author?.id && (user?.id === comment.author.id || user?.auth?.is_staff || user?.auth?.is_superuser));
|
||||
return (
|
||||
<div key={comment.id} className="app-surface-muted rounded-lg px-3 py-2 text-xs text-[var(--foreground)]">
|
||||
@ -1512,14 +1834,6 @@ export default function ProcurementPage() {
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<CommentComposer
|
||||
value={commentDrafts[req.id] || ""}
|
||||
onChange={(value) => setCommentDrafts((previous) => ({ ...previous, [req.id]: value }))}
|
||||
onSubmit={() => handleAddComment(req.id)}
|
||||
disabled={busyKey === `comment-${req.id}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@ -1540,9 +1854,12 @@ export default function ProcurementPage() {
|
||||
onMarkAllReceived={handleMarkAllReceived}
|
||||
itemCommentsMap={itemCommentsMap}
|
||||
itemCommentDrafts={itemCommentDrafts}
|
||||
itemCommentFiles={itemCommentFiles}
|
||||
expandedItemComments={expandedItemComments}
|
||||
unreadItemCommentCounts={unreadProcurementItemCommentCounts}
|
||||
onToggleItemComments={toggleItemComments}
|
||||
onItemCommentDraftChange={(itemId, value) => setItemCommentDrafts((previous) => ({ ...previous, [itemId]: value }))}
|
||||
onItemCommentFilesChange={handleItemCommentFilesChange}
|
||||
onAddItemComment={handleAddItemComment}
|
||||
onDeleteItemComment={handleDeleteItemComment}
|
||||
onDownloadItemAttachment={handleDownloadItemAttachment}
|
||||
@ -1581,20 +1898,20 @@ export default function ProcurementPage() {
|
||||
</div>
|
||||
|
||||
{/* ── load more ── */}
|
||||
{nextPage && (
|
||||
<div className="mt-4 flex justify-center">
|
||||
<button type="button" onClick={handleLoadMore} disabled={loadingMore} className="app-action-secondary rounded-lg px-4 py-2 text-sm font-medium disabled:opacity-60">
|
||||
{loadingMore ? "Загружаем..." : "Загрузить ещё"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className={desktopWideMode ? "lg:hidden" : ""}>
|
||||
<InfiniteRequestLoader
|
||||
hasMore={Boolean(nextPage)}
|
||||
loading={loadingMore}
|
||||
onLoadMore={handleLoadMore}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
isOpen={detailModalId !== null}
|
||||
isOpen={detailModalId !== null && detailDialogOpen}
|
||||
onClose={closeDetailModal}
|
||||
title={selectedRequest?.title || "Карточка заявки"}
|
||||
size="lg"
|
||||
@ -1723,9 +2040,12 @@ export default function ProcurementPage() {
|
||||
onMarkAllReceived={handleMarkAllReceived}
|
||||
itemCommentsMap={itemCommentsMap}
|
||||
itemCommentDrafts={itemCommentDrafts}
|
||||
itemCommentFiles={itemCommentFiles}
|
||||
expandedItemComments={expandedItemComments}
|
||||
unreadItemCommentCounts={unreadProcurementItemCommentCounts}
|
||||
onToggleItemComments={toggleItemComments}
|
||||
onItemCommentDraftChange={(itemId, value) => setItemCommentDrafts((previous) => ({ ...previous, [itemId]: value }))}
|
||||
onItemCommentFilesChange={handleItemCommentFilesChange}
|
||||
onAddItemComment={handleAddItemComment}
|
||||
onDeleteItemComment={handleDeleteItemComment}
|
||||
onDownloadItemAttachment={handleDownloadItemAttachment}
|
||||
@ -1735,11 +2055,19 @@ export default function ProcurementPage() {
|
||||
|
||||
<div className="app-surface rounded-xl p-4">
|
||||
<p className="app-card-caption">Комментарии</p>
|
||||
<div className="mt-3">
|
||||
<CommentComposer
|
||||
value={commentDrafts[selectedRequest.id] || ""}
|
||||
onChange={(value) => setCommentDrafts((previous) => ({ ...previous, [selectedRequest.id]: value }))}
|
||||
onSubmit={() => handleAddComment(selectedRequest.id)}
|
||||
disabled={busyKey === `comment-${selectedRequest.id}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{(commentsMap[selectedRequest.id] || []).length === 0 ? (
|
||||
<p className="app-text-muted text-xs">Комментариев пока нет</p>
|
||||
) : (
|
||||
(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 (
|
||||
<div key={comment.id} className="app-surface-muted rounded-lg px-3 py-2 text-xs text-[var(--foreground)]">
|
||||
@ -1760,14 +2088,6 @@ export default function ProcurementPage() {
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<CommentComposer
|
||||
value={commentDrafts[selectedRequest.id] || ""}
|
||||
onChange={(value) => setCommentDrafts((previous) => ({ ...previous, [selectedRequest.id]: value }))}
|
||||
onSubmit={() => handleAddComment(selectedRequest.id)}
|
||||
disabled={busyKey === `comment-${selectedRequest.id}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@ -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<HTMLDivElement | null>;
|
||||
loading: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div ref={loadMoreRef} className="flex min-h-10 items-center justify-center py-2">
|
||||
{loading ? <span className="app-text-muted text-xs">Загрузка...</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RequestsPageContent() {
|
||||
const { user } = useUser();
|
||||
const canViewStats = canViewRequestStatistics(user);
|
||||
const [taskLinkRequest, setTaskLinkRequest] = useState<Request | null>(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 (
|
||||
<AppShell>
|
||||
<AppShell
|
||||
desktopWideMode={desktopWideMode}
|
||||
onDesktopWideModeChange={changeDesktopWideMode}
|
||||
>
|
||||
{h.loading ? (
|
||||
<div className="app-surface rounded-2xl p-8 text-center">
|
||||
<div className="mb-4 inline-block h-8 w-8 animate-spin rounded-full border-4 border-[var(--border-strong)] border-t-[var(--accent-primary)]" />
|
||||
@ -94,11 +118,13 @@ function RequestsPageContent() {
|
||||
<div className="app-feedback-danger rounded-2xl p-6 text-center"><p className="text-sm">{h.error}</p></div>
|
||||
) : (
|
||||
<>
|
||||
<RequestStatisticsPanel
|
||||
canView={canViewStats}
|
||||
employees={h.employees}
|
||||
/>
|
||||
<section className="app-surface rounded-2xl p-4">
|
||||
{!desktopWideLayout ? (
|
||||
<RequestStatisticsPanel
|
||||
canView={canViewStats}
|
||||
employees={h.employees}
|
||||
/>
|
||||
) : null}
|
||||
<section className={`app-surface rounded-2xl p-4 ${desktopWideMode ? "lg:flex lg:min-h-0 lg:flex-1 lg:flex-col lg:overflow-hidden" : ""}`}>
|
||||
{h.swipeMode && h.pendingDecisionRequests.length > 0 ? (
|
||||
<RequestSwipeModePanel
|
||||
actionError={h.actionError}
|
||||
@ -111,39 +137,143 @@ function RequestsPageContent() {
|
||||
<>
|
||||
<RequestListControls
|
||||
actions={requestControlsActions}
|
||||
desktopWideMode={desktopWideMode}
|
||||
feedback={requestControlsFeedback}
|
||||
onToggleDesktopWideMode={() => changeDesktopWideMode(!desktopWideMode)}
|
||||
state={requestControlsState}
|
||||
/>
|
||||
<RequestListSection
|
||||
busyKey={h.busyKey}
|
||||
commentDrafts={h.commentDrafts}
|
||||
commentsMap={h.commentsMap}
|
||||
currentUserId={user?.id}
|
||||
departmentNameMap={h.departmentNameMap}
|
||||
expandedComments={h.expandedComments}
|
||||
expandedRows={h.expandedRows}
|
||||
handleAddComment={h.handleAddComment}
|
||||
handleApprove={h.handleApprove}
|
||||
handleCancel={h.handleCancel}
|
||||
handleDelete={h.handleDelete}
|
||||
handleDeleteComment={h.handleDeleteComment}
|
||||
handleReject={h.handleReject}
|
||||
isFinal={h.isFinal}
|
||||
loadMoreRef={h.loadMoreRef}
|
||||
loadingMore={h.loadingMore}
|
||||
nextPage={h.nextPage}
|
||||
onLinkTask={setTaskLinkRequest}
|
||||
openEdit={h.openEdit}
|
||||
requestMenuOpenId={screen.requestMenuOpenId}
|
||||
requestMenuRef={screen.requestMenuRef}
|
||||
requests={h.requests}
|
||||
setAttachmentPreview={h.setAttachmentPreview}
|
||||
onSetCommentDraft={h.setCommentDraft}
|
||||
setDetailsRequest={h.setDetailsRequest}
|
||||
setRequestMenuOpenId={screen.setRequestMenuOpenId}
|
||||
toggleComments={h.toggleComments}
|
||||
toggleRow={h.toggleRow}
|
||||
/>
|
||||
{desktopWideLayout ? (
|
||||
<div className="grid min-h-0 flex-1 grid-cols-[minmax(300px,380px)_minmax(0,1fr)] gap-3">
|
||||
<aside className="app-surface-muted min-h-0 overflow-y-auto rounded-xl border border-[var(--border-subtle)]" aria-label="Заявления и статистика">
|
||||
<div className="sticky top-0 z-10 border-b border-[var(--border-subtle)] bg-[var(--surface-primary)] p-2">
|
||||
<div className={`grid ${canViewStats ? "grid-cols-2" : "grid-cols-1"} gap-1 rounded-lg bg-[var(--surface-secondary)] p-1`} role="tablist" aria-label="Содержимое левой панели">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={wideSidebarView === "requests"}
|
||||
onClick={() => setWideSidebarView("requests")}
|
||||
className={`${wideSidebarView === "requests" ? "app-selected" : "app-action-ghost"} flex min-w-0 items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium`}
|
||||
>
|
||||
<FileSignature size={13} />
|
||||
<span className="truncate">Заявления</span>
|
||||
<span className="app-counter inline-flex h-4 min-w-4 shrink-0 items-center justify-center px-1 text-[9px] font-bold">
|
||||
{h.requests.length}
|
||||
</span>
|
||||
</button>
|
||||
{canViewStats ? (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={wideSidebarView === "statistics"}
|
||||
onClick={() => setWideSidebarView("statistics")}
|
||||
className={`${wideSidebarView === "statistics" ? "app-selected" : "app-action-ghost"} flex min-w-0 items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium`}
|
||||
>
|
||||
<ChartNoAxesCombined size={13} />
|
||||
<span className="truncate">Статистика</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{wideSidebarView === "statistics" && canViewStats ? (
|
||||
<RequestStatisticsPanel
|
||||
canView={canViewStats}
|
||||
employees={h.employees}
|
||||
embedded
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-2 p-2">
|
||||
{h.requests.length > 0 ? h.requests.map((request) => (
|
||||
<RequestWideListItem
|
||||
key={request.id}
|
||||
active={h.detailsRequest?.id === request.id}
|
||||
request={request}
|
||||
onOpen={screen.openDetailsRequest}
|
||||
unreadNotificationCount={h.unreadEmployeeRequestCounts[request.id] || 0}
|
||||
/>
|
||||
)) : (
|
||||
<div className="p-6 text-center">
|
||||
<FileSignature size={22} className="app-text-muted mx-auto mb-2" />
|
||||
<p className="app-text-muted text-sm">Заявления не найдены</p>
|
||||
</div>
|
||||
)}
|
||||
{h.nextPage ? (
|
||||
<RequestWideListLoader
|
||||
loadMoreRef={h.loadMoreRef}
|
||||
loading={h.loadingMore}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<section className="app-surface-muted min-h-0 overflow-y-auto rounded-xl border border-[var(--border-subtle)] p-4" aria-label="Просмотр выбранного заявления">
|
||||
{h.detailsRequest ? (
|
||||
<RequestDetailPanel
|
||||
actionError={h.actionError}
|
||||
busyKey={h.busyKey}
|
||||
commentDraft={h.commentDrafts[h.detailsRequest.id] || ""}
|
||||
comments={h.commentsMap[h.detailsRequest.id] || []}
|
||||
commentsLoading={Boolean(h.commentsLoadingMap[h.detailsRequest.id])}
|
||||
currentUserId={user?.id}
|
||||
departmentNameMap={h.departmentNameMap}
|
||||
isFinal={h.isFinal}
|
||||
onAddComment={h.handleAddComment}
|
||||
onApprove={h.handleApprove}
|
||||
onCancel={h.handleCancel}
|
||||
onDelete={h.handleDelete}
|
||||
onDeleteComment={h.handleDeleteComment}
|
||||
onEdit={(request) => {
|
||||
screen.closeDetailsRequest();
|
||||
h.openEdit(request);
|
||||
}}
|
||||
onLinkTask={setTaskLinkRequest}
|
||||
onPreviewAttachment={h.setAttachmentPreview}
|
||||
onReject={h.handleReject}
|
||||
onSetCommentDraft={h.setCommentDraft}
|
||||
request={h.detailsRequest}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full min-h-64 flex-col items-center justify-center text-center">
|
||||
<FileSignature size={28} className="app-text-muted mb-3" />
|
||||
<p className="text-sm font-medium text-[var(--foreground)]">Выберите заявление</p>
|
||||
<p className="app-text-muted mt-1 text-xs">Полная информация появится в этой области.</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
) : (
|
||||
<RequestListSection
|
||||
busyKey={h.busyKey}
|
||||
commentDrafts={h.commentDrafts}
|
||||
commentsMap={h.commentsMap}
|
||||
currentUserId={user?.id}
|
||||
departmentNameMap={h.departmentNameMap}
|
||||
expandedComments={h.expandedComments}
|
||||
expandedRows={h.expandedRows}
|
||||
handleAddComment={h.handleAddComment}
|
||||
handleApprove={h.handleApprove}
|
||||
handleCancel={h.handleCancel}
|
||||
handleDelete={h.handleDelete}
|
||||
handleDeleteComment={h.handleDeleteComment}
|
||||
handleReject={h.handleReject}
|
||||
isFinal={h.isFinal}
|
||||
loadMoreRef={h.loadMoreRef}
|
||||
loadingMore={h.loadingMore}
|
||||
nextPage={h.nextPage}
|
||||
onLinkTask={setTaskLinkRequest}
|
||||
openEdit={h.openEdit}
|
||||
requestMenuOpenId={screen.requestMenuOpenId}
|
||||
requestMenuRef={screen.requestMenuRef}
|
||||
requests={h.requests}
|
||||
setAttachmentPreview={h.setAttachmentPreview}
|
||||
onSetCommentDraft={h.setCommentDraft}
|
||||
openDetailsRequest={screen.openDetailsRequest}
|
||||
setRequestMenuOpenId={screen.setRequestMenuOpenId}
|
||||
toggleComments={h.toggleComments}
|
||||
toggleRow={h.toggleRow}
|
||||
unreadNotificationCounts={h.unreadEmployeeRequestCounts}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
@ -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 && (
|
||||
|
||||
@ -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<string | null>(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 (
|
||||
<span className="app-selected flex h-9 w-11 shrink-0 items-center justify-center rounded-lg">
|
||||
<FileText size={15} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!source || failed}
|
||||
onClick={() => {
|
||||
if (!source || failed) return;
|
||||
onPreview({
|
||||
type: video ? "video" : "image",
|
||||
src: source,
|
||||
name: fileName,
|
||||
});
|
||||
}}
|
||||
className="group relative flex h-9 w-11 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-[var(--surface-secondary)] disabled:cursor-default"
|
||||
title={source && !failed ? `Просмотреть ${fileName}` : undefined}
|
||||
aria-label={`Предпросмотр ${fileName}`}
|
||||
>
|
||||
{failed ? (
|
||||
<FileText size={15} className="app-text-muted" />
|
||||
) : source && image ? (
|
||||
<Image
|
||||
src={source}
|
||||
alt=""
|
||||
fill
|
||||
unoptimized
|
||||
sizes="44px"
|
||||
className="object-cover"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
) : source && video ? (
|
||||
<>
|
||||
<video
|
||||
src={source}
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
className="pointer-events-none h-full w-full object-cover"
|
||||
onError={() => setFailed(true)}
|
||||
onLoadedMetadata={(event) => {
|
||||
const media = event.currentTarget;
|
||||
if (Number.isFinite(media.duration) && media.duration > 0.2) {
|
||||
media.currentTime = Math.min(0.25, media.duration / 10);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="pointer-events-none absolute inset-0 flex items-center justify-center bg-black/20 text-white transition-opacity group-hover:opacity-0">
|
||||
<Play size={13} className="translate-x-px drop-shadow" fill="currentColor" />
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<Loader2 size={14} className="app-text-muted animate-spin" />
|
||||
)}
|
||||
{source && !failed ? (
|
||||
<span className="pointer-events-none absolute inset-0 flex items-center justify-center bg-black/45 text-white opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100">
|
||||
<Eye size={15} />
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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}
|
||||
>
|
||||
<span className={`h-3 w-3 rounded-full ${option.dotClassName}`} aria-hidden="true" />
|
||||
{!selected ? (
|
||||
<span
|
||||
className={`h-3 w-3 shrink-0 rounded-full ${option.dotClassName}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
{selected ? (
|
||||
<span className="whitespace-nowrap text-[10px] font-semibold leading-none text-white">
|
||||
{option.label}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@ -877,6 +1007,90 @@ function QuickTaskComposer({
|
||||
);
|
||||
}
|
||||
|
||||
function TaskAddMenuPopover({
|
||||
anchorRef,
|
||||
menuRef,
|
||||
placement,
|
||||
children,
|
||||
}: {
|
||||
anchorRef: RefObject<HTMLDivElement | null>;
|
||||
menuRef: RefObject<HTMLDivElement | null>;
|
||||
placement: "top" | "bottom";
|
||||
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 = 240;
|
||||
const viewportGap = 8;
|
||||
const menuGap = 8;
|
||||
const preferredLeft = placement === "bottom"
|
||||
? rect.right - menuWidth
|
||||
: rect.left;
|
||||
const left = Math.min(
|
||||
Math.max(viewportGap, preferredLeft),
|
||||
Math.max(viewportGap, viewportWidth - menuWidth - viewportGap),
|
||||
);
|
||||
|
||||
if (placement === "bottom") {
|
||||
setPosition({
|
||||
left,
|
||||
bottom: viewportHeight - rect.top + menuGap,
|
||||
maxHeight: Math.max(96, rect.top - viewportGap - menuGap),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setPosition({
|
||||
left,
|
||||
top: rect.bottom + menuGap,
|
||||
maxHeight: Math.max(
|
||||
96,
|
||||
viewportHeight - rect.bottom - viewportGap - menuGap,
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
updatePosition();
|
||||
window.addEventListener("resize", updatePosition);
|
||||
window.addEventListener("scroll", updatePosition, true);
|
||||
return () => {
|
||||
window.removeEventListener("resize", updatePosition);
|
||||
window.removeEventListener("scroll", updatePosition, true);
|
||||
};
|
||||
}, [anchorRef, placement]);
|
||||
|
||||
if (!position || typeof document === "undefined") return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="app-menu fixed z-[220] w-60 overflow-y-auto overscroll-contain rounded-xl 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;
|
||||
@ -3411,6 +3625,7 @@ function TasksPageContent() {
|
||||
const [taskAttachments, setTaskAttachments] = useState<TaskAttachment[]>([]);
|
||||
const [taskAttachmentsLoading, setTaskAttachmentsLoading] = useState(false);
|
||||
const [taskAttachmentsUploading, setTaskAttachmentsUploading] = useState(false);
|
||||
const [taskMediaPreview, setTaskMediaPreview] = useState<MediaPreview | null>(null);
|
||||
const [checklistOpen, setChecklistOpen] = useState(false);
|
||||
const [taskChecklist, setTaskChecklist] = useState<TaskChecklistItem[]>([]);
|
||||
const [taskChecklistLoaded, setTaskChecklistLoaded] = useState(false);
|
||||
@ -3428,6 +3643,7 @@ function TasksPageContent() {
|
||||
const taskMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const taskAddMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const taskAddMenuBottomRef = useRef<HTMLDivElement | null>(null);
|
||||
const taskAddMenuPopoverRef = useRef<HTMLDivElement | null>(null);
|
||||
const viewColumnMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const cardMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const columnMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
@ -4036,6 +4252,7 @@ function TasksPageContent() {
|
||||
if (
|
||||
activeMenuRef.current
|
||||
&& !activeMenuRef.current.contains(event.target as Node)
|
||||
&& !taskAddMenuPopoverRef.current?.contains(event.target as Node)
|
||||
) {
|
||||
setTaskAddMenuOpen(false);
|
||||
}
|
||||
@ -4555,6 +4772,7 @@ function TasksPageContent() {
|
||||
setEditingCommentId(null);
|
||||
setEditingCommentDraft("");
|
||||
setTaskAttachments([]);
|
||||
setTaskMediaPreview(null);
|
||||
setTaskChecklist([]);
|
||||
setTaskChecklistLoaded(false);
|
||||
setTaskChecklistDraft("");
|
||||
@ -4599,6 +4817,7 @@ function TasksPageContent() {
|
||||
setEditingCommentId(null);
|
||||
setEditingCommentDraft("");
|
||||
setTaskAttachments([]);
|
||||
setTaskMediaPreview(null);
|
||||
setTaskChecklist([]);
|
||||
setTaskChecklistLoaded(false);
|
||||
setTaskActivities([]);
|
||||
@ -6831,6 +7050,18 @@ function TasksPageContent() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{!desktopWideMode ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changeDesktopWideMode(true)}
|
||||
className="app-action-ghost hidden h-8 w-8 items-center justify-center rounded-md lg:flex"
|
||||
title="Развернуть доску"
|
||||
aria-label="Развернуть доску"
|
||||
aria-pressed="false"
|
||||
>
|
||||
<Maximize2 size={16} />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openCreateTask()}
|
||||
@ -7715,11 +7946,8 @@ function TasksPageContent() {
|
||||
onClose={closeTaskView}
|
||||
title="Задача"
|
||||
size="md"
|
||||
className={taskAddMenuOpen
|
||||
? "min-h-[min(34rem,95vh)] sm:min-h-[min(34rem,90vh)]"
|
||||
: ""
|
||||
}
|
||||
closeOnClickOutside
|
||||
closeOnEsc={!taskMediaPreview}
|
||||
footer={viewTask ? (
|
||||
<div ref={taskAddMenuBottomRef} className="relative flex justify-end">
|
||||
<button
|
||||
@ -7740,9 +7968,13 @@ function TasksPageContent() {
|
||||
<Plus size={15} />
|
||||
</button>
|
||||
{taskAddMenuOpen && taskAddMenuPlacement === "bottom" ? (
|
||||
<div className="app-menu absolute bottom-full right-0 z-40 mb-2 w-60 rounded-xl p-1.5">
|
||||
<TaskAddMenuPopover
|
||||
anchorRef={taskAddMenuBottomRef}
|
||||
menuRef={taskAddMenuPopoverRef}
|
||||
placement="bottom"
|
||||
>
|
||||
{taskAddMenuItems}
|
||||
</div>
|
||||
</TaskAddMenuPopover>
|
||||
) : null}
|
||||
</div>
|
||||
) : undefined}
|
||||
@ -7778,9 +8010,13 @@ function TasksPageContent() {
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
{taskAddMenuOpen && taskAddMenuPlacement === "top" ? (
|
||||
<div className="app-menu absolute left-0 top-full z-40 mt-2 w-60 rounded-xl p-1.5">
|
||||
<TaskAddMenuPopover
|
||||
anchorRef={taskAddMenuRef}
|
||||
menuRef={taskAddMenuPopoverRef}
|
||||
placement="top"
|
||||
>
|
||||
{taskAddMenuItems}
|
||||
</div>
|
||||
</TaskAddMenuPopover>
|
||||
) : null}
|
||||
</div>
|
||||
{!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"
|
||||
>
|
||||
<TaskAttachmentThumbnail
|
||||
attachment={attachment}
|
||||
onPreview={setTaskMediaPreview}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void downloadViewedTaskAttachment(attachment)}
|
||||
className="flex min-w-0 flex-1 items-center gap-3 text-left"
|
||||
title={`Скачать ${attachment.file_name}`}
|
||||
>
|
||||
<span className="app-selected flex h-8 w-8 shrink-0 items-center justify-center rounded-lg">
|
||||
<FileText size={15} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-[var(--foreground)]">
|
||||
{attachment.file_name}
|
||||
@ -8951,6 +9188,13 @@ function TasksPageContent() {
|
||||
) : null}
|
||||
</Modal>
|
||||
|
||||
{taskMediaPreview ? (
|
||||
<ChatMediaPreviewModal
|
||||
preview={taskMediaPreview}
|
||||
onClose={() => setTaskMediaPreview(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Modal
|
||||
isOpen={coverPickerOpen}
|
||||
onClose={() => {
|
||||
|
||||
@ -61,7 +61,7 @@ const navItems = [
|
||||
{ href: "/employees", label: "Сотрудники", icon: Users },
|
||||
{ href: "/departments", label: "Отделы", icon: Building2 },
|
||||
{ href: "/attendance", label: "Посещаемость", icon: CalendarCheck },
|
||||
{ href: "/requests", label: "Заявления", icon: FileSignature, category: NAV_NOTIFICATION_CATEGORIES.requests },
|
||||
{ href: "/requests", label: "Заявления", icon: FileSignature, category: NAV_NOTIFICATION_CATEGORIES.requests, autoReadOnNavigate: false },
|
||||
{ href: "/guests", label: "Гости", icon: UserRoundPlus, category: NAV_NOTIFICATION_CATEGORIES.guests },
|
||||
// { href: "/equipment", label: "Оборудование", icon: Monitor },
|
||||
{ href: "/procurement", label: "Закупки", icon: ShoppingCart, category: NAV_NOTIFICATION_CATEGORIES.procurement, autoReadOnNavigate: false },
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { CalendarCheck2, Check, Loader2, Pencil, RotateCcw } from "lucide-react";
|
||||
import { CalendarCheck2, Check, ChevronDown, Loader2, Pencil, RotateCcw } from "lucide-react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
|
||||
import type {
|
||||
PayrollAdminInputLine,
|
||||
@ -20,6 +21,57 @@ function RecordStatus({ status }: { status: PayrollApprovalStatus }) {
|
||||
return <span className={`app-status-pill ${meta.className}`}>{meta.label}</span>;
|
||||
}
|
||||
|
||||
function CompactRecordCard({
|
||||
title,
|
||||
subtitle,
|
||||
summary,
|
||||
status,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
summary: ReactNode;
|
||||
status: PayrollApprovalStatus;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<article className="app-surface-muted overflow-hidden rounded-xl transition hover:border-[var(--border-strong)]">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-start gap-3 p-3 text-left"
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span className="app-action-secondary mt-0.5 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg">
|
||||
<ChevronDown size={15} className={`transition ${open ? "rotate-180" : ""}`} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-semibold text-[var(--foreground)]">{title}</span>
|
||||
<span className="app-text-muted mt-0.5 block truncate text-xs">{subtitle}</span>
|
||||
<span className="mt-2 block text-sm font-medium text-[var(--foreground)]">{summary}</span>
|
||||
</span>
|
||||
<span className="shrink-0"><RecordStatus status={status} /></span>
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="border-t border-[var(--border-subtle)] bg-[var(--surface-primary)] p-3">
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function CompactField({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[minmax(7rem,40%)_minmax(0,1fr)] items-start gap-3 py-1.5 text-sm">
|
||||
<span className="app-text-muted">{label}</span>
|
||||
<span className="min-w-0 text-right font-medium text-[var(--foreground)]">{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="overflow-hidden rounded-xl border border-[var(--border-subtle)] md:overflow-x-auto">
|
||||
<div className="md:min-w-[900px]">
|
||||
<div className="app-surface-muted hidden grid-cols-[minmax(180px,1.5fr)_minmax(100px,.8fr)_minmax(105px,.8fr)_120px_115px_minmax(150px,1fr)] gap-3 border-0 border-b border-[var(--border-subtle)] px-4 py-2.5 text-xs font-medium text-[var(--muted-foreground)] md:grid">
|
||||
<span>Сотрудник</span><span>Оклад</span><span>Цена сверх нормы</span><span>Действует с</span><span>Статус</span><span className="text-right">Действия</span>
|
||||
</div>
|
||||
<div className="divide-y divide-[var(--border-subtle)]">
|
||||
<div className="payroll-admin-records-responsive">
|
||||
<div className="payroll-admin-records-compact space-y-2">
|
||||
{records.map((record) => (
|
||||
<div key={record.id} className="grid gap-3 bg-[var(--surface-primary)] px-4 py-3 md:grid-cols-[minmax(180px,1.5fr)_minmax(100px,.8fr)_minmax(105px,.8fr)_120px_115px_minmax(150px,1fr)] md:items-center">
|
||||
<CompactRecordCard
|
||||
key={record.id}
|
||||
title={record.employee.display_name}
|
||||
subtitle={record.employee.position || record.employee.department || "Сотрудник"}
|
||||
summary={formatPayrollMoney(record.amount, record.currency)}
|
||||
status={record.status}
|
||||
>
|
||||
<div className="divide-y divide-[var(--border-subtle)]">
|
||||
<CompactField label="Оклад">{formatPayrollMoney(record.amount, record.currency)}</CompactField>
|
||||
<CompactField label="Цена сверх нормы">
|
||||
{record.point_rate === null ? "Как в норме" : formatPayrollMoney(record.point_rate, record.currency)}
|
||||
</CompactField>
|
||||
<CompactField label="Действует с">{formatPayrollDate(record.effective_from)}</CompactField>
|
||||
</div>
|
||||
<div className="mt-3 border-t border-[var(--border-subtle)] pt-3">
|
||||
<RecordActions status={record.status} isAuthor={record.created_by.id === currentUserId} canManage={canManage} canApprove={canApprove} canOverrideApproval={canOverrideApproval} fullAccess={fullAccess} onEdit={() => onEdit(record)} onApprove={() => onApprove(record)} onRevise={() => onRevise(record)} />
|
||||
</div>
|
||||
</CompactRecordCard>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="payroll-admin-records-table overflow-x-auto rounded-xl border border-[var(--border-subtle)]">
|
||||
<div className="min-w-[900px]">
|
||||
<div className="app-surface-muted grid grid-cols-[minmax(180px,1.5fr)_minmax(100px,.8fr)_minmax(105px,.8fr)_120px_115px_minmax(150px,1fr)] gap-3 border-0 border-b border-[var(--border-subtle)] px-4 py-2.5 text-xs font-medium text-[var(--muted-foreground)]">
|
||||
<span>Сотрудник</span><span>Оклад</span><span>Цена сверх нормы</span><span>Действует с</span><span>Статус</span><span className="text-right">Действия</span>
|
||||
</div>
|
||||
<div className="divide-y divide-[var(--border-subtle)]">
|
||||
{records.map((record) => (
|
||||
<div key={record.id} className="grid grid-cols-[minmax(180px,1.5fr)_minmax(100px,.8fr)_minmax(105px,.8fr)_120px_115px_minmax(150px,1fr)] items-center gap-3 bg-[var(--surface-primary)] px-4 py-3">
|
||||
<div className="min-w-0"><p className="truncate text-sm font-semibold text-[var(--foreground)]">{record.employee.display_name}</p><p className="app-text-muted mt-0.5 truncate text-xs">{record.employee.position || record.employee.department || "Сотрудник"}</p></div>
|
||||
<div><span className="app-text-muted mr-2 text-xs md:hidden">Оклад</span><span className="text-sm font-medium text-[var(--foreground)]">{formatPayrollMoney(record.amount, record.currency)}</span></div>
|
||||
<div><span className="app-text-muted mr-2 text-xs md:hidden">Цена сверх нормы</span><span className={`text-sm ${record.point_rate === null ? "text-[var(--muted-foreground)]" : "text-[var(--foreground)]"}`}>{record.point_rate === null ? "Как в норме" : formatPayrollMoney(record.point_rate, record.currency)}</span></div>
|
||||
<div><span className="app-text-muted mr-2 text-xs md:hidden">С</span><span className="text-sm text-[var(--foreground)]">{formatPayrollDate(record.effective_from)}</span></div>
|
||||
<div><span className="text-sm font-medium text-[var(--foreground)]">{formatPayrollMoney(record.amount, record.currency)}</span></div>
|
||||
<div><span className={`text-sm ${record.point_rate === null ? "text-[var(--muted-foreground)]" : "text-[var(--foreground)]"}`}>{record.point_rate === null ? "Как в норме" : formatPayrollMoney(record.point_rate, record.currency)}</span></div>
|
||||
<div><span className="text-sm text-[var(--foreground)]">{formatPayrollDate(record.effective_from)}</span></div>
|
||||
<RecordStatus status={record.status} />
|
||||
<RecordActions status={record.status} isAuthor={record.created_by.id === currentUserId} canManage={canManage} canApprove={canApprove} canOverrideApproval={canOverrideApproval} fullAccess={fullAccess} onEdit={() => onEdit(record)} onApprove={() => onApprove(record)} onRevise={() => onRevise(record)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -178,16 +255,51 @@ export function PayrollWorkRecordsTable({
|
||||
if (loading || error || records.length === 0) return state;
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-[var(--border-subtle)] md:overflow-x-auto">
|
||||
<div className="md:min-w-[880px]">
|
||||
<div className="app-surface-muted hidden grid-cols-[minmax(190px,1.5fr)_110px_110px_120px_115px_minmax(150px,1fr)] gap-3 border-0 border-b border-[var(--border-subtle)] px-4 py-2.5 text-xs font-medium text-[var(--muted-foreground)] md:grid">
|
||||
<span>Сотрудник</span><span>Норма</span><span>Факт</span><span>Контроль</span><span>Статус</span><span className="text-right">Действия</span>
|
||||
</div>
|
||||
<div className="divide-y divide-[var(--border-subtle)]">
|
||||
<div className="payroll-admin-records-responsive">
|
||||
<div className="payroll-admin-records-compact space-y-2">
|
||||
{records.map((record) => {
|
||||
const fromAttendance = isAttendancePayrollWorkRecord(record.source);
|
||||
return (
|
||||
<div key={record.id} className="grid gap-3 bg-[var(--surface-primary)] px-4 py-3 md:grid-cols-[minmax(190px,1.5fr)_110px_110px_120px_115px_minmax(150px,1fr)] md:items-center">
|
||||
<CompactRecordCard
|
||||
key={record.id}
|
||||
title={record.employee.display_name}
|
||||
subtitle={record.employee.position || record.employee.department || "Сотрудник"}
|
||||
summary={(
|
||||
<span className="inline-flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span>{formatPayrollWorkMetric(record.actual_points, record.source)} / {formatPayrollWorkMetric(record.target_points, record.source)}</span>
|
||||
{fromAttendance ? (
|
||||
<span className="app-selected inline-flex items-center gap-1 rounded-full px-2 py-1 text-[10px] font-semibold leading-none">
|
||||
<CalendarCheck2 size={11} /> Из посещаемости
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
)}
|
||||
status={record.status}
|
||||
>
|
||||
<div className="divide-y divide-[var(--border-subtle)]">
|
||||
<CompactField label="Норма">{formatPayrollWorkMetric(record.target_points, record.source)}</CompactField>
|
||||
<CompactField label="Факт">{formatPayrollWorkMetric(record.actual_points, record.source)}</CompactField>
|
||||
<CompactField label="Контроль">{record.expected_payable ? formatPayrollMoney(record.expected_payable) : "—"}</CompactField>
|
||||
<CompactField label="Источник">{fromAttendance ? "Посещаемость" : "Введено вручную"}</CompactField>
|
||||
</div>
|
||||
<div className="mt-3 border-t border-[var(--border-subtle)] pt-3">
|
||||
<RecordActions status={record.status} isAuthor={record.created_by.id === currentUserId} canManage={canManage} canApprove={canApprove} canOverrideApproval={canOverrideApproval} fullAccess={fullAccess} onEdit={() => onEdit(record)} onApprove={() => onApprove(record)} onRevise={() => onRevise(record)} />
|
||||
</div>
|
||||
</CompactRecordCard>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="payroll-admin-records-table overflow-x-auto rounded-xl border border-[var(--border-subtle)]">
|
||||
<div className="min-w-[880px]">
|
||||
<div className="app-surface-muted grid grid-cols-[minmax(190px,1.5fr)_110px_110px_120px_115px_minmax(150px,1fr)] gap-3 border-0 border-b border-[var(--border-subtle)] px-4 py-2.5 text-xs font-medium text-[var(--muted-foreground)]">
|
||||
<span>Сотрудник</span><span>Норма</span><span>Факт</span><span>Контроль</span><span>Статус</span><span className="text-right">Действия</span>
|
||||
</div>
|
||||
<div className="divide-y divide-[var(--border-subtle)]">
|
||||
{records.map((record) => {
|
||||
const fromAttendance = isAttendancePayrollWorkRecord(record.source);
|
||||
return (
|
||||
<div key={record.id} className="grid grid-cols-[minmax(190px,1.5fr)_110px_110px_120px_115px_minmax(150px,1fr)] items-center gap-3 bg-[var(--surface-primary)] px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-[var(--foreground)]">{record.employee.display_name}</p>
|
||||
<p className="app-text-muted mt-0.5 truncate text-xs">{record.employee.position || record.employee.department || "Сотрудник"}</p>
|
||||
@ -197,15 +309,16 @@ export function PayrollWorkRecordsTable({
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div><span className="app-text-muted mr-2 text-xs md:hidden">Норма</span><span className="text-sm font-medium text-[var(--foreground)]">{formatPayrollWorkMetric(record.target_points, record.source)}</span></div>
|
||||
<div><span className="app-text-muted mr-2 text-xs md:hidden">Факт</span><span className="text-sm font-medium text-[var(--foreground)]">{formatPayrollWorkMetric(record.actual_points, record.source)}</span></div>
|
||||
<div><span className="app-text-muted mr-2 text-xs md:hidden">Контроль</span><span className="text-sm text-[var(--foreground)]">{record.expected_payable ? formatPayrollMoney(record.expected_payable) : "—"}</span></div>
|
||||
<div><span className="text-sm font-medium text-[var(--foreground)]">{formatPayrollWorkMetric(record.target_points, record.source)}</span></div>
|
||||
<div><span className="text-sm font-medium text-[var(--foreground)]">{formatPayrollWorkMetric(record.actual_points, record.source)}</span></div>
|
||||
<div><span className="text-sm text-[var(--foreground)]">{record.expected_payable ? formatPayrollMoney(record.expected_payable) : "—"}</span></div>
|
||||
<RecordStatus status={record.status} />
|
||||
<RecordActions status={record.status} isAuthor={record.created_by.id === currentUserId} canManage={canManage} canApprove={canApprove} canOverrideApproval={canOverrideApproval} fullAccess={fullAccess} onEdit={() => onEdit(record)} onApprove={() => onApprove(record)} onRevise={() => onRevise(record)} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -238,14 +351,46 @@ export function PayrollInputLinesTable({
|
||||
if (loading || error || records.length === 0) return state;
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-[var(--border-subtle)] md:overflow-x-auto">
|
||||
<div className="md:min-w-[800px]">
|
||||
<div className="app-surface-muted hidden grid-cols-[minmax(170px,1.35fr)_minmax(150px,1.2fr)_120px_115px_minmax(150px,1fr)] gap-3 border-0 border-b border-[var(--border-subtle)] px-4 py-2.5 text-xs font-medium text-[var(--muted-foreground)] md:grid">
|
||||
<span>Сотрудник</span><span>Операция</span><span>Сумма</span><span>Статус</span><span className="text-right">Действия</span>
|
||||
</div>
|
||||
<div className="divide-y divide-[var(--border-subtle)]">
|
||||
<div className="payroll-admin-records-responsive">
|
||||
<div className="payroll-admin-records-compact space-y-2">
|
||||
{records.map((record) => (
|
||||
<div key={record.id} className="grid gap-3 bg-[var(--surface-primary)] px-4 py-3 md:grid-cols-[minmax(170px,1.35fr)_minmax(150px,1.2fr)_120px_115px_minmax(150px,1fr)] md:items-center">
|
||||
<CompactRecordCard
|
||||
key={record.id}
|
||||
title={record.employee.display_name}
|
||||
subtitle={record.employee.position || record.employee.department || "Сотрудник"}
|
||||
summary={(
|
||||
<span className="inline-flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span>{record.component.name}</span>
|
||||
<span className="app-accent-text">{formatPayrollMoney(record.amount)}</span>
|
||||
</span>
|
||||
)}
|
||||
status={record.status}
|
||||
>
|
||||
<div className="divide-y divide-[var(--border-subtle)]">
|
||||
<CompactField label="Операция">{record.component.name}</CompactField>
|
||||
<CompactField label="Сумма">{formatPayrollMoney(record.amount)}</CompactField>
|
||||
{record.relates_to_period ? (
|
||||
<CompactField label={record.is_automatic_carryover ? "Автоперенос" : "Перерасчёт"}>
|
||||
{record.relates_to_period.name || record.relates_to_period.code}
|
||||
</CompactField>
|
||||
) : null}
|
||||
{record.reason ? <CompactField label="Причина">{record.reason}</CompactField> : null}
|
||||
</div>
|
||||
<div className="mt-3 border-t border-[var(--border-subtle)] pt-3">
|
||||
<RecordActions status={record.status} isAuthor={record.created_by.id === currentUserId} canManage={canManage} canApprove={canApprove} canOverrideApproval={canOverrideApproval} fullAccess={fullAccess} onEdit={() => onEdit(record)} onApprove={() => onApprove(record)} />
|
||||
</div>
|
||||
</CompactRecordCard>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="payroll-admin-records-table overflow-x-auto rounded-xl border border-[var(--border-subtle)]">
|
||||
<div className="min-w-[800px]">
|
||||
<div className="app-surface-muted grid grid-cols-[minmax(170px,1.35fr)_minmax(150px,1.2fr)_120px_115px_minmax(150px,1fr)] gap-3 border-0 border-b border-[var(--border-subtle)] px-4 py-2.5 text-xs font-medium text-[var(--muted-foreground)]">
|
||||
<span>Сотрудник</span><span>Операция</span><span>Сумма</span><span>Статус</span><span className="text-right">Действия</span>
|
||||
</div>
|
||||
<div className="divide-y divide-[var(--border-subtle)]">
|
||||
{records.map((record) => (
|
||||
<div key={record.id} className="grid grid-cols-[minmax(170px,1.35fr)_minmax(150px,1.2fr)_120px_115px_minmax(150px,1fr)] items-center gap-3 bg-[var(--surface-primary)] px-4 py-3">
|
||||
<div className="min-w-0"><p className="truncate text-sm font-semibold text-[var(--foreground)]">{record.employee.display_name}</p><p className="app-text-muted mt-0.5 truncate text-xs">{record.employee.position || record.employee.department || "Сотрудник"}</p></div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm text-[var(--foreground)]">{record.component.name}</p>
|
||||
@ -259,12 +404,13 @@ export function PayrollInputLinesTable({
|
||||
) : null}
|
||||
{record.reason ? <p className="app-text-muted mt-1 truncate text-xs" title={record.reason}>{record.reason}</p> : null}
|
||||
</div>
|
||||
<div><span className="app-text-muted mr-2 text-xs md:hidden">Сумма</span><span className="text-sm font-medium text-[var(--foreground)]">{formatPayrollMoney(record.amount)}</span></div>
|
||||
<div><span className="text-sm font-medium text-[var(--foreground)]">{formatPayrollMoney(record.amount)}</span></div>
|
||||
<RecordStatus status={record.status} />
|
||||
<RecordActions status={record.status} isAuthor={record.created_by.id === currentUserId} canManage={canManage} canApprove={canApprove} canOverrideApproval={canOverrideApproval} fullAccess={fullAccess} onEdit={() => onEdit(record)} onApprove={() => onApprove(record)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -264,17 +264,18 @@ function TableToolbar({
|
||||
}, [addMenuOpen]);
|
||||
|
||||
return (
|
||||
<div className="mb-4 flex flex-col gap-2 sm:flex-row">
|
||||
<label className="relative min-w-0 flex-1">
|
||||
<div className="payroll-admin-toolbar-responsive mb-4">
|
||||
<div className="payroll-admin-toolbar-layout">
|
||||
<label className="payroll-admin-toolbar-search relative min-w-0">
|
||||
<Search className="app-text-muted pointer-events-none absolute left-3 top-1/2 -translate-y-1/2" size={15} />
|
||||
<input className="app-input w-full rounded-lg py-2.5 pl-9 pr-3 text-sm" value={search} placeholder="Поиск по сотруднику" onChange={(event) => onSearch(event.target.value)} />
|
||||
</label>
|
||||
<select className="app-select rounded-lg px-3 py-2.5 text-sm sm:w-44" value={status} onChange={(event) => onStatus(event.target.value as "" | PayrollApprovalStatus)}>
|
||||
<select className="payroll-admin-toolbar-status app-select min-w-0 rounded-lg px-3 py-2.5 text-sm" value={status} onChange={(event) => onStatus(event.target.value as "" | PayrollApprovalStatus)}>
|
||||
<option value="">Все статусы</option><option value="draft">Черновики</option><option value="approved">Утверждённые</option><option value="voided">Аннулированные</option>
|
||||
</select>
|
||||
{onAdd ? (
|
||||
<div ref={addMenuRef} className="relative flex shrink-0 items-center gap-1">
|
||||
<button type="button" className="app-action-primary inline-flex items-center justify-center gap-2 rounded-lg px-3.5 py-2.5 text-sm font-semibold" onClick={onAdd}>
|
||||
<div ref={addMenuRef} className="payroll-admin-toolbar-actions relative flex min-w-0 items-center gap-1">
|
||||
<button type="button" className="app-action-primary inline-flex min-w-0 flex-1 items-center justify-center gap-2 rounded-lg px-3.5 py-2.5 text-sm font-semibold" onClick={onAdd}>
|
||||
<Plus size={16} /> {addLabel}
|
||||
</button>
|
||||
{hasAdditionalActions ? (
|
||||
@ -348,6 +349,7 @@ function TableToolbar({
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import Image from "next/image";
|
||||
import { X } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import type { MediaPreview } from "@/components/messages/ChatMessageItem";
|
||||
|
||||
@ -11,8 +12,19 @@ type ChatMediaPreviewModalProps = {
|
||||
};
|
||||
|
||||
export default function ChatMediaPreviewModal({ preview, onClose }: ChatMediaPreviewModalProps) {
|
||||
useEffect(() => {
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return;
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
onClose();
|
||||
};
|
||||
document.addEventListener("keydown", handleEscape, true);
|
||||
return () => document.removeEventListener("keydown", handleEscape, true);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div data-overlay-root="true" className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 p-4" onClick={onClose}>
|
||||
<div data-overlay-root="true" className="fixed inset-0 z-[300] flex items-center justify-center bg-black/80 p-4" onClick={onClose}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
@ -38,4 +50,4 @@ export default function ChatMediaPreviewModal({ preview, onClose }: ChatMediaPre
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { Download, FileText, Loader2, Play } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { apiClient } from "@/lib/api";
|
||||
import type { ProcurementItemAttachment } from "@/types/api";
|
||||
|
||||
function isImage(attachment: ProcurementItemAttachment): boolean {
|
||||
if (attachment.mime_type?.toLowerCase().startsWith("image/")) return true;
|
||||
return /\.(avif|bmp|gif|heic|heif|jpe?g|png|webp)$/i.test(attachment.file_name);
|
||||
}
|
||||
|
||||
function isVideo(attachment: ProcurementItemAttachment): boolean {
|
||||
if (attachment.mime_type?.toLowerCase().startsWith("video/")) return true;
|
||||
return /\.(m4v|mkv|mov|mp4|mpeg|mpg|ogv|webm)$/i.test(attachment.file_name);
|
||||
}
|
||||
|
||||
function formatFileSize(size: number): string {
|
||||
if (size < 1024) return `${size} Б`;
|
||||
if (size < 1024 * 1024) return `${Math.ceil(size / 1024)} КБ`;
|
||||
return `${(size / (1024 * 1024)).toLocaleString("ru-RU", {
|
||||
maximumFractionDigits: 1,
|
||||
})} МБ`;
|
||||
}
|
||||
|
||||
export function ProcurementCommentAttachment({
|
||||
itemId,
|
||||
attachment,
|
||||
onDownload,
|
||||
}: {
|
||||
itemId: number;
|
||||
attachment: ProcurementItemAttachment;
|
||||
onDownload: (attachment: ProcurementItemAttachment) => void | Promise<void>;
|
||||
}) {
|
||||
const image = isImage(attachment);
|
||||
const video = isVideo(attachment);
|
||||
const [source, setSource] = useState<string | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!image && !video) return;
|
||||
|
||||
let active = true;
|
||||
let objectUrl: string | null = null;
|
||||
|
||||
void apiClient
|
||||
.downloadProcurementItemAttachment(itemId, attachment)
|
||||
.then(({ blob }) => {
|
||||
if (!active) return;
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setSource(objectUrl);
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setFailed(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [attachment, image, itemId, video]);
|
||||
|
||||
if (image || video) {
|
||||
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 ? (
|
||||
<Image
|
||||
src={source}
|
||||
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"
|
||||
/>
|
||||
) : (
|
||||
<span className="app-text-muted absolute inset-0 flex items-center justify-center">
|
||||
{failed ? <Play size={20} /> : <Loader2 size={18} className="animate-spin" />}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onDownload(attachment)}
|
||||
className="flex w-full min-w-0 items-center gap-2 border-t border-[var(--border-subtle)] px-2.5 py-2 text-left"
|
||||
title={`Скачать ${attachment.file_name}`}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium text-[var(--foreground)]">
|
||||
{attachment.file_name}
|
||||
</span>
|
||||
<Download size={13} className="app-text-muted shrink-0" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onDownload(attachment)}
|
||||
className="app-surface-elevated flex min-w-0 items-center gap-2 rounded-lg border border-[var(--border-subtle)] px-2.5 py-2 text-left"
|
||||
title={`Скачать ${attachment.file_name}`}
|
||||
>
|
||||
<span className="app-selected flex h-8 w-8 shrink-0 items-center justify-center rounded-lg">
|
||||
<FileText size={15} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-xs font-medium text-[var(--foreground)]">
|
||||
{attachment.file_name}
|
||||
</span>
|
||||
<span className="app-text-muted mt-0.5 block text-[10px]">
|
||||
{formatFileSize(attachment.file_size)}
|
||||
</span>
|
||||
</span>
|
||||
<Download size={13} className="app-text-muted shrink-0" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@ -2,8 +2,9 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { AlertTriangle, Check, CheckCircle2, ChevronRight, CircleDot, Download, ExternalLink, MessageSquare, Minus, Paperclip, Plus, RotateCcw, Save, ShoppingCart, SlidersHorizontal, Trash2, X } from "lucide-react";
|
||||
import { AlertTriangle, Check, CheckCircle2, ChevronRight, CircleDot, Download, ExternalLink, MessageSquare, Minus, Paperclip, Pencil, Plus, RotateCcw, Save, ShoppingCart, SlidersHorizontal, Trash2, X } from "lucide-react";
|
||||
import { RequestAvatar } from "@/components/requests/RequestAvatar";
|
||||
import { ProcurementCommentAttachment } from "@/components/procurement/ProcurementCommentAttachment";
|
||||
import { ProcurementRequestHistory } from "@/components/procurement/ProcurementRequestHistory";
|
||||
import { CommentComposer, CommentDeleteButton } from "@/components/shared/CommentControls";
|
||||
import { Modal } from "@/components/ui";
|
||||
@ -34,15 +35,19 @@ interface ProcurementRequestDetailContentProps {
|
||||
onMarkAllReceived?: (requestId: number) => void | Promise<unknown>;
|
||||
itemCommentsMap?: Record<number, ProcurementItemComment[]>;
|
||||
itemCommentDrafts?: Record<number, string>;
|
||||
itemCommentFiles?: Record<number, File[]>;
|
||||
expandedItemComments?: Record<number, boolean>;
|
||||
unreadItemCommentCounts?: Record<number, number>;
|
||||
onToggleItemComments?: (itemId: number) => void | Promise<void>;
|
||||
onItemCommentDraftChange?: (itemId: number, value: string) => void;
|
||||
onItemCommentFilesChange?: (itemId: number, files: File[]) => void;
|
||||
onAddItemComment?: (requestId: number, itemId: number) => void | Promise<void>;
|
||||
onDeleteItemComment?: (requestId: number, itemId: number, commentId: number) => void | Promise<void>;
|
||||
onDownloadItemAttachment?: (itemId: number, attachment: ProcurementItemAttachment) => void | Promise<void>;
|
||||
onUploadItemAttachments?: (requestId: number, itemId: number, files: File[]) => void | Promise<unknown>;
|
||||
onDeleteItemAttachment?: (requestId: number, itemId: number, attachmentId: number) => void | Promise<unknown>;
|
||||
footer?: React.ReactNode;
|
||||
wideLayout?: boolean;
|
||||
}
|
||||
|
||||
type ItemProcessingDraft = {
|
||||
@ -127,6 +132,13 @@ const cleanDateRows = (dates: string[]): string[] => (
|
||||
dates.map((date) => date.trim()).filter(Boolean)
|
||||
);
|
||||
|
||||
const newestItemCommentsFirst = (comments: ProcurementItemComment[]): ProcurementItemComment[] => (
|
||||
[...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 approvalIconByStatus = (status?: string) => {
|
||||
const normalized = String(status || "").toLowerCase();
|
||||
if (normalized === "approved") {
|
||||
@ -180,14 +192,18 @@ interface ProcurementItemCardProps {
|
||||
onCancelItemReceived?: (requestId: number, itemId: number, cancelQuantity?: number) => void | Promise<unknown>;
|
||||
comments?: ProcurementItemComment[];
|
||||
commentDraft?: string;
|
||||
commentFiles?: File[];
|
||||
commentsOpen?: boolean;
|
||||
unreadCommentCount?: number;
|
||||
onToggleComments?: (itemId: number) => void | Promise<void>;
|
||||
onCommentDraftChange?: (itemId: number, value: string) => void;
|
||||
onCommentFilesChange?: (itemId: number, files: File[]) => void;
|
||||
onAddComment?: (requestId: number, itemId: number) => void | Promise<void>;
|
||||
onDeleteComment?: (requestId: number, itemId: number, commentId: number) => void | Promise<void>;
|
||||
onDownloadAttachment?: (itemId: number, attachment: ProcurementItemAttachment) => void | Promise<void>;
|
||||
onUploadAttachments?: (requestId: number, itemId: number, files: File[]) => void | Promise<unknown>;
|
||||
onDeleteAttachment?: (requestId: number, itemId: number, attachmentId: number) => void | Promise<unknown>;
|
||||
wideLayout?: boolean;
|
||||
}
|
||||
|
||||
function ProcurementItemCard({
|
||||
@ -211,14 +227,18 @@ function ProcurementItemCard({
|
||||
onCancelItemReceived,
|
||||
comments = [],
|
||||
commentDraft = "",
|
||||
commentFiles = [],
|
||||
commentsOpen = false,
|
||||
unreadCommentCount = 0,
|
||||
onToggleComments,
|
||||
onCommentDraftChange,
|
||||
onCommentFilesChange,
|
||||
onAddComment,
|
||||
onDeleteComment,
|
||||
onDownloadAttachment,
|
||||
onUploadAttachments,
|
||||
onDeleteAttachment,
|
||||
wideLayout = false,
|
||||
}: ProcurementItemCardProps) {
|
||||
const [draft, setDraft] = useState<ItemProcessingDraft>(() => normalizeItemDraft(item));
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
@ -232,8 +252,9 @@ function ProcurementItemCard({
|
||||
const [cancelReceiptQuantity, setCancelReceiptQuantity] = useState("1");
|
||||
const links = Array.isArray(item.links) ? item.links.filter(Boolean) : [];
|
||||
const expectedDeliveryDates = toDateRows(item.expected_delivery_dates);
|
||||
const commentsTotal = item.comments_count ?? comments.length;
|
||||
const commentsTotal = Math.max(item.comments_count ?? 0, comments.length);
|
||||
const attachments = item.attachments || [];
|
||||
const hasMaterials = attachments.length > 0 || links.length > 0;
|
||||
const requestedQuantity = Math.max(0, Math.trunc(toNumber(item.quantity)));
|
||||
const orderedQuantity = effectiveOrderedQuantity(item, requestedQuantity);
|
||||
const receivedQuantity = effectiveReceivedQuantity(item, requestedQuantity);
|
||||
@ -242,6 +263,7 @@ function ProcurementItemCard({
|
||||
const remainingToOrderQuantity = Math.max(0, requestedQuantity - orderedQuantity);
|
||||
const itemUnitLabel = getProcurementUnitLabel(item.unit);
|
||||
const orderedQuantityMarkerClass = quantityProgressMarkerClass(orderedQuantity, requestedQuantity);
|
||||
const receivedQuantityMarkerClass = quantityProgressMarkerClass(receivedQuantity, requestedQuantity);
|
||||
const isItemFullyReceived = fulfillmentTargetQuantity > 0 && receivedQuantity >= fulfillmentTargetQuantity;
|
||||
const status = item.execution_status || "pending";
|
||||
const statusLabel = item.execution_status_display || executionStatusOptions.find((option) => option.value === status)?.label || "Не выполнено";
|
||||
@ -254,8 +276,7 @@ function ProcurementItemCard({
|
||||
canConfirmOrder &&
|
||||
onConfirmItemOrdered &&
|
||||
requestedQuantity > 0 &&
|
||||
status !== "rejected" &&
|
||||
status !== "defective"
|
||||
status !== "rejected"
|
||||
);
|
||||
const canConfirmReceived = Boolean(
|
||||
canConfirmReceipt &&
|
||||
@ -400,13 +421,147 @@ function ProcurementItemCard({
|
||||
links: cleanLinkRows(draft.links),
|
||||
});
|
||||
};
|
||||
const handleToggleDetails = () => {
|
||||
const nextOpen = !detailsOpen;
|
||||
setDetailsOpen(nextOpen);
|
||||
if (wideLayout && nextOpen && !commentsOpen) {
|
||||
void onToggleComments?.(item.id);
|
||||
}
|
||||
};
|
||||
const handleToggleProcessing = () => {
|
||||
setProcessingOpen((open) => !open);
|
||||
};
|
||||
const widePanelOpen = detailsOpen || processingOpen;
|
||||
|
||||
return (
|
||||
<div className="app-surface-muted rounded-lg px-3 py-3 text-xs">
|
||||
<div role={wideLayout ? "rowgroup" : undefined} className={wideLayout ? "procurement-item-wide-rowgroup border-b border-[var(--border-subtle)] text-xs last:border-b-0" : "app-surface-muted rounded-lg px-3 py-3 text-xs"}>
|
||||
{wideLayout ? (
|
||||
<div className="procurement-item-wide-main procurement-items-table-row" role="row">
|
||||
<div className="relative flex min-w-0 items-center gap-2 px-2 py-2.5 pr-8" role="cell">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggleDetails}
|
||||
aria-expanded={detailsOpen}
|
||||
aria-controls={`procurement-item-${item.id}-details`}
|
||||
className="app-icon-button inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg"
|
||||
title={detailsOpen ? "Свернуть позицию" : "Раскрыть позицию"}
|
||||
>
|
||||
<ChevronRight size={14} className={`transition-transform duration-200 ${detailsOpen ? "rotate-90" : ""}`} />
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<button type="button" onClick={handleToggleDetails} className="block w-full text-left">
|
||||
<span className="app-text-wrap block min-w-0 font-semibold text-[var(--foreground)]">{item.name}</span>
|
||||
</button>
|
||||
{item.supplier_info ? <span className="app-text-muted mt-0.5 block truncate text-[10px]">{item.supplier_info}</span> : null}
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1">
|
||||
{canEditItemProcessing ? (
|
||||
<button type="button" onClick={handleToggleProcessing} className="app-icon-button inline-flex h-6 w-6 items-center justify-center rounded-md" title="Исполнение">
|
||||
<SlidersHorizontal size={12} />
|
||||
</button>
|
||||
) : null}
|
||||
{canCancelReceived ? (
|
||||
<button type="button" onClick={handleCancelReceiptClick} disabled={busyKey === `item-cancel-received-${item.id}`} className="app-icon-button inline-flex h-6 w-6 items-center justify-center rounded-md disabled:opacity-60" title="Отменить получение">
|
||||
<RotateCcw size={12} />
|
||||
</button>
|
||||
) : null}
|
||||
{canMarkIssue ? (
|
||||
<button type="button" onClick={() => void onReportItemIssue?.(requestId, item.id)} disabled={busyKey === `item-issue-${item.id}`} className="app-icon-button inline-flex h-6 w-6 items-center justify-center rounded-md text-amber-500 disabled:opacity-60" title="Отметить брак">
|
||||
<AlertTriangle size={12} />
|
||||
</button>
|
||||
) : null}
|
||||
{canCancelItemIssue ? (
|
||||
<button type="button" onClick={() => void onCancelItemIssue?.(requestId, item.id)} disabled={busyKey === `item-cancel-issue-${item.id}`} className="app-icon-button inline-flex h-6 w-6 items-center justify-center rounded-md text-amber-500 disabled:opacity-60" title="Отменить брак">
|
||||
<RotateCcw size={12} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<span className={`${unreadCommentCount > 0 ? "app-selected app-accent-text" : "app-badge"} absolute right-1.5 top-1.5 inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[9px]`} title={`Комментариев: ${commentsTotal}${unreadCommentCount > 0 ? `, новых: ${unreadCommentCount}` : ""}`} aria-label={`Комментариев: ${commentsTotal}${unreadCommentCount > 0 ? `, новых: ${unreadCommentCount}` : ""}`}>
|
||||
<MessageSquare size={10} /> {commentsTotal}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1 px-2 py-2.5 font-medium text-[var(--foreground)] tabular-nums" role="cell">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{orderedQuantityMarkerClass ? <span aria-hidden="true" className={`h-2 w-2 shrink-0 rounded-full ${orderedQuantityMarkerClass}`} /> : null}
|
||||
<span>{orderedQuantity}/{requestedQuantity} {itemUnitLabel}</span>
|
||||
</span>
|
||||
{canConfirmOrdered ? (
|
||||
<button type="button" onClick={openOrderModal} disabled={busyKey === `item-confirm-ordered-${item.id}`} className="app-icon-button inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-sky-500 disabled:opacity-60" title="Подтвердить заказ">
|
||||
<ShoppingCart size={13} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1 px-2 py-2.5 font-medium text-[var(--foreground)] tabular-nums" role="cell">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{receivedQuantityMarkerClass ? <span aria-hidden="true" className={`h-2 w-2 shrink-0 rounded-full ${receivedQuantityMarkerClass}`} /> : null}
|
||||
<span>{receivedQuantity}/{requestedQuantity} {itemUnitLabel}</span>
|
||||
</span>
|
||||
{canConfirmReceived ? (
|
||||
<button type="button" onClick={handleConfirmReceiptClick} disabled={busyKey === `item-confirm-received-${item.id}`} className="app-icon-button inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-emerald-500 disabled:opacity-60" title="Подтвердить получение">
|
||||
<CheckCircle2 size={13} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center px-2 py-2.5" role="cell">
|
||||
{expectedDeliveryDates.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{expectedDeliveryDates.map((date, dateIndex) => (
|
||||
<span key={`${item.id}-table-date-${dateIndex}`} className={`${getExpectedDeliveryDateBadgeClass(date, isItemFullyReceived)} rounded-full px-2 py-0.5 text-[11px] font-medium`}>
|
||||
{formatDate(date)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : <span className="app-text-muted">—</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 px-2 py-2.5 tabular-nums" role="cell">
|
||||
<div className="min-w-0 flex-1 space-y-1 text-right">
|
||||
<div><span className="app-text-muted">Ориентир</span> {formatMoney(item.estimated_unit_price)}</div>
|
||||
<div><span className="app-text-muted">Факт</span> {item.actual_unit_price ? formatMoney(item.actual_unit_price) : "—"}</div>
|
||||
<div className="font-semibold text-[var(--foreground)]"><span className="app-text-muted font-normal">Расчёт</span> {formatMoney(item.total_price)}</div>
|
||||
</div>
|
||||
{canEditItemProcessing ? (
|
||||
<button type="button" onClick={handleToggleProcessing} className="app-icon-button inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg" title="Указать фактическую стоимость">
|
||||
<Pencil size={12} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={`${hasMaterials ? "space-y-1" : "flex items-center justify-center"} min-w-0 px-2 py-2.5`} role="cell">
|
||||
{attachments.map((attachment) => (
|
||||
<button
|
||||
key={attachment.id}
|
||||
type="button"
|
||||
onClick={() => void onDownloadAttachment?.(item.id, attachment)}
|
||||
className="app-badge flex w-full min-w-0 items-center gap-1 rounded-md px-2 py-1 text-left text-[10px]"
|
||||
title={`Скачать ${attachment.file_name}`}
|
||||
>
|
||||
<Paperclip size={11} className="shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate">{attachment.file_name}</span>
|
||||
</button>
|
||||
))}
|
||||
{links.map((link, linkIndex) => (
|
||||
<a
|
||||
key={`${item.id}-cell-link-${linkIndex}`}
|
||||
href={linkHref(link)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="app-badge flex w-full min-w-0 items-center gap-1 rounded-md px-2 py-1 text-[10px] no-underline hover:bg-[var(--surface-tertiary)] hover:no-underline"
|
||||
title={link}
|
||||
>
|
||||
<ExternalLink size={11} className="shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate">{link}</span>
|
||||
</a>
|
||||
))}
|
||||
{!hasMaterials ? <span className="app-text-muted">—</span> : null}
|
||||
</div>
|
||||
<div className="flex items-center px-2 py-2.5" role="cell">
|
||||
<span title={statusLabel} className={`app-status-pill inline-flex max-w-full truncate ${executionStatusBadgeClass(status)}`}>{statusLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDetailsOpen((open) => !open)}
|
||||
onClick={handleToggleDetails}
|
||||
aria-expanded={detailsOpen}
|
||||
aria-controls={`procurement-item-${item.id}-details`}
|
||||
className="app-icon-button inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg"
|
||||
@ -420,7 +575,7 @@ function ProcurementItemCard({
|
||||
<div className="min-w-0 flex-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDetailsOpen((open) => !open)}
|
||||
onClick={handleToggleDetails}
|
||||
className="block w-full text-left"
|
||||
>
|
||||
<span className="app-text-wrap block font-semibold text-[var(--foreground)]">{item.name}</span>
|
||||
@ -447,11 +602,59 @@ function ProcurementItemCard({
|
||||
</div>
|
||||
<span className={`app-status-pill shrink-0 ${executionStatusBadgeClass(status)}`}>{statusLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
id={`procurement-item-${item.id}-details`}
|
||||
className={detailsOpen ? "" : "hidden"}
|
||||
className={`${wideLayout ? (widePanelOpen ? "" : "hidden") : (detailsOpen ? "" : "hidden")} ${wideLayout ? "procurement-item-wide-details" : ""}`}
|
||||
>
|
||||
{wideLayout ? (
|
||||
detailsOpen ? (
|
||||
<div className="procurement-item-wide-description border-t border-[var(--border-subtle)] bg-[var(--surface-primary)] px-3 py-3">
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p className="app-text-muted text-[10px] font-semibold uppercase tracking-wide">Описание</p>
|
||||
<p className="app-text-wrap text-xs text-[var(--foreground)]">{item.description || "—"}</p>
|
||||
{item.supplier_info ? <p className="app-text-wrap text-xs text-[var(--muted-foreground)]">Поставщик: {item.supplier_info}</p> : null}
|
||||
</div>
|
||||
<div className="min-w-0 space-y-2">
|
||||
{links.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{links.map((link, linkIndex) => (
|
||||
<a key={`${item.id}-table-link-${linkIndex}`} href={linkHref(link)} target="_blank" rel="noreferrer" className="app-badge inline-flex max-w-full items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium no-underline hover:bg-[var(--surface-tertiary)] hover:no-underline">
|
||||
<ExternalLink size={11} /><span className="truncate">{link}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{attachments.map((attachment) => (
|
||||
<span key={attachment.id} className="app-badge inline-flex max-w-full items-center gap-1 rounded-full px-2 py-1 text-[11px]">
|
||||
<button type="button" onClick={() => void onDownloadAttachment?.(item.id, attachment)} className="inline-flex min-w-0 items-center gap-1" title={`Скачать ${attachment.file_name}`}>
|
||||
<Paperclip size={11} className="shrink-0" /><span className="max-w-48 truncate">{attachment.file_name}</span><Download size={10} className="shrink-0" />
|
||||
</button>
|
||||
{canEditItemProcessing && onDeleteAttachment ? (
|
||||
<button type="button" title="Удалить файл" disabled={busyKey === `item-attachment-delete-${attachment.id}`} onClick={() => void onDeleteAttachment(requestId, item.id, attachment.id)} className="app-action-ghost inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full disabled:opacity-60"><Trash2 size={10} /></button>
|
||||
) : null}
|
||||
</span>
|
||||
))}
|
||||
{canEditItemProcessing && onUploadAttachments ? (
|
||||
<label className="app-action-ghost inline-flex cursor-pointer items-center gap-1 rounded-lg px-2 py-1 text-[11px] font-medium">
|
||||
<Plus size={11} /> Добавить файл
|
||||
<input type="file" multiple className="hidden" onChange={(event) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
if (files.length > 0) void onUploadAttachments(requestId, item.id, files);
|
||||
event.target.value = "";
|
||||
}} />
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-3 grid gap-x-6 gap-y-3 lg:grid-cols-[minmax(0,1fr)_minmax(190px,0.55fr)]">
|
||||
<div className="min-w-0">
|
||||
<p className="app-text-muted mb-1 text-[11px] font-semibold uppercase tracking-wide">Исполнение</p>
|
||||
@ -596,7 +799,10 @@ function ProcurementItemCard({
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!wideLayout ? (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<div>
|
||||
<button
|
||||
@ -617,7 +823,7 @@ function ProcurementItemCard({
|
||||
{canEditItemProcessing ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setProcessingOpen((open) => !open)}
|
||||
onClick={handleToggleProcessing}
|
||||
className="app-action-ghost inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium"
|
||||
>
|
||||
<SlidersHorizontal size={13} />
|
||||
@ -685,13 +891,26 @@ function ProcurementItemCard({
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{commentsOpen ? (
|
||||
<div className="mt-2 space-y-2">
|
||||
{commentsOpen && (!wideLayout || detailsOpen) ? (
|
||||
<div className={`${wideLayout ? "procurement-item-wide-comments border-t border-[var(--border-subtle)] bg-[var(--surface-primary)] px-3 py-3" : "mt-2"} space-y-2`}>
|
||||
<CommentComposer
|
||||
value={commentDraft}
|
||||
onChange={(value) => onCommentDraftChange?.(item.id, value)}
|
||||
files={commentFiles}
|
||||
onFilesChange={(files) => onCommentFilesChange?.(item.id, files)}
|
||||
onSubmit={() => onAddComment?.(requestId, item.id)}
|
||||
disabled={busyKey === `item-comment-${item.id}`}
|
||||
submitDisabled={busyKey === `item-comment-${item.id}` || (!commentDraft.trim() && commentFiles.length === 0)}
|
||||
multiline
|
||||
rows={2}
|
||||
placeholder="Комментарий по позиции"
|
||||
/>
|
||||
{comments.length === 0 ? (
|
||||
<p className="app-text-muted text-xs">Комментариев по позиции пока нет</p>
|
||||
) : (
|
||||
comments.map((comment) => {
|
||||
newestItemCommentsFirst(comments).map((comment) => {
|
||||
const canDeleteComment = Boolean(
|
||||
canDeleteAnyComment || (comment.author?.id && currentUserId === comment.author.id),
|
||||
);
|
||||
@ -712,23 +931,32 @@ function ProcurementItemCard({
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<p className="app-text-wrap text-[var(--foreground)]">{comment.text}</p>
|
||||
{comment.text ? (
|
||||
<p className="app-text-wrap text-[var(--foreground)]">{comment.text}</p>
|
||||
) : null}
|
||||
{comment.attachments?.length ? (
|
||||
<div className="mt-2 grid gap-2 sm:grid-cols-2">
|
||||
{comment.attachments.map((attachment) => (
|
||||
<ProcurementCommentAttachment
|
||||
key={attachment.id}
|
||||
itemId={item.id}
|
||||
attachment={attachment}
|
||||
onDownload={(selectedAttachment) => (
|
||||
onDownloadAttachment?.(item.id, selectedAttachment)
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
<CommentComposer
|
||||
value={commentDraft}
|
||||
onChange={(value) => onCommentDraftChange?.(item.id, value)}
|
||||
onSubmit={() => onAddComment?.(requestId, item.id)}
|
||||
disabled={busyKey === `item-comment-${item.id}`}
|
||||
placeholder="Комментарий по позиции"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canEditItemProcessing && processingOpen ? (
|
||||
<div className="mt-3 grid gap-2 rounded-lg bg-[var(--surface-primary)] p-3 sm:grid-cols-2">
|
||||
<div className={`${wideLayout ? "procurement-item-wide-processing border-t border-[var(--border-subtle)]" : "mt-3 rounded-lg"} grid gap-2 bg-[var(--surface-primary)] p-3 sm:grid-cols-2`}>
|
||||
<div>
|
||||
<label className="app-text-muted mb-1 block text-[11px] font-medium">Статус позиции</label>
|
||||
<select
|
||||
@ -1161,15 +1389,19 @@ export function ProcurementRequestDetailContent({
|
||||
onMarkAllReceived,
|
||||
itemCommentsMap = {},
|
||||
itemCommentDrafts = {},
|
||||
itemCommentFiles = {},
|
||||
expandedItemComments = {},
|
||||
unreadItemCommentCounts = {},
|
||||
onToggleItemComments,
|
||||
onItemCommentDraftChange,
|
||||
onItemCommentFilesChange,
|
||||
onAddItemComment,
|
||||
onDeleteItemComment,
|
||||
onDownloadItemAttachment,
|
||||
onUploadItemAttachments,
|
||||
onDeleteItemAttachment,
|
||||
footer,
|
||||
wideLayout = false,
|
||||
}: ProcurementRequestDetailContentProps) {
|
||||
const canEditItemProcessing = Boolean(canProcessItems && onUpdateItem);
|
||||
const requestStatus = String(request.status || "").toLowerCase();
|
||||
@ -1210,9 +1442,56 @@ export function ProcurementRequestDetailContent({
|
||||
canEditItemProcessing
|
||||
),
|
||||
);
|
||||
const renderedItems = displayItems.map((item) => (
|
||||
<ProcurementItemCard
|
||||
key={[
|
||||
item.id,
|
||||
item.execution_status || "",
|
||||
Array.isArray(item.expected_delivery_dates) ? item.expected_delivery_dates.join("|") : "",
|
||||
item.actual_unit_price || "",
|
||||
item.ordered_quantity ?? "",
|
||||
item.received_quantity ?? "",
|
||||
Array.isArray(item.links) ? item.links.join("|") : "",
|
||||
].join(":")}
|
||||
item={item}
|
||||
requestId={request.id}
|
||||
displayUserName={displayUserName}
|
||||
canEditItemProcessing={canEditItemProcessing}
|
||||
canReportIssue={canReportIssues}
|
||||
canCancelIssue={canCancelIssues}
|
||||
canConfirmOrder={canConfirmOrder}
|
||||
canConfirmReceipt={canConfirmReceipt}
|
||||
canCancelReceipt={canCancelReceipt}
|
||||
currentUserId={currentUserId}
|
||||
canDeleteAnyComment={canDeleteAnyComment}
|
||||
busyKey={busyKey}
|
||||
onUpdateItem={onUpdateItem}
|
||||
onReportItemIssue={onReportItemIssue}
|
||||
onCancelItemIssue={onCancelItemIssue}
|
||||
onConfirmItemOrdered={onConfirmItemOrdered}
|
||||
onConfirmItemReceived={onConfirmItemReceived}
|
||||
onCancelItemReceived={onCancelItemReceived}
|
||||
comments={itemCommentsMap[item.id] || []}
|
||||
commentDraft={itemCommentDrafts[item.id] || ""}
|
||||
commentFiles={itemCommentFiles[item.id] || []}
|
||||
commentsOpen={Boolean(expandedItemComments[item.id])}
|
||||
unreadCommentCount={unreadItemCommentCounts[item.id] || 0}
|
||||
onToggleComments={onToggleItemComments}
|
||||
onCommentDraftChange={onItemCommentDraftChange}
|
||||
onCommentFilesChange={onItemCommentFilesChange}
|
||||
onAddComment={onAddItemComment}
|
||||
onDeleteComment={onDeleteItemComment}
|
||||
onDownloadAttachment={onDownloadItemAttachment}
|
||||
onUploadAttachments={onUploadItemAttachments}
|
||||
onDeleteAttachment={onDeleteItemAttachment}
|
||||
wideLayout={wideLayout}
|
||||
/>
|
||||
));
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className={wideLayout ? "procurement-detail-layout" : ""}>
|
||||
<div className={wideLayout ? "procurement-detail-layout-grid" : "space-y-3"}>
|
||||
{!wideLayout ? (
|
||||
<div className="app-surface rounded-xl p-4">
|
||||
<p className="text-sm font-semibold text-[var(--foreground)]">Описание</p>
|
||||
<p className="app-text-wrap mt-2 whitespace-pre-line text-sm leading-6 text-[var(--foreground)]">
|
||||
@ -1242,9 +1521,10 @@ export function ProcurementRequestDetailContent({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{displayItems.length > 0 ? (
|
||||
<div className="app-surface rounded-xl p-4">
|
||||
<div className={`${wideLayout ? "procurement-detail-layout-full" : ""} app-surface rounded-xl p-4`}>
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-sm font-semibold text-[var(--foreground)]">Позиции</p>
|
||||
{canMarkAllReceived && onMarkAllReceived ? (
|
||||
@ -1258,52 +1538,29 @@ export function ProcurementRequestDetailContent({
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{displayItems.map((item) => (
|
||||
<ProcurementItemCard
|
||||
key={[
|
||||
item.id,
|
||||
item.execution_status || "",
|
||||
Array.isArray(item.expected_delivery_dates) ? item.expected_delivery_dates.join("|") : "",
|
||||
item.actual_unit_price || "",
|
||||
item.ordered_quantity ?? "",
|
||||
item.received_quantity ?? "",
|
||||
Array.isArray(item.links) ? item.links.join("|") : "",
|
||||
].join(":")}
|
||||
item={item}
|
||||
requestId={request.id}
|
||||
displayUserName={displayUserName}
|
||||
canEditItemProcessing={canEditItemProcessing}
|
||||
canReportIssue={canReportIssues}
|
||||
canCancelIssue={canCancelIssues}
|
||||
canConfirmOrder={canConfirmOrder}
|
||||
canConfirmReceipt={canConfirmReceipt}
|
||||
canCancelReceipt={canCancelReceipt}
|
||||
currentUserId={currentUserId}
|
||||
canDeleteAnyComment={canDeleteAnyComment}
|
||||
busyKey={busyKey}
|
||||
onUpdateItem={onUpdateItem}
|
||||
onReportItemIssue={onReportItemIssue}
|
||||
onCancelItemIssue={onCancelItemIssue}
|
||||
onConfirmItemOrdered={onConfirmItemOrdered}
|
||||
onConfirmItemReceived={onConfirmItemReceived}
|
||||
onCancelItemReceived={onCancelItemReceived}
|
||||
comments={itemCommentsMap[item.id] || []}
|
||||
commentDraft={itemCommentDrafts[item.id] || ""}
|
||||
commentsOpen={Boolean(expandedItemComments[item.id])}
|
||||
onToggleComments={onToggleItemComments}
|
||||
onCommentDraftChange={onItemCommentDraftChange}
|
||||
onAddComment={onAddItemComment}
|
||||
onDeleteComment={onDeleteItemComment}
|
||||
onDownloadAttachment={onDownloadItemAttachment}
|
||||
onUploadAttachments={onUploadItemAttachments}
|
||||
onDeleteAttachment={onDeleteItemAttachment}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{wideLayout ? (
|
||||
<div className="overflow-x-auto rounded-lg border border-[var(--border-subtle)]" role="table" aria-label="Позиции заявки">
|
||||
<div role="rowgroup">
|
||||
<div className="procurement-items-table-row border-b border-[var(--border-subtle)] bg-[var(--surface-secondary)] text-[10px] font-semibold uppercase tracking-wide text-[var(--muted-foreground)]" role="row">
|
||||
<div className="px-2 py-2" role="columnheader">Позиция</div>
|
||||
<div className="px-2 py-2" role="columnheader">Заказано</div>
|
||||
<div className="px-2 py-2" role="columnheader">Получено</div>
|
||||
<div className="px-2 py-2" role="columnheader">Ожидается</div>
|
||||
<div className="px-2 py-2 text-right" role="columnheader">Стоимость</div>
|
||||
<div className="px-2 py-2" role="columnheader">Файлы и ссылки</div>
|
||||
<div className="px-2 py-2" role="columnheader">Статус</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>{renderedItems}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">{renderedItems}</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={wideLayout ? "procurement-detail-layout-full procurement-detail-support-grid" : "space-y-3"}>
|
||||
<div className="min-w-0 space-y-3">
|
||||
{request.approvals && request.approvals.length > 0 ? (
|
||||
<div className="app-surface rounded-xl p-4">
|
||||
<p className="mb-3 text-sm font-semibold text-[var(--foreground)]">Согласования</p>
|
||||
@ -1360,9 +1617,14 @@ export function ProcurementRequestDetailContent({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ProcurementRequestHistory request={request} displayUserName={displayUserName} />
|
||||
<div className="min-w-0">
|
||||
<ProcurementRequestHistory request={request} displayUserName={displayUserName} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{footer}
|
||||
{footer ? <div className={wideLayout ? "procurement-detail-support-comments min-w-0" : "min-w-0"}>{footer}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
330
frontend/src/components/requests/RequestDetailPanel.tsx
Normal file
330
frontend/src/components/requests/RequestDetailPanel.tsx
Normal file
@ -0,0 +1,330 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
defaultStatusMeta,
|
||||
formatRequestPeriod,
|
||||
requestTypeLabels,
|
||||
statusMeta,
|
||||
type RequestAttachmentPreview,
|
||||
} from "@/hooks/useRequestsPage";
|
||||
import { CommentComposer, CommentDeleteButton } from "@/components/shared/CommentControls";
|
||||
import TaskLinkPill from "@/components/tasks/TaskLinkPill";
|
||||
import { displayUserName, formatDate, formatDateTime } from "@/lib/shared";
|
||||
import type { Request, RequestComment } from "@/types/api";
|
||||
import {
|
||||
Ban,
|
||||
Link2,
|
||||
Paperclip,
|
||||
Pencil,
|
||||
ThumbsDown,
|
||||
ThumbsUp,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { getRequestActionState } from "./requestActions";
|
||||
import { RequestUserBadge } from "./RequestUserBadge";
|
||||
|
||||
type RequestDetailPanelProps = {
|
||||
actionError?: string | null;
|
||||
busyKey: string | null;
|
||||
commentDraft: string;
|
||||
comments: RequestComment[];
|
||||
commentsLoading: boolean;
|
||||
currentUserId?: number | null;
|
||||
departmentNameMap: Map<number, string>;
|
||||
isFinal: (status?: string) => boolean;
|
||||
onAddComment: (requestId: number) => void | Promise<void>;
|
||||
onApprove: (requestId: number) => void | Promise<void>;
|
||||
onCancel: (requestId: number) => void | Promise<void>;
|
||||
onDelete: (requestId: number) => void | Promise<void>;
|
||||
onDeleteComment: (requestId: number, commentId: number) => void | Promise<void>;
|
||||
onEdit: (request: Request) => void;
|
||||
onLinkTask: (request: Request) => void;
|
||||
onPreviewAttachment: (preview: RequestAttachmentPreview) => void;
|
||||
onReject: (requestId: number) => void | Promise<void>;
|
||||
onSetCommentDraft: (requestId: number, value: string) => void;
|
||||
request: Request;
|
||||
};
|
||||
|
||||
const newestCommentsFirst = (comments: RequestComment[]) => (
|
||||
[...comments].sort((left, right) => (
|
||||
new Date(right.created_at).getTime() - new Date(left.created_at).getTime()
|
||||
))
|
||||
);
|
||||
|
||||
export function RequestDetailPanel({
|
||||
actionError,
|
||||
busyKey,
|
||||
commentDraft,
|
||||
comments,
|
||||
commentsLoading,
|
||||
currentUserId,
|
||||
departmentNameMap,
|
||||
isFinal,
|
||||
onAddComment,
|
||||
onApprove,
|
||||
onCancel,
|
||||
onDelete,
|
||||
onDeleteComment,
|
||||
onEdit,
|
||||
onLinkTask,
|
||||
onPreviewAttachment,
|
||||
onReject,
|
||||
onSetCommentDraft,
|
||||
request,
|
||||
}: RequestDetailPanelProps) {
|
||||
const author = request.employee || request.created_by;
|
||||
const approver = request.approver || request.assigned_to;
|
||||
const actionState = getRequestActionState(request, currentUserId, isFinal);
|
||||
const { canCancel, canComment, canDelete, canEdit, canProcess } = actionState;
|
||||
const status = statusMeta[String(request.status || "").toLowerCase()] ?? defaultStatusMeta;
|
||||
const typeKey = String(request.type || request.request_type || "").toLowerCase();
|
||||
const typeLabel = requestTypeLabels[typeKey] || String(request.type || request.request_type || "Другое");
|
||||
const attachmentUrl = request.attachment_url || request.attachment || "";
|
||||
const attachmentName = attachmentUrl
|
||||
? decodeURIComponent(attachmentUrl.split("/").pop() || "Вложение")
|
||||
: "";
|
||||
const commentCount = request.comments_count ?? comments.length;
|
||||
const linkedTasks = request.linked_tasks || [];
|
||||
const hasActions = canProcess || canCancel || canEdit || canDelete;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 text-sm text-[var(--foreground)]">
|
||||
<section className="app-surface rounded-xl p-4">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<span className="app-badge app-badge-accent px-2.5 py-1 text-xs font-medium">
|
||||
{typeLabel}
|
||||
</span>
|
||||
<span className={`app-status-pill ${status.className}`}>{status.label}</span>
|
||||
<h2 className="app-text-wrap min-w-0 flex-[1_1_20rem] text-lg font-semibold">
|
||||
{request.display_title || request.title || "Без заголовка"}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{(request.comment || request.description) ? (
|
||||
<p className="app-text-wrap mt-3 whitespace-pre-wrap text-sm leading-relaxed">
|
||||
{request.comment || request.description}
|
||||
</p>
|
||||
) : (
|
||||
<p className="app-text-muted mt-3 text-sm">Описание отсутствует</p>
|
||||
)}
|
||||
|
||||
{actionError ? (
|
||||
<div className="app-feedback-danger mt-3 rounded-xl px-3 py-2 text-sm">
|
||||
{actionError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{hasActions ? (
|
||||
<div className="mt-4 flex flex-wrap items-center justify-end gap-2 border-t border-[var(--border-subtle)] pt-3">
|
||||
{canProcess ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onApprove(request.id)}
|
||||
disabled={busyKey === `approve-${request.id}`}
|
||||
className="app-action-approve inline-flex h-9 items-center gap-2 rounded-lg px-3 text-xs font-medium disabled:opacity-60"
|
||||
>
|
||||
<ThumbsUp size={14} className="text-emerald-500" />
|
||||
<span className="text-emerald-500">Одобрить</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onReject(request.id)}
|
||||
disabled={busyKey === `reject-${request.id}`}
|
||||
className="app-action-reject inline-flex h-9 items-center gap-2 rounded-lg px-3 text-xs font-medium disabled:opacity-60"
|
||||
>
|
||||
<ThumbsDown size={14} /> Отклонить
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
{canCancel ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onCancel(request.id)}
|
||||
disabled={busyKey === `cancel-${request.id}`}
|
||||
className="app-action-secondary inline-flex h-9 items-center gap-2 rounded-lg px-3 text-xs font-medium disabled:opacity-60"
|
||||
>
|
||||
<Ban size={14} /> Отменить
|
||||
</button>
|
||||
) : null}
|
||||
{canEdit ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEdit(request)}
|
||||
className="app-action-secondary inline-flex h-9 items-center gap-2 rounded-lg px-3 text-xs font-medium"
|
||||
>
|
||||
<Pencil size={14} /> Редактировать
|
||||
</button>
|
||||
) : null}
|
||||
{canDelete ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onDelete(request.id)}
|
||||
disabled={busyKey === `delete-${request.id}`}
|
||||
className="app-action-danger inline-flex h-9 items-center gap-2 rounded-lg px-3 text-xs font-medium disabled:opacity-60"
|
||||
>
|
||||
<Trash2 size={14} /> Удалить
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
<section className="app-surface rounded-xl p-4">
|
||||
<p className="app-card-caption">Даты</p>
|
||||
<dl className="mt-3 grid gap-3 text-xs sm:grid-cols-3 xl:grid-cols-1">
|
||||
<div>
|
||||
<dt className="app-text-muted">{typeKey === "short_day_off" ? "Время" : "Период"}</dt>
|
||||
<dd className="mt-1 font-medium">{formatRequestPeriod(request)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="app-text-muted">Создано</dt>
|
||||
<dd className="mt-1">{formatDateTime(request.created_at) || "—"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="app-text-muted">Обновлено</dt>
|
||||
<dd className="mt-1">{formatDateTime(request.updated_at) || "—"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="app-surface rounded-xl p-4">
|
||||
<p className="app-card-caption">Участники</p>
|
||||
<div className="mt-3 space-y-3">
|
||||
<div>
|
||||
<p className="app-text-muted text-xs">Автор</p>
|
||||
<div className="mt-1.5">{author ? <RequestUserBadge person={author} currentUserId={currentUserId} large /> : "—"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="app-text-muted text-xs">Принял решение</p>
|
||||
<div className="mt-1.5">{approver ? <RequestUserBadge person={approver} currentUserId={currentUserId} large /> : <span className="app-text-muted">—</span>}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="app-surface rounded-xl p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="app-card-caption">Связанные задачи</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onLinkTask(request)}
|
||||
className="app-action-secondary inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium"
|
||||
>
|
||||
<Link2 size={13} /> Связать
|
||||
</button>
|
||||
</div>
|
||||
{linkedTasks.length > 0 ? (
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{linkedTasks.map((task) => (
|
||||
<TaskLinkPill key={task.link_id || task.id} task={task} maxTitleClassName="max-w-56" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="app-text-muted mt-3 text-xs">Связанных задач нет</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
<section className="app-surface rounded-xl p-4">
|
||||
<p className="app-card-caption">Отделы</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{(request.departments || []).length > 0 ? (request.departments || []).map((id) => (
|
||||
<span key={id} className="app-badge px-3 py-1.5 text-xs font-medium">
|
||||
{departmentNameMap.get(Number(id)) || `Отдел #${id}`}
|
||||
</span>
|
||||
)) : <span className="app-text-muted text-xs">Не указаны</span>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="app-surface rounded-xl p-4">
|
||||
<p className="app-card-caption">Получатели</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{(request.recipients || []).length > 0 ? (request.recipients || []).map((recipient) => (
|
||||
<RequestUserBadge key={recipient.id} person={recipient} currentUserId={currentUserId} />
|
||||
)) : <span className="app-text-muted text-xs">Не указаны</span>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="app-surface rounded-xl p-4">
|
||||
<p className="app-card-caption">В копии</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{(request.cc_users || []).length > 0 ? (request.cc_users || []).map((ccUser) => (
|
||||
<RequestUserBadge key={ccUser.id} person={ccUser} currentUserId={currentUserId} />
|
||||
)) : <span className="app-text-muted text-xs">Не указаны</span>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="app-surface rounded-xl p-4">
|
||||
<p className="app-card-caption">Вложение</p>
|
||||
{attachmentUrl ? (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPreviewAttachment({ url: attachmentUrl, name: attachmentName })}
|
||||
className="app-action-secondary inline-flex min-w-0 items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium"
|
||||
>
|
||||
<Paperclip size={15} className="shrink-0" />
|
||||
<span className="truncate">{attachmentName}</span>
|
||||
</button>
|
||||
<a href={attachmentUrl} target="_blank" rel="noreferrer" className="app-link-accent text-sm font-medium hover:underline">
|
||||
Открыть в новой вкладке
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<p className="app-text-muted mt-3 text-xs">Вложение отсутствует</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="app-surface rounded-xl p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="app-card-caption">Комментарии</p>
|
||||
<span className="app-badge px-2.5 py-1 text-xs font-medium">{commentCount}</span>
|
||||
</div>
|
||||
{!canComment ? (
|
||||
<p className="app-text-muted mt-3 text-sm">Комментарии будут доступны после отправки заявления.</p>
|
||||
) : (
|
||||
<div className="mt-3 space-y-3">
|
||||
<CommentComposer
|
||||
disabled={busyKey === `comment-${request.id}`}
|
||||
value={commentDraft}
|
||||
onChange={(value) => onSetCommentDraft(request.id, value)}
|
||||
onSubmit={() => onAddComment(request.id)}
|
||||
placeholder="Добавить комментарий"
|
||||
/>
|
||||
{commentsLoading ? (
|
||||
<div className="app-text-muted flex items-center gap-2 text-sm">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-[var(--border-strong)] border-t-[var(--accent-primary)]" />
|
||||
Загрузка комментариев...
|
||||
</div>
|
||||
) : comments.length > 0 ? (
|
||||
<div className="grid gap-2 2xl:grid-cols-2">
|
||||
{newestCommentsFirst(comments).map((comment) => (
|
||||
<div key={comment.id} className="app-surface-muted rounded-lg border border-[var(--border-subtle)] px-3 py-2.5">
|
||||
<div className="mb-1 flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">{displayUserName(comment.author)}</p>
|
||||
<p className="app-text-muted text-xs">{formatDateTime(comment.created_at) || formatDate(comment.created_at)}</p>
|
||||
</div>
|
||||
{Boolean(comment.author?.id && currentUserId === comment.author.id) ? (
|
||||
<CommentDeleteButton
|
||||
disabled={busyKey === `comment-delete-${comment.id}`}
|
||||
onClick={() => onDeleteComment(request.id, comment.id)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="app-text-wrap whitespace-pre-wrap text-sm leading-relaxed">{comment.text}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="app-text-muted text-sm">Комментариев пока нет</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,7 +1,13 @@
|
||||
import { orderingOptions, requestTypeLabels, statusMeta } from "@/hooks/useRequestsPage";
|
||||
import {
|
||||
orderingOptions,
|
||||
requestTypeLabels,
|
||||
statusMeta,
|
||||
type RequestQuickScope,
|
||||
type RequestScopeCounts,
|
||||
} from "@/hooks/useRequestsPage";
|
||||
import { displayUserName } from "@/lib/shared";
|
||||
import type { User } from "@/types/api";
|
||||
import { ArrowUpDown, ChevronDown, Filter, Plus, Search, Zap } from "lucide-react";
|
||||
import { ArrowUpDown, ChevronDown, Filter, Maximize2, Minimize2, Plus, Search, Zap } from "lucide-react";
|
||||
|
||||
type RequestListControlsState = {
|
||||
activeFiltersCount: number;
|
||||
@ -16,9 +22,10 @@ type RequestListControlsState = {
|
||||
periodFromFilter: string;
|
||||
periodToFilter: string;
|
||||
search: string;
|
||||
scopeCounts: RequestScopeCounts;
|
||||
statusFilter: string;
|
||||
typeFilter: string;
|
||||
view: "" | "mine" | "addressed";
|
||||
view: RequestQuickScope;
|
||||
};
|
||||
|
||||
type RequestListControlsFeedback = {
|
||||
@ -39,21 +46,32 @@ type RequestListControlsActions = {
|
||||
setSearch: (value: string) => void;
|
||||
setStatusFilter: (value: string) => void;
|
||||
setTypeFilter: (value: string) => void;
|
||||
setView: (value: "" | "mine" | "addressed") => void;
|
||||
setView: (value: RequestQuickScope) => void;
|
||||
toggleFilters: () => void;
|
||||
};
|
||||
|
||||
type RequestListControlsProps = {
|
||||
actions: RequestListControlsActions;
|
||||
desktopWideMode?: boolean;
|
||||
feedback: RequestListControlsFeedback;
|
||||
onToggleDesktopWideMode?: () => void;
|
||||
state: RequestListControlsState;
|
||||
};
|
||||
|
||||
export function RequestListControls({
|
||||
actions,
|
||||
desktopWideMode = false,
|
||||
feedback,
|
||||
onToggleDesktopWideMode,
|
||||
state,
|
||||
}: RequestListControlsProps) {
|
||||
const quickScopes: { value: RequestQuickScope; label: string; count: number }[] = [
|
||||
{ value: "", label: "Все", count: state.scopeCounts.all },
|
||||
{ value: "mine", label: "Мои", count: state.scopeCounts.mine },
|
||||
{ value: "addressed", label: "Адресованные мне", count: state.scopeCounts.addressed },
|
||||
{ value: "decision", label: "Требуют решения", count: state.scopeCounts.decision },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||
@ -70,13 +88,27 @@ export function RequestListControls({
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={actions.openCreate}
|
||||
className="app-action-primary inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm font-medium"
|
||||
>
|
||||
<Plus size={14} /> Создать заявление
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{onToggleDesktopWideMode ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleDesktopWideMode}
|
||||
className="app-action-ghost hidden h-9 w-9 items-center justify-center rounded-lg lg:inline-flex"
|
||||
title={desktopWideMode ? "Вернуть обычный вид" : "Развернуть заявления"}
|
||||
aria-label={desktopWideMode ? "Вернуть обычный вид" : "Развернуть заявления"}
|
||||
aria-pressed={desktopWideMode}
|
||||
>
|
||||
{desktopWideMode ? <Minimize2 size={17} /> : <Maximize2 size={17} />}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={actions.openCreate}
|
||||
className="app-action-primary inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm font-medium"
|
||||
>
|
||||
<Plus size={14} /> Создать заявление
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{feedback.actionError ? (
|
||||
@ -129,16 +161,48 @@ export function RequestListControls({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
{quickScopes.map((scope) => {
|
||||
const active = state.view === scope.value;
|
||||
return (
|
||||
<button
|
||||
key={scope.value || "all"}
|
||||
type="button"
|
||||
onClick={() => actions.setView(scope.value)}
|
||||
className={`${active ? "app-pill-active" : "app-pill"} inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition`}
|
||||
>
|
||||
<span>{scope.label}</span>
|
||||
<span className={`${active ? "app-pill-count-active" : "app-pill-count"} app-badge px-1.5 py-0.5 text-[10px] font-bold`}>
|
||||
{scope.count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{state.filtersOpen ? (
|
||||
<div className="app-surface-muted mb-4 flex flex-col gap-2 rounded-xl p-3">
|
||||
<div className="app-surface-muted mb-4 grid grid-cols-1 gap-2 rounded-xl p-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="flex min-h-7 items-center justify-between gap-3 sm:col-span-2 xl:col-span-4">
|
||||
<span className="app-text-muted text-xs font-semibold uppercase tracking-wide">Фильтры</span>
|
||||
{state.hasActiveFilters ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={actions.clearFilters}
|
||||
className="app-link-accent text-xs font-medium"
|
||||
>
|
||||
Очистить
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<select
|
||||
value={state.view}
|
||||
onChange={(event) => actions.setView(event.target.value as "" | "mine" | "addressed")}
|
||||
onChange={(event) => actions.setView(event.target.value as RequestQuickScope)}
|
||||
className="app-select rounded-lg px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Все заявления</option>
|
||||
<option value="mine">Мои заявления</option>
|
||||
<option value="addressed">Адресованные мне</option>
|
||||
<option value="decision">Требуют решения</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
@ -180,51 +244,42 @@ export function RequestListControls({
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="min-w-0 space-y-1.5 sm:col-span-2 xl:col-span-2">
|
||||
<label className="app-text-muted px-1 text-xs font-medium">Дата создания</label>
|
||||
<div className="flex gap-2">
|
||||
<div className="grid min-w-0 grid-cols-2 gap-2">
|
||||
<input
|
||||
type="date"
|
||||
value={state.createdFromFilter}
|
||||
onChange={(event) => actions.setCreatedFromFilter(event.target.value)}
|
||||
className="app-input flex-1 rounded-lg px-3 py-2 text-sm"
|
||||
className="app-input min-w-0 rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={state.createdToFilter}
|
||||
onChange={(event) => actions.setCreatedToFilter(event.target.value)}
|
||||
className="app-input flex-1 rounded-lg px-3 py-2 text-sm"
|
||||
className="app-input min-w-0 rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="min-w-0 space-y-1.5 sm:col-span-2 xl:col-span-2">
|
||||
<label className="app-text-muted px-1 text-xs font-medium">Период заявления</label>
|
||||
<div className="flex gap-2">
|
||||
<div className="grid min-w-0 grid-cols-2 gap-2">
|
||||
<input
|
||||
type="date"
|
||||
value={state.periodFromFilter}
|
||||
onChange={(event) => actions.setPeriodFromFilter(event.target.value)}
|
||||
className="app-input flex-1 rounded-lg px-3 py-2 text-sm"
|
||||
className="app-input min-w-0 rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={state.periodToFilter}
|
||||
onChange={(event) => actions.setPeriodToFilter(event.target.value)}
|
||||
className="app-input flex-1 rounded-lg px-3 py-2 text-sm"
|
||||
className="app-input min-w-0 rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{state.hasActiveFilters ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={actions.clearFilters}
|
||||
className="app-action-secondary rounded-lg px-3 py-2 text-sm font-medium transition"
|
||||
>
|
||||
Очистить фильтры
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
@ -53,6 +53,7 @@ type RequestListItemProps = {
|
||||
onToggleRow: (requestId: number) => void;
|
||||
request: Request;
|
||||
rowOpen: boolean;
|
||||
unreadNotificationCount?: number;
|
||||
};
|
||||
|
||||
function renderDecisionMaker(decisionMaker: User | undefined, currentUserId?: number | null) {
|
||||
@ -87,6 +88,7 @@ export function RequestListItem({
|
||||
onToggleRow,
|
||||
request,
|
||||
rowOpen,
|
||||
unreadNotificationCount = 0,
|
||||
}: RequestListItemProps) {
|
||||
const requestAuthor = request.employee || request.created_by;
|
||||
const authorName = displayUserName(requestAuthor);
|
||||
@ -148,6 +150,11 @@ export function RequestListItem({
|
||||
<div className="min-w-0 flex-[1_1_9rem]">
|
||||
{authorLink ? (
|
||||
<Link href={authorLink} className="group flex min-w-0 max-w-full items-center gap-2">
|
||||
{unreadNotificationCount > 0 ? (
|
||||
<span className="app-counter inline-flex h-5 min-w-5 shrink-0 items-center justify-center px-1 text-[10px] font-bold text-white">
|
||||
{unreadNotificationCount > 99 ? "99+" : unreadNotificationCount}
|
||||
</span>
|
||||
) : null}
|
||||
<RequestAvatar
|
||||
alt={authorName}
|
||||
fallback={authorFallback}
|
||||
@ -160,6 +167,11 @@ export function RequestListItem({
|
||||
</Link>
|
||||
) : (
|
||||
<div className="flex min-w-0 max-w-full items-center gap-2">
|
||||
{unreadNotificationCount > 0 ? (
|
||||
<span className="app-counter inline-flex h-5 min-w-5 shrink-0 items-center justify-center px-1 text-[10px] font-bold text-white">
|
||||
{unreadNotificationCount > 99 ? "99+" : unreadNotificationCount}
|
||||
</span>
|
||||
) : null}
|
||||
<RequestAvatar
|
||||
alt={authorName}
|
||||
fallback={authorFallback}
|
||||
|
||||
@ -29,10 +29,11 @@ type RequestListSectionProps = {
|
||||
requests: Request[];
|
||||
onSetCommentDraft: (requestId: number, value: string) => void;
|
||||
setAttachmentPreview: (preview: RequestAttachmentPreview | null) => void;
|
||||
setDetailsRequest: (request: Request | null) => void;
|
||||
openDetailsRequest: (request: Request) => void;
|
||||
setRequestMenuOpenId: (requestId: number | null) => void;
|
||||
toggleComments: (requestId: number) => void | Promise<void>;
|
||||
toggleRow: (requestId: number) => void;
|
||||
unreadNotificationCounts: Record<number, number>;
|
||||
};
|
||||
|
||||
export function RequestListSection({
|
||||
@ -60,10 +61,11 @@ export function RequestListSection({
|
||||
requests,
|
||||
onSetCommentDraft,
|
||||
setAttachmentPreview,
|
||||
setDetailsRequest,
|
||||
openDetailsRequest,
|
||||
setRequestMenuOpenId,
|
||||
toggleComments,
|
||||
toggleRow,
|
||||
unreadNotificationCounts,
|
||||
}: RequestListSectionProps) {
|
||||
return (
|
||||
<>
|
||||
@ -92,7 +94,7 @@ export function RequestListSection({
|
||||
onDeleteComment={handleDeleteComment}
|
||||
onEdit={openEdit}
|
||||
onLinkTask={onLinkTask}
|
||||
onOpenDetails={setDetailsRequest}
|
||||
onOpenDetails={openDetailsRequest}
|
||||
onPreviewAttachment={setAttachmentPreview}
|
||||
onReject={handleReject}
|
||||
onSetCommentDraft={onSetCommentDraft}
|
||||
@ -101,6 +103,7 @@ export function RequestListSection({
|
||||
onToggleRow={toggleRow}
|
||||
request={request}
|
||||
rowOpen={Boolean(expandedRows[request.id])}
|
||||
unreadNotificationCount={unreadNotificationCounts[request.id] || 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -19,13 +19,15 @@ type StatsPeriod = (typeof statsPeriods)[number]["value"];
|
||||
type RequestStatisticsPanelProps = {
|
||||
canView: boolean;
|
||||
employees: User[];
|
||||
embedded?: boolean;
|
||||
};
|
||||
|
||||
export function RequestStatisticsPanel({
|
||||
canView,
|
||||
employees,
|
||||
embedded = false,
|
||||
}: RequestStatisticsPanelProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [open, setOpen] = useState(embedded);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [employeeId, setEmployeeId] = useState<number | null>(null);
|
||||
const [period, setPeriod] = useState<StatsPeriod>("all");
|
||||
@ -34,11 +36,12 @@ export function RequestStatisticsPanel({
|
||||
const [stats, setStats] = useState<RequestEmployeeStatistics | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const panelOpen = embedded || open;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!canView
|
||||
|| !open
|
||||
|| !panelOpen
|
||||
|| !employeeId
|
||||
|| (period === "custom" && (!dateFrom || !dateTo))
|
||||
) {
|
||||
@ -84,7 +87,7 @@ export function RequestStatisticsPanel({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [canView, dateFrom, dateTo, employeeId, open, period]);
|
||||
}, [canView, dateFrom, dateTo, employeeId, panelOpen, period]);
|
||||
|
||||
if (!canView) return null;
|
||||
|
||||
@ -117,28 +120,30 @@ export function RequestStatisticsPanel({
|
||||
] : [];
|
||||
|
||||
return (
|
||||
<div className="app-surface overflow-hidden rounded-2xl">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition hover:bg-[var(--surface-secondary)]"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-[var(--foreground)]">Статистика по заявлениям</p>
|
||||
<p className="app-text-muted mt-1 text-xs">
|
||||
Выбор сотрудника и периода для сводной статистики
|
||||
</p>
|
||||
</div>
|
||||
<ChevronDown
|
||||
size={18}
|
||||
className={`shrink-0 text-[var(--foreground-muted)] transition-transform ${open ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
<div className={`${embedded ? "min-h-full" : "app-surface overflow-hidden rounded-2xl"}`}>
|
||||
{!embedded ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition hover:bg-[var(--surface-secondary)]"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-[var(--foreground)]">Статистика по заявлениям</p>
|
||||
<p className="app-text-muted mt-1 text-xs">
|
||||
Выбор сотрудника и периода для сводной статистики
|
||||
</p>
|
||||
</div>
|
||||
<ChevronDown
|
||||
size={18}
|
||||
className={`shrink-0 text-[var(--foreground-muted)] transition-transform ${open ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{open ? (
|
||||
<div className="border-t border-[var(--border-subtle)] px-4 py-4">
|
||||
<div className="app-surface-muted rounded-2xl p-4">
|
||||
{panelOpen ? (
|
||||
<div className={embedded ? "p-2" : "border-t border-[var(--border-subtle)] px-4 py-4"}>
|
||||
<div className={`app-surface-muted rounded-xl ${embedded ? "p-3" : "p-4"}`}>
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={16}
|
||||
@ -302,7 +307,7 @@ export function RequestStatisticsPanel({
|
||||
? "Период: текущий календарный месяц"
|
||||
: `Период: ${stats?.date_from || dateFrom} — ${stats?.date_to || dateTo}`}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div className={`grid gap-3 ${embedded ? "grid-cols-2" : "grid-cols-1 sm:grid-cols-2 xl:grid-cols-4"}`}>
|
||||
{statsItems.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
|
||||
111
frontend/src/components/requests/RequestWideListItem.tsx
Normal file
111
frontend/src/components/requests/RequestWideListItem.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
import {
|
||||
defaultStatusMeta,
|
||||
formatRequestPeriod,
|
||||
requestTypeLabels,
|
||||
statusMeta,
|
||||
} from "@/hooks/useRequestsPage";
|
||||
import { displayUserName } from "@/lib/shared";
|
||||
import type { Request } from "@/types/api";
|
||||
import { Link2, MessageSquare, Paperclip } from "lucide-react";
|
||||
import { RequestAvatar } from "./RequestAvatar";
|
||||
|
||||
type RequestWideListItemProps = {
|
||||
active: boolean;
|
||||
onOpen: (request: Request) => void;
|
||||
request: Request;
|
||||
unreadNotificationCount?: number;
|
||||
};
|
||||
|
||||
export function RequestWideListItem({
|
||||
active,
|
||||
onOpen,
|
||||
request,
|
||||
unreadNotificationCount = 0,
|
||||
}: RequestWideListItemProps) {
|
||||
const author = request.employee || request.created_by;
|
||||
const authorName = displayUserName(author);
|
||||
const authorFallback = (author?.first_name?.[0] || author?.last_name?.[0] || "?").toUpperCase();
|
||||
const status = statusMeta[String(request.status || "").toLowerCase()] ?? defaultStatusMeta;
|
||||
const typeKey = String(request.type || request.request_type || "").toLowerCase();
|
||||
const typeLabel = requestTypeLabels[typeKey] || String(request.type || request.request_type || "Другое");
|
||||
const title = request.display_title || request.title || "Без заголовка";
|
||||
const summary = request.comment || request.description || "";
|
||||
const commentCount = request.comments_count ?? 0;
|
||||
const linkedTaskCount = request.linked_tasks?.length ?? 0;
|
||||
const hasAttachment = Boolean(request.attachment_url || request.attachment);
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`${active ? "app-selected border-[var(--accent-primary)]" : "app-surface hover:border-[var(--border-strong)]"} relative overflow-hidden rounded-xl border transition`}
|
||||
aria-current={active ? "true" : undefined}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(request)}
|
||||
className="block w-full p-3 text-left"
|
||||
>
|
||||
<span className="flex min-w-0 items-start gap-2.5">
|
||||
<RequestAvatar
|
||||
alt={authorName}
|
||||
fallback={authorFallback}
|
||||
size="md"
|
||||
src={author?.avatar}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex min-w-0 items-start justify-between gap-2">
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
{unreadNotificationCount > 0 ? (
|
||||
<span
|
||||
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}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="min-w-0 truncate text-sm font-semibold text-[var(--foreground)]">
|
||||
{authorName}
|
||||
</span>
|
||||
</span>
|
||||
<span className={`app-status-pill !shrink-0 !whitespace-nowrap ${status.className}`}>
|
||||
{status.label}
|
||||
</span>
|
||||
</span>
|
||||
<span className="app-text-wrap mt-1 block line-clamp-2 text-xs font-medium text-[var(--foreground)]">
|
||||
<span className="app-text-muted">{typeLabel}:</span>{" "}{title}
|
||||
</span>
|
||||
<span className="app-text-muted mt-1 block truncate text-[11px]">
|
||||
{typeKey === "short_day_off" ? "Время" : "Период"}: {formatRequestPeriod(request)}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{summary ? (
|
||||
<span className="app-text-muted mt-2 block line-clamp-2 text-xs leading-relaxed">
|
||||
{summary}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{(commentCount > 0 || linkedTaskCount > 0 || hasAttachment) ? (
|
||||
<span className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
{commentCount > 0 ? (
|
||||
<span className="app-badge inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px]">
|
||||
<MessageSquare size={10} /> {commentCount}
|
||||
</span>
|
||||
) : null}
|
||||
{linkedTaskCount > 0 ? (
|
||||
<span className="app-badge inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px]">
|
||||
<Link2 size={10} /> {linkedTaskCount}
|
||||
</span>
|
||||
) : null}
|
||||
{hasAttachment ? (
|
||||
<span className="app-badge inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px]">
|
||||
<Paperclip size={10} /> Файл
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@ -8,6 +8,7 @@ import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Filter,
|
||||
Folder,
|
||||
FolderInput,
|
||||
Kanban,
|
||||
LockKeyhole,
|
||||
@ -92,6 +93,7 @@ function BoardRow({
|
||||
onToggleNotifications,
|
||||
onMove,
|
||||
onViewParticipants,
|
||||
groupName,
|
||||
compact = false,
|
||||
}: {
|
||||
board: TaskBoardSummary;
|
||||
@ -107,6 +109,7 @@ function BoardRow({
|
||||
onToggleNotifications: () => void;
|
||||
onMove: () => void;
|
||||
onViewParticipants: () => void;
|
||||
groupName?: string;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
if (compact) {
|
||||
@ -179,6 +182,12 @@ function BoardRow({
|
||||
{board.access_scope === "private" ? <LockKeyhole size={11} /> : <Users size={11} />}
|
||||
{board.access_scope === "private" ? "Личная" : "Общая"}
|
||||
</span>
|
||||
{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>
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
@ -344,6 +353,10 @@ export default function TaskBoardNavigator({
|
||||
])),
|
||||
};
|
||||
}, [filteredBoards, groups, unreadCounts]);
|
||||
const groupNames = useMemo(
|
||||
() => new Map(groups.map((group) => [group.id, group.name])),
|
||||
[groups],
|
||||
);
|
||||
const activeFiltersCount = [unreadFilter !== "all", accessFilter !== "all"].filter(Boolean).length;
|
||||
const compactNavigator = wideMode && navigatorCollapsed;
|
||||
const filteredMoveGroups = useMemo(() => {
|
||||
@ -691,6 +704,7 @@ export default function TaskBoardNavigator({
|
||||
<BoardRow
|
||||
key={board.id}
|
||||
board={board}
|
||||
groupName={board.group_id ? groupNames.get(board.group_id) : undefined}
|
||||
active={selectedBoardId === board.id}
|
||||
unread={unreadCounts[board.id] || 0}
|
||||
compact={compactNavigator}
|
||||
|
||||
@ -39,7 +39,9 @@ interface NotificationsContextType {
|
||||
notifications: NotificationItem[];
|
||||
unreadCount: number;
|
||||
unreadCategoryCounts: Record<string, number>;
|
||||
unreadEmployeeRequestCounts: Record<number, number>;
|
||||
unreadProcurementRequestCounts: Record<number, number>;
|
||||
unreadProcurementItemCommentCounts: Record<number, number>;
|
||||
unreadTaskBoardCounts: Record<number, number>;
|
||||
unreadRegulationDepartmentCounts: Record<number, number>;
|
||||
unreadCompanyRegulationCount: number;
|
||||
@ -65,7 +67,9 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [unreadCategoryCounts, setUnreadCategoryCounts] = useState<Record<string, number>>({});
|
||||
const [unreadEmployeeRequestCounts, setUnreadEmployeeRequestCounts] = useState<Record<number, number>>({});
|
||||
const [unreadProcurementRequestCounts, setUnreadProcurementRequestCounts] = useState<Record<number, number>>({});
|
||||
const [unreadProcurementItemCommentCounts, setUnreadProcurementItemCommentCounts] = useState<Record<number, number>>({});
|
||||
const [unreadTaskBoardCounts, setUnreadTaskBoardCounts] = useState<Record<number, number>>({});
|
||||
const [unreadRegulationDepartmentCounts, setUnreadRegulationDepartmentCounts] = useState<Record<number, number>>({});
|
||||
const [unreadCompanyRegulationCount, setUnreadCompanyRegulationCount] = useState(0);
|
||||
@ -80,18 +84,27 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
|
||||
const applyUnreadSummary = useCallback((summary: {
|
||||
total?: number;
|
||||
verbs?: Array<{ verb?: string; unread?: number }>;
|
||||
employee_requests?: Array<{ request_id?: number; unread?: number }>;
|
||||
procurement_requests?: Array<{ request_id?: number; unread?: number }>;
|
||||
procurement_item_comments?: Array<{ item_id?: number; unread?: number }>;
|
||||
task_boards?: Array<{ board_id?: number; unread?: number }>;
|
||||
regulation_departments?: Array<{ department_id?: number; unread?: number }>;
|
||||
regulation_company_unread?: number;
|
||||
regulation_personal_unread?: number;
|
||||
}) => {
|
||||
const nextCategoryCounts: Record<string, number> = {};
|
||||
const nextEmployeeRequestCounts: Record<number, number> = {};
|
||||
const nextProcurementRequestCounts: Record<number, number> = {};
|
||||
const verbs = Array.isArray(summary.verbs) ? summary.verbs : [];
|
||||
const employeeRequests = Array.isArray(summary.employee_requests)
|
||||
? summary.employee_requests
|
||||
: [];
|
||||
const procurementRequests = Array.isArray(summary.procurement_requests)
|
||||
? summary.procurement_requests
|
||||
: [];
|
||||
const procurementItemComments = Array.isArray(summary.procurement_item_comments)
|
||||
? summary.procurement_item_comments
|
||||
: [];
|
||||
const taskBoards = Array.isArray(summary.task_boards)
|
||||
? summary.task_boards
|
||||
: [];
|
||||
@ -107,6 +120,14 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
|
||||
nextCategoryCounts[category] = (nextCategoryCounts[category] || 0) + item.unread;
|
||||
});
|
||||
|
||||
employeeRequests.forEach((item) => {
|
||||
const requestId = Number(item.request_id);
|
||||
if (!Number.isFinite(requestId) || requestId <= 0 || typeof item.unread !== 'number' || item.unread <= 0) {
|
||||
return;
|
||||
}
|
||||
nextEmployeeRequestCounts[requestId] = item.unread;
|
||||
});
|
||||
|
||||
procurementRequests.forEach((item) => {
|
||||
const requestId = Number(item.request_id);
|
||||
if (!Number.isFinite(requestId) || requestId <= 0 || typeof item.unread !== 'number' || item.unread <= 0) {
|
||||
@ -115,6 +136,15 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
|
||||
nextProcurementRequestCounts[requestId] = item.unread;
|
||||
});
|
||||
|
||||
const nextProcurementItemCommentCounts: Record<number, number> = {};
|
||||
procurementItemComments.forEach((item) => {
|
||||
const itemId = Number(item.item_id);
|
||||
if (!Number.isFinite(itemId) || itemId <= 0 || typeof item.unread !== 'number' || item.unread <= 0) {
|
||||
return;
|
||||
}
|
||||
nextProcurementItemCommentCounts[itemId] = item.unread;
|
||||
});
|
||||
|
||||
const nextTaskBoardCounts: Record<number, number> = {};
|
||||
taskBoards.forEach((item) => {
|
||||
const boardId = Number(item.board_id);
|
||||
@ -134,7 +164,9 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
|
||||
});
|
||||
|
||||
setUnreadCategoryCounts(nextCategoryCounts);
|
||||
setUnreadEmployeeRequestCounts(nextEmployeeRequestCounts);
|
||||
setUnreadProcurementRequestCounts(nextProcurementRequestCounts);
|
||||
setUnreadProcurementItemCommentCounts(nextProcurementItemCommentCounts);
|
||||
setUnreadTaskBoardCounts(nextTaskBoardCounts);
|
||||
setUnreadRegulationDepartmentCounts(nextRegulationDepartmentCounts);
|
||||
setUnreadCompanyRegulationCount(
|
||||
@ -219,7 +251,9 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
|
||||
setNotifications([]);
|
||||
setUnreadCount(0);
|
||||
setUnreadCategoryCounts({});
|
||||
setUnreadEmployeeRequestCounts({});
|
||||
setUnreadProcurementRequestCounts({});
|
||||
setUnreadProcurementItemCommentCounts({});
|
||||
setUnreadTaskBoardCounts({});
|
||||
setUnreadRegulationDepartmentCounts({});
|
||||
setUnreadCompanyRegulationCount(0);
|
||||
@ -268,6 +302,15 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
|
||||
...prev,
|
||||
[category]: (prev[category] || 0) + 1,
|
||||
}));
|
||||
if (category === 'Заявления') {
|
||||
const requestId = Number(incomingNotification.data?.request_id);
|
||||
if (Number.isFinite(requestId) && requestId > 0) {
|
||||
setUnreadEmployeeRequestCounts(prev => ({
|
||||
...prev,
|
||||
[requestId]: (prev[requestId] || 0) + 1,
|
||||
}));
|
||||
}
|
||||
}
|
||||
if (category === 'Закупки') {
|
||||
const requestId = Number(incomingNotification.data?.request_id);
|
||||
if (Number.isFinite(requestId) && requestId > 0) {
|
||||
@ -276,6 +319,15 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
|
||||
[requestId]: (prev[requestId] || 0) + 1,
|
||||
}));
|
||||
}
|
||||
if (incomingNotification.verb === 'procurement_item_commented') {
|
||||
const itemId = Number(incomingNotification.data?.item_id);
|
||||
if (Number.isFinite(itemId) && itemId > 0) {
|
||||
setUnreadProcurementItemCommentCounts(prev => ({
|
||||
...prev,
|
||||
[itemId]: (prev[itemId] || 0) + 1,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (category === 'Доска') {
|
||||
const boardId = Number(incomingNotification.data?.board_id);
|
||||
@ -407,7 +459,9 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
|
||||
setNotifications(prev => prev.map(n => ({ ...n, is_read: true, unread: false })));
|
||||
setUnreadCount(0);
|
||||
setUnreadCategoryCounts({});
|
||||
setUnreadEmployeeRequestCounts({});
|
||||
setUnreadProcurementRequestCounts({});
|
||||
setUnreadProcurementItemCommentCounts({});
|
||||
setUnreadTaskBoardCounts({});
|
||||
setUnreadRegulationDepartmentCounts({});
|
||||
setUnreadCompanyRegulationCount(0);
|
||||
@ -432,8 +486,12 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
|
||||
delete next[category];
|
||||
return next;
|
||||
});
|
||||
if (category === 'Заявления') {
|
||||
setUnreadEmployeeRequestCounts({});
|
||||
}
|
||||
if (category === 'Закупки') {
|
||||
setUnreadProcurementRequestCounts({});
|
||||
setUnreadProcurementItemCommentCounts({});
|
||||
}
|
||||
if (category === 'Доска') {
|
||||
setUnreadTaskBoardCounts({});
|
||||
@ -481,7 +539,9 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
|
||||
notifications,
|
||||
unreadCount,
|
||||
unreadCategoryCounts,
|
||||
unreadEmployeeRequestCounts,
|
||||
unreadProcurementRequestCounts,
|
||||
unreadProcurementItemCommentCounts,
|
||||
unreadTaskBoardCounts,
|
||||
unreadRegulationDepartmentCounts,
|
||||
unreadCompanyRegulationCount,
|
||||
|
||||
59
frontend/src/hooks/useProcurementDesktopWideMode.ts
Normal file
59
frontend/src/hooks/useProcurementDesktopWideMode.ts
Normal file
@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useSyncExternalStore } from "react";
|
||||
|
||||
const STORAGE_PREFIX = "procurement.desktop-wide-mode";
|
||||
const CHANGE_EVENT = "procurement-desktop-wide-mode-change";
|
||||
|
||||
function storageKey(userId: number): string {
|
||||
return `${STORAGE_PREFIX}.${userId}`;
|
||||
}
|
||||
|
||||
export function useProcurementDesktopWideMode(userId?: number | null) {
|
||||
const subscribe = useCallback((notify: () => void) => {
|
||||
if (!userId) return () => undefined;
|
||||
const key = storageKey(userId);
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
if (event.key === key) notify();
|
||||
};
|
||||
window.addEventListener("storage", handleStorage);
|
||||
window.addEventListener(CHANGE_EVENT, notify);
|
||||
return () => {
|
||||
window.removeEventListener("storage", handleStorage);
|
||||
window.removeEventListener(CHANGE_EVENT, notify);
|
||||
};
|
||||
}, [userId]);
|
||||
|
||||
const getSnapshot = useCallback(() => (
|
||||
userId ? window.localStorage.getItem(storageKey(userId)) === "1" : false
|
||||
), [userId]);
|
||||
|
||||
const desktopWideMode = useSyncExternalStore(subscribe, getSnapshot, () => false);
|
||||
|
||||
const subscribeDesktopViewport = useCallback((notify: () => void) => {
|
||||
const query = window.matchMedia("(min-width: 1024px)");
|
||||
query.addEventListener("change", notify);
|
||||
return () => query.removeEventListener("change", notify);
|
||||
}, []);
|
||||
const getDesktopViewportSnapshot = useCallback(
|
||||
() => window.matchMedia("(min-width: 1024px)").matches,
|
||||
[],
|
||||
);
|
||||
const desktopViewport = useSyncExternalStore(
|
||||
subscribeDesktopViewport,
|
||||
getDesktopViewportSnapshot,
|
||||
() => false,
|
||||
);
|
||||
|
||||
const changeDesktopWideMode = useCallback((enabled: boolean) => {
|
||||
if (!userId) return;
|
||||
window.localStorage.setItem(storageKey(userId), enabled ? "1" : "0");
|
||||
window.dispatchEvent(new Event(CHANGE_EVENT));
|
||||
}, [userId]);
|
||||
|
||||
return [
|
||||
desktopWideMode,
|
||||
changeDesktopWideMode,
|
||||
desktopWideMode && desktopViewport,
|
||||
] as const;
|
||||
}
|
||||
@ -146,6 +146,7 @@ export function useProcurementPage(user: User | null) {
|
||||
const {
|
||||
refreshUnreadSummary,
|
||||
unreadProcurementRequestCounts,
|
||||
unreadProcurementItemCommentCounts,
|
||||
} = useNotifications();
|
||||
const canManage = canManageRequests(user);
|
||||
const canSupplierManage = canManageSupplier(user);
|
||||
@ -199,6 +200,7 @@ export function useProcurementPage(user: User | null) {
|
||||
const [expandedItemComments, setExpandedItemComments] = useState<Record<number, boolean>>({});
|
||||
const [itemCommentsMap, setItemCommentsMap] = useState<Record<number, ProcurementItemComment[]>>({});
|
||||
const [itemCommentDrafts, setItemCommentDrafts] = useState<Record<number, string>>({});
|
||||
const [itemCommentFiles, setItemCommentFiles] = useState<Record<number, File[]>>({});
|
||||
const [detailsCache, setDetailsCache] = useState<Record<number, ProcurementRequest>>({});
|
||||
const detailsCacheRef = useRef<Record<number, ProcurementRequest>>({});
|
||||
const hasLoadedOnceRef = useRef(false);
|
||||
@ -926,14 +928,24 @@ export function useProcurementPage(user: User | null) {
|
||||
const isOpen = Boolean(expandedItemComments[itemId]);
|
||||
setExpandedItemComments((previous) => ({ ...previous, [itemId]: !isOpen }));
|
||||
|
||||
if (!isOpen && !itemCommentsMap[itemId]) {
|
||||
if (!isOpen) {
|
||||
try {
|
||||
await ensureItemCommentsLoaded(itemId);
|
||||
if (!itemCommentsMap[itemId]) {
|
||||
await ensureItemCommentsLoaded(itemId);
|
||||
}
|
||||
} catch {
|
||||
setActionError("Не удалось загрузить комментарии позиции");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await apiClient.markProcurementItemCommentNotificationsRead(itemId);
|
||||
await refreshUnreadSummary();
|
||||
} catch (readError) {
|
||||
console.error("Mark procurement item comment notifications read error:", readError);
|
||||
}
|
||||
}
|
||||
}, [ensureItemCommentsLoaded, expandedItemComments, itemCommentsMap]);
|
||||
}, [ensureItemCommentsLoaded, expandedItemComments, itemCommentsMap, refreshUnreadSummary]);
|
||||
|
||||
const handleAddComment = useCallback(async (id: number) => {
|
||||
const text = (commentDrafts[id] || "").trim();
|
||||
@ -970,20 +982,33 @@ export function useProcurementPage(user: User | null) {
|
||||
|
||||
const handleAddItemComment = useCallback(async (requestId: number, itemId: number) => {
|
||||
const text = (itemCommentDrafts[itemId] || "").trim();
|
||||
if (!text) return;
|
||||
const files = itemCommentFiles[itemId] || [];
|
||||
if (!text && files.length === 0) return;
|
||||
|
||||
try {
|
||||
setBusyKey(`item-comment-${itemId}`);
|
||||
const created = await apiClient.addProcurementItemComment(itemId, text);
|
||||
const created = await apiClient.addProcurementItemComment(itemId, text, files);
|
||||
setItemCommentsMap((previous) => ({
|
||||
...previous,
|
||||
[itemId]: [...(previous[itemId] || []), created],
|
||||
}));
|
||||
setItemCommentDrafts((previous) => ({ ...previous, [itemId]: "" }));
|
||||
setItemCommentFiles((previous) => ({ ...previous, [itemId]: [] }));
|
||||
updateCachedItem(requestId, itemId, (item) => ({
|
||||
...item,
|
||||
comments_count: (item.comments_count || 0) + 1,
|
||||
links: created.item_links ?? item.links,
|
||||
attachments: created.attachments?.length
|
||||
? [
|
||||
...(item.attachments || []).filter((attachment) => (
|
||||
!created.attachments?.some((createdAttachment) => createdAttachment.id === attachment.id)
|
||||
)),
|
||||
...created.attachments,
|
||||
]
|
||||
: item.attachments,
|
||||
attachments_count: created.attachments?.length
|
||||
? (item.attachments_count || item.attachments?.length || 0) + created.attachments.length
|
||||
: item.attachments_count,
|
||||
}));
|
||||
await markRequestNotificationsRead(requestId);
|
||||
} catch {
|
||||
@ -991,7 +1016,11 @@ export function useProcurementPage(user: User | null) {
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
}, [itemCommentDrafts, markRequestNotificationsRead, updateCachedItem]);
|
||||
}, [itemCommentDrafts, itemCommentFiles, markRequestNotificationsRead, updateCachedItem]);
|
||||
|
||||
const handleItemCommentFilesChange = useCallback((itemId: number, files: File[]) => {
|
||||
setItemCommentFiles((previous) => ({ ...previous, [itemId]: files }));
|
||||
}, []);
|
||||
|
||||
const handleDeleteComment = useCallback(async (requestId: number, commentId: number) => {
|
||||
try {
|
||||
@ -1363,6 +1392,7 @@ export function useProcurementPage(user: User | null) {
|
||||
nextPage,
|
||||
scopeCounts,
|
||||
unreadProcurementRequestCounts,
|
||||
unreadProcurementItemCommentCounts,
|
||||
scope,
|
||||
setScope,
|
||||
searchQuery,
|
||||
@ -1404,6 +1434,8 @@ export function useProcurementPage(user: User | null) {
|
||||
expandedItemComments,
|
||||
itemCommentsMap,
|
||||
itemCommentDrafts,
|
||||
itemCommentFiles,
|
||||
handleItemCommentFilesChange,
|
||||
setItemCommentDrafts,
|
||||
setCommentDrafts,
|
||||
detailsCache,
|
||||
|
||||
59
frontend/src/hooks/useRequestsDesktopWideMode.ts
Normal file
59
frontend/src/hooks/useRequestsDesktopWideMode.ts
Normal file
@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useSyncExternalStore } from "react";
|
||||
|
||||
const STORAGE_PREFIX = "requests.desktop-wide-mode";
|
||||
const CHANGE_EVENT = "requests-desktop-wide-mode-change";
|
||||
|
||||
function storageKey(userId: number): string {
|
||||
return `${STORAGE_PREFIX}.${userId}`;
|
||||
}
|
||||
|
||||
export function useRequestsDesktopWideMode(userId?: number | null) {
|
||||
const subscribe = useCallback((notify: () => void) => {
|
||||
if (!userId) return () => undefined;
|
||||
const key = storageKey(userId);
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
if (event.key === key) notify();
|
||||
};
|
||||
window.addEventListener("storage", handleStorage);
|
||||
window.addEventListener(CHANGE_EVENT, notify);
|
||||
return () => {
|
||||
window.removeEventListener("storage", handleStorage);
|
||||
window.removeEventListener(CHANGE_EVENT, notify);
|
||||
};
|
||||
}, [userId]);
|
||||
|
||||
const getSnapshot = useCallback(() => (
|
||||
userId ? window.localStorage.getItem(storageKey(userId)) === "1" : false
|
||||
), [userId]);
|
||||
|
||||
const desktopWideMode = useSyncExternalStore(subscribe, getSnapshot, () => false);
|
||||
|
||||
const subscribeDesktopViewport = useCallback((notify: () => void) => {
|
||||
const query = window.matchMedia("(min-width: 1024px)");
|
||||
query.addEventListener("change", notify);
|
||||
return () => query.removeEventListener("change", notify);
|
||||
}, []);
|
||||
const getDesktopViewportSnapshot = useCallback(
|
||||
() => window.matchMedia("(min-width: 1024px)").matches,
|
||||
[],
|
||||
);
|
||||
const desktopViewport = useSyncExternalStore(
|
||||
subscribeDesktopViewport,
|
||||
getDesktopViewportSnapshot,
|
||||
() => false,
|
||||
);
|
||||
|
||||
const changeDesktopWideMode = useCallback((enabled: boolean) => {
|
||||
if (!userId) return;
|
||||
window.localStorage.setItem(storageKey(userId), enabled ? "1" : "0");
|
||||
window.dispatchEvent(new Event(CHANGE_EVENT));
|
||||
}, [userId]);
|
||||
|
||||
return [
|
||||
desktopWideMode,
|
||||
changeDesktopWideMode,
|
||||
desktopWideMode && desktopViewport,
|
||||
] as const;
|
||||
}
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { useNotifications } from "@/contexts/NotificationsContext";
|
||||
import type { Request, RequestComment, User, Department } from "@/types/api";
|
||||
import { extractNextPage, loadAllPages, displayUserName, formatDate } from "@/lib/shared";
|
||||
|
||||
@ -68,6 +69,22 @@ export const orderingOptions = [
|
||||
{ value: "-date_from", label: "По периоду ↓" },
|
||||
];
|
||||
|
||||
export type RequestQuickScope = "" | "mine" | "addressed" | "decision";
|
||||
|
||||
export type RequestScopeCounts = {
|
||||
all: number;
|
||||
mine: number;
|
||||
addressed: number;
|
||||
decision: number;
|
||||
};
|
||||
|
||||
const emptyScopeCounts: RequestScopeCounts = {
|
||||
all: 0,
|
||||
mine: 0,
|
||||
addressed: 0,
|
||||
decision: 0,
|
||||
};
|
||||
|
||||
export function getRequestDateMode(type: string): "range" | "single" | "timed" | "optional" {
|
||||
switch (String(type || "").toLowerCase()) {
|
||||
case "short_day_off":
|
||||
@ -137,6 +154,7 @@ function getRequestSubmitError(form: RequestFormState): string | null {
|
||||
/* ── hook ── */
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export function useRequestsPage(_userId: number | null | undefined) {
|
||||
const { refreshUnreadSummary, unreadEmployeeRequestCounts } = useNotifications();
|
||||
/* data */
|
||||
const [requests, setRequests] = useState<Request[]>([]);
|
||||
const [employees, setEmployees] = useState<User[]>([]);
|
||||
@ -155,7 +173,8 @@ export function useRequestsPage(_userId: number | null | undefined) {
|
||||
|
||||
/* filters */
|
||||
const [search, setSearch] = useState("");
|
||||
const [view, setView] = useState<"" | "mine" | "addressed">("");
|
||||
const [view, setView] = useState<RequestQuickScope>("");
|
||||
const [scopeCounts, setScopeCounts] = useState<RequestScopeCounts>(emptyScopeCounts);
|
||||
const [typeFilter, setTypeFilter] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [employeeFilter, setEmployeeFilter] = useState("");
|
||||
@ -189,6 +208,7 @@ export function useRequestsPage(_userId: number | null | undefined) {
|
||||
const params: Record<string, string | number> = { page, limit: 20 };
|
||||
if (view === "mine") params.view = "mine";
|
||||
if (view === "addressed") params.addressed_to_me = "true";
|
||||
if (view === "decision") params.decision_required = "true";
|
||||
if (typeFilter) params.type = typeFilter;
|
||||
if (statusFilter) params.status = statusFilter;
|
||||
if (employeeFilter) params.employee_id = employeeFilter;
|
||||
@ -206,6 +226,7 @@ export function useRequestsPage(_userId: number | null | undefined) {
|
||||
const nextRequests = response.results || [];
|
||||
setRequests(nextRequests);
|
||||
setNextPage(extractNextPage(response.next));
|
||||
setScopeCounts(response.scope_counts || emptyScopeCounts);
|
||||
setDetailsRequest((prev) => {
|
||||
if (!prev) return prev;
|
||||
const updated = nextRequests.find((request: Request) => request.id === prev.id);
|
||||
@ -225,6 +246,7 @@ export function useRequestsPage(_userId: number | null | undefined) {
|
||||
if (cancelled) return;
|
||||
setRequests(response.results || []);
|
||||
setNextPage(extractNextPage(response.next));
|
||||
setScopeCounts(response.scope_counts || emptyScopeCounts);
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
console.error("Ошибка загрузки заявлений:", err);
|
||||
@ -290,7 +312,7 @@ export function useRequestsPage(_userId: number | null | undefined) {
|
||||
() => filteredRequests.filter((request) => String(request.status || "").toLowerCase() === "pending" && request.can_decide),
|
||||
[filteredRequests],
|
||||
);
|
||||
const pendingDecisionCount = pendingDecisionRequests.length;
|
||||
const pendingDecisionCount = scopeCounts.decision;
|
||||
const activeFiltersCount = useMemo(
|
||||
() => [
|
||||
view,
|
||||
@ -409,6 +431,7 @@ export function useRequestsPage(_userId: number | null | undefined) {
|
||||
const response = await apiClient.getRequests(buildRequestParams(1));
|
||||
setRequests(response.results || []);
|
||||
setNextPage(extractNextPage(response.next));
|
||||
setScopeCounts(response.scope_counts || emptyScopeCounts);
|
||||
} catch (e: unknown) {
|
||||
const raw = String((e as Error)?.message || "Не удалось сохранить заявление");
|
||||
let readable = raw;
|
||||
@ -449,19 +472,19 @@ export function useRequestsPage(_userId: number | null | undefined) {
|
||||
}, [nextPage, loadingMore, buildRequestParams]);
|
||||
|
||||
const handleApprove = async (id: number) => {
|
||||
try { setBusyKey(`approve-${id}`); setActionError(null); const updated = await apiClient.approveRequest(id); applyUpdatedRequest(updated); }
|
||||
try { setBusyKey(`approve-${id}`); setActionError(null); const updated = await apiClient.approveRequest(id); applyUpdatedRequest(updated); await refreshRequests(); }
|
||||
catch (e: unknown) { setActionError(String((e as Error)?.message || "Не удалось одобрить")); }
|
||||
finally { setBusyKey(null); }
|
||||
};
|
||||
|
||||
const handleReject = async (id: number) => {
|
||||
try { setBusyKey(`reject-${id}`); setActionError(null); const updated = await apiClient.rejectRequest(id); applyUpdatedRequest(updated); }
|
||||
try { setBusyKey(`reject-${id}`); setActionError(null); const updated = await apiClient.rejectRequest(id); applyUpdatedRequest(updated); await refreshRequests(); }
|
||||
catch (e: unknown) { setActionError(String((e as Error)?.message || "Не удалось отклонить")); }
|
||||
finally { setBusyKey(null); }
|
||||
};
|
||||
|
||||
const handleCancel = async (id: number) => {
|
||||
try { setBusyKey(`cancel-${id}`); setActionError(null); const updated = await apiClient.cancelRequest(id); applyUpdatedRequest(updated); }
|
||||
try { setBusyKey(`cancel-${id}`); setActionError(null); const updated = await apiClient.cancelRequest(id); applyUpdatedRequest(updated); await refreshRequests(); }
|
||||
catch (e: unknown) { setActionError(String((e as Error)?.message || "Не удалось отменить")); }
|
||||
finally { setBusyKey(null); }
|
||||
};
|
||||
@ -476,6 +499,7 @@ export function useRequestsPage(_userId: number | null | undefined) {
|
||||
await apiClient.deleteRequest(id);
|
||||
setRequests((p) => p.filter((r) => r.id !== id));
|
||||
setDetailsRequest((prev) => (prev?.id === id ? null : prev));
|
||||
await refreshRequests();
|
||||
}
|
||||
catch (e: unknown) { setActionError(String((e as Error)?.message || "Не удалось удалить")); }
|
||||
finally { setBusyKey(null); }
|
||||
@ -534,6 +558,7 @@ export function useRequestsPage(_userId: number | null | undefined) {
|
||||
const isFinal = (status?: string) => ["approved", "rejected", "cancelled"].includes(String(status || "").toLowerCase());
|
||||
|
||||
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||
const readRequestIdRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
@ -547,14 +572,35 @@ export function useRequestsPage(_userId: number | null | undefined) {
|
||||
|
||||
useEffect(() => {
|
||||
const requestId = detailsRequest?.id;
|
||||
if (!requestId) return;
|
||||
if (!requestId) {
|
||||
readRequestIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (String(detailsRequest.status || "").toLowerCase() === "draft") return;
|
||||
void ensureCommentsLoaded(requestId);
|
||||
}, [detailsRequest, ensureCommentsLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
const requestId = detailsRequest?.id;
|
||||
if (!requestId) {
|
||||
readRequestIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (readRequestIdRef.current === requestId) return;
|
||||
|
||||
readRequestIdRef.current = requestId;
|
||||
void apiClient.markRequestNotificationsRead(requestId)
|
||||
.then(() => refreshUnreadSummary())
|
||||
.catch((readError) => {
|
||||
readRequestIdRef.current = null;
|
||||
console.error("Не удалось отметить уведомления заявления прочитанными:", readError);
|
||||
});
|
||||
}, [detailsRequest?.id, refreshUnreadSummary]);
|
||||
|
||||
return {
|
||||
/* data */
|
||||
requests: filteredRequests,
|
||||
unreadEmployeeRequestCounts,
|
||||
pendingDecisionRequests,
|
||||
employees,
|
||||
recipientEmployees,
|
||||
@ -583,6 +629,7 @@ export function useRequestsPage(_userId: number | null | undefined) {
|
||||
/* filters */
|
||||
search, setSearch,
|
||||
view, setView,
|
||||
scopeCounts,
|
||||
typeFilter, setTypeFilter,
|
||||
statusFilter, setStatusFilter,
|
||||
employeeFilter, setEmployeeFilter,
|
||||
|
||||
@ -37,6 +37,13 @@ export function useRequestsPageScreen({
|
||||
clearRequestParam();
|
||||
};
|
||||
|
||||
const openDetailsRequest = (request: Request) => {
|
||||
setDetailsRequest(request);
|
||||
const nextParams = new URLSearchParams(searchParams.toString());
|
||||
nextParams.set("request", String(request.id));
|
||||
router.replace(`${pathname}?${nextParams.toString()}`, { scroll: false });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!linkedRequestId) {
|
||||
openedLinkedRequestIdRef.current = null;
|
||||
@ -114,6 +121,7 @@ export function useRequestsPageScreen({
|
||||
|
||||
return {
|
||||
closeDetailsRequest,
|
||||
openDetailsRequest,
|
||||
requestMenuOpenId,
|
||||
requestMenuRef,
|
||||
setRequestMenuOpenId,
|
||||
|
||||
@ -12,10 +12,18 @@ export type UnreadNotificationsSummary = {
|
||||
verb: string;
|
||||
unread: number;
|
||||
}>;
|
||||
employee_requests?: Array<{
|
||||
request_id: number;
|
||||
unread: number;
|
||||
}>;
|
||||
procurement_requests?: Array<{
|
||||
request_id: number;
|
||||
unread: number;
|
||||
}>;
|
||||
procurement_item_comments?: Array<{
|
||||
item_id: number;
|
||||
unread: number;
|
||||
}>;
|
||||
task_boards?: Array<{
|
||||
board_id: number;
|
||||
unread: number;
|
||||
|
||||
@ -40,6 +40,8 @@ export function createProcurementApi(request: RequestFn, requestRaw: RawRequestF
|
||||
request(`/api/v1/procurement/requests/${id}/set_viewed/`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ is_viewed: isViewed }) }),
|
||||
markProcurementRequestNotificationsRead: (id: number): Promise<{ status: string; count: number; notification_ids: number[] }> =>
|
||||
request(`/api/v1/procurement/requests/${id}/notifications/read/`, { method: 'POST' }),
|
||||
markProcurementItemCommentNotificationsRead: (itemId: number): Promise<{ status: string; count: number; notification_ids: number[] }> =>
|
||||
request(`/api/v1/procurement/items/${itemId}/comment-notifications/read/`, { method: 'POST' }),
|
||||
cancelProcurementRequest: (id: number, reason?: string) =>
|
||||
request(`/api/v1/procurement/requests/${id}/cancel/`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reason: reason || '' }) }),
|
||||
getProcurementComments: (requestId: number) => request(`/api/v1/procurement/requests/${requestId}/comments/`),
|
||||
@ -79,8 +81,19 @@ export function createProcurementApi(request: RequestFn, requestRaw: RawRequestF
|
||||
}),
|
||||
deleteProcurementItem: (id: number): Promise<void> => request(`/api/v1/procurement/items/${id}/`, { method: 'DELETE' }),
|
||||
getProcurementItemComments: (itemId: number) => request(`/api/v1/procurement/items/${itemId}/comments/`),
|
||||
addProcurementItemComment: (itemId: number, text: string): Promise<ProcurementItemComment> =>
|
||||
request(`/api/v1/procurement/items/${itemId}/comments/`, { method: 'POST', body: JSON.stringify({ text }) }),
|
||||
addProcurementItemComment: (
|
||||
itemId: number,
|
||||
text: string,
|
||||
files: File[] = [],
|
||||
): Promise<ProcurementItemComment> => {
|
||||
const formData = new FormData();
|
||||
formData.append('text', text);
|
||||
files.forEach((file) => formData.append('files', file));
|
||||
return request(`/api/v1/procurement/items/${itemId}/comments/`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
},
|
||||
deleteProcurementItemComment: (itemId: number, commentId: number): Promise<void> =>
|
||||
request(`/api/v1/procurement/items/${itemId}/comments/${commentId}/`, { method: 'DELETE' }),
|
||||
getProcurementItemAttachments: (itemId: number): Promise<ProcurementItemAttachment[]> =>
|
||||
|
||||
@ -19,13 +19,15 @@ export function createRequestsApi(request: RequestFn) {
|
||||
getRequests: (params?: Record<string, any>) => {
|
||||
const qp = new URLSearchParams();
|
||||
if (params) {
|
||||
const keys = ['status','type','search','page','limit','view','addressed_to_me','employee_id','created_from','created_to','date_from','date_to'];
|
||||
const keys = ['status','type','search','page','limit','view','addressed_to_me','decision_required','employee_id','created_from','created_to','date_from','date_to'];
|
||||
keys.forEach(k => { if (params[k]) qp.append(k, String(params[k])); });
|
||||
}
|
||||
const qs = qp.toString();
|
||||
return request(`/api/v1/requests/${qs ? '?' + qs : ''}`);
|
||||
},
|
||||
getRequest: (requestId: number) => request(`/api/v1/requests/${requestId}/`),
|
||||
markRequestNotificationsRead: (requestId: number): Promise<{ status: string; count: number; notification_ids: number[] }> =>
|
||||
request(`/api/v1/requests/${requestId}/notifications/read/`, { method: 'POST' }),
|
||||
getRequestEmployeeStatistics: (requestId: number, period: 'all' | 'year' | 'month' = 'all'): Promise<RequestEmployeeStatistics> =>
|
||||
request(`/api/v1/requests/${requestId}/employee-statistics/?period=${period}`),
|
||||
getEmployeeRequestStatistics: (employeeId: number, period: 'all' | 'year' | 'month' = 'all'): Promise<RequestEmployeeStatistics> =>
|
||||
|
||||
@ -1422,6 +1422,7 @@ export interface ProcurementItemComment extends ProcurementComment {
|
||||
item: number;
|
||||
item_links?: string[];
|
||||
added_links?: string[];
|
||||
attachments?: ProcurementItemAttachment[];
|
||||
}
|
||||
|
||||
export interface ProcurementSupplier {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user