Add comments to request detail modal

This commit is contained in:
lizerk 2026-04-20 08:08:57 +03:00
parent c977a012f8
commit a0ffad7585
3 changed files with 212 additions and 99 deletions

View File

@ -140,19 +140,25 @@ function RequestsPageContent() {
<RequestDetailModal
actionError={h.actionError}
busyKey={h.busyKey}
commentDraft={h.detailsRequest ? (h.commentDrafts[h.detailsRequest.id] || "") : ""}
comments={h.detailsRequest ? (h.commentsMap[h.detailsRequest.id] || []) : []}
commentsLoading={h.detailsRequest ? Boolean(h.commentsLoadingMap[h.detailsRequest.id]) : false}
currentUserId={user?.id}
departmentNameMap={h.departmentNameMap}
isFinal={h.isFinal}
onAddComment={h.handleAddComment}
onApprove={h.handleApprove}
onCancel={h.handleCancel}
onClose={screen.closeDetailsRequest}
onDelete={h.handleDelete}
onDeleteComment={h.handleDeleteComment}
onEdit={(request) => {
screen.closeDetailsRequest();
h.openEdit(request);
}}
onPreviewAttachment={h.setAttachmentPreview}
onReject={h.handleReject}
onSetCommentDraft={h.setCommentDraft}
request={h.detailsRequest}
/>

View File

@ -5,41 +5,54 @@ import {
statusMeta,
type RequestAttachmentPreview,
} from "@/hooks/useRequestsPage";
import { formatDate, formatDateTime } from "@/lib/shared";
import type { Request } from "@/types/api";
import { displayUserName, formatDate, formatDateTime } from "@/lib/shared";
import type { Request, RequestComment } from "@/types/api";
import { Ban, Paperclip, Pencil, ThumbsDown, ThumbsUp, Trash2 } from "lucide-react";
import { CommentComposer, CommentDeleteButton } from "@/components/shared/CommentControls";
import { getRequestActionState } from "./requestActions";
import { RequestUserBadge } from "./RequestUserBadge";
type RequestDetailModalProps = {
actionError?: string | null;
busyKey: string | null;
commentDraft: string;
comments: RequestComment[];
commentsLoading: boolean;
currentUserId?: number | null;
departmentNameMap: Map<number, string>;
isFinal: (status?: string) => boolean;
onAddComment: (requestId: number) => void | Promise<void>;
onApprove: (requestId: number) => void | Promise<void>;
onCancel: (requestId: number) => void | Promise<void>;
onClose: () => void;
onDelete: (requestId: number) => void | Promise<void>;
onDeleteComment: (requestId: number, commentId: number) => void | Promise<void>;
onEdit: (request: Request) => void;
onPreviewAttachment: (preview: RequestAttachmentPreview) => void;
onReject: (requestId: number) => void | Promise<void>;
onSetCommentDraft: (requestId: number, value: string) => void;
request: Request | null;
};
export function RequestDetailModal({
actionError,
busyKey,
commentDraft,
comments,
commentsLoading,
currentUserId,
departmentNameMap,
isFinal,
onAddComment,
onApprove,
onCancel,
onClose,
onDelete,
onDeleteComment,
onEdit,
onPreviewAttachment,
onReject,
onSetCommentDraft,
request,
}: RequestDetailModalProps) {
if (!request) return null;
@ -47,7 +60,7 @@ export function RequestDetailModal({
const author = request.employee || request.created_by;
const approver = request.approver || request.assigned_to;
const actionState = getRequestActionState(request, currentUserId, isFinal);
const { canCancel, canDelete, canEdit, canProcess } = actionState;
const { canCancel, canComment, canDelete, canEdit, canProcess } = actionState;
const status = statusMeta[String(request.status || "").toLowerCase()] ?? defaultStatusMeta;
const typeLabel = requestTypeLabels[String(request.type || request.request_type || "").toLowerCase()]
|| String(request.type || request.request_type || "Другое");
@ -55,6 +68,7 @@ export function RequestDetailModal({
const attachmentName = attachmentUrl
? decodeURIComponent(attachmentUrl.split("/").pop() || "Вложение")
: "";
const commentCount = request.comments_count ?? comments.length;
return (
<Modal
@ -131,31 +145,98 @@ export function RequestDetailModal({
)}
>
<div className="space-y-5 text-sm text-[var(--foreground)]">
<div className="app-surface-muted rounded-xl p-4">
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<span className="app-badge app-badge-accent px-2.5 py-1 text-xs font-medium">
{typeLabel}
</span>
<span className={`app-status-pill ${status.className}`}>
{status.label}
</span>
</div>
<h2 className="app-text-wrap text-lg font-semibold text-[var(--foreground)]">
{request.display_title || request.title || "Без заголовка"}
</h2>
{(request.comment || request.description) ? (
<div className="app-text-wrap mt-3 whitespace-pre-wrap text-sm leading-relaxed text-[var(--foreground)]">
{request.comment || request.description}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:items-start">
<div className="app-surface-muted h-full rounded-xl p-4 lg:min-h-0">
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<span className="app-badge app-badge-accent px-2.5 py-1 text-xs font-medium">
{typeLabel}
</span>
<span className={`app-status-pill ${status.className}`}>
{status.label}
</span>
</div>
) : (
<p className="app-text-muted mt-3 text-sm">Описание отсутствует</p>
)}
<h2 className="app-text-wrap text-lg font-semibold text-[var(--foreground)]">
{request.display_title || request.title || "Без заголовка"}
</h2>
{(request.comment || request.description) ? (
<div className="app-text-wrap mt-3 whitespace-pre-wrap text-sm leading-relaxed text-[var(--foreground)]">
{request.comment || request.description}
</div>
) : (
<p className="app-text-muted mt-3 text-sm">Описание отсутствует</p>
)}
</div>
</div>
<div className="app-surface overflow-hidden rounded-xl p-4 lg:h-[16.25rem]">
<div className="flex h-full min-h-0 flex-col">
<div className="flex items-center justify-between gap-2">
<p className="app-text-muted text-xs font-semibold uppercase tracking-wide">Комментарии</p>
<span className="app-badge px-2.5 py-1 text-xs font-medium">
{commentCount}
</span>
</div>
{!canComment ? (
<p className="app-text-muted mt-3 text-sm leading-relaxed">
Комментарии будут доступны после отправки заявления.
</p>
) : (
<div className="mt-3 flex min-h-0 flex-1 flex-col gap-3 overflow-hidden">
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto overscroll-contain pr-1">
{commentsLoading ? (
<div className="app-text-muted flex items-center gap-2 text-sm">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-[var(--border-strong)] border-t-[var(--accent-primary)]" />
<span>Загрузка комментариев...</span>
</div>
) : comments.length > 0 ? (
comments.map((comment) => (
<div
key={comment.id}
className="rounded-lg border border-[var(--border-subtle)] bg-[var(--surface-primary)] px-3 py-2.5"
>
<div className="mb-1 flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-[var(--foreground)]">
{displayUserName(comment.author)}
</p>
<p className="app-text-muted text-xs">
{formatDateTime(comment.created_at) || formatDate(comment.created_at)}
</p>
</div>
{Boolean(comment.author?.id && currentUserId === comment.author.id) ? (
<CommentDeleteButton
disabled={busyKey === `comment-delete-${comment.id}`}
onClick={() => onDeleteComment(request.id, comment.id)}
/>
) : null}
</div>
<p className="app-text-wrap whitespace-pre-wrap text-sm leading-relaxed text-[var(--foreground)]">
{comment.text}
</p>
</div>
))
) : (
<p className="app-text-muted text-sm">Комментариев пока нет</p>
)}
</div>
<CommentComposer
disabled={busyKey === `comment-${request.id}`}
value={commentDraft}
onChange={(value) => onSetCommentDraft(request.id, value)}
onSubmit={() => onAddComment(request.id)}
placeholder="Добавить комментарий"
/>
</div>
)}
</div>
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="app-surface rounded-xl p-4">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:items-start">
<div className="app-surface h-full rounded-xl p-4 lg:min-h-0">
<p className="app-text-muted text-xs font-semibold uppercase tracking-wide">Даты</p>
<div className="mt-3 space-y-2">
<div>
@ -180,7 +261,7 @@ export function RequestDetailModal({
</div>
</div>
<div className="app-surface rounded-xl p-4">
<div className="app-surface h-full rounded-xl p-4 lg:min-h-0">
<p className="app-text-muted text-xs font-semibold uppercase tracking-wide">Участники</p>
<div className="mt-3 space-y-3">
<div>
@ -207,81 +288,85 @@ export function RequestDetailModal({
</div>
</div>
<div className="app-surface rounded-xl p-4">
<p className="app-text-muted text-xs font-semibold uppercase tracking-wide">Отделы</p>
{(request.departments || []).length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{(request.departments || []).map((id) => (
<span key={id} className="app-badge px-3 py-1.5 text-sm font-medium">
{departmentNameMap.get(Number(id)) || `Отдел #${id}`}
</span>
))}
</div>
) : (
<p className="app-text-muted mt-3 text-sm">Отделы не указаны</p>
)}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:items-start">
<div className="app-surface rounded-xl p-4">
<p className="app-text-muted text-xs font-semibold uppercase tracking-wide">Отделы</p>
{(request.departments || []).length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{(request.departments || []).map((id) => (
<span key={id} className="app-badge px-3 py-1.5 text-sm font-medium">
{departmentNameMap.get(Number(id)) || `Отдел #${id}`}
</span>
))}
</div>
) : (
<p className="app-text-muted mt-3 text-sm">Отделы не указаны</p>
)}
</div>
<div className="app-surface rounded-xl p-4">
<p className="app-text-muted text-xs font-semibold uppercase tracking-wide">Получатели</p>
{(request.recipients || []).length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{(request.recipients || []).map((recipient) => (
<RequestUserBadge
key={recipient.id}
person={recipient}
currentUserId={currentUserId}
large
/>
))}
</div>
) : (
<p className="app-text-muted mt-3 text-sm">Получатели не указаны</p>
)}
</div>
</div>
<div className="app-surface rounded-xl p-4">
<p className="app-text-muted text-xs font-semibold uppercase tracking-wide">Получатели</p>
{(request.recipients || []).length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{(request.recipients || []).map((recipient) => (
<RequestUserBadge
key={recipient.id}
person={recipient}
currentUserId={currentUserId}
large
/>
))}
</div>
) : (
<p className="app-text-muted mt-3 text-sm">Получатели не указаны</p>
)}
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:items-start">
<div className="app-surface rounded-xl p-4">
<p className="app-text-muted text-xs font-semibold uppercase tracking-wide">В копии</p>
{(request.cc_users || []).length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{(request.cc_users || []).map((ccUser) => (
<RequestUserBadge
key={ccUser.id}
person={ccUser}
currentUserId={currentUserId}
large
/>
))}
</div>
) : (
<p className="app-text-muted mt-3 text-sm">Копия не указана</p>
)}
</div>
<div className="app-surface rounded-xl p-4">
<p className="app-text-muted text-xs font-semibold uppercase tracking-wide">В копии</p>
{(request.cc_users || []).length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{(request.cc_users || []).map((ccUser) => (
<RequestUserBadge
key={ccUser.id}
person={ccUser}
currentUserId={currentUserId}
large
/>
))}
</div>
) : (
<p className="app-text-muted mt-3 text-sm">Копия не указана</p>
)}
</div>
<div className="app-surface rounded-xl p-4">
<p className="app-text-muted text-xs font-semibold uppercase tracking-wide">Вложение</p>
{attachmentUrl ? (
<div className="mt-3 flex flex-wrap items-center gap-3">
<button
type="button"
onClick={() => onPreviewAttachment({ url: attachmentUrl, name: attachmentName })}
className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium"
>
<Paperclip size={15} />
<span className="app-text-wrap">{attachmentName}</span>
</button>
<a
href={attachmentUrl}
target="_blank"
rel="noreferrer"
className="app-link-accent text-sm font-medium hover:underline"
>
Открыть в новой вкладке
</a>
</div>
) : (
<p className="app-text-muted mt-3 text-sm">Вложение отсутствует</p>
)}
<div className="app-surface rounded-xl p-4">
<p className="app-text-muted text-xs font-semibold uppercase tracking-wide">Вложение</p>
{attachmentUrl ? (
<div className="mt-3 flex flex-wrap items-center gap-3">
<button
type="button"
onClick={() => onPreviewAttachment({ url: attachmentUrl, name: attachmentName })}
className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium"
>
<Paperclip size={15} />
<span className="app-text-wrap">{attachmentName}</span>
</button>
<a
href={attachmentUrl}
target="_blank"
rel="noreferrer"
className="app-link-accent text-sm font-medium hover:underline"
>
Открыть в новой вкладке
</a>
</div>
) : (
<p className="app-text-muted mt-3 text-sm">Вложение отсутствует</p>
)}
</div>
</div>
</div>
</Modal>

View File

@ -109,6 +109,7 @@ export function useRequestsPage(_userId: number | null | undefined) {
const [employees, setEmployees] = useState<User[]>([]);
const [departments, setDepartments] = useState<Department[]>([]);
const [commentsMap, setCommentsMap] = useState<Record<number, RequestComment[]>>({});
const [commentsLoadingMap, setCommentsLoadingMap] = useState<Record<number, boolean>>({});
const [expandedRows, setExpandedRows] = useState<Record<number, boolean>>({});
const [expandedComments, setExpandedComments] = useState<Record<number, boolean>>({});
const [commentDrafts, setCommentDrafts] = useState<Record<number, string>>({});
@ -427,12 +428,25 @@ export function useRequestsPage(_userId: number | null | undefined) {
};
/* ── comments ── */
const ensureCommentsLoaded = useCallback(async (requestId: number) => {
if (commentsMap[requestId] || commentsLoadingMap[requestId]) return;
setCommentsLoadingMap((prev) => ({ ...prev, [requestId]: true }));
try {
const comments = await apiClient.getRequestComments(requestId);
setCommentsMap((prev) => ({ ...prev, [requestId]: comments }));
} catch {
setCommentsMap((prev) => ({ ...prev, [requestId]: [] }));
} finally {
setCommentsLoadingMap((prev) => ({ ...prev, [requestId]: false }));
}
}, [commentsLoadingMap, commentsMap]);
const toggleComments = async (requestId: number) => {
const isOpen = Boolean(expandedComments[requestId]);
setExpandedComments((p) => ({ ...p, [requestId]: !isOpen }));
if (!isOpen && !commentsMap[requestId]) {
try { const c = await apiClient.getRequestComments(requestId); setCommentsMap((p) => ({ ...p, [requestId]: c })); }
catch { setCommentsMap((p) => ({ ...p, [requestId]: [] })); }
await ensureCommentsLoaded(requestId);
}
};
@ -477,6 +491,13 @@ export function useRequestsPage(_userId: number | null | undefined) {
return () => { if (el) observer.unobserve(el); };
}, [nextPage, loadingMore, loading, handleLoadMore]);
useEffect(() => {
const requestId = detailsRequest?.id;
if (!requestId) return;
if (String(detailsRequest.status || "").toLowerCase() === "draft") return;
void ensureCommentsLoaded(requestId);
}, [detailsRequest, ensureCommentsLoaded]);
return {
/* data */
requests: filteredRequests,
@ -485,6 +506,7 @@ export function useRequestsPage(_userId: number | null | undefined) {
departments,
departmentNameMap,
commentsMap,
commentsLoadingMap,
expandedRows,
expandedComments,
commentDrafts,