Merge branch 'develop'

This commit is contained in:
lizerk 2026-08-07 15:35:47 +03:00
commit 17bd90b02d
38 changed files with 3110 additions and 336 deletions

View File

@ -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(

View File

@ -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:

View File

@ -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,

View File

@ -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"],

View File

@ -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="Комментарий",
),
),
]

View File

@ -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,

View File

@ -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)

View 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,
)

View File

@ -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},

View File

@ -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
# ==============================================================================
# ТЕСТЫ УДАЛЕНИЯ

View File

@ -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:

View File

@ -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;
}

View File

@ -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>
) : (

View File

@ -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 && (

View File

@ -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={() => {

View File

@ -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 },

View File

@ -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>
);

View File

@ -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>
);
}

View File

@ -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>
);
}
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View 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>
);
}

View File

@ -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}
</>

View File

@ -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}

View File

@ -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>

View File

@ -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}

View 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>
);
}

View File

@ -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}

View File

@ -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,

View 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;
}

View File

@ -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,

View 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;
}

View File

@ -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,

View File

@ -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,

View File

@ -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;

View File

@ -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[]> =>

View File

@ -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> =>

View File

@ -1422,6 +1422,7 @@ export interface ProcurementItemComment extends ProcurementComment {
item: number;
item_links?: string[];
added_links?: string[];
attachments?: ProcurementItemAttachment[];
}
export interface ProcurementSupplier {