improve procurement request workspace

This commit is contained in:
lizerk 2026-08-07 14:02:42 +03:00
parent a8926987e2
commit c0092437e1
17 changed files with 906 additions and 211 deletions

View File

@ -7,12 +7,12 @@ from django.contrib.contenttypes.models import ContentType
from django.db import transaction
from django.http import FileResponse, Http404
from drf_spectacular.utils import OpenApiParameter, extend_schema
from django.db.models import Q
from django.db.models import Prefetch, Q
from django.utils import timezone
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import filters, permissions, status, viewsets
from rest_framework.decorators import action
from rest_framework.parsers import MultiPartParser
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.response import Response
from employees.models import Department, EmployeeDepartment
@ -1508,6 +1508,7 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
"create_equipment",
"link_equipment",
"comments",
"mark_comment_notifications_read",
"delete_comment",
"report_issue",
"confirm_ordered",
@ -2191,13 +2192,33 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
self._check_item_edit_permission(instance)
instance.delete()
@action(detail=True, methods=["get", "post"])
def _item_comment_payload(self, item, message):
attachments = [
attachment
for attachment in message.procurement_item_attachments.all()
if attachment.item_id == item.id
]
return {
"id": message.id,
"item": item.id,
"request": item.request_id,
"author": EmployeeBriefSerializer(message.author).data,
"text": message.content,
"created_at": message.created_at,
"attachments": ProcurementItemAttachmentSerializer(
attachments,
many=True,
context=self.get_serializer_context(),
).data,
}
@action(
detail=True,
methods=["get", "post"],
parser_classes=[JSONParser, MultiPartParser, FormParser],
)
def comments(self, request, pk=None):
"""Список/создание комментариев для позиции заявки."""
from api.v1.employees.serializers.employee import (
EmployeeBriefSerializer,
)
item = self.get_object()
if not self._can_access_item_comments(request.user, item.request):
return Response(
@ -2206,38 +2227,68 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
)
if request.method in ("GET", "HEAD"):
messages = comments_helpers.get_comments(item)
comments_data = []
for msg in messages:
author_ser = EmployeeBriefSerializer(msg.author)
comments_data.append(
{
"id": msg.id,
"item": item.id,
"request": item.request_id,
"author": author_ser.data,
"text": msg.content,
"created_at": msg.created_at,
}
messages = comments_helpers.get_comments(item).prefetch_related(
Prefetch(
"procurement_item_attachments",
queryset=ProcurementItemAttachment.objects.filter(
item=item,
).select_related("uploaded_by"),
)
return Response(comments_data)
)
return Response([
self._item_comment_payload(item, message)
for message in messages
])
text = request.data.get("text", "").strip()
if not text:
files = request.FILES.getlist("files")
if not files and request.FILES.get("file"):
files = [request.FILES["file"]]
if not text and not files:
return Response(
{"text": ["Это поле не может быть пустым."]},
{"detail": "Введите комментарий или добавьте файл."},
status=status.HTTP_400_BAD_REQUEST,
)
message = comments_helpers.create_comment(
obj=item,
author=request.user,
content=text,
)
item_links, added_links = merge_comment_links(item.links, text)
if added_links:
item.links = item_links
item.save(update_fields=["links"])
with transaction.atomic():
message = comments_helpers.create_comment(
obj=item,
author=request.user,
content=text,
)
created_attachments = []
for uploaded_file in files:
attachment = ProcurementItemAttachment.objects.create(
item=item,
comment=message,
file=uploaded_file,
file_name=Path(uploaded_file.name.replace("\\", "/")).name,
file_size=uploaded_file.size,
mime_type=uploaded_file.content_type or "",
uploaded_by=request.user,
)
created_attachments.append(attachment)
create_procurement_activity(
item.request,
request.user,
ProcurementActivityAction.ATTACHMENT_ADDED,
object_kind=ProcurementActivityObjectKind.ATTACHMENT,
object_id=attachment.pk,
metadata={
"item_id": item.pk,
"item_name": item.name,
"file_name": attachment.file_name,
"comment_id": message.pk,
},
)
if created_attachments:
message.has_attachments = True
message.save(update_fields=["has_attachments"])
item_links, added_links = merge_comment_links(item.links, text)
if added_links:
item.links = item_links
item.save(update_fields=["links"])
notify_item_comment(
item,
message,
@ -2256,19 +2307,35 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
"added_links": added_links,
},
)
author_ser = EmployeeBriefSerializer(message.author)
payload = self._item_comment_payload(item, message)
payload.update({
"item_links": item_links,
"added_links": added_links,
})
return Response(payload, status=status.HTTP_201_CREATED)
@extend_schema(
responses=ProcurementNotificationReadResponseSerializer,
summary="Отметить уведомления о комментариях позиции как прочитанные",
)
@action(
detail=True,
methods=["post"],
url_path="comment-notifications/read",
)
def mark_comment_notifications_read(self, request, pk=None):
item = self.get_object()
self._check_item_read_permission(item)
result = ProcurementNotificationReadService().mark_item_comment_notifications_as_read(
user=request.user,
item=item,
)
return Response(
{
"id": message.id,
"item": item.id,
"request": item.request_id,
"author": author_ser.data,
"text": message.content,
"created_at": message.created_at,
"item_links": item_links,
"added_links": added_links,
},
status=status.HTTP_201_CREATED,
"status": "success",
"count": result.count,
"notification_ids": result.notification_ids,
}
)
@extend_schema(

View File

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

View File

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

View File

@ -0,0 +1,25 @@
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("communications", "0039_alter_chat_context_object_id_bigint"),
("procurement", "0017_add_item_defective_at"),
]
operations = [
migrations.AddField(
model_name="procurementitemattachment",
name="comment",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="procurement_item_attachments",
to="communications.message",
verbose_name="Комментарий",
),
),
]

