"use client" import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { formatDistanceToNow } from "date-fns" import { ptBR } from "date-fns/locale" import { IconLock, IconMessage, IconFileText } from "@tabler/icons-react" import { FileIcon, Image as ImageIcon, PencilLine, Trash2, X } from "lucide-react" import { useAction, useMutation, useQuery } from "convex/react" import { api } from "@/convex/_generated/api" import { useAuth } from "@/lib/auth-client" import type { Id } from "@/convex/_generated/dataModel" import type { TicketWithDetails } from "@/lib/schemas/ticket" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Badge } from "@/components/ui/badge" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Button } from "@/components/ui/button" import { toast } from "sonner" import { Dropzone } from "@/components/ui/dropzone" import { RichTextEditor, RichTextContent, sanitizeEditorHtml } from "@/components/ui/rich-text-editor" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select" import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty" import { Spinner } from "@/components/ui/spinner" interface TicketCommentsProps { ticket: TicketWithDetails } const badgeInternal = "gap-1 rounded-full border border-slate-300 bg-neutral-900 px-2 py-0.5 text-xs font-semibold uppercase tracking-wide text-white" const selectTriggerClass = "h-8 w-[140px] rounded-lg border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]" const submitButtonClass = "inline-flex items-center gap-2 rounded-lg border border-black bg-black px-3 py-2 text-sm font-semibold text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#18181b]/30" export function TicketComments({ ticket }: TicketCommentsProps) { const { convexUserId, isStaff, role } = useAuth() const isManager = role === "manager" const addComment = useMutation(api.tickets.addComment) const removeAttachment = useMutation(api.tickets.removeCommentAttachment) const updateComment = useMutation(api.tickets.updateComment) const [body, setBody] = useState("") const [attachmentsToSend, setAttachmentsToSend] = useState>([]) const [preview, setPreview] = useState(null) const [pending, setPending] = useState[]>([]) const [visibility, setVisibility] = useState<"PUBLIC" | "INTERNAL">("PUBLIC") const [attachmentToRemove, setAttachmentToRemove] = useState<{ commentId: string; attachmentId: string; name: string } | null>(null) const [removingAttachment, setRemovingAttachment] = useState(false) const [editingComment, setEditingComment] = useState<{ id: string; value: string } | null>(null) const [savingCommentId, setSavingCommentId] = useState(null) const [localBodies, setLocalBodies] = useState>({}) const templateArgs = convexUserId && isStaff ? { tenantId: ticket.tenantId, viewerId: convexUserId as Id<"users"> } : "skip" const templatesResult = useQuery(convexUserId && isStaff ? api.commentTemplates.list : "skip", templateArgs) as | { id: string; title: string; body: string }[] | undefined const templates = templatesResult ?? [] const templatesLoading = Boolean(convexUserId && isStaff) && templatesResult === undefined const canUseTemplates = Boolean(convexUserId && isStaff) const insertTemplateIntoBody = (html: string) => { const sanitized = sanitizeEditorHtml(html) setBody((current) => { if (!current) return sanitized const merged = `${current}


${sanitized}` return sanitizeEditorHtml(merged) }) } const startEditingComment = useCallback((commentId: string, currentBody: string) => { setEditingComment({ id: commentId, value: currentBody || "" }) }, []) const cancelEditingComment = useCallback(() => { setEditingComment(null) }, []) const saveEditedComment = useCallback( async (commentId: string, originalBody: string) => { if (!editingComment || editingComment.id !== commentId) return if (!convexUserId) return if (commentId.startsWith("temp-")) return const sanitized = sanitizeEditorHtml(editingComment.value) if (sanitized === originalBody) { setEditingComment(null) return } const toastId = `edit-comment-${commentId}` setSavingCommentId(commentId) toast.loading("Salvando comentário...", { id: toastId }) try { await updateComment({ ticketId: ticket.id as Id<"tickets">, commentId: commentId as unknown as Id<"ticketComments">, actorId: convexUserId as Id<"users">, body: sanitized, }) setLocalBodies((prev) => ({ ...prev, [commentId]: sanitized })) setEditingComment(null) toast.success("Comentário atualizado!", { id: toastId }) } catch (error) { console.error(error) toast.error("Não foi possível atualizar o comentário.", { id: toastId }) } finally { setSavingCommentId(null) } }, [editingComment, ticket.id, updateComment, convexUserId] ) const commentsAll = useMemo(() => { return [...pending, ...ticket.comments] }, [pending, ticket.comments]) async function handleSubmit(event: React.FormEvent) { event.preventDefault() if (!convexUserId) return const now = new Date() const selectedVisibility = isManager ? "PUBLIC" : visibility const attachments = attachmentsToSend.map((item) => ({ ...item })) const previewsToRevoke = attachments .map((attachment) => attachment.previewUrl) .filter((previewUrl): previewUrl is string => Boolean(previewUrl && previewUrl.startsWith("blob:"))) const optimistic = { id: `temp-${now.getTime()}`, author: ticket.requester, visibility: selectedVisibility, body: sanitizeEditorHtml(body), attachments: attachments.map((attachment) => ({ id: attachment.storageId, name: attachment.name, type: attachment.type, url: attachment.previewUrl, })), createdAt: now, updatedAt: now, } setPending((current) => [optimistic, ...current]) setBody("") setAttachmentsToSend([]) toast.loading("Enviando comentário...", { id: "comment" }) try { const payload = attachments.map((attachment) => ({ storageId: attachment.storageId as unknown as Id<"_storage">, name: attachment.name, size: attachment.size, type: attachment.type, })) await addComment({ ticketId: ticket.id as Id<"tickets">, authorId: convexUserId as Id<"users">, visibility: selectedVisibility, body: optimistic.body, attachments: payload, }) setPending([]) toast.success("Comentário enviado!", { id: "comment" }) } catch { setPending([]) toast.error("Falha ao enviar comentário.", { id: "comment" }) } previewsToRevoke.forEach((previewUrl) => { try { URL.revokeObjectURL(previewUrl) } catch (error) { console.error("Failed to revoke preview URL", error) } }) } async function handleRemoveAttachment() { if (!attachmentToRemove || !convexUserId) return setRemovingAttachment(true) toast.loading("Removendo anexo...", { id: "remove-attachment" }) try { await removeAttachment({ ticketId: ticket.id as unknown as Id<"tickets">, commentId: attachmentToRemove.commentId as Id<"ticketComments">, attachmentId: attachmentToRemove.attachmentId as Id<"_storage">, actorId: convexUserId as Id<"users">, }) toast.success("Anexo removido.", { id: "remove-attachment" }) setAttachmentToRemove(null) } catch (error) { console.error(error) toast.error("Não foi possível remover o anexo.", { id: "remove-attachment" }) } finally { setRemovingAttachment(false) } } return ( Comentários {commentsAll.length === 0 ? ( Nenhum comentário ainda Registre o próximo passo abaixo. ) : ( commentsAll.map((comment) => { const initials = comment.author.name .split(" ") .slice(0, 2) .map((part) => part[0]?.toUpperCase()) .join("") const commentId = String(comment.id) const storedBody = localBodies[commentId] ?? comment.body ?? "" const bodyPlain = storedBody.replace(/<[^>]*>/g, "").trim() const isEditing = editingComment?.id === commentId const isPending = commentId.startsWith("temp-") const canEdit = Boolean(convexUserId && String(comment.author.id) === convexUserId && !isPending) const hasBody = bodyPlain.length > 0 || isEditing return (
{initials}
{comment.author.name} {comment.visibility === "INTERNAL" ? ( Interno ) : null} {formatDistanceToNow(comment.createdAt, { addSuffix: true, locale: ptBR })}
{isEditing ? (
setEditingComment((prev) => (prev && prev.id === commentId ? { ...prev, value: next } : prev)) } disabled={savingCommentId === commentId} placeholder="Edite o comentário..." />
) : hasBody ? (
{canEdit ? ( ) : null}
) : canEdit ? (
) : null} {comment.attachments?.length ? (
{comment.attachments.map((attachment) => ( setPreview(url)} onRequestRemoval={() => setAttachmentToRemove({ commentId: comment.id, attachmentId: attachment.id, name: attachment.name }) } /> ))}
) : null}
) }) )}
setAttachmentsToSend((prev) => [...prev, ...files])} /> {attachmentsToSend.length > 0 ? (
{attachmentsToSend.map((attachment, index) => { const name = attachment.name const previewUrl = attachment.previewUrl const isImage = (attachment.type ?? "").startsWith("image/") || /\.(png|jpe?g|gif|webp|svg)$/i.test(name) return (
{isImage && previewUrl ? ( ) : (
{name}
)}
{name}
) })}
) : null}
{canUseTemplates ? ( {templatesLoading ? (
Carregando templates...
) : templates.length === 0 ? (
Nenhum template disponível. Cadastre novos em configurações.
) : ( templates.map((template) => ( insertTemplateIntoBody(template.body)} > {template.title} )) )}
) : null}
Visibilidade:
{ if (!open && !removingAttachment) setAttachmentToRemove(null) }}> Remover anexo Tem certeza de que deseja remover "{attachmentToRemove?.name}" deste comentário?
!open && setPreview(null)}> Visualização de anexo {preview ? ( <> {/* eslint-disable-next-line @next/next/no-img-element */} Preview ) : null}
) } type CommentAttachment = TicketWithDetails["comments"][number]["attachments"][number] function CommentAttachmentCard({ attachment, onOpenPreview, onRequestRemoval, }: { attachment: CommentAttachment onOpenPreview: (url: string) => void onRequestRemoval: () => void }) { const getFileUrl = useAction(api.files.getUrl) const [url, setUrl] = useState(attachment.url ?? null) const [refreshing, setRefreshing] = useState(false) const [errored, setErrored] = useState(false) const hasRefreshedRef = useRef(false) const isImageType = useMemo(() => { const name = attachment.name ?? "" const type = attachment.type ?? "" return (!!type && type.startsWith("image/")) || /\.(png|jpe?g|gif|webp|svg)$/i.test(name) }, [attachment.name, attachment.type]) useEffect(() => { setUrl(attachment.url ?? null) setErrored(false) hasRefreshedRef.current = false }, [attachment.id, attachment.url]) const ensureUrl = useCallback(async () => { try { setRefreshing(true) const fresh = await getFileUrl({ storageId: attachment.id as Id<"_storage"> }) if (fresh) { setUrl(fresh) setErrored(false) return fresh } } catch (error) { console.error("Failed to refresh attachment URL", error) } finally { setRefreshing(false) } return null }, [attachment.id, getFileUrl]) useEffect(() => { let cancelled = false ;(async () => { const fresh = await ensureUrl() if (!cancelled && fresh) { setUrl(fresh) } })() return () => { cancelled = true } }, [ensureUrl]) const handleImageError = useCallback(async () => { if (hasRefreshedRef.current) { setErrored(true) return } hasRefreshedRef.current = true const fresh = await ensureUrl() if (!fresh) { setErrored(true) } }, [ensureUrl]) const handlePreview = useCallback(async () => { const target = url ?? (await ensureUrl()) if (target) { onOpenPreview(target) } }, [ensureUrl, onOpenPreview, url]) const handleDownload = useCallback(async () => { const target = url ?? (await ensureUrl()) if (target) { window.open(target, "_blank", "noopener,noreferrer") } }, [ensureUrl, url]) const name = attachment.name ?? "" const urlLooksImage = url ? /\.(png|jpe?g|gif|webp|svg)$/i.test(url) : false const showImage = isImageType && url && !errored return (
{showImage ? ( ) : ( )}
{name}
) }