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

434 lines
19 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 { Modal } from "@/components/ui";
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, CalendarDays, Link2, Paperclip, Pencil, ThumbsDown, ThumbsUp, Trash2 } from "lucide-react";
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;
onLinkTask: (request: Request) => void;
onPreviewAttachment: (preview: RequestAttachmentPreview) => void;
onReject: (requestId: number) => void | Promise<void>;
onSetCommentDraft: (requestId: number, value: string) => void;
onVacationTerms: (request: Request) => void;
request: Request | null;
};
export function RequestDetailModal({
actionError,
busyKey,
commentDraft,
comments,
commentsLoading,
currentUserId,
departmentNameMap,
isFinal,
onAddComment,
onApprove,
onCancel,
onClose,
onDelete,
onDeleteComment,
onEdit,
onLinkTask,
onPreviewAttachment,
onReject,
onSetCommentDraft,
onVacationTerms,
request,
}: RequestDetailModalProps) {
if (!request) return null;
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 typeLabel = requestTypeLabels[String(request.type || request.request_type || "").toLowerCase()]
|| 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 canEditVacationTerms = Boolean(
String(request.type || request.request_type || "").toLowerCase() === "vacation"
&& request.status === "approved"
&& request.can_manage_vacation_terms,
);
return (
<Modal
isOpen
onClose={onClose}
title="Полная информация по заявлению"
size="lg"
footer={(
<div className="space-y-3">
{actionError ? (
<div className="app-feedback-danger rounded-xl px-3 py-2 text-sm">
{actionError}
</div>
) : null}
{(canProcess || canCancel || canEdit || canDelete || canEditVacationTerms) ? (
<div className="flex flex-wrap items-center justify-end gap-2">
{canEditVacationTerms ? (
<button
type="button"
onClick={() => onVacationTerms(request)}
className={`${request.vacation_terms_required ? "app-feedback-warning" : "app-action-secondary"} inline-flex items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium`}
>
<CalendarDays size={16} />
{request.vacation_terms_required ? "Заполнить условия отпуска" : "Условия отпуска"}
</button>
) : null}
{canProcess ? (
<>
<button
type="button"
onClick={() => void onApprove(request.id)}
disabled={busyKey === `approve-${request.id}`}
className="app-action-approve inline-flex items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium disabled:opacity-60"
>
<ThumbsUp size={16} 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 items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium disabled:opacity-60"
>
<ThumbsDown size={16} />
Отклонить
</button>
</>
) : null}
{canCancel ? (
<button
type="button"
onClick={() => void onCancel(request.id)}
disabled={busyKey === `cancel-${request.id}`}
className="app-action-secondary inline-flex items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium disabled:opacity-60"
>
<Ban size={16} />
Отменить
</button>
) : null}
{canEdit ? (
<button
type="button"
onClick={() => onEdit(request)}
className="app-action-secondary inline-flex items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium"
>
<Pencil size={16} />
Редактировать
</button>
) : null}
{canDelete ? (
<button
type="button"
onClick={() => void onDelete(request.id)}
disabled={busyKey === `delete-${request.id}`}
className="app-action-danger inline-flex items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium disabled:opacity-60"
>
<Trash2 size={16} />
Удалить
</button>
) : null}
</div>
) : null}
</div>
)}
>
<div className="space-y-5 text-sm text-[var(--foreground)]">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:items-start">
<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>
) : (
<p className="app-text-muted mt-3 text-sm">Описание отсутствует</p>
)}
<div className="mt-4 rounded-xl border border-[var(--border-subtle)] p-3">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<Link2 size={15} className="app-text-muted shrink-0" />
<span className="text-sm font-medium text-[var(--foreground)]">
Связанные задачи
</span>
<span className="app-badge rounded-full px-2 py-0.5 text-[11px]">
{linkedTasks.length}
</span>
</div>
<button
type="button"
onClick={() => onLinkTask(request)}
className="app-action-secondary inline-flex shrink-0 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="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 text-xs">Связанных задач нет</p>
)}
</div>
</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 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>
<div className="mt-3 space-y-2">
<div>
<p className="app-text-muted text-xs">
{String(request.type || request.request_type) === "short_day_off" ? "Время" : "Период"}
</p>
<p className="mt-1 text-sm text-[var(--foreground)]">
{formatRequestPeriod(request)}
</p>
</div>
<div>
<p className="app-text-muted text-xs">Создано</p>
<p className="mt-1 text-sm text-[var(--foreground)]">
{formatDateTime(request.created_at) || "—"}
</p>
</div>
<div>
<p className="app-text-muted text-xs">Обновлено</p>
<p className="mt-1 text-sm text-[var(--foreground)]">
{formatDateTime(request.updated_at) || "—"}
</p>
</div>
</div>
</div>
<div className="app-surface rounded-xl p-4">
<p className="app-text-muted text-xs font-semibold uppercase tracking-wide">Участники</p>
<div className="mt-3 space-y-3">
<div>
<p className="app-text-muted text-xs">Автор</p>
<div className="mt-2 flex flex-wrap gap-2">
{author ? (
<RequestUserBadge person={author} currentUserId={currentUserId} large />
) : (
<span className="app-text-muted text-sm">—</span>
)}
</div>
</div>
<div>
<p className="app-text-muted text-xs">Принял решение</p>
<div className="mt-2 flex flex-wrap gap-2">
{approver ? (
<RequestUserBadge person={approver} currentUserId={currentUserId} large />
) : (
<span className="app-text-muted text-sm">—</span>
)}
</div>
</div>
</div>
</div>
</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.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="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>
{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>
);
}