View File

@ -680,6 +680,14 @@ class ProcurementItemAttachment(models.Model):
related_name="attachments",
verbose_name="Позиция",
)
comment = models.ForeignKey(
"communications.Message",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="procurement_item_attachments",
verbose_name="Комментарий",
)
file = models.FileField(
"Файл",
upload_to=procurement_item_attachment_upload_to,

View File

@ -8,7 +8,7 @@ from django.utils import timezone
from notifications.cache import invalidate_unread_summary
from notifications.models import Notification
from notifications.realtime import send_notifications_read_all_event
from procurement.models import ProcurementRequest
from procurement.models import ProcurementItem, ProcurementRequest
@dataclass(frozen=True)
@ -34,7 +34,32 @@ class ProcurementNotificationReadService:
queryset = self._get_unread_queryset(
user_id=user.id,
procurement_request=procurement_request,
).exclude(verb="procurement_item_commented")
return self._mark_queryset_as_read(user_id=user.id, queryset=queryset)
def mark_item_comment_notifications_as_read(
self,
*,
user,
item: ProcurementItem,
) -> ProcurementNotificationReadResult:
item_id = item.id
queryset = Notification.objects.filter(
recipient_id=user.id,
unread=True,
deleted=False,
verb="procurement_item_commented",
).filter(
Q(data__item_id=item_id) | Q(data__item_id=str(item_id))
)
return self._mark_queryset_as_read(user_id=user.id, queryset=queryset)
def _mark_queryset_as_read(
self,
*,
user_id: int,
queryset,
) -> ProcurementNotificationReadResult:
notification_ids = list(queryset.values_list("id", flat=True))
if not notification_ids:
@ -51,11 +76,11 @@ class ProcurementNotificationReadService:
timestamp_read=read_at,
)
invalidate_unread_summary(user.id)
invalidate_unread_summary(user_id)
def notify_after_commit():
invalidate_unread_summary(user.id)
send_notifications_read_all_event(user.id, notification_ids)
invalidate_unread_summary(user_id)
send_notifications_read_all_event(user_id, notification_ids)
transaction.on_commit(notify_after_commit)

View File

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

View File

