feat(ui): unify action menus and procurement comments
This commit is contained in:
parent
ae7e1daf89
commit
44da9c46c1
@ -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
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@ -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):
|
||||
"""Отправить заявку на согласование."""
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# ТЕСТЫ ОБНОВЛЕНИЯ ЗАЯВОК
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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/`),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user