From 44da9c46c122948aa86f3c8b8ff8e912cfefab61 Mon Sep 17 00:00:00 2001 From: lizerk Date: Sun, 12 Apr 2026 20:07:34 +0300 Subject: [PATCH] feat(ui): unify action menus and procurement comments --- backend/api/v1/procurement/permissions.py | 8 + backend/api/v1/procurement/serializers.py | 6 + backend/api/v1/procurement/views.py | 119 +++++++- .../procurement/test_procurement_requests.py | 68 +++++ frontend/src/app/equipment/page.tsx | 113 ++++++-- frontend/src/app/page.tsx | 166 +++++++++-- frontend/src/app/procurement/page.tsx | 257 ++++++++++++++++-- frontend/src/app/requests/page.tsx | 111 +++++++- .../messages/MessageActionsMenu.tsx | 4 +- frontend/src/components/ui/Modal.tsx | 2 +- frontend/src/hooks/useProcurementPage.ts | 96 +++++++ frontend/src/lib/api/feed.ts | 2 + frontend/src/lib/api/procurement.ts | 5 + frontend/src/types/api.ts | 10 + 14 files changed, 882 insertions(+), 85 deletions(-) diff --git a/backend/api/v1/procurement/permissions.py b/backend/api/v1/procurement/permissions.py index 9d9998e7..682886d2 100644 --- a/backend/api/v1/procurement/permissions.py +++ b/backend/api/v1/procurement/permissions.py @@ -230,6 +230,14 @@ class CanManageProcurementRequest(permissions.BasePermission): if action == "cancel": return obj.requestor == user + # Комментарии доступны тем, кто видит заявку + if action == "comments": + return True + + # Удаление комментария дополнительно валидируется во view по автору/админу + if action == "delete_comment": + return True + # Start_work (взять в работу) - любой авторизованный if action == "start_work": return True diff --git a/backend/api/v1/procurement/serializers.py b/backend/api/v1/procurement/serializers.py index babbbd13..905a66ca 100644 --- a/backend/api/v1/procurement/serializers.py +++ b/backend/api/v1/procurement/serializers.py @@ -122,6 +122,7 @@ class ProcurementRequestListSerializer(serializers.ModelSerializer): executor_name = serializers.CharField( source="executor.get_full_name", read_only=True, allow_null=True ) + comments_count = serializers.IntegerField(read_only=True, default=0) class Meta: model = ProcurementRequest @@ -140,6 +141,7 @@ class ProcurementRequestListSerializer(serializers.ModelSerializer): "urgency_display", "total_cost", "items_count", + "comments_count", "created_at", "submitted_at", "started_at", @@ -156,6 +158,7 @@ class ProcurementRequestListSerializer(serializers.ModelSerializer): "urgency_display", "items_count", "total_cost", + "comments_count", ] def update(self, instance, validated_data): @@ -200,6 +203,7 @@ class ProcurementRequestDetailSerializer(serializers.ModelSerializer): source="get_required_approval_priorities", read_only=True ) is_editable = serializers.BooleanField(read_only=True) + comments_count = serializers.IntegerField(read_only=True, default=0) class Meta: model = ProcurementRequest @@ -224,6 +228,7 @@ class ProcurementRequestDetailSerializer(serializers.ModelSerializer): "approvals", "required_approval_priorities", "is_editable", + "comments_count", "created_at", "updated_at", "submitted_at", @@ -240,6 +245,7 @@ class ProcurementRequestDetailSerializer(serializers.ModelSerializer): "is_editable", "total_cost", "executor_name", + "comments_count", ] diff --git a/backend/api/v1/procurement/views.py b/backend/api/v1/procurement/views.py index 5e1078d5..87facd5d 100644 --- a/backend/api/v1/procurement/views.py +++ b/backend/api/v1/procurement/views.py @@ -95,9 +95,34 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): def get_queryset(self): """Фильтровать заявки в зависимости от роли пользователя и scope.""" from datetime import timedelta + from django.contrib.contenttypes.models import ContentType + from django.db.models import Count, IntegerField, OuterRef, Subquery, Value + from django.db.models.functions import Coalesce from django.utils import timezone + from communications.models import Chat - queryset = super().get_queryset() + ct = ContentType.objects.get_for_model(ProcurementRequest) + comments_sub = ( + Chat.objects.filter( + type="comments", + context_content_type=ct, + context_object_id=OuterRef("pk"), + ) + .annotate( + msg_count=Count( + "messages", filter=Q(messages__is_deleted=False) + ) + ) + .values("msg_count")[:1] + ) + + queryset = super().get_queryset().annotate( + comments_count=Coalesce( + Subquery(comments_sub, output_field=IntegerField()), + Value(0), + output_field=IntegerField(), + ) + ) user = self.request.user scope = self.request.query_params.get("scope", None) period = self.request.query_params.get("period", None) @@ -158,6 +183,98 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): return queryset.distinct() + @action(detail=True, methods=["get", "post"]) + def comments(self, request, pk=None): + """Список/создание комментариев для заявки на закупку.""" + from api.v1.employees.serializers.employee import ( + EmployeeBriefSerializer, + ) + + procurement_request = self.get_object() + + if request.method in ("GET", "HEAD"): + messages = comments_helpers.get_comments(procurement_request) + comments_data = [] + for msg in messages: + author_ser = EmployeeBriefSerializer(msg.author) + comments_data.append( + { + "id": msg.id, + "request": procurement_request.id, + "author": author_ser.data, + "text": msg.content, + "created_at": msg.created_at, + } + ) + return Response(comments_data) + + text = request.data.get("text", "").strip() + if not text: + return Response( + {"text": ["Это поле не может быть пустым."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + + message = comments_helpers.create_comment( + obj=procurement_request, + author=request.user, + content=text, + ) + author_ser = EmployeeBriefSerializer(message.author) + return Response( + { + "id": message.id, + "request": procurement_request.id, + "author": author_ser.data, + "text": message.content, + "created_at": message.created_at, + }, + status=status.HTTP_201_CREATED, + ) + + @extend_schema( + parameters=[ + OpenApiParameter( + name="comment_id", + type=int, + location=OpenApiParameter.PATH, + description="ID комментария в чате комментариев заявки.", + ) + ] + ) + @action( + detail=True, + methods=["delete"], + url_path="comments/(?P[^/.]+)", + ) + def delete_comment(self, request, pk=None, comment_id=None): + """Удаление комментария к заявке на закупку.""" + procurement_request = self.get_object() + chat = comments_helpers.get_or_create_comments_chat(procurement_request) + if isinstance(chat, tuple): + chat = chat[0] + + try: + message = Message.objects.get(id=comment_id, chat=chat) + except Message.DoesNotExist: + return Response( + {"detail": "Комментарий не найден"}, + status=status.HTTP_404_NOT_FOUND, + ) + + if not (request.user.is_staff or message.author == request.user): + return Response( + {"detail": "Нет прав на удаление"}, + status=status.HTTP_403_FORBIDDEN, + ) + + comments_helpers.delete_comment( + message=message, + deleted_by=request.user, + soft_delete=True, + ) + return Response(status=status.HTTP_204_NO_CONTENT) + @action(detail=True, methods=["post"]) def submit(self, request, pk=None): """Отправить заявку на согласование.""" diff --git a/backend/tests/api/v1/procurement/test_procurement_requests.py b/backend/tests/api/v1/procurement/test_procurement_requests.py index b97f720b..2ac82f30 100644 --- a/backend/tests/api/v1/procurement/test_procurement_requests.py +++ b/backend/tests/api/v1/procurement/test_procurement_requests.py @@ -341,6 +341,74 @@ class TestProcurementRequestDetail: assert response.status_code == status.HTTP_404_NOT_FOUND + def test_retrieve_includes_comments_count( + self, api_client, user, procurement_request, procurement_item + ): + """В detail отдается comments_count.""" + api_client.force_authenticate(user=user) + url = reverse( + 'api:v1:procurement:procurementrequest-detail', + kwargs={'pk': procurement_request.id} + ) + + response = api_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert response.data['comments_count'] == 0 + + +class TestProcurementRequestComments: + """Комментарии к заявке на закупку.""" + + def test_comment_lifecycle_updates_comments_count( + self, api_client, user, procurement_request + ): + api_client.force_authenticate(user=user) + + list_url = reverse('api:v1:procurement:procurementrequest-list') + comments_url = reverse( + 'api:v1:procurement:procurementrequest-comments', + kwargs={'pk': procurement_request.id} + ) + + before = api_client.get(list_url) + assert before.status_code == status.HTTP_200_OK + before_item = next(item for item in before.data['results'] if item['id'] == procurement_request.id) + assert before_item['comments_count'] == 0 + + create_response = api_client.post( + comments_url, + {'text': 'Проверочный комментарий'}, + format='json', + ) + assert create_response.status_code == status.HTTP_201_CREATED + assert create_response.data['request'] == procurement_request.id + comment_id = create_response.data['id'] + + comments_response = api_client.get(comments_url) + assert comments_response.status_code == status.HTTP_200_OK + assert len(comments_response.data) == 1 + assert comments_response.data[0]['text'] == 'Проверочный комментарий' + + middle = api_client.get(list_url) + middle_item = next(item for item in middle.data['results'] if item['id'] == procurement_request.id) + assert middle_item['comments_count'] == 1 + + delete_url = reverse( + 'api:v1:procurement:procurementrequest-delete-comment', + kwargs={'pk': procurement_request.id, 'comment_id': comment_id} + ) + delete_response = api_client.delete(delete_url) + assert delete_response.status_code == status.HTTP_204_NO_CONTENT + + after_comments = api_client.get(comments_url) + assert after_comments.status_code == status.HTTP_200_OK + assert after_comments.data == [] + + after = api_client.get(list_url) + after_item = next(item for item in after.data['results'] if item['id'] == procurement_request.id) + assert after_item['comments_count'] == 0 + # ============================================================================== # ТЕСТЫ ОБНОВЛЕНИЯ ЗАЯВОК diff --git a/frontend/src/app/equipment/page.tsx b/frontend/src/app/equipment/page.tsx index 407d3ca5..2aeae180 100644 --- a/frontend/src/app/equipment/page.tsx +++ b/frontend/src/app/equipment/page.tsx @@ -6,7 +6,7 @@ import { useUser } from "@/contexts/UserContext"; import Link from "next/link"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Archive, ArrowRightLeft, ArrowUpDown, Check, ChevronDown, Copy, Download, ExternalLink, Filter, MessageSquare, Monitor, Pencil, Plus, QrCode, Search, Shield, Trash2, Wrench, X } from "lucide-react"; +import { Archive, ArrowRightLeft, ArrowUpDown, Check, ChevronDown, ChevronRight, Copy, Download, ExternalLink, Filter, MessageSquare, Monitor, Pencil, Plus, QrCode, Search, Shield, Trash2, Wrench, X } from "lucide-react"; import { SearchableSelectSingle } from "@/components/shared/SearchableSelect"; import { formatDate, formatMoney } from "@/lib/shared"; import { useEquipmentPage } from "@/hooks/useEquipmentPage"; @@ -277,6 +277,8 @@ function EquipmentPageContent() { const [qrError, setQrError] = useState(null); const [qrCopySuccess, setQrCopySuccess] = useState(false); const handledLinkedEquipmentRef = useRef(null); + const equipmentMenuRef = useRef(null); + const [equipmentMenuOpenId, setEquipmentMenuOpenId] = useState(null); const linkedEquipmentParam = searchParams.get("item"); const linkedEquipmentId = Number(linkedEquipmentParam || ""); @@ -356,6 +358,30 @@ function EquipmentPageContent() { }; }, [buildEquipmentLink, qrEquipment]); + useEffect(() => { + if (equipmentMenuOpenId === null) return; + + const handlePointerDown = (event: MouseEvent) => { + if (equipmentMenuRef.current && !equipmentMenuRef.current.contains(event.target as Node)) { + setEquipmentMenuOpenId(null); + } + }; + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setEquipmentMenuOpenId(null); + } + }; + + document.addEventListener("mousedown", handlePointerDown); + document.addEventListener("keydown", handleEscape); + + return () => { + document.removeEventListener("mousedown", handlePointerDown); + document.removeEventListener("keydown", handleEscape); + }; + }, [equipmentMenuOpenId]); + const handleOpenQrModal = useCallback((equipment: typeof filteredItems[number]) => { setQrCopySuccess(false); setQrEquipment(equipment); @@ -552,6 +578,7 @@ function EquipmentPageContent() { const st = statusMeta[statusKey] ?? defaultStatusMeta; const canEditThis = canManage; const canDeleteThis = canManage; + const hasSecondaryActions = canEditThis || canDeleteThis; const comments = commentsMap[item.id] || []; const rowOpen = Boolean(expandedRows[item.id]); const commentsOpen = Boolean(expandedComments[item.id]); @@ -563,7 +590,7 @@ function EquipmentPageContent() { const rowLoading = Boolean(loadingRowDetails[item.id]); return ( -
+
-
- - {canEditThis && ( - - )} - {canDeleteThis && ( - - )} +
+ {statusKey && {st.label}} + {hasSecondaryActions ? ( +
+ + {equipmentMenuOpenId === item.id ? ( +
+ {canEditThis ? ( + + ) : null} + {canDeleteThis ? ( + + ) : null} +
+ ) : null} +
+ ) : null}
- {statusKey && {st.label}} {item.is_under_warranty && ( Гарантия @@ -637,6 +701,15 @@ function EquipmentPageContent() {
Ответственный не назначен
)}
+ +
+ +
diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 4283c1e6..4ebcf179 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { Heart, ImageIcon, MessageSquare, Paperclip, Pencil, Plus, Send, Trash2, X } from "lucide-react"; +import { ChevronRight, Heart, ImageIcon, MessageSquare, Paperclip, Pencil, Pin, Plus, Send, Trash2, X } from "lucide-react"; import { AppShell } from "../components/AppShell"; import { Modal } from "@/components/ui"; import { apiClient } from "@/lib/api"; @@ -78,6 +78,8 @@ function HomePageContent() { const [likesUsersMap, setLikesUsersMap] = useState>({}); const [likesUsersEndpointUnavailable, setLikesUsersEndpointUnavailable] = useState(false); const [imageCacheBuster, setImageCacheBuster] = useState>({}); + const [postMenuOpenId, setPostMenuOpenId] = useState(null); + const postMenuRef = useRef(null); const formatUserName = (u: LikeUser) => { const composed = `${u.last_name || ""} ${u.first_name || ""}`.trim(); @@ -155,6 +157,19 @@ function HomePageContent() { hasPermission("delete_post") || hasPermission("employees.manage_feed") ); + const canPinPost = Boolean( + auth?.is_staff || + auth?.is_superuser || + hasPermission("feed.pin_post") || + hasPermission("pin_post") + ); + + const sortPostsPinnedFirst = (items: Post[]) => [...items].sort((left, right) => { + const leftPinned = left.pinned ? 1 : 0; + const rightPinned = right.pinned ? 1 : 0; + if (leftPinned !== rightPinned) return rightPinned - leftPinned; + return new Date(right.created_at).getTime() - new Date(left.created_at).getTime(); + }); const canEditPost = (post: Post) => { if (canManageAnyPost) return true; @@ -186,7 +201,7 @@ function HomePageContent() { const refreshPosts = async () => { const response = await apiClient.getPosts(); - setPosts(response.results); + setPosts(sortPostsPinnedFirst(response.results)); }; useEffect(() => { @@ -221,6 +236,30 @@ function HomePageContent() { }; }, [commentsOpen, createPostOpen]); + useEffect(() => { + if (postMenuOpenId === null) return; + + const handlePointerDown = (event: MouseEvent) => { + if (postMenuRef.current && !postMenuRef.current.contains(event.target as Node)) { + setPostMenuOpenId(null); + } + }; + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setPostMenuOpenId(null); + } + }; + + document.addEventListener("mousedown", handlePointerDown); + document.addEventListener("keydown", handleEscape); + + return () => { + document.removeEventListener("mousedown", handlePointerDown); + document.removeEventListener("keydown", handleEscape); + }; + }, [postMenuOpenId]); + useEffect(() => { if (createType !== "department") return; if (createDepartmentId) return; @@ -275,6 +314,35 @@ function HomePageContent() { } }; + const handlePinToggle = async (post: Post) => { + if (postActionId === post.id) return; + + const currentlyPinned = Boolean(post.pinned); + setPostActionId(post.id); + setPostMenuOpenId(null); + + setPosts((prev) => sortPostsPinnedFirst( + prev.map((item) => item.id === post.id ? { ...item, pinned: !currentlyPinned } : item) + )); + + try { + const response = currentlyPinned + ? await apiClient.unpinPost(post.id) + : await apiClient.pinPost(post.id); + + setPosts((prev) => sortPostsPinnedFirst( + prev.map((item) => item.id === post.id ? { ...item, pinned: Boolean(response?.pinned) } : item) + )); + } catch (err) { + console.error("Ошибка закрепления публикации:", err); + setPosts((prev) => sortPostsPinnedFirst( + prev.map((item) => item.id === post.id ? { ...item, pinned: currentlyPinned } : item) + )); + } finally { + setPostActionId(null); + } + }; + const loadComments = async (postId: number) => { setCommentsLoading(true); setCommentsError(null); @@ -597,33 +665,83 @@ function HomePageContent() {

{authorName}

-

{timeAgo}

+
+ {timeAgo} + {post.pinned ? ( + + + + ) : null} +
{(canEditPost(post) || canDeletePost(post)) ? ( -
- {canEditPost(post) ? ( - - ) : null} +
+ - {canDeletePost(post) ? ( - + {postMenuOpenId === post.id ? ( +
+ {canPinPost ? ( + + ) : null} + {canEditPost(post) ? ( + + ) : null} + + {canDeletePost(post) ? ( + + ) : null} +
) : null}
) : null} diff --git a/frontend/src/app/procurement/page.tsx b/frontend/src/app/procurement/page.tsx index d02d184e..67395e83 100644 --- a/frontend/src/app/procurement/page.tsx +++ b/frontend/src/app/procurement/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { AppShell } from "../../components/AppShell"; import { useUser } from "@/contexts/UserContext"; @@ -13,9 +13,11 @@ import { ArrowUpDown, Check, ChevronDown, + ChevronRight, ClipboardCheck, Filter, Loader2, + MessageSquare, Package, Pencil, Play, @@ -112,6 +114,7 @@ function ProcurementRequestActionButtons({ isAuthor, isExecutor, isFinal, + showSecondaryActions = true, onSubmit, onEdit, onDelete, @@ -127,6 +130,7 @@ function ProcurementRequestActionButtons({ isAuthor: boolean; isExecutor: boolean; isFinal: (status?: string) => boolean; + showSecondaryActions?: boolean; onSubmit: (id: number) => void | Promise; onEdit: (request: ProcurementRequest) => void; onDelete: (id: number) => void | Promise; @@ -155,7 +159,7 @@ function ProcurementRequestActionButtons({ ) : null} - {isDraft && isAuthor ? ( + {showSecondaryActions && isDraft && isAuthor ? ( ) : null} - {isDraft && (isAuthor || canManage) ? ( + {showSecondaryActions && isDraft && (isAuthor || canManage) ? ( ) : null} - {isAuthor && !isFinal(status) && status !== "draft" ? ( + {showSecondaryActions && isAuthor && !isFinal(status) && status !== "draft" ? (
-
- {sMeta.label} +
+
+ {sMeta.label} + {hasSecondaryActions ? ( +
+ + {procurementMenuOpenId === req.id ? ( +
+ {canCancelThis ? ( + + ) : null} + {canEditThis ? ( + + ) : null} + {canDeleteThis ? ( + + ) : null} +
+ ) : null} +
+ ) : null} +
{urg.label} срочность
@@ -609,34 +727,80 @@ export default function ProcurementPage() { )} + +
+ +
- {expanded && ( + {(expanded || commentsOpen) && (
- - )} - /> + {expanded ? ( + + )} + /> + ) : null} + + {commentsOpen ? ( +
+
+ {comments.length === 0 ? ( +

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

+ ) : ( + comments.map((comment) => { + const canDeleteCommentThis = Boolean(comment.author?.id && (user?.id === comment.author.id || user?.auth?.is_staff || user?.auth?.is_superuser)); + return ( +
+
+ {displayUserName(comment.author)} +
+ {fmt(comment.created_at)} + {canDeleteCommentThis ? ( + + ) : null} +
+
+

{comment.text}

+
+ ); + }) + )} +
+
+ setCommentDrafts((previous) => ({ ...previous, [req.id]: e.target.value }))} placeholder="Добавить комментарий" className="app-input flex-1 rounded-lg px-3 py-2 text-xs" /> + +
+
+ ) : null}
)}
@@ -739,6 +903,39 @@ export default function ProcurementPage() { /> )} /> + +
+

Комментарии

+
+ {(commentsMap[selectedRequest.id] || []).length === 0 ? ( +

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

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

{comment.text}

+
+ ); + }) + )} +
+
+ setCommentDrafts((previous) => ({ ...previous, [selectedRequest.id]: e.target.value }))} placeholder="Добавить комментарий" className="app-input flex-1 rounded-lg px-3 py-2 text-xs" /> + +
+
) : (
diff --git a/frontend/src/app/requests/page.tsx b/frontend/src/app/requests/page.tsx index f53bdba4..92ae8b8e 100644 --- a/frontend/src/app/requests/page.tsx +++ b/frontend/src/app/requests/page.tsx @@ -6,11 +6,11 @@ import { Modal } from "@/components/ui"; import { apiClient } from "@/lib/api"; import { useUser } from "@/contexts/UserContext"; import { canManageRequests, canProcessRequests } from "@/lib/permissions"; -import { Suspense, useEffect, useRef } from "react"; +import { Suspense, useEffect, useRef, useState } from "react"; import Link from "next/link"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import type { User } from "@/types/api"; -import { ArrowUpDown, Ban, ChevronDown, FileSignature, Filter, MessageSquare, Paperclip, Pencil, Plus, Search, ThumbsDown, ThumbsUp, Trash2, X, Zap } from "lucide-react"; +import { ArrowUpDown, Ban, ChevronDown, ChevronRight, FileSignature, Filter, MessageSquare, Paperclip, Pencil, Plus, Search, ThumbsDown, ThumbsUp, Trash2, X, Zap } from "lucide-react"; import dynamic from "next/dynamic"; import { SearchableSelectMulti } from "@/components/shared/SearchableSelect"; import { formatDate, displayUserName, userProfileLink } from "@/lib/shared"; @@ -53,8 +53,10 @@ function RequestsPageContent() { const auth = user?.auth; const h = useRequestsPage(user?.id); const fileInputRef = useRef(null); + const requestMenuRef = useRef(null); const openedLinkedRequestIdRef = useRef(null); const loadingLinkedRequestIdRef = useRef(null); + const [requestMenuOpenId, setRequestMenuOpenId] = useState(null); const linkedRequestId = Number(searchParams.get("request") || ""); const clearRequestParam = () => { @@ -141,6 +143,30 @@ function RequestsPageContent() { }; }, [h.detailsRequest?.id, h.requests, h.setDetailsRequest, linkedRequestId]); + useEffect(() => { + if (requestMenuOpenId === null) return; + + const handlePointerDown = (event: MouseEvent) => { + if (requestMenuRef.current && !requestMenuRef.current.contains(event.target as Node)) { + setRequestMenuOpenId(null); + } + }; + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setRequestMenuOpenId(null); + } + }; + + document.addEventListener("mousedown", handlePointerDown); + document.addEventListener("keydown", handleEscape); + + return () => { + document.removeEventListener("mousedown", handlePointerDown); + document.removeEventListener("keydown", handleEscape); + }; + }, [requestMenuOpenId]); + return ( {h.loading ? ( @@ -226,6 +252,10 @@ function RequestsPageContent() { const tt = item.display_title || item.title || "Без заголовка"; const canPr = Boolean(sk === "pending" && requestAuthor?.id && user?.id && requestAuthor.id !== user.id && (canManage || (canProcess && item.is_recipient))); const isAuth = Boolean(requestAuthor?.id && user?.id && requestAuthor.id === user.id); + const canCancel = Boolean(isAuth && !h.isFinal(sk)); + const canEdit = Boolean(isAuth && !h.isFinal(sk)); + const canDelete = Boolean((isAuth && !h.isFinal(sk)) || canManage); + const hasSecondaryActions = canCancel || canEdit || canDelete; const rowOpen = Boolean(h.expandedRows[item.id]); const comments = h.commentsMap[item.id] || []; const commentsOpen = Boolean(h.expandedComments[item.id]); @@ -235,7 +265,7 @@ function RequestsPageContent() { const summary = item.comment || item.description; return ( -
+
@@ -257,14 +287,81 @@ function RequestsPageContent() {
Период: {item.date_from ? formatDate(item.date_from) : "—"}{item.date_to ? ` — ${formatDate(item.date_to)}` : ""}
-
{st.label}
+
+
+ {st.label} + {hasSecondaryActions ? ( +
+ + {requestMenuOpenId === item.id ? ( +
+ {canCancel ? ( + + ) : null} + {canEdit ? ( + + ) : null} + {canDelete ? ( + + ) : null} +
+ ) : null} +
+ ) : null} +
+
{summary &&

{summary}

}
- {isAuth && !h.isFinal(sk) && } - {isAuth && !h.isFinal(sk) && } - {((isAuth && !h.isFinal(sk)) || canManage) && } {canPr && }
diff --git a/frontend/src/components/messages/MessageActionsMenu.tsx b/frontend/src/components/messages/MessageActionsMenu.tsx index cf1bc75c..375091d6 100644 --- a/frontend/src/components/messages/MessageActionsMenu.tsx +++ b/frontend/src/components/messages/MessageActionsMenu.tsx @@ -120,9 +120,9 @@ export default function MessageActionsMenu({ ) : null} diff --git a/frontend/src/components/ui/Modal.tsx b/frontend/src/components/ui/Modal.tsx index df39eb11..6c6fdddc 100644 --- a/frontend/src/components/ui/Modal.tsx +++ b/frontend/src/components/ui/Modal.tsx @@ -167,7 +167,7 @@ export function Modal({ {/* Footer */} {footer && ( -
{footer}
+
{footer}
)} {/* Bottom padding */} diff --git a/frontend/src/hooks/useProcurementPage.ts b/frontend/src/hooks/useProcurementPage.ts index 37a063ae..fe13baf4 100644 --- a/frontend/src/hooks/useProcurementPage.ts +++ b/frontend/src/hooks/useProcurementPage.ts @@ -6,6 +6,7 @@ import { canManageRequests, canManageSupplier } from "@/lib/permissions"; import { displayUserName, extractNextPage, loadAllPages } from "@/lib/shared"; import type { Department, + ProcurementComment, ProcurementRequest, UrgencyLevel, User, @@ -155,6 +156,9 @@ export function useProcurementPage(user: User | null) { const [form, setForm] = useState(emptyForm); const [expandedIds, setExpandedIds] = useState>(new Set()); + const [expandedComments, setExpandedComments] = useState>({}); + const [commentsMap, setCommentsMap] = useState>({}); + const [commentDrafts, setCommentDrafts] = useState>({}); const [detailsCache, setDetailsCache] = useState>({}); const detailsCacheRef = useRef>({}); @@ -530,6 +534,90 @@ export function useProcurementPage(user: User | null) { } }, [ensureRequestDetail]); + const ensureCommentsLoaded = useCallback(async (id: number) => { + if (commentsMap[id]) { + return commentsMap[id]; + } + + const comments = await apiClient.getProcurementComments(id); + const normalized = Array.isArray(comments) ? comments : []; + setCommentsMap((previous) => ({ ...previous, [id]: normalized })); + return normalized; + }, [commentsMap]); + + const toggleComments = useCallback(async (id: number) => { + const isOpen = Boolean(expandedComments[id]); + setExpandedComments((previous) => ({ ...previous, [id]: !isOpen })); + + if (!isOpen && !commentsMap[id]) { + try { + await ensureCommentsLoaded(id); + } catch { + setActionError("Не удалось загрузить комментарии"); + } + } + }, [commentsMap, ensureCommentsLoaded, expandedComments]); + + const handleAddComment = useCallback(async (id: number) => { + const text = (commentDrafts[id] || "").trim(); + if (!text) return; + + try { + setBusyKey(`comment-${id}`); + const created = await apiClient.addProcurementComment(id, text); + setCommentsMap((previous) => ({ + ...previous, + [id]: [...(previous[id] || []), created], + })); + setCommentDrafts((previous) => ({ ...previous, [id]: "" })); + setRequests((previous) => previous.map((request) => ( + request.id === id + ? { ...request, comments_count: (request.comments_count || 0) + 1 } + : request + ))); + setDetailsCache((previous) => { + const detail = previous[id]; + if (!detail) return previous; + return { + ...previous, + [id]: { ...detail, comments_count: (detail.comments_count || 0) + 1 }, + }; + }); + } catch { + setActionError("Не удалось добавить комментарий"); + } finally { + setBusyKey(null); + } + }, [commentDrafts]); + + const handleDeleteComment = useCallback(async (requestId: number, commentId: number) => { + try { + setBusyKey(`comment-delete-${commentId}`); + await apiClient.deleteProcurementComment(requestId, commentId); + setCommentsMap((previous) => ({ + ...previous, + [requestId]: (previous[requestId] || []).filter((comment) => comment.id !== commentId), + })); + setRequests((previous) => previous.map((request) => ( + request.id === requestId + ? { ...request, comments_count: Math.max(0, (request.comments_count || 0) - 1) } + : request + ))); + setDetailsCache((previous) => { + const detail = previous[requestId]; + if (!detail) return previous; + return { + ...previous, + [requestId]: { ...detail, comments_count: Math.max(0, (detail.comments_count || 0) - 1) }, + }; + }); + } catch { + setActionError("Не удалось удалить комментарий"); + } finally { + setBusyKey(null); + } + }, []); + const handleSubmit = useCallback((id: number) => doAction( `submit-${id}`, () => apiClient.submitProcurementRequest(id), @@ -643,6 +731,10 @@ export function useProcurementPage(user: User | null) { form, setForm, expandedIds, + expandedComments, + commentsMap, + commentDrafts, + setCommentDrafts, detailsCache, filteredRequests, openCreate, @@ -653,6 +745,8 @@ export function useProcurementPage(user: User | null) { handleSave, handleLoadMore, toggleExpand, + toggleComments, + ensureCommentsLoaded, handleSubmit, handleApprove, handleReject, @@ -660,6 +754,8 @@ export function useProcurementPage(user: User | null) { handleComplete, handleCancel, handleDelete, + handleAddComment, + handleDeleteComment, addItemRow, removeItemRow, updateItemRow, diff --git a/frontend/src/lib/api/feed.ts b/frontend/src/lib/api/feed.ts index 6ac3c353..f506c3d3 100644 --- a/frontend/src/lib/api/feed.ts +++ b/frontend/src/lib/api/feed.ts @@ -32,6 +32,8 @@ export function createFeedApi(request: RequestFn, getToken: GetTokenFn) { createPost: (data: Record) => formDataPost('/api/v1/posts/', 'POST', data), updatePost: (postId: number, data: Record) => formDataPost(`/api/v1/posts/${postId}/`, 'PATCH', data), deletePost: (postId: number): Promise => request(`/api/v1/posts/${postId}/`, { method: 'DELETE' }), + pinPost: (postId: number) => request(`/api/v1/posts/${postId}/pin/`, { method: 'POST' }), + unpinPost: (postId: number) => request(`/api/v1/posts/${postId}/unpin/`, { method: 'POST' }), likePost: (postId: number) => request(`/api/v1/posts/${postId}/like/`, { method: 'POST' }), unlikePost: (postId: number) => request(`/api/v1/posts/${postId}/unlike/`, { method: 'POST' }), getPostLikers: (postId: number) => request(`/api/v1/posts/${postId}/likers/`), diff --git a/frontend/src/lib/api/procurement.ts b/frontend/src/lib/api/procurement.ts index 940f2a4a..23839302 100644 --- a/frontend/src/lib/api/procurement.ts +++ b/frontend/src/lib/api/procurement.ts @@ -17,6 +17,11 @@ export function createProcurementApi(request: RequestFn) { completeProcurementRequest: (id: number) => request(`/api/v1/procurement/requests/${id}/complete/`, { 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/`), + addProcurementComment: (requestId: number, text: string) => + request(`/api/v1/procurement/requests/${requestId}/comments/`, { method: 'POST', body: JSON.stringify({ text }) }), + deleteProcurementComment: (requestId: number, commentId: number): Promise => + request(`/api/v1/procurement/requests/${requestId}/comments/${commentId}/`, { method: 'DELETE' }), getMyProcurementRequests: (params?: Record) => request(`/api/v1/procurement/requests/my_requests/${buildQuery(params)}`), getPendingApprovals: (params?: Record) => request(`/api/v1/procurement/requests/pending_approvals/${buildQuery(params)}`), // Items diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 86a759d8..2c66004e 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -145,6 +145,7 @@ export interface Post { tags?: string[]; created_at: string; updated_at: string; + pinned?: boolean; likes_count: number; comments_count: number; is_liked?: boolean; @@ -453,6 +454,7 @@ export interface ProcurementRequest { is_editable?: boolean; total_cost?: string | number; total_estimated_cost?: string | number; + comments_count?: number; created_at: string; updated_at: string; submitted_at?: string | null; @@ -460,6 +462,14 @@ export interface ProcurementRequest { completed_at?: string | null; } +export interface ProcurementComment { + id: number; + request: number; + author: User; + text: string; + created_at: string; +} + export interface ProcurementSupplier { id: number; name: string;