@ -997,6 +997,63 @@ class TestProcurementItemComments:
]
assert comment_activities.first().metadata["item_id"] == procurement_item.id
def test_item_comment_files_are_added_to_item_and_returned_with_comment(
self,
api_client,
user,
procurement_item,
settings,
tmp_path,
):
settings.MEDIA_ROOT = tmp_path
api_client.force_authenticate(user=user)
comments_url = reverse(
'api:v1:procurement:procurementitem-comments',
kwargs={'pk': procurement_item.id},
)
image_content = b'\x89PNG\r\n\x1a\nprocurement comment image'
create_response = api_client.post(
comments_url,
{
'text': '',
'files': SimpleUploadedFile(
'position-photo.png',
image_content,
content_type='image/png',
),
},
format='multipart',
)
assert create_response.status_code == status.HTTP_201_CREATED
assert create_response.data['text'] == ''
assert len(create_response.data['attachments']) == 1
attachment_data = create_response.data['attachments'][0]
assert attachment_data['file_name'] == 'position-photo.png'
assert attachment_data['mime_type'] == 'image/png'
attachment = ProcurementItemAttachment.objects.get(
pk=attachment_data['id'],
)
assert attachment.item_id == procurement_item.id
assert attachment.comment_id == create_response.data['id']
comments_response = api_client.get(comments_url)
assert comments_response.status_code == status.HTTP_200_OK
assert comments_response.data[0]['attachments'][0]['id'] == attachment.id
attachments_response = api_client.get(reverse(
'api:v1:procurement:procurementitem-attachments',
kwargs={'pk': procurement_item.id},
))
assert attachments_response.status_code == status.HTTP_200_OK
assert attachments_response.data[0]['id'] == attachment.id
download_response = api_client.get(attachment_data['download_url'])
assert download_response.status_code == status.HTTP_200_OK
assert b''.join(download_response.streaming_content) == image_content
def test_comment_links_are_added_to_item_without_duplicates(
self, api_client, user, procurement_item
):
@ -6063,7 +6120,8 @@ class TestProcurementRequestNotificationRead:
"""Тесты прочтения уведомлений, связанных с конкретной закупкой."""
def test_marks_only_current_request_procurement_notifications_read(
self, api_client, user, department, procurement_request
self, api_client, user, department, procurement_request,
procurement_item_factory,
):
request_content_type = ContentType.objects.get_for_model(
ProcurementRequest,
@ -6088,6 +6146,17 @@ class TestProcurementRequestNotificationRead:
target_object_id=str(procurement_request.id),
data={},
)
procurement_item = procurement_item_factory(
request=procurement_request,
)
item_comment_notification = Notification.objects.create(
recipient=user,
verb="procurement_item_commented",
data={
"request_id": procurement_request.id,
"item_id": procurement_item.id,
},
)
other_request_notification = Notification.objects.create(
recipient=user,
verb="procurement_department_request",
@ -6124,16 +6193,54 @@ class TestProcurementRequestNotificationRead:
matching_by_data.refresh_from_db()
matching_by_target.refresh_from_db()
item_comment_notification.refresh_from_db()
other_request_notification.refresh_from_db()
non_procurement_notification.refresh_from_db()
already_read_notification.refresh_from_db()
assert matching_by_data.unread is False
assert matching_by_target.unread is False
assert item_comment_notification.unread is True
assert other_request_notification.unread is True
assert non_procurement_notification.unread is True
assert already_read_notification.unread is False
def test_marks_item_comment_notifications_only_after_opening_item_comments(
self, api_client, user, procurement_request, procurement_item_factory
):
item = procurement_item_factory(request=procurement_request)
other_item = procurement_item_factory(request=procurement_request)
matching_notification = Notification.objects.create(
recipient=user,
verb="procurement_item_commented",
data={"request_id": procurement_request.id, "item_id": item.id},
)
other_item_notification = Notification.objects.create(
recipient=user,
verb="procurement_item_commented",
data={
"request_id": procurement_request.id,
"item_id": other_item.id,
},
)
api_client.force_authenticate(user=user)
url = reverse(
'api:v1:procurement:procurementitem-mark-comment-notifications-read',
kwargs={'pk': item.id},
)
response = api_client.post(url)
assert response.status_code == status.HTTP_200_OK
assert response.data["count"] == 1
assert response.data["notification_ids"] == [matching_notification.id]
matching_notification.refresh_from_db()
other_item_notification.refresh_from_db()
assert matching_notification.unread is False
assert other_item_notification.unread is True
# ==============================================================================
# ТЕСТЫ УДАЛЕНИЯ

View File

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

View File

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

View File

@ -0,0 +1,127 @@
"use client";
import Image from "next/image";
import { Download, FileText, Loader2, Play } from "lucide-react";
import { useEffect, useState } from "react";
import { apiClient } from "@/lib/api";
import type { ProcurementItemAttachment } from "@/types/api";
function isImage(attachment: ProcurementItemAttachment): boolean {
if (attachment.mime_type?.toLowerCase().startsWith("image/")) return true;
return /\.(avif|bmp|gif|heic|heif|jpe?g|png|webp)$/i.test(attachment.file_name);
}
function isVideo(attachment: ProcurementItemAttachment): boolean {
if (attachment.mime_type?.toLowerCase().startsWith("video/")) return true;
return /\.(m4v|mkv|mov|mp4|mpeg|mpg|ogv|webm)$/i.test(attachment.file_name);
}
function formatFileSize(size: number): string {
if (size < 1024) return `${size} Б`;
if (size < 1024 * 1024) return `${Math.ceil(size / 1024)} КБ`;
return `${(size / (1024 * 1024)).toLocaleString("ru-RU", {
maximumFractionDigits: 1,
})} МБ`;
}
export function ProcurementCommentAttachment({
itemId,
attachment,
onDownload,
}: {
itemId: number;
attachment: ProcurementItemAttachment;
onDownload: (attachment: ProcurementItemAttachment) => void | Promise<void>;
}) {
const image = isImage(attachment);
const video = isVideo(attachment);
const [source, setSource] = useState<string | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
if (!image && !video) return;
let active = true;
let objectUrl: string | null = null;
void apiClient
.downloadProcurementItemAttachment(itemId, attachment)
.then(({ blob }) => {
if (!active) return;
objectUrl = URL.createObjectURL(blob);
setSource(objectUrl);
})
.catch(() => {
if (active) setFailed(true);
});
return () => {
active = false;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [attachment, image, itemId, video]);
if (image || video) {
return (
<div className="app-surface-elevated overflow-hidden rounded-lg border border-[var(--border-subtle)]">
<div className="relative aspect-video min-h-32 bg-[var(--surface-secondary)]">
{source && image ? (
<Image
src={source}
alt={attachment.file_name}
fill
unoptimized
sizes="(max-width: 640px) 100vw, 320px"
className="object-contain"
/>
) : source && video ? (
<video
src={source}
controls
preload="metadata"
className="h-full w-full object-contain"
/>
) : (
<span className="app-text-muted absolute inset-0 flex items-center justify-center">
{failed ? <Play size={20} /> : <Loader2 size={18} className="animate-spin" />}
</span>
)}
</div>
<button
type="button"
onClick={() => void onDownload(attachment)}
className="flex w-full min-w-0 items-center gap-2 border-t border-[var(--border-subtle)] px-2.5 py-2 text-left"
title={`Скачать ${attachment.file_name}`}
>
<span className="min-w-0 flex-1 truncate text-xs font-medium text-[var(--foreground)]">
{attachment.file_name}
</span>
<Download size={13} className="app-text-muted shrink-0" />
</button>
</div>
);
}
return (
<button
type="button"
onClick={() => void onDownload(attachment)}
className="app-surface-elevated flex min-w-0 items-center gap-2 rounded-lg border border-[var(--border-subtle)] px-2.5 py-2 text-left"
title={`Скачать ${attachment.file_name}`}
>
<span className="app-selected flex h-8 w-8 shrink-0 items-center justify-center rounded-lg">
<FileText size={15} />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-medium text-[var(--foreground)]">
{attachment.file_name}
</span>
<span className="app-text-muted mt-0.5 block text-[10px]">
{formatFileSize(attachment.file_size)}
</span>
</span>
<Download size={13} className="app-text-muted shrink-0" />
</button>
);
}

View File

@ -2,8 +2,9 @@
import { useState } from "react";
import Link from "next/link";
import { AlertTriangle, Check, CheckCircle2, ChevronRight, CircleDot, Download, ExternalLink, MessageSquare, Minus, Paperclip, Plus, RotateCcw, Save, ShoppingCart, SlidersHorizontal, Trash2, X } from "lucide-react";
import { AlertTriangle, Check, CheckCircle2, ChevronRight, CircleDot, Download, ExternalLink, MessageSquare, Minus, Paperclip, Pencil, Plus, RotateCcw, Save, ShoppingCart, SlidersHorizontal, Trash2, X } from "lucide-react";
import { RequestAvatar } from "@/components/requests/RequestAvatar";
import { ProcurementCommentAttachment } from "@/components/procurement/ProcurementCommentAttachment";
import { ProcurementRequestHistory } from "@/components/procurement/ProcurementRequestHistory";
import { CommentComposer, CommentDeleteButton } from "@/components/shared/CommentControls";
import { Modal } from "@/components/ui";
@ -34,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>
);

