From fb7d918dff3113d3caef07705dd8afbf13c03caa Mon Sep 17 00:00:00 2001 From: lizerk Date: Fri, 22 May 2026 15:06:30 +0300 Subject: [PATCH] procurement: wire item comments UI --- frontend/src/app/procurement/page.tsx | 24 +++- .../ProcurementRequestDetailContent.tsx | 113 +++++++++++++++++- frontend/src/hooks/useProcurementPage.ts | 111 +++++++++++++++++ frontend/src/lib/api/procurement.ts | 5 + frontend/src/types/api.ts | 5 + 5 files changed, 255 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/procurement/page.tsx b/frontend/src/app/procurement/page.tsx index 7100b204..261340bc 100644 --- a/frontend/src/app/procurement/page.tsx +++ b/frontend/src/app/procurement/page.tsx @@ -271,6 +271,10 @@ export default function ProcurementPage() { commentsMap, commentDrafts, setCommentDrafts, + expandedItemComments, + itemCommentsMap, + itemCommentDrafts, + setItemCommentDrafts, expandedIds, expandedComments, filteredRequests, @@ -305,7 +309,6 @@ export default function ProcurementPage() { resolveUserId, requests, searchQuery, - setActiveSection, setDepartmentFilter, setFiltersOpen, setForm, @@ -325,6 +328,9 @@ export default function ProcurementPage() { scopeCounts, handleAddComment, handleDeleteComment, + handleAddItemComment, + handleDeleteItemComment, + toggleItemComments, } = useProcurementPage(user); const [detailModalId, setDetailModalId] = useState(null); @@ -884,8 +890,16 @@ export default function ProcurementPage() { displayUserName={displayUserName} canProcessItems={["waiting", "in_progress"].includes(st)} busyKey={busyKey} + canDeleteAnyComment={Boolean(user?.auth?.is_staff || user?.auth?.is_superuser)} onUpdateItem={handleUpdateItem} onMarkAllReceived={handleMarkAllReceived} + itemCommentsMap={itemCommentsMap} + itemCommentDrafts={itemCommentDrafts} + expandedItemComments={expandedItemComments} + onToggleItemComments={toggleItemComments} + onItemCommentDraftChange={(itemId, value) => setItemCommentDrafts((previous) => ({ ...previous, [itemId]: value }))} + onAddItemComment={handleAddItemComment} + onDeleteItemComment={handleDeleteItemComment} footer={( setItemCommentDrafts((previous) => ({ ...previous, [itemId]: value }))} + onAddItemComment={handleAddItemComment} + onDeleteItemComment={handleDeleteItemComment} footer={( string; canProcessItems?: boolean; busyKey?: string | null; + canDeleteAnyComment?: boolean; onUpdateItem?: (requestId: number, itemId: number, patch: Record) => void | Promise; onMarkAllReceived?: (requestId: number) => void | Promise; + itemCommentsMap?: Record; + itemCommentDrafts?: Record; + expandedItemComments?: Record; + onToggleItemComments?: (itemId: number) => void | Promise; + onItemCommentDraftChange?: (itemId: number, value: string) => void; + onAddItemComment?: (requestId: number, itemId: number) => void | Promise; + onDeleteItemComment?: (requestId: number, itemId: number, commentId: number) => void | Promise; footer?: React.ReactNode; } @@ -71,20 +80,45 @@ const normalizeItemDraft = (item: ProcurementItem): ItemProcessingDraft => ({ interface ProcurementItemCardProps { item: ProcurementItem; requestId: number; + displayUserName: ( + person?: User | number | null, + fallbackName?: string | null, + fallbackEmail?: string | null, + ) => string; canEditItemProcessing: boolean; + currentUserId?: number | null; + canDeleteAnyComment?: boolean; busyKey?: string | null; onUpdateItem?: (requestId: number, itemId: number, patch: Record) => void | Promise; + comments?: ProcurementItemComment[]; + commentDraft?: string; + commentsOpen?: boolean; + onToggleComments?: (itemId: number) => void | Promise; + onCommentDraftChange?: (itemId: number, value: string) => void; + onAddComment?: (requestId: number, itemId: number) => void | Promise; + onDeleteComment?: (requestId: number, itemId: number, commentId: number) => void | Promise; } function ProcurementItemCard({ item, requestId, + displayUserName, canEditItemProcessing, + currentUserId, + canDeleteAnyComment = false, busyKey, onUpdateItem, + comments = [], + commentDraft = "", + commentsOpen = false, + onToggleComments, + onCommentDraftChange, + onAddComment, + onDeleteComment, }: ProcurementItemCardProps) { const [draft, setDraft] = useState(() => normalizeItemDraft(item)); const links = Array.isArray(item.links) ? item.links.filter(Boolean) : []; + const commentsTotal = item.comments_count ?? comments.length; const updateDraft = (patch: Partial) => { setDraft((previous) => ({ ...previous, ...patch })); @@ -149,6 +183,63 @@ function ProcurementItemCard({

) : null} +
+ + + {commentsOpen ? ( +
+ {comments.length === 0 ? ( +

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

+ ) : ( + comments.map((comment) => { + const canDeleteComment = Boolean( + canDeleteAnyComment || (comment.author?.id && currentUserId === comment.author.id), + ); + + return ( +
+
+ + {displayUserName(comment.author)} + +
+ {formatDate(comment.created_at)} + {canDeleteComment ? ( + onDeleteComment?.(requestId, item.id, comment.id)} + /> + ) : null} +
+
+

{comment.text}

+
+ ); + }) + )} + onCommentDraftChange?.(item.id, value)} + onSubmit={() => onAddComment?.(requestId, item.id)} + disabled={busyKey === `item-comment-${item.id}`} + placeholder="Комментарий по позиции" + /> +
+ ) : null} +
+ {canEditItemProcessing ? (
@@ -251,8 +342,16 @@ export function ProcurementRequestDetailContent({ displayUserName, canProcessItems = false, busyKey, + canDeleteAnyComment = false, onUpdateItem, onMarkAllReceived, + itemCommentsMap = {}, + itemCommentDrafts = {}, + expandedItemComments = {}, + onToggleItemComments, + onItemCommentDraftChange, + onAddItemComment, + onDeleteItemComment, footer, }: ProcurementRequestDetailContentProps) { const canEditItemProcessing = Boolean(canProcessItems && onUpdateItem); @@ -313,9 +412,19 @@ export function ProcurementRequestDetailContent({ ].join(":")} item={item} requestId={request.id} + displayUserName={displayUserName} canEditItemProcessing={canEditItemProcessing} + currentUserId={currentUserId} + canDeleteAnyComment={canDeleteAnyComment} busyKey={busyKey} onUpdateItem={onUpdateItem} + comments={itemCommentsMap[item.id] || []} + commentDraft={itemCommentDrafts[item.id] || ""} + commentsOpen={Boolean(expandedItemComments[item.id])} + onToggleComments={onToggleItemComments} + onCommentDraftChange={onItemCommentDraftChange} + onAddComment={onAddItemComment} + onDeleteComment={onDeleteItemComment} /> ))}
diff --git a/frontend/src/hooks/useProcurementPage.ts b/frontend/src/hooks/useProcurementPage.ts index 712d3d6c..f3ba8da2 100644 --- a/frontend/src/hooks/useProcurementPage.ts +++ b/frontend/src/hooks/useProcurementPage.ts @@ -8,6 +8,8 @@ import { displayUserName, extractNextPage, loadAllPages } from "@/lib/shared"; import type { Department, ProcurementComment, + ProcurementItem, + ProcurementItemComment, ProcurementRequest, UrgencyLevel, User, @@ -167,6 +169,9 @@ export function useProcurementPage(user: User | null) { const [expandedComments, setExpandedComments] = useState>({}); const [commentsMap, setCommentsMap] = useState>({}); const [commentDrafts, setCommentDrafts] = useState>({}); + const [expandedItemComments, setExpandedItemComments] = useState>({}); + const [itemCommentsMap, setItemCommentsMap] = useState>({}); + const [itemCommentDrafts, setItemCommentDrafts] = useState>({}); const [detailsCache, setDetailsCache] = useState>({}); const detailsCacheRef = useRef>({}); const hasLoadedOnceRef = useRef(false); @@ -596,6 +601,62 @@ export function useProcurementPage(user: User | null) { } }, [commentsMap, ensureCommentsLoaded, expandedComments]); + const updateCachedItem = useCallback(( + requestId: number, + itemId: number, + updater: (item: ProcurementItem) => ProcurementItem, + ) => { + const updateRequest = (request: ProcurementRequest): ProcurementRequest => { + if (!Array.isArray(request.items)) { + return request; + } + + return { + ...request, + items: request.items.map((item) => (item.id === itemId ? updater(item) : item)), + }; + }; + + setRequests((previous) => previous.map((request) => ( + request.id === requestId ? updateRequest(request) : request + ))); + setDetailsCache((previous) => { + const detail = previous[requestId]; + if (!detail) { + return previous; + } + + const updatedDetail = updateRequest(detail); + const next = { ...previous, [requestId]: updatedDetail }; + detailsCacheRef.current = { ...detailsCacheRef.current, [requestId]: updatedDetail }; + return next; + }); + }, []); + + const ensureItemCommentsLoaded = useCallback(async (itemId: number) => { + if (itemCommentsMap[itemId]) { + return itemCommentsMap[itemId]; + } + + const comments = await apiClient.getProcurementItemComments(itemId); + const normalized = Array.isArray(comments) ? comments : []; + setItemCommentsMap((previous) => ({ ...previous, [itemId]: normalized })); + return normalized; + }, [itemCommentsMap]); + + const toggleItemComments = useCallback(async (itemId: number) => { + const isOpen = Boolean(expandedItemComments[itemId]); + setExpandedItemComments((previous) => ({ ...previous, [itemId]: !isOpen })); + + if (!isOpen && !itemCommentsMap[itemId]) { + try { + await ensureItemCommentsLoaded(itemId); + } catch { + setActionError("Не удалось загрузить комментарии позиции"); + } + } + }, [ensureItemCommentsLoaded, expandedItemComments, itemCommentsMap]); + const handleAddComment = useCallback(async (id: number) => { const text = (commentDrafts[id] || "").trim(); if (!text) return; @@ -628,6 +689,29 @@ export function useProcurementPage(user: User | null) { } }, [commentDrafts]); + const handleAddItemComment = useCallback(async (requestId: number, itemId: number) => { + const text = (itemCommentDrafts[itemId] || "").trim(); + if (!text) return; + + try { + setBusyKey(`item-comment-${itemId}`); + const created = await apiClient.addProcurementItemComment(itemId, text); + setItemCommentsMap((previous) => ({ + ...previous, + [itemId]: [...(previous[itemId] || []), created], + })); + setItemCommentDrafts((previous) => ({ ...previous, [itemId]: "" })); + updateCachedItem(requestId, itemId, (item) => ({ + ...item, + comments_count: (item.comments_count || 0) + 1, + })); + } catch { + setActionError("Не удалось добавить комментарий к позиции"); + } finally { + setBusyKey(null); + } + }, [itemCommentDrafts, updateCachedItem]); + const handleDeleteComment = useCallback(async (requestId: number, commentId: number) => { try { setBusyKey(`comment-delete-${commentId}`); @@ -656,6 +740,25 @@ export function useProcurementPage(user: User | null) { } }, []); + const handleDeleteItemComment = useCallback(async (requestId: number, itemId: number, commentId: number) => { + try { + setBusyKey(`item-comment-delete-${commentId}`); + await apiClient.deleteProcurementItemComment(itemId, commentId); + setItemCommentsMap((previous) => ({ + ...previous, + [itemId]: (previous[itemId] || []).filter((comment) => comment.id !== commentId), + })); + updateCachedItem(requestId, itemId, (item) => ({ + ...item, + comments_count: Math.max(0, (item.comments_count || 0) - 1), + })); + } catch { + setActionError("Не удалось удалить комментарий позиции"); + } finally { + setBusyKey(null); + } + }, [updateCachedItem]); + const handleSubmit = useCallback((id: number) => doAction( `submit-${id}`, () => apiClient.submitProcurementRequest(id), @@ -798,6 +901,10 @@ export function useProcurementPage(user: User | null) { expandedComments, commentsMap, commentDrafts, + expandedItemComments, + itemCommentsMap, + itemCommentDrafts, + setItemCommentDrafts, setCommentDrafts, detailsCache, filteredRequests, @@ -811,7 +918,9 @@ export function useProcurementPage(user: User | null) { handleLoadMore, toggleExpand, toggleComments, + toggleItemComments, ensureCommentsLoaded, + ensureItemCommentsLoaded, handleSubmit, handleApprove, handleReject, @@ -823,6 +932,8 @@ export function useProcurementPage(user: User | null) { handleDelete, handleAddComment, handleDeleteComment, + handleAddItemComment, + handleDeleteItemComment, addItemRow, removeItemRow, updateItemRow, diff --git a/frontend/src/lib/api/procurement.ts b/frontend/src/lib/api/procurement.ts index 94ee70cb..8fd3d140 100644 --- a/frontend/src/lib/api/procurement.ts +++ b/frontend/src/lib/api/procurement.ts @@ -30,6 +30,11 @@ export function createProcurementApi(request: RequestFn) { createProcurementItem: (data: Record) => request('/api/v1/procurement/items/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }), updateProcurementItem: (id: number, data: Record) => request(`/api/v1/procurement/items/${id}/`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }), deleteProcurementItem: (id: number): Promise => request(`/api/v1/procurement/items/${id}/`, { method: 'DELETE' }), + getProcurementItemComments: (itemId: number) => request(`/api/v1/procurement/items/${itemId}/comments/`), + addProcurementItemComment: (itemId: number, text: string) => + request(`/api/v1/procurement/items/${itemId}/comments/`, { method: 'POST', body: JSON.stringify({ text }) }), + deleteProcurementItemComment: (itemId: number, commentId: number): Promise => + request(`/api/v1/procurement/items/${itemId}/comments/${commentId}/`, { method: 'DELETE' }), // Suppliers getProcurementSuppliers: (params?: Record) => request(`/api/v1/procurement/suppliers/${buildQuery(params)}`), createProcurementSupplier: (data: Record) => request('/api/v1/procurement/suppliers/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }), diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 1170c39f..f6d3535b 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -503,6 +503,7 @@ export interface ProcurementItem { execution_status?: ProcurementItemExecutionStatus; execution_status_display?: string; executor_comment?: string; + comments_count?: number; equipment?: number | null; } @@ -564,6 +565,10 @@ export interface ProcurementComment { created_at: string; } +export interface ProcurementItemComment extends ProcurementComment { + item: number; +} + export interface ProcurementSupplier { id: number; name: string;