procurement: wire item comments UI
This commit is contained in:
parent
9cd21a1b1e
commit
fb7d918dff
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) }),
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user