View File

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

View File

@ -146,6 +146,7 @@ export function useProcurementPage(user: User | null) {
const {
refreshUnreadSummary,
unreadProcurementRequestCounts,
unreadProcurementItemCommentCounts,
} = useNotifications();
const canManage = canManageRequests(user);
const canSupplierManage = canManageSupplier(user);
@ -199,6 +200,7 @@ export function useProcurementPage(user: User | null) {
const [expandedItemComments, setExpandedItemComments] = useState<Record<number, boolean>>({});
const [itemCommentsMap, setItemCommentsMap] = useState<Record<number, ProcurementItemComment[]>>({});
const [itemCommentDrafts, setItemCommentDrafts] = useState<Record<number, string>>({});
const [itemCommentFiles, setItemCommentFiles] = useState<Record<number, File[]>>({});
const [detailsCache, setDetailsCache] = useState<Record<number, ProcurementRequest>>({});
const detailsCacheRef = useRef<Record<number, ProcurementRequest>>({});
const hasLoadedOnceRef = useRef(false);
@ -926,14 +928,24 @@ export function useProcurementPage(user: User | null) {
const isOpen = Boolean(expandedItemComments[itemId]);
setExpandedItemComments((previous) => ({ ...previous, [itemId]: !isOpen }));
if (!isOpen && !itemCommentsMap[itemId]) {
if (!isOpen) {
try {
await ensureItemCommentsLoaded(itemId);
if (!itemCommentsMap[itemId]) {
await ensureItemCommentsLoaded(itemId);
}
} catch {
setActionError("Не удалось загрузить комментарии позиции");
return;
}
try {
await apiClient.markProcurementItemCommentNotificationsRead(itemId);
await refreshUnreadSummary();
} catch (readError) {
console.error("Mark procurement item comment notifications read error:", readError);
}
}
}, [ensureItemCommentsLoaded, expandedItemComments, itemCommentsMap]);
}, [ensureItemCommentsLoaded, expandedItemComments, itemCommentsMap, refreshUnreadSummary]);
const handleAddComment = useCallback(async (id: number) => {
const text = (commentDrafts[id] || "").trim();
@ -970,20 +982,33 @@ export function useProcurementPage(user: User | null) {
const handleAddItemComment = useCallback(async (requestId: number, itemId: number) => {
const text = (itemCommentDrafts[itemId] || "").trim();
if (!text) return;
const files = itemCommentFiles[itemId] || [];
if (!text && files.length === 0) return;
try {
setBusyKey(`item-comment-${itemId}`);
const created = await apiClient.addProcurementItemComment(itemId, text);
const created = await apiClient.addProcurementItemComment(itemId, text, files);
setItemCommentsMap((previous) => ({
...previous,
[itemId]: [...(previous[itemId] || []), created],
}));
setItemCommentDrafts((previous) => ({ ...previous, [itemId]: "" }));
setItemCommentFiles((previous) => ({ ...previous, [itemId]: [] }));
updateCachedItem(requestId, itemId, (item) => ({
...item,
comments_count: (item.comments_count || 0) + 1,
links: created.item_links ?? item.links,
attachments: created.attachments?.length
? [
...(item.attachments || []).filter((attachment) => (
!created.attachments?.some((createdAttachment) => createdAttachment.id === attachment.id)
)),
...created.attachments,
]
: item.attachments,
attachments_count: created.attachments?.length
? (item.attachments_count || item.attachments?.length || 0) + created.attachments.length
: item.attachments_count,
}));
await markRequestNotificationsRead(requestId);
} catch {
@ -991,7 +1016,11 @@ export function useProcurementPage(user: User | null) {
} finally {
setBusyKey(null);
}
}, [itemCommentDrafts, markRequestNotificationsRead, updateCachedItem]);
}, [itemCommentDrafts, itemCommentFiles, markRequestNotificationsRead, updateCachedItem]);
const handleItemCommentFilesChange = useCallback((itemId: number, files: File[]) => {
setItemCommentFiles((previous) => ({ ...previous, [itemId]: files }));
}, []);
const handleDeleteComment = useCallback(async (requestId: number, commentId: number) => {
try {
@ -1363,6 +1392,7 @@ export function useProcurementPage(user: User | null) {
nextPage,
scopeCounts,
unreadProcurementRequestCounts,
unreadProcurementItemCommentCounts,
scope,
setScope,
searchQuery,
@ -1404,6 +1434,8 @@ export function useProcurementPage(user: User | null) {
expandedItemComments,
itemCommentsMap,
itemCommentDrafts,
itemCommentFiles,
handleItemCommentFilesChange,
setItemCommentDrafts,
setCommentDrafts,
detailsCache,

View File

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

View File

@ -40,6 +40,8 @@ export function createProcurementApi(request: RequestFn, requestRaw: RawRequestF
request(`/api/v1/procurement/requests/${id}/set_viewed/`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ is_viewed: isViewed }) }),
markProcurementRequestNotificationsRead: (id: number): Promise<{ status: string; count: number; notification_ids: number[] }> =>
request(`/api/v1/procurement/requests/${id}/notifications/read/`, { method: 'POST' }),
markProcurementItemCommentNotificationsRead: (itemId: number): Promise<{ status: string; count: number; notification_ids: number[] }> =>
request(`/api/v1/procurement/items/${itemId}/comment-notifications/read/`, { method: 'POST' }),
cancelProcurementRequest: (id: number, reason?: string) =>
request(`/api/v1/procurement/requests/${id}/cancel/`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reason: reason || '' }) }),
getProcurementComments: (requestId: number) => request(`/api/v1/procurement/requests/${requestId}/comments/`),
@ -79,8 +81,19 @@ export function createProcurementApi(request: RequestFn, requestRaw: RawRequestF
}),
deleteProcurementItem: (id: number): Promise<void> => request(`/api/v1/procurement/items/${id}/`, { method: 'DELETE' }),
getProcurementItemComments: (itemId: number) => request(`/api/v1/procurement/items/${itemId}/comments/`),
addProcurementItemComment: (itemId: number, text: string): Promise<ProcurementItemComment> =>
request(`/api/v1/procurement/items/${itemId}/comments/`, { method: 'POST', body: JSON.stringify({ text }) }),
addProcurementItemComment: (
itemId: number,
text: string,
files: File[] = [],
): Promise<ProcurementItemComment> => {
const formData = new FormData();
formData.append('text', text);
files.forEach((file) => formData.append('files', file));
return request(`/api/v1/procurement/items/${itemId}/comments/`, {
method: 'POST',
body: formData,
});
},
deleteProcurementItemComment: (itemId: number, commentId: number): Promise<void> =>
request(`/api/v1/procurement/items/${itemId}/comments/${commentId}/`, { method: 'DELETE' }),
getProcurementItemAttachments: (itemId: number): Promise<ProcurementItemAttachment[]> =>

View File

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