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;