697 lines
30 KiB
TypeScript
697 lines
30 KiB
TypeScript
"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<TicketWithDetails["priority"], string> = {
|
|
LOW: "Baixa",
|
|
MEDIUM: "Média",
|
|
HIGH: "Alta",
|
|
URGENT: "Urgente",
|
|
}
|
|
|
|
const priorityTone: Record<TicketWithDetails["priority"], string> = {
|
|
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, """)
|
|
.replace(/'/g, "'")
|
|
return `<p>${escaped.replace(/\n/g, "<br />")}</p>`
|
|
}
|
|
|
|
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<ClientTimelineEntry | null>((event) => {
|
|
const payload = (event.payload ?? {}) as Record<string, unknown>
|
|
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 (
|
|
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
|
<CardHeader className="px-5 py-5">
|
|
<CardTitle className="text-lg font-semibold text-neutral-900">Carregando ticket...</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3 px-5 pb-6">
|
|
<Skeleton className="h-6 w-2/3" />
|
|
<Skeleton className="h-4 w-full" />
|
|
<Skeleton className="h-4 w-5/6" />
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
if (!ticket) {
|
|
return (
|
|
<Empty>
|
|
<EmptyHeader>
|
|
<EmptyMedia variant="icon">
|
|
<span className="text-2xl">🔍</span>
|
|
</EmptyMedia>
|
|
<EmptyTitle className="text-neutral-900">Ticket não encontrado</EmptyTitle>
|
|
<EmptyDescription className="text-neutral-600">
|
|
Verifique o endereço ou retorne à lista de chamados.
|
|
</EmptyDescription>
|
|
</EmptyHeader>
|
|
</Empty>
|
|
)
|
|
}
|
|
|
|
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)) : "<p></p>"
|
|
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 (
|
|
<div className="space-y-6">
|
|
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
|
<CardHeader className="px-5 pb-3 pt-6">
|
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
|
<div>
|
|
<p className="text-sm text-neutral-500">Ticket #{ticket.reference}</p>
|
|
<h1 className="mt-1 text-2xl font-semibold text-neutral-900">{ticket.subject}</h1>
|
|
{ticket.summary ? (
|
|
<p className="mt-2 max-w-3xl text-sm text-neutral-600">{ticket.summary}</p>
|
|
) : null}
|
|
</div>
|
|
<div className="flex flex-col items-end gap-2 text-sm">
|
|
<TicketStatusBadge status={ticket.status} className="px-3 py-1 text-xs font-semibold uppercase" />
|
|
{!isCustomer ? (
|
|
<Badge className={`rounded-full px-3 py-1 text-xs font-semibold uppercase ${priorityTone[ticket.priority]}`}>
|
|
{priorityLabel[ticket.priority]}
|
|
</Badge>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="grid gap-4 border-t border-slate-100 px-5 py-5 text-sm text-neutral-700 sm:grid-cols-2">
|
|
{isCustomer ? null : <DetailItem label="Fila" value={ticket.queue ?? "Sem fila"} />}
|
|
<DetailItem label="Categoria" value={ticket.category?.name ?? "Não classificada"} />
|
|
<DetailItem label="Subcategoria" value={ticket.subcategory?.name ?? "—"} />
|
|
<DetailItem label="Solicitante" value={ticket.requester.name} subtitle={ticket.requester.email} />
|
|
{ticket.assignee ? (
|
|
<DetailItem label="Responsável" value={ticket.assignee.name} />
|
|
) : null}
|
|
<DetailItem label="Criado em" value={createdAt} />
|
|
<DetailItem label="Última atualização" value={updatedAgo} />
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<div className="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
|
|
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
|
<CardHeader className="flex flex-row items-center justify-between px-5 py-4">
|
|
<CardTitle className="flex items-center gap-2 text-lg font-semibold text-neutral-900">
|
|
<MessageCircle className="size-5 text-neutral-500" /> Conversas
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-6 px-5 pb-6">
|
|
{machineInactive ? (
|
|
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
|
|
Esta máquina está desativada. Ative-a novamente para enviar novas mensagens.
|
|
</div>
|
|
) : null}
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
<div className="space-y-5">
|
|
<label htmlFor="comment" className="block text-sm font-medium text-neutral-800">
|
|
Enviar uma mensagem para a equipe
|
|
</label>
|
|
<RichTextEditor
|
|
value={comment}
|
|
onChange={(html) => 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}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Dropzone
|
|
onUploaded={(files) => 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 ? (
|
|
<div className="grid max-w-xl grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-3">
|
|
{attachments.map((attachment, index) => {
|
|
const isImage =
|
|
(attachment.type ?? "").startsWith("image/") ||
|
|
/\.(png|jpe?g|gif|webp|svg)$/i.test(attachment.name)
|
|
return (
|
|
<div
|
|
key={`${attachment.storageId}-${index}`}
|
|
className="group relative overflow-hidden rounded-lg border border-slate-200 bg-white p-0.5"
|
|
>
|
|
{isImage && attachment.previewUrl ? (
|
|
<div className="block w-full overflow-hidden rounded-md">
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img
|
|
src={attachment.previewUrl}
|
|
alt={attachment.name}
|
|
className="h-24 w-full rounded-md object-cover"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="flex h-24 w-full flex-col items-center justify-center gap-2 rounded-md bg-slate-50 text-xs text-neutral-600">
|
|
<FileIcon className="size-4" />
|
|
<span className="line-clamp-2 px-2 text-center">{attachment.name}</span>
|
|
</div>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
setAttachments((prev) => {
|
|
const next = [...prev]
|
|
const removed = next.splice(index, 1)[0]
|
|
if (removed?.previewUrl?.startsWith("blob:")) {
|
|
URL.revokeObjectURL(removed.previewUrl)
|
|
}
|
|
return next
|
|
})
|
|
}
|
|
className="absolute right-1.5 top-1.5 inline-flex size-7 items-center justify-center rounded-full border border-black bg-black text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black/30"
|
|
aria-label={`Remover ${attachment.name}`}
|
|
>
|
|
<X className="size-3.5" />
|
|
</button>
|
|
<div className="mt-1 line-clamp-1 w-full text-ellipsis text-center text-[11px] text-neutral-500">
|
|
{attachment.name}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
<div className="flex justify-end">
|
|
<Button type="submit" disabled={machineInactive} className="rounded-full bg-neutral-900 px-6 text-sm font-semibold text-white hover:bg-neutral-900/90 disabled:opacity-60">
|
|
Enviar comentário
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
|
|
<div className="space-y-5">
|
|
{ticket.comments.length === 0 ? (
|
|
<Empty>
|
|
<EmptyHeader>
|
|
<EmptyMedia variant="icon">
|
|
<MessageCircle className="size-5 text-neutral-500" />
|
|
</EmptyMedia>
|
|
<EmptyTitle className="text-neutral-900">Nenhum comentário ainda</EmptyTitle>
|
|
<EmptyDescription className="text-neutral-600">
|
|
Registre a primeira atualização acima.
|
|
</EmptyDescription>
|
|
</EmptyHeader>
|
|
</Empty>
|
|
) : (
|
|
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 (
|
|
<div key={commentItem.id} className="rounded-xl border border-slate-100 bg-slate-50/70 p-4">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="flex items-center gap-3">
|
|
<Avatar className="size-9 border border-slate-200">
|
|
<AvatarImage src={commentItem.author.avatarUrl} alt={commentItem.author.name} />
|
|
<AvatarFallback>{initials}</AvatarFallback>
|
|
</Avatar>
|
|
<div className="flex flex-col">
|
|
<span className="text-sm font-semibold text-neutral-900">{commentItem.author.name}</span>
|
|
<span className="text-xs text-neutral-500">{createdAgo}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div
|
|
className="prose prose-sm mt-3 max-w-none text-neutral-800"
|
|
dangerouslySetInnerHTML={{ __html: sanitizeEditorHtml(commentItem.body ?? "") }}
|
|
/>
|
|
{commentItem.attachments && commentItem.attachments.length > 0 ? (
|
|
<div className="mt-3 grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-3">
|
|
{commentItem.attachments.map((attachment) => (
|
|
<PortalCommentAttachmentCard
|
|
key={attachment.id}
|
|
attachment={attachment}
|
|
getFileUrl={getFileUrl}
|
|
onOpenPreview={(payload) => setPreviewAttachment(payload)}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)
|
|
})
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
|
<CardHeader className="px-5 py-4">
|
|
<CardTitle className="text-lg font-semibold text-neutral-900">Linha do tempo</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-5 px-5 pb-6 text-sm text-neutral-700">
|
|
{clientTimeline.length === 0 ? (
|
|
<p className="text-sm text-neutral-500">Nenhum evento registrado ainda.</p>
|
|
) : (
|
|
clientTimeline.map((event) => {
|
|
const when = formatDistanceToNow(event.when, { addSuffix: true, locale: ptBR })
|
|
return (
|
|
<div key={event.id} className="flex flex-col gap-1 rounded-xl border border-slate-100 bg-slate-50/50 p-3">
|
|
<span className="text-sm font-semibold text-neutral-900">{event.title}</span>
|
|
{event.description ? (
|
|
<span className="text-xs text-neutral-600">{event.description}</span>
|
|
) : null}
|
|
<span className="text-xs text-neutral-500">{when}</span>
|
|
</div>
|
|
)
|
|
})
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
<Dialog open={!!previewAttachment} onOpenChange={(open) => { if (!open) setPreviewAttachment(null) }}>
|
|
<DialogContent className="max-w-3xl border border-slate-200 p-0">
|
|
<DialogHeader className="relative px-4 py-3">
|
|
<DialogTitle className="pr-10 text-base font-semibold text-neutral-800">
|
|
{previewAttachment?.name ?? "Visualização do anexo"}
|
|
</DialogTitle>
|
|
<DialogClose className="absolute right-4 top-3 inline-flex size-7 items-center justify-center rounded-full border border-slate-200 bg-white text-neutral-600 transition hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-400/30">
|
|
<X className="size-4" />
|
|
</DialogClose>
|
|
</DialogHeader>
|
|
{previewAttachment ? (
|
|
isPreviewImage ? (
|
|
<div className="rounded-b-2xl bg-neutral-900/5">
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img src={previewAttachment.url} alt={previewAttachment.name ?? "Anexo"} className="h-auto w-full rounded-b-2xl" />
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3 px-6 pb-6 text-sm text-neutral-700">
|
|
<p>Não é possível visualizar este tipo de arquivo aqui. Abra em uma nova aba para conferi-lo.</p>
|
|
<a
|
|
href={previewAttachment.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-4 py-2 text-sm font-semibold text-neutral-800 transition hover:bg-slate-100"
|
|
>
|
|
Abrir em nova aba
|
|
</a>
|
|
</div>
|
|
)
|
|
) : null}
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
interface DetailItemProps {
|
|
label: string
|
|
value: string
|
|
subtitle?: string | null
|
|
}
|
|
|
|
function DetailItem({ label, value, subtitle }: DetailItemProps) {
|
|
return (
|
|
<div className="rounded-xl border border-dashed border-slate-200 bg-white/60 px-4 py-3 shadow-[0_1px_2px_rgba(15,23,42,0.04)]">
|
|
<p className="text-xs uppercase tracking-wide text-neutral-500">{label}</p>
|
|
<p className="text-sm font-medium text-neutral-900">{value}</p>
|
|
{subtitle ? <p className="text-xs text-neutral-500">{subtitle}</p> : null}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
type CommentAttachment = TicketWithDetails["comments"][number]["attachments"][number]
|
|
type GetFileUrlAction = (args: { storageId: Id<"_storage"> }) => Promise<string | null>
|
|
|
|
function PortalCommentAttachmentCard({
|
|
attachment,
|
|
getFileUrl,
|
|
onOpenPreview,
|
|
}: {
|
|
attachment: CommentAttachment
|
|
getFileUrl: GetFileUrlAction
|
|
onOpenPreview: (payload: { url: string; name: string; type?: string }) => void
|
|
}) {
|
|
const [url, setUrl] = useState<string | null>(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 (
|
|
<div className="group relative overflow-hidden rounded-lg border border-slate-200 bg-white p-0.5 shadow-sm">
|
|
{isImageType && resolvedUrl ? (
|
|
<button
|
|
type="button"
|
|
onClick={handlePreview}
|
|
className="relative block w-full overflow-hidden rounded-md"
|
|
>
|
|
{loading ? (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-white/70">
|
|
<Spinner className="size-5 text-neutral-600" />
|
|
</div>
|
|
) : null}
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img src={resolvedUrl} alt={attachment.name ?? "Anexo"} className="h-24 w-full rounded-md object-cover" />
|
|
</button>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={handleDownload}
|
|
className="flex h-24 w-full flex-col items-center justify-center gap-2 rounded-md bg-slate-50 text-xs text-neutral-700 transition hover:bg-slate-100"
|
|
disabled={loading}
|
|
>
|
|
{loading ? <Spinner className="size-5 text-neutral-600" /> : <FileIcon className="size-5 text-neutral-600" />}
|
|
<span className="font-medium">
|
|
{errored ? "Gerar link novamente" : "Baixar"}
|
|
</span>
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={handleDownload}
|
|
className="absolute left-1.5 top-1.5 inline-flex size-7 items-center justify-center rounded-full border border-black bg-black text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black/30"
|
|
aria-label={`Baixar ${attachment.name ?? "anexo"}`}
|
|
>
|
|
<Download className="size-3.5" />
|
|
</button>
|
|
<div className="mt-1 line-clamp-2 w-full text-ellipsis text-center text-[11px] text-neutral-500">
|
|
{attachment.name}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|