improve procurement request workspace
This commit is contained in:
parent
a8926987e2
commit
c0092437e1
@ -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(
|
||||
|
||||
@ -51,6 +51,11 @@ class UnreadSummaryProcurementRequestSerializer(serializers.Serializer):
|
||||
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()
|
||||
@ -65,6 +70,9 @@ class UnreadSummaryResponseSerializer(serializers.Serializer):
|
||||
total = serializers.IntegerField()
|
||||
verbs = UnreadSummaryVerbSerializer(many=True)
|
||||
procurement_requests = UnreadSummaryProcurementRequestSerializer(many=True)
|
||||
procurement_item_comments = UnreadSummaryProcurementItemCommentSerializer(
|
||||
many=True,
|
||||
)
|
||||
task_boards = UnreadSummaryTaskBoardSerializer(many=True)
|
||||
regulation_departments = UnreadSummaryRegulationDepartmentSerializer(
|
||||
many=True,
|
||||
|
||||
@ -72,6 +72,29 @@ def _build_procurement_request_counts(user_id):
|
||||
]
|
||||
|
||||
|
||||
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
|
||||
|
||||
@ -209,6 +232,7 @@ def build_unread_summary(user_id):
|
||||
"total": sum(item["unread"] for item in verbs),
|
||||
"verbs": verbs,
|
||||
"procurement_requests": _build_procurement_request_counts(user_id),
|
||||
"procurement_item_comments": _build_procurement_item_comment_counts(user_id),
|
||||
"task_boards": _build_task_board_counts(user_id),
|
||||
"regulation_departments": regulation_counts["departments"],
|
||||
"regulation_company_unread": regulation_counts["company_unread"],
|
||||
|
||||
@ -0,0 +1,25 @@
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("communications", "0039_alter_chat_context_object_id_bigint"),
|
||||
("procurement", "0017_add_item_defective_at"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="procurementitemattachment",
|
||||
name="comment",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="procurement_item_attachments",
|
||||
to="communications.message",
|
||||
verbose_name="Комментарий",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -680,6 +680,14 @@ class ProcurementItemAttachment(models.Model):
|
||||
related_name="attachments",
|
||||
verbose_name="Позиция",
|
||||
)
|
||||
comment = models.ForeignKey(
|
||||
"communications.Message",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="procurement_item_attachments",
|
||||
verbose_name="Комментарий",
|
||||
)
|
||||
file = models.FileField(
|
||||
"Файл",
|
||||
upload_to=procurement_item_attachment_upload_to,
|
||||
|
||||
@ -8,7 +8,7 @@ from django.utils import timezone
|
||||
from notifications.cache import invalidate_unread_summary
|
||||
from notifications.models import Notification
|
||||
from notifications.realtime import send_notifications_read_all_event
|
||||
from procurement.models import ProcurementRequest
|
||||
from procurement.models import ProcurementItem, ProcurementRequest
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@ -34,7 +34,32 @@ class ProcurementNotificationReadService:
|
||||
queryset = self._get_unread_queryset(
|
||||
user_id=user.id,
|
||||
procurement_request=procurement_request,
|
||||
).exclude(verb="procurement_item_commented")
|
||||
return self._mark_queryset_as_read(user_id=user.id, queryset=queryset)
|
||||
|
||||
def mark_item_comment_notifications_as_read(
|
||||
self,
|
||||
*,
|
||||
user,
|
||||
item: ProcurementItem,
|
||||
) -> ProcurementNotificationReadResult:
|
||||
item_id = item.id
|
||||
queryset = Notification.objects.filter(
|
||||
recipient_id=user.id,
|
||||
unread=True,
|
||||
deleted=False,
|
||||
verb="procurement_item_commented",
|
||||
).filter(
|
||||
Q(data__item_id=item_id) | Q(data__item_id=str(item_id))
|
||||
)
|
||||
return self._mark_queryset_as_read(user_id=user.id, queryset=queryset)
|
||||
|
||||
def _mark_queryset_as_read(
|
||||
self,
|
||||
*,
|
||||
user_id: int,
|
||||
queryset,
|
||||
) -> ProcurementNotificationReadResult:
|
||||
notification_ids = list(queryset.values_list("id", flat=True))
|
||||
|
||||
if not notification_ids:
|
||||
@ -51,11 +76,11 @@ class ProcurementNotificationReadService:
|
||||
timestamp_read=read_at,
|
||||
)
|
||||
|
||||
invalidate_unread_summary(user.id)
|
||||
invalidate_unread_summary(user_id)
|
||||
|
||||
def notify_after_commit():
|
||||
invalidate_unread_summary(user.id)
|
||||
send_notifications_read_all_event(user.id, notification_ids)
|
||||
invalidate_unread_summary(user_id)
|
||||
send_notifications_read_all_event(user_id, notification_ids)
|
||||
|
||||
transaction.on_commit(notify_after_commit)
|
||||
|
||||
|
||||
@ -34,6 +34,12 @@ 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",
|
||||
@ -96,12 +102,13 @@ class TestNotificationUnreadSummary:
|
||||
response = api_client.get("/api/v1/notifications/summary/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["total"] == 9
|
||||
assert response.data["total"] == 10
|
||||
assert {
|
||||
item["verb"]: item["unread"]
|
||||
for item in response.data["verbs"]
|
||||
} == {
|
||||
"procurement_department_request": 2,
|
||||
"procurement_item_commented": 1,
|
||||
"chat_new_message": 1,
|
||||
"task_assigned": 1,
|
||||
"task_comment": 1,
|
||||
@ -110,9 +117,12 @@ class TestNotificationUnreadSummary:
|
||||
"regulation_signed_all": 1,
|
||||
}
|
||||
assert response.data["procurement_requests"] == [
|
||||
{"request_id": 10, "unread": 1},
|
||||
{"request_id": 10, "unread": 2},
|
||||
{"request_id": 12, "unread": 1},
|
||||
]
|
||||
assert response.data["procurement_item_comments"] == [
|
||||
{"item_id": 101, "unread": 1},
|
||||
]
|
||||
assert response.data["task_boards"] == [
|
||||
{"board_id": 21, "unread": 2},
|
||||
{"board_id": 22, "unread": 1},
|
||||
|
||||
@ -997,6 +997,63 @@ class TestProcurementItemComments:
|
||||
]
|
||||
assert comment_activities.first().metadata["item_id"] == procurement_item.id
|
||||
|
||||
def test_item_comment_files_are_added_to_item_and_returned_with_comment(
|
||||
self,
|
||||
api_client,
|
||||
user,
|
||||
procurement_item,
|
||||
settings,
|
||||
tmp_path,
|
||||
):
|
||||
settings.MEDIA_ROOT = tmp_path
|
||||
api_client.force_authenticate(user=user)
|
||||
comments_url = reverse(
|
||||
'api:v1:procurement:procurementitem-comments',
|
||||
kwargs={'pk': procurement_item.id},
|
||||
)
|
||||
image_content = b'\x89PNG\r\n\x1a\nprocurement comment image'
|
||||
|
||||
create_response = api_client.post(
|
||||
comments_url,
|
||||
{
|
||||
'text': '',
|
||||
'files': SimpleUploadedFile(
|
||||
'position-photo.png',
|
||||
image_content,
|
||||
content_type='image/png',
|
||||
),
|
||||
},
|
||||
format='multipart',
|
||||
)
|
||||
|
||||
assert create_response.status_code == status.HTTP_201_CREATED
|
||||
assert create_response.data['text'] == ''
|
||||
assert len(create_response.data['attachments']) == 1
|
||||
attachment_data = create_response.data['attachments'][0]
|
||||
assert attachment_data['file_name'] == 'position-photo.png'
|
||||
assert attachment_data['mime_type'] == 'image/png'
|
||||
|
||||
attachment = ProcurementItemAttachment.objects.get(
|
||||
pk=attachment_data['id'],
|
||||
)
|
||||
assert attachment.item_id == procurement_item.id
|
||||
assert attachment.comment_id == create_response.data['id']
|
||||
|
||||
comments_response = api_client.get(comments_url)
|
||||
assert comments_response.status_code == status.HTTP_200_OK
|
||||
assert comments_response.data[0]['attachments'][0]['id'] == attachment.id
|
||||
|
||||
attachments_response = api_client.get(reverse(
|
||||
'api:v1:procurement:procurementitem-attachments',
|
||||
kwargs={'pk': procurement_item.id},
|
||||
))
|
||||
assert attachments_response.status_code == status.HTTP_200_OK
|
||||
assert attachments_response.data[0]['id'] == attachment.id
|
||||
|
||||
download_response = api_client.get(attachment_data['download_url'])
|
||||
assert download_response.status_code == status.HTTP_200_OK
|
||||
assert b''.join(download_response.streaming_content) == image_content
|
||||
|
||||
def test_comment_links_are_added_to_item_without_duplicates(
|
||||
self, api_client, user, procurement_item
|
||||
):
|
||||
@ -6063,7 +6120,8 @@ class TestProcurementRequestNotificationRead:
|
||||
"""Тесты прочтения уведомлений, связанных с конкретной закупкой."""
|
||||
|
||||
def test_marks_only_current_request_procurement_notifications_read(
|
||||
self, api_client, user, department, procurement_request
|
||||
self, api_client, user, department, procurement_request,
|
||||
procurement_item_factory,
|
||||
):
|
||||
request_content_type = ContentType.objects.get_for_model(
|
||||
ProcurementRequest,
|
||||
@ -6088,6 +6146,17 @@ class TestProcurementRequestNotificationRead:
|
||||
target_object_id=str(procurement_request.id),
|
||||
data={},
|
||||
)
|
||||
procurement_item = procurement_item_factory(
|
||||
request=procurement_request,
|
||||
)
|
||||
item_comment_notification = Notification.objects.create(
|
||||
recipient=user,
|
||||
verb="procurement_item_commented",
|
||||
data={
|
||||
"request_id": procurement_request.id,
|
||||
"item_id": procurement_item.id,
|
||||
},
|
||||
)
|
||||
other_request_notification = Notification.objects.create(
|
||||
recipient=user,
|
||||
verb="procurement_department_request",
|
||||
@ -6124,16 +6193,54 @@ class TestProcurementRequestNotificationRead:
|
||||
|
||||
matching_by_data.refresh_from_db()
|
||||
matching_by_target.refresh_from_db()
|
||||
item_comment_notification.refresh_from_db()
|
||||
other_request_notification.refresh_from_db()
|
||||
non_procurement_notification.refresh_from_db()
|
||||
already_read_notification.refresh_from_db()
|
||||
|
||||
assert matching_by_data.unread is False
|
||||
assert matching_by_target.unread is False
|
||||
assert item_comment_notification.unread is True
|
||||
assert other_request_notification.unread is True
|
||||
assert non_procurement_notification.unread is True
|
||||
assert already_read_notification.unread is False
|
||||
|
||||
def test_marks_item_comment_notifications_only_after_opening_item_comments(
|
||||
self, api_client, user, procurement_request, procurement_item_factory
|
||||
):
|
||||
item = procurement_item_factory(request=procurement_request)
|
||||
other_item = procurement_item_factory(request=procurement_request)
|
||||
matching_notification = Notification.objects.create(
|
||||
recipient=user,
|
||||
verb="procurement_item_commented",
|
||||
data={"request_id": procurement_request.id, "item_id": item.id},
|
||||
)
|
||||
other_item_notification = Notification.objects.create(
|
||||
recipient=user,
|
||||
verb="procurement_item_commented",
|
||||
data={
|
||||
"request_id": procurement_request.id,
|
||||
"item_id": other_item.id,
|
||||
},
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=user)
|
||||
url = reverse(
|
||||
'api:v1:procurement:procurementitem-mark-comment-notifications-read',
|
||||
kwargs={'pk': item.id},
|
||||
)
|
||||
|
||||
response = api_client.post(url)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["count"] == 1
|
||||
assert response.data["notification_ids"] == [matching_notification.id]
|
||||
|
||||
matching_notification.refresh_from_db()
|
||||
other_item_notification.refresh_from_db()
|
||||
assert matching_notification.unread is False
|
||||
assert other_item_notification.unread is True
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# ТЕСТЫ УДАЛЕНИЯ
|
||||
|
||||
@ -553,16 +553,22 @@ html[data-bs-theme="dark"] .app-input::-webkit-calendar-picker-indicator {
|
||||
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.55fr)
|
||||
minmax(0, 0.9fr)
|
||||
minmax(0, 0.7fr)
|
||||
minmax(0, 0.7fr)
|
||||
minmax(0, 0.9fr)
|
||||
minmax(0, 1fr)
|
||||
minmax(0, 0.9fr)
|
||||
minmax(0, 1.15fr);
|
||||
minmax(0, 1.15fr)
|
||||
minmax(0, 0.9fr);
|
||||
}
|
||||
|
||||
.procurement-items-table-row > * {
|
||||
@ -574,16 +580,55 @@ html[data-bs-theme="dark"] .app-input::-webkit-calendar-picker-indicator {
|
||||
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 {
|
||||
|
||||
@ -107,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);
|
||||
@ -447,6 +454,7 @@ export default function ProcurementPage() {
|
||||
expandedItemComments,
|
||||
itemCommentsMap,
|
||||
itemCommentDrafts,
|
||||
itemCommentFiles,
|
||||
setItemCommentDrafts,
|
||||
expandedIds,
|
||||
expandedComments,
|
||||
@ -518,9 +526,11 @@ export default function ProcurementPage() {
|
||||
scope,
|
||||
scopeCounts,
|
||||
unreadProcurementRequestCounts,
|
||||
unreadProcurementItemCommentCounts,
|
||||
handleAddComment,
|
||||
handleDeleteComment,
|
||||
handleAddItemComment,
|
||||
handleItemCommentFilesChange,
|
||||
handleDeleteItemComment,
|
||||
handleDownloadItemAttachment,
|
||||
handleUploadItemAttachments,
|
||||
@ -813,12 +823,6 @@ export default function ProcurementPage() {
|
||||
}
|
||||
}, [desktopWideLayout, ensureRequestDetail, markRequestNotificationsRead, syncRequestUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!desktopWideLayout || filteredRequests.length === 0) return;
|
||||
if (detailModalId && filteredRequests.some((request) => request.id === detailModalId)) return;
|
||||
void openDetailModal(filteredRequests[0].id);
|
||||
}, [desktopWideLayout, detailModalId, filteredRequests, openDetailModal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (initialRequestParamHandledRef.current) return;
|
||||
@ -1213,8 +1217,8 @@ export default function ProcurementPage() {
|
||||
|
||||
{/* ══════════ 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)] p-2" aria-label="Список заявок на закупку">
|
||||
<div className="app-divider sticky top-0 z-10 mb-2 flex items-center justify-between gap-2 border-b bg-[var(--surface-secondary)] px-2 py-2">
|
||||
<aside className="app-surface-muted relative isolate min-h-0 overflow-y-auto rounded-xl border border-[var(--border-subtle)] px-2 pb-2" aria-label="Список заявок на закупку">
|
||||
<div className="app-divider sticky top-0 z-30 mb-2 flex items-center justify-between gap-2 border-b bg-[var(--surface-secondary)] px-2 py-2 shadow-sm">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-[var(--muted-foreground)]">Заявки</span>
|
||||
<span className="app-counter inline-flex h-5 min-w-5 px-1 text-[10px]">{filteredRequests.length}</span>
|
||||
</div>
|
||||
@ -1229,32 +1233,80 @@ export default function ProcurementPage() {
|
||||
const urgency = urgencyMeta[req.urgency] ?? urgencyMeta.medium;
|
||||
const active = detailModalId === req.id;
|
||||
const amount = getRequestAmount(req);
|
||||
const hasUnreadNotifications = (unreadProcurementRequestCounts[req.id] || 0) > 0;
|
||||
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 (
|
||||
<button
|
||||
<article
|
||||
key={req.id}
|
||||
type="button"
|
||||
onClick={() => void openDetailModal(req.id)}
|
||||
className={`${active ? "app-selected border-[var(--accent-primary)]" : "app-surface hover:border-[var(--border-strong)]"} block w-full rounded-xl border p-3 text-left transition`}
|
||||
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}
|
||||
>
|
||||
<span className="flex min-w-0 items-start gap-2">
|
||||
{hasUnreadNotifications ? <span className="app-dot-accent mt-1.5 h-2 w-2 shrink-0 rounded-full" aria-label="Есть новые уведомления" /> : null}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-semibold text-[var(--foreground)]">#{req.id} · {req.title || "Без названия"}</span>
|
||||
<span className="app-text-muted mt-1 block truncate text-xs">{getDeptName(req)}</span>
|
||||
<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>
|
||||
<ChevronRight size={15} className="app-text-muted mt-0.5 shrink-0" />
|
||||
</span>
|
||||
<span className="mt-2 flex min-w-0 items-center justify-between gap-2">
|
||||
<span className="app-text-muted min-w-0 truncate text-xs">{getRequestListDateLabel(req, String(req.status || "").toLowerCase())}</span>
|
||||
<span className={`app-status-pill shrink-0 ${status.cls}`}>{status.label}</span>
|
||||
</span>
|
||||
<span className="mt-2 flex min-w-0 items-center justify-between gap-2 text-xs">
|
||||
<span className={`truncate font-medium ${urgency.cls}`}>{urgency.label} срочность</span>
|
||||
<span className="shrink-0 font-semibold text-[var(--foreground)] tabular-nums">{amount ? money(amount) : "—"}</span>
|
||||
</span>
|
||||
</button>
|
||||
<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>
|
||||
@ -1267,7 +1319,7 @@ export default function ProcurementPage() {
|
||||
|
||||
<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 min-h-64 flex-col items-center justify-center text-center">
|
||||
<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>
|
||||
@ -1288,8 +1340,17 @@ export default function ProcurementPage() {
|
||||
{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>
|
||||
<span>{fmt(selectedRequest.created_at)}</span>
|
||||
{getRequestAmount(selectedRequest) ? <span className="font-semibold text-[var(--foreground)]">{money(getRequestAmount(selectedRequest))}</span> : null}
|
||||
{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">
|
||||
@ -1334,9 +1395,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}
|
||||
@ -1346,10 +1410,18 @@ export default function ProcurementPage() {
|
||||
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>
|
||||
) : (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)]">
|
||||
@ -1365,20 +1437,12 @@ 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>
|
||||
) : (
|
||||
<div className="flex min-h-64 flex-col items-center justify-center text-center">
|
||||
<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>
|
||||
@ -1421,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 = (
|
||||
@ -1503,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"
|
||||
@ -1737,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)]">
|
||||
@ -1762,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}
|
||||
|
||||
@ -1790,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}
|
||||
@ -1973,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}
|
||||
@ -1985,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)]">
|
||||
@ -2010,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>
|
||||
) : (
|
||||
|
||||
@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { Download, FileText, Loader2, Play } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { apiClient } from "@/lib/api";
|
||||
import type { ProcurementItemAttachment } from "@/types/api";
|
||||
|
||||
function isImage(attachment: ProcurementItemAttachment): boolean {
|
||||
if (attachment.mime_type?.toLowerCase().startsWith("image/")) return true;
|
||||
return /\.(avif|bmp|gif|heic|heif|jpe?g|png|webp)$/i.test(attachment.file_name);
|
||||
}
|
||||
|
||||
function isVideo(attachment: ProcurementItemAttachment): boolean {
|
||||
if (attachment.mime_type?.toLowerCase().startsWith("video/")) return true;
|
||||
return /\.(m4v|mkv|mov|mp4|mpeg|mpg|ogv|webm)$/i.test(attachment.file_name);
|
||||
}
|
||||
|
||||
function formatFileSize(size: number): string {
|
||||
if (size < 1024) return `${size} Б`;
|
||||
if (size < 1024 * 1024) return `${Math.ceil(size / 1024)} КБ`;
|
||||
return `${(size / (1024 * 1024)).toLocaleString("ru-RU", {
|
||||
maximumFractionDigits: 1,
|
||||
})} МБ`;
|
||||
}
|
||||
|
||||
export function ProcurementCommentAttachment({
|
||||
itemId,
|
||||
attachment,
|
||||
onDownload,
|
||||
}: {
|
||||
itemId: number;
|
||||
attachment: ProcurementItemAttachment;
|
||||
onDownload: (attachment: ProcurementItemAttachment) => void | Promise<void>;
|
||||
}) {
|
||||
const image = isImage(attachment);
|
||||
const video = isVideo(attachment);
|
||||
const [source, setSource] = useState<string | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!image && !video) return;
|
||||
|
||||
let active = true;
|
||||
let objectUrl: string | null = null;
|
||||
|
||||
void apiClient
|
||||
.downloadProcurementItemAttachment(itemId, attachment)
|
||||
.then(({ blob }) => {
|
||||
if (!active) return;
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setSource(objectUrl);
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setFailed(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [attachment, image, itemId, video]);
|
||||
|
||||
if (image || video) {
|
||||
return (
|
||||
<div className="app-surface-elevated overflow-hidden rounded-lg border border-[var(--border-subtle)]">
|
||||
<div className="relative aspect-video min-h-32 bg-[var(--surface-secondary)]">
|
||||
{source && image ? (
|
||||
<Image
|
||||
src={source}
|
||||
alt={attachment.file_name}
|
||||
fill
|
||||
unoptimized
|
||||
sizes="(max-width: 640px) 100vw, 320px"
|
||||
className="object-contain"
|
||||
/>
|
||||
) : source && video ? (
|
||||
<video
|
||||
src={source}
|
||||
controls
|
||||
preload="metadata"
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<span className="app-text-muted absolute inset-0 flex items-center justify-center">
|
||||
{failed ? <Play size={20} /> : <Loader2 size={18} className="animate-spin" />}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onDownload(attachment)}
|
||||
className="flex w-full min-w-0 items-center gap-2 border-t border-[var(--border-subtle)] px-2.5 py-2 text-left"
|
||||
title={`Скачать ${attachment.file_name}`}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium text-[var(--foreground)]">
|
||||
{attachment.file_name}
|
||||
</span>
|
||||
<Download size={13} className="app-text-muted shrink-0" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onDownload(attachment)}
|
||||
className="app-surface-elevated flex min-w-0 items-center gap-2 rounded-lg border border-[var(--border-subtle)] px-2.5 py-2 text-left"
|
||||
title={`Скачать ${attachment.file_name}`}
|
||||
>
|
||||
<span className="app-selected flex h-8 w-8 shrink-0 items-center justify-center rounded-lg">
|
||||
<FileText size={15} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-xs font-medium text-[var(--foreground)]">
|
||||
{attachment.file_name}
|
||||
</span>
|
||||
<span className="app-text-muted mt-0.5 block text-[10px]">
|
||||
{formatFileSize(attachment.file_size)}
|
||||
</span>
|
||||
</span>
|
||||
<Download size={13} className="app-text-muted shrink-0" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@ -2,8 +2,9 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { AlertTriangle, Check, CheckCircle2, ChevronRight, CircleDot, Download, ExternalLink, MessageSquare, Minus, Paperclip, Plus, RotateCcw, Save, ShoppingCart, SlidersHorizontal, Trash2, X } from "lucide-react";
|
||||
import { AlertTriangle, Check, CheckCircle2, ChevronRight, CircleDot, Download, ExternalLink, MessageSquare, Minus, Paperclip, Pencil, Plus, RotateCcw, Save, ShoppingCart, SlidersHorizontal, Trash2, X } from "lucide-react";
|
||||
import { RequestAvatar } from "@/components/requests/RequestAvatar";
|
||||
import { ProcurementCommentAttachment } from "@/components/procurement/ProcurementCommentAttachment";
|
||||
import { ProcurementRequestHistory } from "@/components/procurement/ProcurementRequestHistory";
|
||||
import { CommentComposer, CommentDeleteButton } from "@/components/shared/CommentControls";
|
||||
import { Modal } from "@/components/ui";
|
||||
@ -34,9 +35,12 @@ 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>;
|
||||
@ -128,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") {
|
||||
@ -181,9 +192,12 @@ 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>;
|
||||
@ -213,9 +227,12 @@ function ProcurementItemCard({
|
||||
onCancelItemReceived,
|
||||
comments = [],
|
||||
commentDraft = "",
|
||||
commentFiles = [],
|
||||
commentsOpen = false,
|
||||
unreadCommentCount = 0,
|
||||
onToggleComments,
|
||||
onCommentDraftChange,
|
||||
onCommentFilesChange,
|
||||
onAddComment,
|
||||
onDeleteComment,
|
||||
onDownloadAttachment,
|
||||
@ -235,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);
|
||||
@ -245,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 || "Не выполнено";
|
||||
@ -257,8 +276,7 @@ function ProcurementItemCard({
|
||||
canConfirmOrder &&
|
||||
onConfirmItemOrdered &&
|
||||
requestedQuantity > 0 &&
|
||||
status !== "rejected" &&
|
||||
status !== "defective"
|
||||
status !== "rejected"
|
||||
);
|
||||
const canConfirmReceived = Boolean(
|
||||
canConfirmReceipt &&
|
||||
@ -403,15 +421,26 @@ 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 role={wideLayout ? "rowgroup" : undefined} className={wideLayout ? "border-b border-[var(--border-subtle)] text-xs last:border-b-0" : "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-items-table-row items-center" role="row">
|
||||
<div className="flex min-w-0 items-center gap-2 px-2 py-2.5" role="cell">
|
||||
<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={() => 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"
|
||||
@ -419,84 +448,120 @@ function ProcurementItemCard({
|
||||
>
|
||||
<ChevronRight size={14} className={`transition-transform duration-200 ${detailsOpen ? "rotate-90" : ""}`} />
|
||||
</button>
|
||||
<div className="min-w-0">
|
||||
<button type="button" onClick={() => setDetailsOpen((open) => !open)} className="block w-full text-left">
|
||||
<span className="app-text-wrap block font-semibold text-[var(--foreground)]">{item.name}</span>
|
||||
<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="px-2 py-2.5 font-medium text-[var(--foreground)]" role="cell">
|
||||
{item.quantity} {itemUnitLabel}
|
||||
</div>
|
||||
<div className="space-y-1 px-2 py-2.5 tabular-nums" role="cell">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<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><span className="app-text-muted">Заказано</span> {orderedQuantity}/{requestedQuantity}</span>
|
||||
</div>
|
||||
<div><span className="app-text-muted">Получено</span> {receivedQuantity}/{requestedQuantity}</div>
|
||||
<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="px-2 py-2.5" role="cell">
|
||||
<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-1.5 py-0.5 text-[10px] font-medium`}>
|
||||
<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="space-y-1 px-2 py-2.5 text-right tabular-nums" role="cell">
|
||||
<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>
|
||||
<div className="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 className="flex flex-wrap items-center gap-1 px-2 py-2.5" role="cell" aria-label={`Действия для позиции ${item.name}`}>
|
||||
<button type="button" onClick={() => void onToggleComments?.(item.id)} className="app-icon-button relative inline-flex h-7 w-7 items-center justify-center rounded-lg" title="Комментарии">
|
||||
<MessageSquare size={13} />
|
||||
{commentsTotal > 0 ? <span className="app-counter absolute -right-1 -top-1 inline-flex h-4 min-w-4 items-center justify-center px-1 text-[9px] text-white">{commentsTotal}</span> : null}
|
||||
</button>
|
||||
<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={() => setProcessingOpen((open) => !open)} className="app-icon-button inline-flex h-7 w-7 items-center justify-center rounded-lg" title="Исполнение">
|
||||
<SlidersHorizontal size={13} />
|
||||
<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}
|
||||
{canConfirmOrdered ? (
|
||||
<button type="button" onClick={openOrderModal} disabled={busyKey === `item-confirm-ordered-${item.id}`} className="app-icon-button inline-flex h-7 w-7 items-center justify-center rounded-lg text-sky-500 disabled:opacity-60" title="Подтвердить заказ">
|
||||
<ShoppingCart size={13} />
|
||||
</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>
|
||||
) : null}
|
||||
{canConfirmReceived ? (
|
||||
<button type="button" onClick={handleConfirmReceiptClick} disabled={busyKey === `item-confirm-received-${item.id}`} className="app-icon-button inline-flex h-7 w-7 items-center justify-center rounded-lg text-emerald-500 disabled:opacity-60" title="Подтвердить получение">
|
||||
<CheckCircle2 size={13} />
|
||||
</button>
|
||||
) : null}
|
||||
{canCancelReceived ? (
|
||||
<button type="button" onClick={handleCancelReceiptClick} disabled={busyKey === `item-cancel-received-${item.id}`} className="app-icon-button inline-flex h-7 w-7 items-center justify-center rounded-lg disabled:opacity-60" title="Отменить получение">
|
||||
<RotateCcw size={13} />
|
||||
</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-7 w-7 items-center justify-center rounded-lg text-amber-500 disabled:opacity-60" title="Отметить брак">
|
||||
<AlertTriangle size={13} />
|
||||
</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-7 w-7 items-center justify-center rounded-lg text-amber-500 disabled:opacity-60" title="Отменить брак">
|
||||
<RotateCcw size={13} />
|
||||
</button>
|
||||
) : null}
|
||||
))}
|
||||
{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"
|
||||
@ -510,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>
|
||||
@ -541,10 +606,11 @@ function ProcurementItemCard({
|
||||
|
||||
<div
|
||||
id={`procurement-item-${item.id}-details`}
|
||||
className={detailsOpen ? "" : "hidden"}
|
||||
className={`${wideLayout ? (widePanelOpen ? "" : "hidden") : (detailsOpen ? "" : "hidden")} ${wideLayout ? "procurement-item-wide-details" : ""}`}
|
||||
>
|
||||
{wideLayout ? (
|
||||
<div className="border-t border-[var(--border-subtle)] bg-[var(--surface-primary)] px-3 py-3">
|
||||
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>
|
||||
@ -586,6 +652,7 @@ function ProcurementItemCard({
|
||||
</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)]">
|
||||
@ -756,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} />
|
||||
@ -826,12 +893,24 @@ function ProcurementItemCard({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{commentsOpen ? (
|
||||
<div className={`${wideLayout ? "border-t border-[var(--border-subtle)] bg-[var(--surface-primary)] px-3 py-3" : "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),
|
||||
);
|
||||
@ -852,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={`${wideLayout ? "border-t border-[var(--border-subtle)]" : "mt-3 rounded-lg"} grid gap-2 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
|
||||
@ -1301,9 +1389,12 @@ export function ProcurementRequestDetailContent({
|
||||
onMarkAllReceived,
|
||||
itemCommentsMap = {},
|
||||
itemCommentDrafts = {},
|
||||
itemCommentFiles = {},
|
||||
expandedItemComments = {},
|
||||
unreadItemCommentCounts = {},
|
||||
onToggleItemComments,
|
||||
onItemCommentDraftChange,
|
||||
onItemCommentFilesChange,
|
||||
onAddItemComment,
|
||||
onDeleteItemComment,
|
||||
onDownloadItemAttachment,
|
||||
@ -1382,9 +1473,12 @@ export function ProcurementRequestDetailContent({
|
||||
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}
|
||||
@ -1397,6 +1491,7 @@ export function ProcurementRequestDetailContent({
|
||||
return (
|
||||
<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)]">
|
||||
@ -1426,6 +1521,7 @@ export function ProcurementRequestDetailContent({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{displayItems.length > 0 ? (
|
||||
<div className={`${wideLayout ? "procurement-detail-layout-full" : ""} app-surface rounded-xl p-4`}>
|
||||
@ -1445,14 +1541,14 @@ export function ProcurementRequestDetailContent({
|
||||
{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 app-surface-muted border-b border-[var(--border-subtle)] text-[10px] font-semibold uppercase tracking-wide text-[var(--muted-foreground)]" role="row">
|
||||
<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" 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 className="px-2 py-2" role="columnheader">Действия</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>{renderedItems}</div>
|
||||
@ -1463,6 +1559,8 @@ export function ProcurementRequestDetailContent({
|
||||
</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>
|
||||
@ -1522,8 +1620,10 @@ export function ProcurementRequestDetailContent({
|
||||
<div className="min-w-0">
|
||||
<ProcurementRequestHistory request={request} displayUserName={displayUserName} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{footer ? <div className="min-w-0">{footer}</div> : null}
|
||||
{footer ? <div className={wideLayout ? "procurement-detail-support-comments min-w-0" : "min-w-0"}>{footer}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -40,6 +40,7 @@ interface NotificationsContextType {
|
||||
unreadCount: number;
|
||||
unreadCategoryCounts: Record<string, number>;
|
||||
unreadProcurementRequestCounts: Record<number, number>;
|
||||
unreadProcurementItemCommentCounts: Record<number, number>;
|
||||
unreadTaskBoardCounts: Record<number, number>;
|
||||
unreadRegulationDepartmentCounts: Record<number, number>;
|
||||
unreadCompanyRegulationCount: number;
|
||||
@ -66,6 +67,7 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [unreadCategoryCounts, setUnreadCategoryCounts] = useState<Record<string, 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);
|
||||
@ -81,6 +83,7 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
|
||||
total?: number;
|
||||
verbs?: Array<{ verb?: string; 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;
|
||||
@ -92,6 +95,9 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
|
||||
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
|
||||
: [];
|
||||
@ -115,6 +121,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);
|
||||
@ -135,6 +150,7 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
|
||||
|
||||
setUnreadCategoryCounts(nextCategoryCounts);
|
||||
setUnreadProcurementRequestCounts(nextProcurementRequestCounts);
|
||||
setUnreadProcurementItemCommentCounts(nextProcurementItemCommentCounts);
|
||||
setUnreadTaskBoardCounts(nextTaskBoardCounts);
|
||||
setUnreadRegulationDepartmentCounts(nextRegulationDepartmentCounts);
|
||||
setUnreadCompanyRegulationCount(
|
||||
@ -220,6 +236,7 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
|
||||
setUnreadCount(0);
|
||||
setUnreadCategoryCounts({});
|
||||
setUnreadProcurementRequestCounts({});
|
||||
setUnreadProcurementItemCommentCounts({});
|
||||
setUnreadTaskBoardCounts({});
|
||||
setUnreadRegulationDepartmentCounts({});
|
||||
setUnreadCompanyRegulationCount(0);
|
||||
@ -276,6 +293,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);
|
||||
@ -408,6 +434,7 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
|
||||
setUnreadCount(0);
|
||||
setUnreadCategoryCounts({});
|
||||
setUnreadProcurementRequestCounts({});
|
||||
setUnreadProcurementItemCommentCounts({});
|
||||
setUnreadTaskBoardCounts({});
|
||||
setUnreadRegulationDepartmentCounts({});
|
||||
setUnreadCompanyRegulationCount(0);
|
||||
@ -434,6 +461,7 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
|
||||
});
|
||||
if (category === 'Закупки') {
|
||||
setUnreadProcurementRequestCounts({});
|
||||
setUnreadProcurementItemCommentCounts({});
|
||||
}
|
||||
if (category === 'Доска') {
|
||||
setUnreadTaskBoardCounts({});
|
||||
@ -482,6 +510,7 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
|
||||
unreadCount,
|
||||
unreadCategoryCounts,
|
||||
unreadProcurementRequestCounts,
|
||||
unreadProcurementItemCommentCounts,
|
||||
unreadTaskBoardCounts,
|
||||
unreadRegulationDepartmentCounts,
|
||||
unreadCompanyRegulationCount,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -16,6 +16,10 @@ export type UnreadNotificationsSummary = {
|
||||
request_id: number;
|
||||
unread: number;
|
||||
}>;
|
||||
procurement_item_comments?: Array<{
|
||||
item_id: number;
|
||||
unread: number;
|
||||
}>;
|
||||
task_boards?: Array<{
|
||||
board_id: number;
|
||||
unread: number;
|
||||
|
||||
@ -40,6 +40,8 @@ export function createProcurementApi(request: RequestFn, requestRaw: RawRequestF
|
||||
request(`/api/v1/procurement/requests/${id}/set_viewed/`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ is_viewed: isViewed }) }),
|
||||
markProcurementRequestNotificationsRead: (id: number): Promise<{ status: string; count: number; notification_ids: number[] }> =>
|
||||
request(`/api/v1/procurement/requests/${id}/notifications/read/`, { method: 'POST' }),
|
||||
markProcurementItemCommentNotificationsRead: (itemId: number): Promise<{ status: string; count: number; notification_ids: number[] }> =>
|
||||
request(`/api/v1/procurement/items/${itemId}/comment-notifications/read/`, { method: 'POST' }),
|
||||
cancelProcurementRequest: (id: number, reason?: string) =>
|
||||
request(`/api/v1/procurement/requests/${id}/cancel/`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reason: reason || '' }) }),
|
||||
getProcurementComments: (requestId: number) => request(`/api/v1/procurement/requests/${requestId}/comments/`),
|
||||
@ -79,8 +81,19 @@ export function createProcurementApi(request: RequestFn, requestRaw: RawRequestF
|
||||
}),
|
||||
deleteProcurementItem: (id: number): Promise<void> => request(`/api/v1/procurement/items/${id}/`, { method: 'DELETE' }),
|
||||
getProcurementItemComments: (itemId: number) => request(`/api/v1/procurement/items/${itemId}/comments/`),
|
||||
addProcurementItemComment: (itemId: number, text: string): Promise<ProcurementItemComment> =>
|
||||
request(`/api/v1/procurement/items/${itemId}/comments/`, { method: 'POST', body: JSON.stringify({ text }) }),
|
||||
addProcurementItemComment: (
|
||||
itemId: number,
|
||||
text: string,
|
||||
files: File[] = [],
|
||||
): Promise<ProcurementItemComment> => {
|
||||
const formData = new FormData();
|
||||
formData.append('text', text);
|
||||
files.forEach((file) => formData.append('files', file));
|
||||
return request(`/api/v1/procurement/items/${itemId}/comments/`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
},
|
||||
deleteProcurementItemComment: (itemId: number, commentId: number): Promise<void> =>
|
||||
request(`/api/v1/procurement/items/${itemId}/comments/${commentId}/`, { method: 'DELETE' }),
|
||||
getProcurementItemAttachments: (itemId: number): Promise<ProcurementItemAttachment[]> =>
|
||||
|
||||
@ -1422,6 +1422,7 @@ export interface ProcurementItemComment extends ProcurementComment {
|
||||
item: number;
|
||||
item_links?: string[];
|
||||
added_links?: string[];
|
||||
attachments?: ProcurementItemAttachment[];
|
||||
}
|
||||
|
||||
export interface ProcurementSupplier {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user