331 lines
14 KiB
TypeScript
331 lines
14 KiB
TypeScript
"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>
|
||
);
|
||
}
|