procurement: wire item comments UI

This commit is contained in:
lizerk 2026-05-22 15:06:30 +03:00
parent 9cd21a1b1e
commit fb7d918dff
5 changed files with 255 additions and 3 deletions

View File

@ -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<number | null>(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={(
<ProcurementRequestActionButtons
request={req}
@ -1002,8 +1016,16 @@ export default function ProcurementPage() {
displayUserName={displayUserName}
canProcessItems={["waiting", "in_progress"].includes(String(selectedRequest.status || "").toLowerCase())}
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={(
<ProcurementRequestActionButtons
request={selectedRequest}

View File

@ -2,12 +2,13 @@
import { useState } from "react";
import Link from "next/link";
import { Check, CheckCircle2, CircleDot, ExternalLink, Plus, Save, X } from "lucide-react";
import { Check, CheckCircle2, CircleDot, ExternalLink, MessageSquare, Plus, Save, X } from "lucide-react";
import { RequestAvatar } from "@/components/requests/RequestAvatar";
import { CommentComposer, CommentDeleteButton } from "@/components/shared/CommentControls";
import { cleanLinkRows, linkHref, toLinkRows } from "@/lib/procurementLinks";
import { formatDate, formatMoney, userProfileLink } from "@/lib/shared";
import type { ProcurementItem, ProcurementItemExecutionStatus, ProcurementRequest, User } from "@/types/api";
import type { ProcurementItem, ProcurementItemComment, ProcurementItemExecutionStatus, ProcurementRequest, User } from "@/types/api";
interface ProcurementRequestDetailContentProps {
currentUserId?: number | null;
@ -19,8 +20,16 @@ interface ProcurementRequestDetailContentProps {
) => string;
canProcessItems?: boolean;
busyKey?: string | null;
canDeleteAnyComment?: boolean;
onUpdateItem?: (requestId: number, itemId: number, patch: Record<string, unknown>) => void | Promise<unknown>;
onMarkAllReceived?: (requestId: number) => void | Promise<unknown>;
itemCommentsMap?: Record<number, ProcurementItemComment[]>;
itemCommentDrafts?: Record<number, string>;
expandedItemComments?: Record<number, boolean>;
onToggleItemComments?: (itemId: number) => void | Promise<void>;
onItemCommentDraftChange?: (itemId: number, value: string) => void;
onAddItemComment?: (requestId: number, itemId: number) => void | Promise<void>;
onDeleteItemComment?: (requestId: number, itemId: number, commentId: number) => void | Promise<void>;
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<string, unknown>) => void | Promise<unknown>;
comments?: ProcurementItemComment[];
commentDraft?: string;
commentsOpen?: boolean;
onToggleComments?: (itemId: number) => void | Promise<void>;
onCommentDraftChange?: (itemId: number, value: string) => void;
onAddComment?: (requestId: number, itemId: number) => void | Promise<void>;
onDeleteComment?: (requestId: number, itemId: number, commentId: number) => void | Promise<void>;
}
function ProcurementItemCard({
item,
requestId,
displayUserName,
canEditItemProcessing,
currentUserId,
canDeleteAnyComment = false,
busyKey,
onUpdateItem,
comments = [],
commentDraft = "",
commentsOpen = false,
onToggleComments,
onCommentDraftChange,
onAddComment,
onDeleteComment,
}: ProcurementItemCardProps) {
const [draft, setDraft] = useState<ItemProcessingDraft>(() => normalizeItemDraft(item));
const links = Array.isArray(item.links) ? item.links.filter(Boolean) : [];
const commentsTotal = item.comments_count ?? comments.length;
const updateDraft = (patch: Partial<ItemProcessingDraft>) => {
setDraft((previous) => ({ ...previous, ...patch }));
@ -149,6 +183,63 @@ function ProcurementItemCard({
</p>
) : null}
<div className="mt-3 border-t border-[var(--border)] pt-3">
<button
type="button"
onClick={() => void onToggleComments?.(item.id)}
className="app-action-secondary inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium"
>
<MessageSquare size={13} />
<span>Комментарии</span>
{commentsTotal > 0 ? (
<span className="app-counter inline-flex min-w-4 items-center justify-center px-1 py-0.5 text-[10px] font-bold text-white">
{commentsTotal}
</span>
) : null}
</button>
{commentsOpen ? (
<div className="mt-2 space-y-2">
{comments.length === 0 ? (
<p className="app-text-muted text-xs">Комментариев по позиции пока нет</p>
) : (
comments.map((comment) => {
const canDeleteComment = Boolean(
canDeleteAnyComment || (comment.author?.id && currentUserId === comment.author.id),
);
return (
<div key={comment.id} className="rounded-lg border border-[var(--border)] px-3 py-2 text-xs">
<div className="mb-1 flex items-center justify-between gap-2">
<span className="font-medium text-[var(--foreground)]">
{displayUserName(comment.author)}
</span>
<div className="flex items-center gap-2">
<span className="app-text-muted">{formatDate(comment.created_at)}</span>
{canDeleteComment ? (
<CommentDeleteButton
disabled={busyKey === `item-comment-delete-${comment.id}`}
onClick={() => onDeleteComment?.(requestId, item.id, comment.id)}
/>
) : null}
</div>
</div>
<p className="app-text-wrap text-[var(--foreground)]">{comment.text}</p>
</div>
);
})
)}
<CommentComposer
value={commentDraft}
onChange={(value) => onCommentDraftChange?.(item.id, value)}
onSubmit={() => onAddComment?.(requestId, item.id)}
disabled={busyKey === `item-comment-${item.id}`}
placeholder="Комментарий по позиции"
/>
</div>
) : null}
</div>
{canEditItemProcessing ? (
<div className="mt-3 grid gap-2 rounded-lg border border-[var(--border)] bg-[var(--surface-primary)] p-3 sm:grid-cols-2">
<div>
@ -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}
/>
))}
</div>

View File

@ -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<Record<number, boolean>>({});
const [commentsMap, setCommentsMap] = useState<Record<number, ProcurementComment[]>>({});
const [commentDrafts, setCommentDrafts] = useState<Record<number, string>>({});
const [expandedItemComments, setExpandedItemComments] = useState<Record<number, boolean>>({});
const [itemCommentsMap, setItemCommentsMap] = useState<Record<number, ProcurementItemComment[]>>({});
const [itemCommentDrafts, setItemCommentDrafts] = useState<Record<number, string>>({});
const [detailsCache, setDetailsCache] = useState<Record<number, ProcurementRequest>>({});
const detailsCacheRef = useRef<Record<number, ProcurementRequest>>({});
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,

View File

@ -30,6 +30,11 @@ export function createProcurementApi(request: RequestFn) {
createProcurementItem: (data: Record<string, any>) => request('/api/v1/procurement/items/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
updateProcurementItem: (id: number, data: Record<string, any>) => request(`/api/v1/procurement/items/${id}/`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
deleteProcurementItem: (id: number): Promise<void> => 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<void> =>
request(`/api/v1/procurement/items/${itemId}/comments/${commentId}/`, { method: 'DELETE' }),
// Suppliers
getProcurementSuppliers: (params?: Record<string, string | number>) => request(`/api/v1/procurement/suppliers/${buildQuery(params)}`),
createProcurementSupplier: (data: Record<string, any>) => request('/api/v1/procurement/suppliers/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),

View File

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