Corp/frontend/src/components/requests/RequestDetailPanel.tsx

331 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import {
defaultStatusMeta,
formatRequestPeriod,
requestTypeLabels,
statusMeta,
type RequestAttachmentPreview,
} from "@/hooks/useRequestsPage";
import { CommentComposer, CommentDeleteButton } from "@/components/shared/CommentControls";
import TaskLinkPill from "@/components/tasks/TaskLinkPill";
import { displayUserName, formatDate, formatDateTime } from "@/lib/shared";
import type { Request, RequestComment } from "@/types/api";
import {
Ban,
Link2,
Paperclip,
Pencil,
ThumbsDown,
ThumbsUp,
Trash2,
} from "lucide-react";
import { getRequestActionState } from "./requestActions";
import { RequestUserBadge } from "./RequestUserBadge";
type RequestDetailPanelProps = {
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>;
onDelete: (requestId: number) => void | Promise<void>;
onDeleteComment: (requestId: number, commentId: number) => void | Promise<void>;
onEdit: (request: Request) => void;
onLinkTask: (request: Request) => void;
onPreviewAttachment: (preview: RequestAttachmentPreview) => void;
onReject: (requestId: number) => void | Promise<void>;
onSetCommentDraft: (requestId: number, value: string) => void;
request: Request;
};
const newestCommentsFirst = (comments: RequestComment[]) => (
[...comments].sort((left, right) => (
new Date(right.created_at).getTime() - new Date(left.created_at).getTime()
))
);
export function RequestDetailPanel({
actionError,
busyKey,
commentDraft,
comments,
commentsLoading,
currentUserId,
departmentNameMap,
isFinal,
onAddComment,
onApprove,
onCancel,
onDelete,
onDeleteComment,
onEdit,
onLinkTask,
onPreviewAttachment,
onReject,
onSetCommentDraft,
request,
}: RequestDetailPanelProps) {
const author = request.employee || request.created_by;
const approver = request.approver || request.assigned_to;
const actionState = getRequestActionState(request, currentUserId, isFinal);
const { canCancel, canComment, canDelete, canEdit, canProcess } = actionState;
const status = statusMeta[String(request.status || "").toLowerCase()] ?? defaultStatusMeta;
const typeKey = String(request.type || request.request_type || "").toLowerCase();
const typeLabel = requestTypeLabels[typeKey] || String(request.type || request.request_type || "Другое");
const attachmentUrl = request.attachment_url || request.attachment || "";
const attachmentName = attachmentUrl
? decodeURIComponent(attachmentUrl.split("/").pop() || "Вложение")
: "";
const commentCount = request.comments_count ?? comments.length;
const linkedTasks = request.linked_tasks || [];
const hasActions = canProcess || canCancel || canEdit || canDelete;
return (
<div className="space-y-4 text-sm text-[var(--foreground)]">
<section className="app-surface rounded-xl p-4">
<div className="flex min-w-0 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>
<h2 className="app-text-wrap min-w-0 flex-[1_1_20rem] text-lg font-semibold">
{request.display_title || request.title || "Без заголовка"}
</h2>
</div>
{(request.comment || request.description) ? (
<p className="app-text-wrap mt-3 whitespace-pre-wrap text-sm leading-relaxed">
{request.comment || request.description}
</p>
) : (
<p className="app-text-muted mt-3 text-sm">Описание отсутствует</p>
)}
{actionError ? (
<div className="app-feedback-danger mt-3 rounded-xl px-3 py-2 text-sm">
{actionError}
</div>
) : null}
{hasActions ? (
<div className="mt-4 flex flex-wrap items-center justify-end gap-2 border-t border-[var(--border-subtle)] pt-3">
{canProcess ? (
<>
<button
type="button"
onClick={() => void onApprove(request.id)}
disabled={busyKey === `approve-${request.id}`}
className="app-action-approve inline-flex h-9 items-center gap-2 rounded-lg px-3 text-xs font-medium disabled:opacity-60"
>
<ThumbsUp size={14} className="text-emerald-500" />
<span className="text-emerald-500">Одобрить</span>
</button>
<button
type="button"
onClick={() => void onReject(request.id)}
disabled={busyKey === `reject-${request.id}`}
className="app-action-reject inline-flex h-9 items-center gap-2 rounded-lg px-3 text-xs font-medium disabled:opacity-60"
>
<ThumbsDown size={14} /> Отклонить
</button>
</>
) : null}
{canCancel ? (
<button
type="button"
onClick={() => void onCancel(request.id)}
disabled={busyKey === `cancel-${request.id}`}
className="app-action-secondary inline-flex h-9 items-center gap-2 rounded-lg px-3 text-xs font-medium disabled:opacity-60"
>
<Ban size={14} /> Отменить
</button>
) : null}
{canEdit ? (
<button
type="button"
onClick={() => onEdit(request)}
className="app-action-secondary inline-flex h-9 items-center gap-2 rounded-lg px-3 text-xs font-medium"
>
<Pencil size={14} /> Редактировать
</button>
) : null}
{canDelete ? (
<button
type="button"
onClick={() => void onDelete(request.id)}
disabled={busyKey === `delete-${request.id}`}
className="app-action-danger inline-flex h-9 items-center gap-2 rounded-lg px-3 text-xs font-medium disabled:opacity-60"
>
<Trash2 size={14} /> Удалить
</button>
) : null}
</div>
) : null}
</section>
<div className="grid gap-4 xl:grid-cols-3">
<section className="app-surface rounded-xl p-4">
<p className="app-card-caption">Даты</p>
<dl className="mt-3 grid gap-3 text-xs sm:grid-cols-3 xl:grid-cols-1">
<div>
<dt className="app-text-muted">{typeKey === "short_day_off" ? "Время" : "Период"}</dt>
<dd className="mt-1 font-medium">{formatRequestPeriod(request)}</dd>
</div>
<div>
<dt className="app-text-muted">Создано</dt>
<dd className="mt-1">{formatDateTime(request.created_at) || "—"}</dd>
</div>
<div>
<dt className="app-text-muted">Обновлено</dt>
<dd className="mt-1">{formatDateTime(request.updated_at) || "—"}</dd>
</div>
</dl>
</section>
<section className="app-surface rounded-xl p-4">
<p className="app-card-caption">Участники</p>
<div className="mt-3 space-y-3">
<div>
<p className="app-text-muted text-xs">Автор</p>
<div className="mt-1.5">{author ? <RequestUserBadge person={author} currentUserId={currentUserId} large /> : "—"}</div>
</div>
<div>
<p className="app-text-muted text-xs">Принял решение</p>
<div className="mt-1.5">{approver ? <RequestUserBadge person={approver} currentUserId={currentUserId} large /> : <span className="app-text-muted">—</span>}</div>
</div>
</div>
</section>
<section className="app-surface rounded-xl p-4">
<div className="flex items-center justify-between gap-2">
<p className="app-card-caption">Связанные задачи</p>
<button
type="button"
onClick={() => onLinkTask(request)}
className="app-action-secondary inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium"
>
<Link2 size={13} /> Связать
</button>
</div>
{linkedTasks.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-1.5">
{linkedTasks.map((task) => (
<TaskLinkPill key={task.link_id || task.id} task={task} maxTitleClassName="max-w-56" />
))}
</div>
) : (
<p className="app-text-muted mt-3 text-xs">Связанных задач нет</p>
)}
</section>
</div>
<div className="grid gap-4 xl:grid-cols-3">
<section className="app-surface rounded-xl p-4">
<p className="app-card-caption">Отделы</p>
<div className="mt-3 flex flex-wrap gap-2">
{(request.departments || []).length > 0 ? (request.departments || []).map((id) => (
<span key={id} className="app-badge px-3 py-1.5 text-xs font-medium">
{departmentNameMap.get(Number(id)) || `Отдел #${id}`}
</span>
)) : <span className="app-text-muted text-xs">Не указаны</span>}
</div>
</section>
<section className="app-surface rounded-xl p-4">
<p className="app-card-caption">Получатели</p>
<div className="mt-3 flex flex-wrap gap-2">
{(request.recipients || []).length > 0 ? (request.recipients || []).map((recipient) => (
<RequestUserBadge key={recipient.id} person={recipient} currentUserId={currentUserId} />
)) : <span className="app-text-muted text-xs">Не указаны</span>}
</div>
</section>
<section className="app-surface rounded-xl p-4">
<p className="app-card-caption">В копии</p>
<div className="mt-3 flex flex-wrap gap-2">
{(request.cc_users || []).length > 0 ? (request.cc_users || []).map((ccUser) => (
<RequestUserBadge key={ccUser.id} person={ccUser} currentUserId={currentUserId} />
)) : <span className="app-text-muted text-xs">Не указаны</span>}
</div>
</section>
</div>
<section className="app-surface rounded-xl p-4">
<p className="app-card-caption">Вложение</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 min-w-0 items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium"
>
<Paperclip size={15} className="shrink-0" />
<span className="truncate">{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-xs">Вложение отсутствует</p>
)}
</section>
<section className="app-surface rounded-xl p-4">
<div className="flex items-center justify-between gap-2">
<p className="app-card-caption">Комментарии</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">Комментарии будут доступны после отправки заявления.</p>
) : (
<div className="mt-3 space-y-3">
<CommentComposer
disabled={busyKey === `comment-${request.id}`}
value={commentDraft}
onChange={(value) => onSetCommentDraft(request.id, value)}
onSubmit={() => onAddComment(request.id)}
placeholder="Добавить комментарий"
/>
{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)]" />
Загрузка комментариев...
</div>
) : comments.length > 0 ? (
<div className="grid gap-2 2xl:grid-cols-2">
{newestCommentsFirst(comments).map((comment) => (
<div key={comment.id} className="app-surface-muted rounded-lg border border-[var(--border-subtle)] 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">{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">{comment.text}</p>
</div>
))}
</div>
) : (
<p className="app-text-muted text-sm">Комментариев пока нет</p>
)}
</div>
)}
</section>
</div>
);
}