feat(ui): unify action menus and procurement comments

This commit is contained in:
lizerk 2026-04-12 20:07:34 +03:00
parent ae7e1daf89
commit 44da9c46c1
14 changed files with 882 additions and 85 deletions

View File

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

View File

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

View File

@ -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<comment_id>[^/.]+)",
)
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):
"""Отправить заявку на согласование."""

View File

@ -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
# ==============================================================================
# ТЕСТЫ ОБНОВЛЕНИЯ ЗАЯВОК

View File

@ -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<string | null>(null);
const [qrCopySuccess, setQrCopySuccess] = useState(false);
const handledLinkedEquipmentRef = useRef<number | null>(null);
const equipmentMenuRef = useRef<HTMLDivElement | null>(null);
const [equipmentMenuOpenId, setEquipmentMenuOpenId] = useState<number | null>(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 (
<article id={`equipment-${item.id}`} key={item.id} className="app-surface-muted overflow-hidden rounded-xl transition hover:border-[var(--border-strong)]">
<article id={`equipment-${item.id}`} key={item.id} className={`app-surface-muted rounded-xl transition hover:border-[var(--border-strong)] ${equipmentMenuOpenId === item.id ? "relative z-20 overflow-visible" : "overflow-hidden"}`}>
<div className="px-4 py-3">
<div className="flex items-start gap-3">
<button
@ -588,28 +615,65 @@ function EquipmentPageContent() {
</div>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<button type="button" title={`Комментарии (${commentsTotal})`} onClick={() => toggleComments(item.id)} className="app-action-secondary relative inline-flex h-8 w-8 items-center justify-center rounded-lg">
<MessageSquare size={15} />
{commentsTotal > 0 && (
<span className="app-counter absolute -right-1 -top-1 inline-flex min-w-4 items-center justify-center px-1 py-0.5 text-[10px] font-bold text-white">{commentsTotal}</span>
)}
</button>
{canEditThis && (
<button type="button" title="Редактировать" onClick={() => openEdit(item)} className="app-action-secondary inline-flex h-8 w-8 items-center justify-center rounded-lg">
<Pencil size={15} />
</button>
)}
{canDeleteThis && (
<button type="button" title="Удалить" onClick={() => handleDelete(item.id)} disabled={busyKey === `delete-${item.id}`} className="app-action-danger inline-flex h-8 w-8 items-center justify-center rounded-lg disabled:opacity-60">
<Trash2 size={15} />
</button>
)}
<div
ref={equipmentMenuOpenId === item.id ? equipmentMenuRef : null}
className="flex shrink-0 items-center gap-2"
>
{statusKey && <span className={`app-status-pill shrink-0 ${st.className}`}>{st.label}</span>}
{hasSecondaryActions ? (
<div className="relative">
<button
type="button"
onClick={() => setEquipmentMenuOpenId((prev) => (prev === item.id ? null : item.id))}
className="app-action-ghost flex h-8 w-8 items-center justify-center rounded-md"
title="Действия с оборудованием"
aria-label="Действия с оборудованием"
aria-expanded={equipmentMenuOpenId === item.id}
aria-haspopup="menu"
>
<ChevronRight
size={15}
className={`transition-transform duration-200 ${equipmentMenuOpenId === item.id ? "rotate-90" : ""}`}
/>
</button>
{equipmentMenuOpenId === item.id ? (
<div className="app-menu absolute right-0 top-full z-20 mt-2 w-44 rounded-xl py-1.5">
{canEditThis ? (
<button
type="button"
onClick={() => {
setEquipmentMenuOpenId(null);
openEdit(item);
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<Pencil size={14} className="app-text-muted" />
Редактировать
</button>
) : null}
{canDeleteThis ? (
<button
type="button"
title="Удалить"
onClick={() => {
setEquipmentMenuOpenId(null);
void handleDelete(item.id);
}}
disabled={busyKey === `delete-${item.id}`}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--danger-foreground)] transition hover:bg-[var(--danger-soft)] disabled:opacity-50"
>
<Trash2 size={13} className="text-[var(--danger-foreground)]" />
Удалить
</button>
) : null}
</div>
) : null}
</div>
) : null}
</div>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2">
{statusKey && <span className={`app-status-pill shrink-0 ${st.className}`}>{st.label}</span>}
{item.is_under_warranty && (
<span className="app-badge inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium" title="На гарантии">
<Shield size={11} /> Гарантия
@ -637,6 +701,15 @@ function EquipmentPageContent() {
<div className="col-span-2">Ответственный не назначен</div>
)}
</div>
<div className="mt-3 flex flex-wrap items-center gap-1.5">
<button type="button" title={`Комментарии (${commentsTotal})`} onClick={() => toggleComments(item.id)} className="app-action-secondary relative inline-flex h-8 w-8 items-center justify-center rounded-lg">
<MessageSquare size={15} />
{commentsTotal > 0 && (
<span className="app-counter absolute -right-1 -top-1 inline-flex min-w-4 items-center justify-center px-1 py-0.5 text-[10px] font-bold text-white">{commentsTotal}</span>
)}
</button>
</div>
</div>
</div>
</div>

View File

@ -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<Record<number, LikeUser[]>>({});
const [likesUsersEndpointUnavailable, setLikesUsersEndpointUnavailable] = useState(false);
const [imageCacheBuster, setImageCacheBuster] = useState<Record<number, number>>({});
const [postMenuOpenId, setPostMenuOpenId] = useState<number | null>(null);
const postMenuRef = useRef<HTMLDivElement | null>(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() {
</div>
<div>
<p className="text-sm font-semibold text-[var(--foreground)]">{authorName}</p>
<p className="app-text-muted text-xs">{timeAgo}</p>
<div className="app-text-muted flex items-center gap-1.5 text-xs">
<span>{timeAgo}</span>
{post.pinned ? (
<span
className="app-accent-text inline-flex items-center justify-center"
title="Закрепленная публикация"
aria-label="Закрепленная публикация"
>
<Pin size={12} className="shrink-0 fill-current" />
</span>
) : null}
</div>
</div>
</div>
{(canEditPost(post) || canDeletePost(post)) ? (
<div className="flex items-center gap-1">
{canEditPost(post) ? (
<button
type="button"
onClick={() => openEditPostModal(post)}
className="app-action-ghost flex h-8 w-8 items-center justify-center rounded-md"
title="Редактировать публикацию"
>
<Pencil size={15} />
</button>
) : null}
<div
ref={postMenuOpenId === post.id ? postMenuRef : null}
className="relative shrink-0"
>
<button
type="button"
onClick={() => setPostMenuOpenId((prev) => (prev === post.id ? null : post.id))}
className="app-action-ghost flex h-8 w-8 items-center justify-center rounded-md"
title="Действия с публикацией"
aria-label="Действия с публикацией"
aria-expanded={postMenuOpenId === post.id}
aria-haspopup="menu"
>
<ChevronRight
size={15}
className={`transition-transform duration-200 ${postMenuOpenId === post.id ? "rotate-90" : ""}`}
/>
</button>
{canDeletePost(post) ? (
<button
type="button"
disabled={postActionId === post.id}
onClick={() => removePost(post)}
className="app-action-danger flex h-8 w-8 items-center justify-center rounded-md disabled:opacity-50"
title="Удалить публикацию"
>
<Trash2 size={15} />
</button>
{postMenuOpenId === post.id ? (
<div className="app-menu absolute right-0 top-full z-20 mt-2 w-48 rounded-xl py-1.5">
{canPinPost ? (
<button
type="button"
disabled={postActionId === post.id}
onClick={() => void handlePinToggle(post)}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)] disabled:opacity-50"
>
<Pin size={14} className={post.pinned ? "fill-current app-accent-text" : ""} />
{post.pinned ? "Открепить" : "Закрепить"}
</button>
) : null}
{canEditPost(post) ? (
<button
type="button"
onClick={() => {
setPostMenuOpenId(null);
openEditPostModal(post);
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<Pencil size={14} />
Редактировать
</button>
) : null}
{canDeletePost(post) ? (
<button
type="button"
disabled={postActionId === post.id}
onClick={() => {
setPostMenuOpenId(null);
void removePost(post);
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--danger-foreground)] transition hover:bg-[var(--danger-soft)] disabled:opacity-50"
>
<Trash2 size={14} />
Удалить
</button>
) : null}
</div>
) : null}
</div>
) : null}

View File

@ -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<void>;
onEdit: (request: ProcurementRequest) => void;
onDelete: (id: number) => void | Promise<void>;
@ -155,7 +159,7 @@ function ProcurementRequestActionButtons({
<Send size={14} />
</button>
) : null}
{isDraft && isAuthor ? (
{showSecondaryActions && isDraft && isAuthor ? (
<button
type="button"
onClick={() => onEdit(request)}
@ -165,7 +169,7 @@ function ProcurementRequestActionButtons({
<Pencil size={14} />
</button>
) : null}
{isDraft && (isAuthor || canManage) ? (
{showSecondaryActions && isDraft && (isAuthor || canManage) ? (
<button
type="button"
onClick={() => onDelete(request.id)}
@ -220,7 +224,7 @@ function ProcurementRequestActionButtons({
<ClipboardCheck size={14} />
</button>
) : null}
{isAuthor && !isFinal(status) && status !== "draft" ? (
{showSecondaryActions && isAuthor && !isFinal(status) && status !== "draft" ? (
<button
type="button"
onClick={() => onCancel(request.id)}
@ -256,8 +260,13 @@ export default function ProcurementPage() {
detailsCache,
displayUserName,
ensureRequestDetail,
ensureCommentsLoaded,
error,
commentsMap,
commentDrafts,
setCommentDrafts,
expandedIds,
expandedComments,
filteredRequests,
filtersOpen,
form,
@ -299,16 +308,21 @@ export default function ProcurementPage() {
setUrgencyFilter,
statusFilter,
toggleExpand,
toggleComments,
updateItemRow,
urgencyFilter,
userLink,
scope,
scopeCounts,
handleAddComment,
handleDeleteComment,
} = useProcurementPage(user);
const [detailModalId, setDetailModalId] = useState<number | null>(null);
const [detailModalLoading, setDetailModalLoading] = useState(false);
const [detailModalError, setDetailModalError] = useState<string | null>(null);
const procurementMenuRef = useRef<HTMLDivElement | null>(null);
const [procurementMenuOpenId, setProcurementMenuOpenId] = useState<number | null>(null);
const syncRequestUrl = useCallback((requestId: number | null) => {
if (typeof window === "undefined") return;
@ -370,6 +384,35 @@ export default function ProcurementPage() {
closeDetailModal();
}, [closeDetailModal, detailModalId, selectedRequest]);
useEffect(() => {
if (!detailModalId) return;
void ensureCommentsLoaded(detailModalId).catch(() => {});
}, [detailModalId, ensureCommentsLoaded]);
useEffect(() => {
if (procurementMenuOpenId === null) return;
const handlePointerDown = (event: MouseEvent) => {
if (procurementMenuRef.current && !procurementMenuRef.current.contains(event.target as Node)) {
setProcurementMenuOpenId(null);
}
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setProcurementMenuOpenId(null);
}
};
document.addEventListener("mousedown", handlePointerDown);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handlePointerDown);
document.removeEventListener("keydown", handleEscape);
};
}, [procurementMenuOpenId]);
/* ══════════════════════════════════════════════════════
RENDER
══════════════════════════════════════════════════════ */
@ -527,9 +570,16 @@ export default function ProcurementPage() {
const isPending = st === "pending";
const isApproved = st === "approved";
const isInProgress = st === "in_progress";
const canEditThis = Boolean(isDraft && isAuthor);
const canDeleteThis = Boolean(isDraft && (isAuthor || canManage));
const canCancelThis = Boolean(isAuthor && !isFinal(st) && st !== "draft");
const hasSecondaryActions = canEditThis || canDeleteThis || canCancelThis;
const expanded = expandedIds.has(req.id);
const detail = detailsCache[req.id];
const resolvedDetail = detail || req;
const comments = commentsMap[req.id] || [];
const commentsOpen = Boolean(expandedComments[req.id]);
const commentsTotal = resolvedDetail.comments_count ?? req.comments_count ?? comments.length;
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 requestorLink = userLink(req.requestor);
@ -538,7 +588,7 @@ export default function ProcurementPage() {
const approvalsCount = resolvedDetail.approvals?.length ?? 0;
return (
<article key={req.id} className="app-surface-muted overflow-hidden rounded-xl transition hover:border-[var(--border-strong)]">
<article key={req.id} className={`app-surface-muted rounded-xl transition hover:border-[var(--border-strong)] ${procurementMenuOpenId === req.id ? "relative z-20 overflow-visible" : "overflow-hidden"}`}>
<div className="px-4 py-3">
<div className="flex items-start gap-3">
<button
@ -571,8 +621,76 @@ export default function ProcurementPage() {
</div>
</div>
<div className="flex shrink-0 flex-col items-end gap-2">
<span className={`app-status-pill shrink-0 ${sMeta.cls}`}>{sMeta.label}</span>
<div
ref={procurementMenuOpenId === req.id ? procurementMenuRef : null}
className="flex shrink-0 flex-col items-end gap-2"
>
<div className="flex items-center gap-2">
<span className={`app-status-pill shrink-0 ${sMeta.cls}`}>{sMeta.label}</span>
{hasSecondaryActions ? (
<div className="relative">
<button
type="button"
onClick={() => setProcurementMenuOpenId((prev) => (prev === req.id ? null : req.id))}
className="app-action-ghost flex h-8 w-8 items-center justify-center rounded-md"
title="Действия с заявкой"
aria-label="Действия с заявкой"
aria-expanded={procurementMenuOpenId === req.id}
aria-haspopup="menu"
>
<ChevronRight
size={15}
className={`transition-transform duration-200 ${procurementMenuOpenId === req.id ? "rotate-90" : ""}`}
/>
</button>
{procurementMenuOpenId === req.id ? (
<div className="app-menu absolute right-0 top-full z-20 mt-2 w-44 rounded-xl py-1.5">
{canCancelThis ? (
<button
type="button"
disabled={busyKey === `cancel-${req.id}`}
onClick={() => {
setProcurementMenuOpenId(null);
void handleCancel(req.id);
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)] disabled:opacity-50"
>
<XCircle size={14} className="app-text-muted" />
Отменить
</button>
) : null}
{canEditThis ? (
<button
type="button"
onClick={() => {
setProcurementMenuOpenId(null);
openEdit(req);
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<Pencil size={14} className="app-text-muted" />
Редактировать
</button>
) : null}
{canDeleteThis ? (
<button
type="button"
disabled={busyKey === `delete-${req.id}`}
onClick={() => {
setProcurementMenuOpenId(null);
void handleDelete(req.id);
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--danger-foreground)] transition hover:bg-[var(--danger-soft)] disabled:opacity-50"
>
<Trash2 size={13} className="text-[var(--danger-foreground)]" />
Удалить
</button>
) : null}
</div>
) : null}
</div>
) : null}
</div>
<span className={`text-[11px] font-medium ${urg.cls}`}>{urg.label} срочность</span>
</div>
</div>
@ -609,34 +727,80 @@ export default function ProcurementPage() {
</div>
)}
</div>
<div className="mt-3 flex flex-wrap items-center gap-1.5">
<button type="button" title={`Комментарии (${commentsTotal})`} onClick={() => void toggleComments(req.id)} className="app-action-secondary relative inline-flex h-8 w-8 items-center justify-center rounded-lg">
<MessageSquare size={15} />
{commentsTotal > 0 && (
<span className="app-counter absolute -right-1 -top-1 inline-flex min-w-4 items-center justify-center px-1 py-0.5 text-[10px] font-bold text-white">{commentsTotal}</span>
)}
</button>
</div>
</div>
</div>
</div>
{expanded && (
{(expanded || commentsOpen) && (
<div className="mt-4 space-y-3 px-4 pb-4">
<ProcurementRequestDetailContent
request={resolvedDetail}
displayUserName={displayUserName}
footer={(
<ProcurementRequestActionButtons
request={req}
busyKey={busyKey}
canManage={canManage}
isAuthor={isAuthor}
isExecutor={isExecutor}
isFinal={isFinal}
onSubmit={handleSubmit}
onEdit={openEdit}
onDelete={handleDelete}
onApprove={handleApprove}
onReject={handleReject}
onStart={handleStart}
onComplete={handleComplete}
onCancel={handleCancel}
/>
)}
/>
{expanded ? (
<ProcurementRequestDetailContent
request={resolvedDetail}
displayUserName={displayUserName}
footer={(
<ProcurementRequestActionButtons
request={req}
busyKey={busyKey}
canManage={canManage}
isAuthor={isAuthor}
isExecutor={isExecutor}
isFinal={isFinal}
showSecondaryActions={false}
onSubmit={handleSubmit}
onEdit={openEdit}
onDelete={handleDelete}
onApprove={handleApprove}
onReject={handleReject}
onStart={handleStart}
onComplete={handleComplete}
onCancel={handleCancel}
/>
)}
/>
) : null}
{commentsOpen ? (
<div className={expanded ? "app-surface rounded-xl p-3" : "app-surface-elevated rounded-xl p-3"}>
<div className="space-y-2">
{comments.length === 0 ? (
<p className="app-text-muted text-xs">Комментариев пока нет</p>
) : (
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)]">
<div className="mb-1 flex items-center justify-between gap-2">
<span className="font-medium">{displayUserName(comment.author)}</span>
<div className="flex items-center gap-2">
<span className="app-text-muted">{fmt(comment.created_at)}</span>
{canDeleteCommentThis ? (
<button type="button" onClick={() => void handleDeleteComment(req.id, comment.id)} className="app-action-danger rounded-md px-1.5 py-0.5">
удалить
</button>
) : null}
</div>
</div>
<p className="app-text-wrap text-[var(--foreground)]">{comment.text}</p>
</div>
);
})
)}
</div>
<div className="mt-2 flex items-center gap-2">
<input value={commentDrafts[req.id] || ""} onChange={(e) => setCommentDrafts((previous) => ({ ...previous, [req.id]: e.target.value }))} placeholder="Добавить комментарий" className="app-input flex-1 rounded-lg px-3 py-2 text-xs" />
<button type="button" onClick={() => void handleAddComment(req.id)} disabled={busyKey === `comment-${req.id}`} className="app-action-primary rounded-lg px-3 py-2 text-xs font-medium disabled:opacity-60">Отправить</button>
</div>
</div>
) : null}
</div>
)}
</article>
@ -739,6 +903,39 @@ export default function ProcurementPage() {
/>
)}
/>
<div className="app-surface rounded-xl p-4">
<p className="app-card-caption">Комментарии</p>
<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) => {
const canDeleteCommentThis = Boolean(comment.author?.id && (user?.id === comment.author.id || user?.auth?.is_staff || user?.auth?.is_superuser));
return (
<div key={comment.id} className="app-surface-muted rounded-lg px-3 py-2 text-xs text-[var(--foreground)]">
<div className="mb-1 flex items-center justify-between gap-2">
<span className="font-medium">{displayUserName(comment.author)}</span>
<div className="flex items-center gap-2">
<span className="app-text-muted">{fmt(comment.created_at)}</span>
{canDeleteCommentThis ? (
<button type="button" onClick={() => void handleDeleteComment(selectedRequest.id, comment.id)} className="app-action-danger rounded-md px-1.5 py-0.5">
удалить
</button>
) : null}
</div>
</div>
<p className="app-text-wrap text-[var(--foreground)]">{comment.text}</p>
</div>
);
})
)}
</div>
<div className="mt-3 flex items-center gap-2">
<input value={commentDrafts[selectedRequest.id] || ""} onChange={(e) => setCommentDrafts((previous) => ({ ...previous, [selectedRequest.id]: e.target.value }))} placeholder="Добавить комментарий" className="app-input flex-1 rounded-lg px-3 py-2 text-xs" />
<button type="button" onClick={() => void handleAddComment(selectedRequest.id)} disabled={busyKey === `comment-${selectedRequest.id}`} className="app-action-primary rounded-lg px-3 py-2 text-xs font-medium disabled:opacity-60">Отправить</button>
</div>
</div>
</div>
) : (
<div className="app-surface-muted rounded-xl px-4 py-6 text-center">

View File

@ -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<HTMLInputElement>(null);
const requestMenuRef = useRef<HTMLDivElement | null>(null);
const openedLinkedRequestIdRef = useRef<number | null>(null);
const loadingLinkedRequestIdRef = useRef<number | null>(null);
const [requestMenuOpenId, setRequestMenuOpenId] = useState<number | null>(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 (
<AppShell>
{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 (
<article key={item.id} className="app-surface-muted overflow-hidden rounded-xl transition hover:border-[var(--border-strong)]">
<article key={item.id} className={`app-surface-muted rounded-xl transition hover:border-[var(--border-strong)] ${requestMenuOpenId === item.id ? "relative z-20 overflow-visible" : "overflow-hidden"}`}>
<div className="p-4">
<div className="flex items-start gap-3">
<button type="button" onClick={() => h.toggleRow(item.id)} className="app-action-secondary mt-0.5 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg transition"><ChevronDown size={15} className={`transition ${rowOpen ? "rotate-180" : ""}`} /></button>
@ -257,14 +287,81 @@ function RequestsPageContent() {
</button>
<div className="app-text-muted mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs"><span>Период: {item.date_from ? formatDate(item.date_from) : "—"}{item.date_to ? ` — ${formatDate(item.date_to)}` : ""}</span></div>
</div>
<div className="shrink-0 text-right"><span className={`inline-flex rounded-full px-2.5 py-1 text-xs ring-1 ${st.className}`}>{st.label}</span></div>
<div className="shrink-0">
<div
ref={requestMenuOpenId === item.id ? requestMenuRef : null}
className="flex items-center justify-end gap-2"
>
<span className={`inline-flex rounded-full px-2.5 py-1 text-xs ring-1 ${st.className}`}>{st.label}</span>
{hasSecondaryActions ? (
<div className="relative">
<button
type="button"
onClick={() => setRequestMenuOpenId((prev) => (prev === item.id ? null : item.id))}
className="app-action-ghost flex h-8 w-8 items-center justify-center rounded-md"
title="Действия с заявлением"
aria-label="Действия с заявлением"
aria-expanded={requestMenuOpenId === item.id}
aria-haspopup="menu"
>
<ChevronRight
size={15}
className={`transition-transform duration-200 ${requestMenuOpenId === item.id ? "rotate-90" : ""}`}
/>
</button>
{requestMenuOpenId === item.id ? (
<div className="app-menu absolute right-0 top-full z-20 mt-2 w-44 rounded-xl py-1.5">
{canCancel ? (
<button
type="button"
disabled={h.busyKey === `cancel-${item.id}`}
onClick={() => {
setRequestMenuOpenId(null);
void h.handleCancel(item.id);
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)] disabled:opacity-50"
>
<Ban size={14} />
Отменить
</button>
) : null}
{canEdit ? (
<button
type="button"
onClick={() => {
setRequestMenuOpenId(null);
h.openEdit(item);
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<Pencil size={14} />
Редактировать
</button>
) : null}
{canDelete ? (
<button
type="button"
disabled={h.busyKey === `delete-${item.id}`}
onClick={() => {
setRequestMenuOpenId(null);
void h.handleDelete(item.id);
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--danger-foreground)] transition hover:bg-[var(--danger-soft)] disabled:opacity-50"
>
<Trash2 size={14} />
Удалить
</button>
) : null}
</div>
) : null}
</div>
) : null}
</div>
</div>
</div>
{summary && <p className={`${rowOpen ? "app-text-wrap line-clamp-10" : "app-text-wrap line-clamp-3"} mt-3 text-sm text-[var(--foreground)]`}>{summary}</p>}
<div className={`${summary ? "mt-3" : "mt-2"} flex flex-wrap items-center gap-1.5`}>
{isAuth && !h.isFinal(sk) && <button type="button" title="Отменить" onClick={() => h.handleCancel(item.id)} disabled={h.busyKey === `cancel-${item.id}`} className="app-action-secondary inline-flex items-center justify-center rounded-lg p-1.5 disabled:opacity-60"><Ban size={15} /></button>}
<button type="button" title={`Комментарии (${item.comments_count ?? comments.length})`} onClick={() => h.toggleComments(item.id)} className="app-action-secondary relative inline-flex items-center justify-center rounded-lg p-1.5"><MessageSquare size={15} />{(item.comments_count ?? comments.length) > 0 && <span className="app-counter absolute -right-1.5 -top-1.5 flex h-4 min-w-4 px-1 text-[10px] font-bold">{item.comments_count ?? comments.length}</span>}</button>
{isAuth && !h.isFinal(sk) && <button type="button" title="Редактировать" onClick={() => h.openEdit(item)} className="app-action-secondary inline-flex items-center justify-center rounded-lg p-1.5"><Pencil size={15} /></button>}
{((isAuth && !h.isFinal(sk)) || canManage) && <button type="button" title="Удалить" onClick={() => h.handleDelete(item.id)} disabled={h.busyKey === `delete-${item.id}`} className="app-action-danger inline-flex items-center justify-center rounded-lg p-1.5 disabled:opacity-60"><Trash2 size={15} /></button>}
{canPr && <span className="ml-auto inline-flex items-center gap-2"><button type="button" title="Одобрить" onClick={() => h.handleApprove(item.id)} disabled={h.busyKey === `approve-${item.id}`} className="app-feedback-success inline-flex items-center justify-center rounded-lg p-2 disabled:opacity-60"><ThumbsUp size={18} /></button><button type="button" title="Отклонить" onClick={() => h.handleReject(item.id)} disabled={h.busyKey === `reject-${item.id}`} className="app-action-danger inline-flex items-center justify-center rounded-lg p-2 disabled:opacity-60"><ThumbsDown size={18} /></button></span>}
</div>
</div>

View File

@ -120,9 +120,9 @@ export default function MessageActionsMenu({
<button
type="button"
onClick={onDelete}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-red-600 transition hover:bg-[var(--danger-soft)]"
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--danger-foreground)] transition hover:bg-[var(--danger-soft)]"
>
<Trash2 size={13} className="text-red-400" />
<Trash2 size={13} className="text-[var(--danger-foreground)]" />
Удалить
</button>
) : null}

View File

@ -167,7 +167,7 @@ export function Modal({
{/* Footer */}
{footer && (
<div className="app-divider shrink-0 border-t px-4 py-3 sm:px-6 sm:py-4">{footer}</div>
<div className="shrink-0 px-4 pt-3 sm:px-6 sm:pt-4">{footer}</div>
)}
{/* Bottom padding */}

View File

@ -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<FormState>(emptyForm);
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
const [expandedComments, setExpandedComments] = useState<Record<number, boolean>>({});
const [commentsMap, setCommentsMap] = useState<Record<number, ProcurementComment[]>>({});
const [commentDrafts, setCommentDrafts] = useState<Record<number, string>>({});
const [detailsCache, setDetailsCache] = useState<Record<number, ProcurementRequest>>({});
const detailsCacheRef = useRef<Record<number, ProcurementRequest>>({});
@ -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,

View File

@ -32,6 +32,8 @@ export function createFeedApi(request: RequestFn, getToken: GetTokenFn) {
createPost: (data: Record<string, any>) => formDataPost('/api/v1/posts/', 'POST', data),
updatePost: (postId: number, data: Record<string, any>) => formDataPost(`/api/v1/posts/${postId}/`, 'PATCH', data),
deletePost: (postId: number): Promise<void> => 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/`),

View File

@ -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<void> =>
request(`/api/v1/procurement/requests/${requestId}/comments/${commentId}/`, { method: 'DELETE' }),
getMyProcurementRequests: (params?: Record<string, string | number>) => request(`/api/v1/procurement/requests/my_requests/${buildQuery(params)}`),
getPendingApprovals: (params?: Record<string, string | number>) => request(`/api/v1/procurement/requests/pending_approvals/${buildQuery(params)}`),
// Items

View File

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