"use client" import { useCallback, useEffect, useMemo, useState } from "react" import { useAction, useMutation, useQuery } from "convex/react" import { format, formatDistanceToNow } from "date-fns" import { ptBR } from "date-fns/locale" import { Download, FileIcon, MessageCircle, X } from "lucide-react" import { toast } from "sonner" import { api } from "@/convex/_generated/api" import type { Id } from "@/convex/_generated/dataModel" import { DEFAULT_TENANT_ID } from "@/lib/constants" import { mapTicketWithDetailsFromServer } from "@/lib/mappers/ticket" import type { TicketWithDetails } from "@/lib/schemas/ticket" import { useAuth } from "@/lib/auth-client" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Dropzone } from "@/components/ui/dropzone" import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty" import { Skeleton } from "@/components/ui/skeleton" // removed wrong import; RichTextEditor comes from rich-text-editor import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { sanitizeEditorHtml, RichTextEditor } from "@/components/ui/rich-text-editor" import { TicketStatusBadge } from "@/components/tickets/status-badge" import { Spinner } from "@/components/ui/spinner" const priorityLabel: Record = { LOW: "Baixa", MEDIUM: "Média", HIGH: "Alta", URGENT: "Urgente", } const priorityTone: Record = { LOW: "bg-slate-100 text-slate-600", MEDIUM: "bg-sky-100 text-sky-700", HIGH: "bg-amber-100 text-amber-700", URGENT: "bg-rose-100 text-rose-700", } function toHtmlFromText(text: string) { const escaped = text .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'") return `

${escaped.replace(/\n/g, "
")}

` } interface PortalTicketDetailProps { ticketId: string } type ClientTimelineEntry = { id: string title: string description: string | null when: Date } export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) { const { convexUserId, session, isCustomer, machineContext } = useAuth() const addComment = useMutation(api.tickets.addComment) const getFileUrl = useAction(api.files.getUrl) const [comment, setComment] = useState("") const [attachments, setAttachments] = useState< Array<{ storageId: string; name: string; size?: number; type?: string; previewUrl?: string }> >([]) const attachmentsTotalBytes = useMemo( () => attachments.reduce((acc, item) => acc + (item.size ?? 0), 0), [attachments] ) const [previewAttachment, setPreviewAttachment] = useState<{ url: string; name: string; type?: string } | null>(null) const isPreviewImage = useMemo(() => { if (!previewAttachment) return false const type = previewAttachment.type ?? "" if (type.startsWith("image/")) return true const name = previewAttachment.name ?? "" return /\.(png|jpe?g|gif|webp|svg)$/i.test(name) }, [previewAttachment]) const machineInactive = machineContext?.isActive === false const ticketRaw = useQuery( api.tickets.getById, convexUserId ? { tenantId: session?.user.tenantId ?? DEFAULT_TENANT_ID, id: ticketId as Id<"tickets">, viewerId: convexUserId as Id<"users">, } : "skip" ) const ticket = useMemo(() => { if (!ticketRaw) return null return mapTicketWithDetailsFromServer(ticketRaw) }, [ticketRaw]) const clientTimeline = useMemo(() => { if (!ticket) return [] return ticket.timeline .map((event) => { const payload = (event.payload ?? {}) as Record const actorName = typeof payload.actorName === "string" && payload.actorName.trim().length > 0 ? String(payload.actorName).trim() : null if (event.type === "CREATED") { const requesterName = typeof payload.requesterName === "string" && payload.requesterName.trim().length > 0 ? String(payload.requesterName).trim() : null return { id: event.id, title: "Chamado criado", description: requesterName ? `Aberto por ${requesterName}` : "Chamado registrado", when: event.createdAt, } } if (event.type === "QUEUE_CHANGED") { const queueNameRaw = (typeof payload.queueName === "string" && payload.queueName.trim()) || (typeof payload.toLabel === "string" && payload.toLabel.trim()) || (typeof payload.to === "string" && payload.to.trim()) || null if (!queueNameRaw) return null const queueName = queueNameRaw.trim() const description = actorName ? `Fila ${queueName} • por ${actorName}` : `Fila ${queueName}` return { id: event.id, title: "Fila atualizada", description, when: event.createdAt, } } if (event.type === "ASSIGNEE_CHANGED") { const assigneeName = typeof payload.assigneeName === "string" && payload.assigneeName.trim().length > 0 ? String(payload.assigneeName).trim() : null const title = assigneeName ? "Responsável atribuído" : "Responsável atualizado" const description = assigneeName ? `Agora com ${assigneeName}` : "Chamado sem responsável no momento" return { id: event.id, title, description, when: event.createdAt, } } if (event.type === "CATEGORY_CHANGED") { const categoryName = typeof payload.categoryName === "string" ? payload.categoryName.trim() : "" const subcategoryName = typeof payload.subcategoryName === "string" ? payload.subcategoryName.trim() : "" const hasCategory = categoryName.length > 0 const hasSubcategory = subcategoryName.length > 0 const description = hasCategory ? hasSubcategory ? `${categoryName} • ${subcategoryName}` : categoryName : "Categoria removida" return { id: event.id, title: "Categoria atualizada", description, when: event.createdAt, } } if (event.type === "COMMENT_ADDED") { const matchingComment = ticket.comments.find((comment) => comment.createdAt.getTime() === event.createdAt.getTime()) if (!matchingComment) { return null } const rawBody = matchingComment.body ?? "" const plainBody = rawBody.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim() const summary = plainBody.length > 0 ? (plainBody.length > 140 ? `${plainBody.slice(0, 140)}…` : plainBody) : null const author = matchingComment.author.name || actorName || "Equipe" const description = summary ?? `Comentário registrado por ${author}` return { id: event.id, title: "Novo comentário", description, when: event.createdAt, } } if (event.type === "STATUS_CHANGED") { const toLabel = typeof payload.toLabel === "string" && payload.toLabel.trim().length > 0 ? String(payload.toLabel).trim() : null const toRaw = typeof payload.to === "string" && payload.to.trim().length > 0 ? String(payload.to).trim() : null const normalized = (toLabel ?? toRaw ?? "").toUpperCase() if (!normalized) return null const isFinal = normalized === "RESOLVED" || normalized === "RESOLVIDO" || normalized === "CLOSED" || normalized === "FINALIZADO" || normalized === "FINALIZED" if (!isFinal) return null const description = `Status alterado para ${toLabel ?? toRaw ?? "Resolvido"}` return { id: event.id, title: normalized === "RESOLVED" || normalized === "RESOLVIDO" ? "Chamado resolvido" : "Chamado finalizado", description, when: event.createdAt, } } return null }) .filter((entry): entry is ClientTimelineEntry => entry !== null) .sort((a, b) => b.when.getTime() - a.when.getTime()) }, [ticket]) if (ticketRaw === undefined) { return ( Carregando ticket... ) } if (!ticket) { return ( 🔍 Ticket não encontrado Verifique o endereço ou retorne à lista de chamados. ) } const createdAt = format(ticket.createdAt, "dd 'de' MMMM 'de' yyyy 'às' HH:mm", { locale: ptBR }) const updatedAgo = formatDistanceToNow(ticket.updatedAt, { addSuffix: true, locale: ptBR }) async function handleSubmit(event: React.FormEvent) { event.preventDefault() if (machineInactive) { toast.error("Esta máquina está desativada. Reative-a para enviar novas mensagens.") return } if (!convexUserId || !ticket) return const trimmed = comment.trim() const hasText = trimmed.length > 0 if (!hasText && attachments.length === 0) { toast.error("Adicione uma mensagem ou anexe ao menos um arquivo antes de enviar.") return } const toastId = "portal-add-comment" toast.loading("Enviando comentário...", { id: toastId }) try { const htmlBody = hasText ? sanitizeEditorHtml(toHtmlFromText(trimmed)) : "

" await addComment({ ticketId: ticket.id as Id<"tickets">, authorId: convexUserId as Id<"users">, visibility: "PUBLIC", body: htmlBody, attachments: attachments.map((f) => ({ storageId: f.storageId as Id<"_storage">, name: f.name, size: f.size, type: f.type, })), }) setComment("") attachments.forEach((file) => { if (file.previewUrl?.startsWith("blob:")) { try { URL.revokeObjectURL(file.previewUrl) } catch { // ignore revoke issues } } }) setAttachments([]) toast.success("Comentário enviado!", { id: toastId }) } catch (error) { console.error(error) toast.error("Não foi possível enviar o comentário.", { id: toastId }) } } return (

Ticket #{ticket.reference}

{ticket.subject}

{ticket.summary ? (

{ticket.summary}

) : null}
{!isCustomer ? ( {priorityLabel[ticket.priority]} ) : null}
{isCustomer ? null : } {ticket.assignee ? ( ) : null}
Conversas {machineInactive ? (
Esta máquina está desativada. Ative-a novamente para enviar novas mensagens.
) : null}
setComment(html)} placeholder="Descreva o que aconteceu, envie atualizações ou compartilhe novas informações." className="mt-3 rounded-2xl border border-slate-200 shadow-sm focus-within:border-neutral-900 focus-within:ring-neutral-900/20" disabled={machineInactive} />
setAttachments((prev) => [...prev, ...files])} className="rounded-xl border border-dashed border-slate-300 bg-slate-50 px-3 py-4 text-sm text-neutral-600 shadow-inner" currentFileCount={attachments.length} currentTotalBytes={attachmentsTotalBytes} disabled={machineInactive} /> {attachments.length > 0 ? (
{attachments.map((attachment, index) => { const isImage = (attachment.type ?? "").startsWith("image/") || /\.(png|jpe?g|gif|webp|svg)$/i.test(attachment.name) return (
{isImage && attachment.previewUrl ? (
{/* eslint-disable-next-line @next/next/no-img-element */} {attachment.name}
) : (
{attachment.name}
)}
{attachment.name}
) })}
) : null}
{ticket.comments.length === 0 ? ( Nenhum comentário ainda Registre a primeira atualização acima. ) : ( ticket.comments.map((commentItem) => { const initials = commentItem.author.name .split(" ") .slice(0, 2) .map((part) => part.charAt(0).toUpperCase()) .join("") const createdAgo = formatDistanceToNow(commentItem.createdAt, { addSuffix: true, locale: ptBR, }) return (
{initials}
{commentItem.author.name} {createdAgo}
{commentItem.attachments && commentItem.attachments.length > 0 ? (
{commentItem.attachments.map((attachment) => ( setPreviewAttachment(payload)} /> ))}
) : null}
) }) )}
Linha do tempo {clientTimeline.length === 0 ? (

Nenhum evento registrado ainda.

) : ( clientTimeline.map((event) => { const when = formatDistanceToNow(event.when, { addSuffix: true, locale: ptBR }) return (
{event.title} {event.description ? ( {event.description} ) : null} {when}
) }) )}
{ if (!open) setPreviewAttachment(null) }}> {previewAttachment?.name ?? "Visualização do anexo"} {previewAttachment ? ( isPreviewImage ? (
{/* eslint-disable-next-line @next/next/no-img-element */} {previewAttachment.name
) : (

Não é possível visualizar este tipo de arquivo aqui. Abra em uma nova aba para conferi-lo.

Abrir em nova aba
) ) : null}
) } interface DetailItemProps { label: string value: string subtitle?: string | null } function DetailItem({ label, value, subtitle }: DetailItemProps) { return (

{label}

{value}

{subtitle ?

{subtitle}

: null}
) } type CommentAttachment = TicketWithDetails["comments"][number]["attachments"][number] type GetFileUrlAction = (args: { storageId: Id<"_storage"> }) => Promise function PortalCommentAttachmentCard({ attachment, getFileUrl, onOpenPreview, }: { attachment: CommentAttachment getFileUrl: GetFileUrlAction onOpenPreview: (payload: { url: string; name: string; type?: string }) => void }) { const [url, setUrl] = useState(attachment.url ?? null) const [loading, setLoading] = useState(false) const [errored, setErrored] = useState(false) const isImageType = useMemo(() => { const name = attachment.name ?? "" const type = attachment.type ?? "" return type.startsWith("image/") || /\.(png|jpe?g|gif|webp|svg)$/i.test(name) }, [attachment.name, attachment.type]) const ensureUrl = useCallback(async () => { if (url) return url try { setLoading(true) const fresh = await getFileUrl({ storageId: attachment.id as Id<"_storage"> }) if (fresh) { setUrl(fresh) setErrored(false) return fresh } setErrored(true) } catch (error) { console.error("Falha ao obter URL do anexo", error) setErrored(true) } finally { setLoading(false) } return null }, [attachment.id, getFileUrl, url]) useEffect(() => { if (attachment.url) { setUrl(attachment.url) setErrored(false) return } if (isImageType) { void ensureUrl() } }, [attachment.url, ensureUrl, isImageType]) const handlePreview = useCallback(async () => { const target = await ensureUrl() if (!target) return if (isImageType) { onOpenPreview({ url: target, name: attachment.name ?? "Anexo", type: attachment.type ?? undefined }) return } window.open(target, "_blank", "noopener,noreferrer") }, [attachment.name, attachment.type, ensureUrl, isImageType, onOpenPreview]) const handleDownload = useCallback(async () => { const target = await ensureUrl() if (!target) return const toastId = `portal-attachment-download-${attachment.id}` toast.loading("Baixando anexo...", { id: toastId }) try { const response = await fetch(target, { credentials: "include" }) if (!response.ok) { throw new Error(`Unexpected status ${response.status}`) } const blob = await response.blob() const blobUrl = window.URL.createObjectURL(blob) const link = document.createElement("a") link.href = blobUrl link.download = attachment.name ?? "anexo" link.rel = "noopener noreferrer" document.body.appendChild(link) link.click() document.body.removeChild(link) window.setTimeout(() => { window.URL.revokeObjectURL(blobUrl) }, 1000) toast.success("Download concluído!", { id: toastId }) } catch (error) { console.error("Falha ao iniciar download do anexo", error) window.open(target, "_blank", "noopener,noreferrer") toast.error("Não foi possível baixar o anexo automaticamente.", { id: toastId }) } }, [attachment.name, ensureUrl]) const resolvedUrl = url return (
{isImageType && resolvedUrl ? ( ) : ( )}
{attachment.name}
) }