feat: surface ticket work metrics and refresh list layout

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
esdrasrenan 2025-10-04 22:22:02 -03:00
parent 744d5933d4
commit 55511f3a0e
20 changed files with 1102 additions and 357 deletions

View file

@ -4,7 +4,7 @@ import { useMemo, useState } from "react"
import { formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale"
import { IconLock, IconMessage } from "@tabler/icons-react"
import { Download, FileIcon } from "lucide-react"
import { FileIcon, Trash2, X } from "lucide-react"
import { useMutation } from "convex/react"
// @ts-ignore
import { api } from "@/convex/_generated/api"
@ -18,7 +18,7 @@ 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 } from "@/components/ui/dialog"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
@ -28,16 +28,20 @@ interface TicketCommentsProps {
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-[#00e8ff] px-3 py-2 text-sm font-semibold text-black transition hover:bg-[#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 { userId } = useAuth()
const addComment = useMutation(api.tickets.addComment)
const removeAttachment = useMutation(api.tickets.removeCommentAttachment)
const [body, setBody] = useState("")
const [attachmentsToSend, setAttachmentsToSend] = useState<Array<{ storageId: string; name: string; size?: number; type?: string; previewUrl?: string }>>([])
const [preview, setPreview] = useState<string | null>(null)
const [pending, setPending] = useState<Pick<TicketWithDetails["comments"][number], "id" | "author" | "visibility" | "body" | "attachments" | "createdAt" | "updatedAt">[]>([])
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 commentsAll = useMemo(() => {
return [...pending, ...ticket.comments]
@ -47,7 +51,10 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
event.preventDefault()
if (!userId) return
const now = new Date()
const attachments = attachmentsToSend
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,
@ -56,6 +63,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
attachments: attachments.map((attachment) => ({
id: attachment.storageId,
name: attachment.name,
type: attachment.type,
url: attachment.previewUrl,
})),
createdAt: now,
@ -87,6 +95,34 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
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 || !userId) 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: userId 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 (
@ -114,6 +150,9 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join("")
const bodyHtml = comment.body ?? ""
const bodyPlain = bodyHtml.replace(/<[^>]*>/g, "").trim()
const hasBody = bodyPlain.length > 0
return (
<div key={comment.id} className="flex gap-3">
@ -133,39 +172,62 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
{formatDistanceToNow(comment.createdAt, { addSuffix: true, locale: ptBR })}
</span>
</div>
<div className="break-words rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm leading-relaxed text-neutral-700">
<RichTextContent html={comment.body} />
</div>
{hasBody ? (
<div className="break-words rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm leading-relaxed text-neutral-700">
<RichTextContent html={bodyHtml} />
</div>
) : null}
{comment.attachments?.length ? (
<div className="grid max-w-xl grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-3">
{comment.attachments.map((attachment) => {
const isImage = (attachment?.url ?? "").match(/\.(png|jpe?g|gif|webp|svg)$/i)
if (isImage && attachment.url) {
return (
<button
key={attachment.id}
type="button"
onClick={() => setPreview(attachment.url || null)}
className="group overflow-hidden rounded-lg border border-slate-200 bg-white p-0.5 hover:border-slate-400"
>
<img src={attachment.url} alt={attachment.name} className="h-24 w-24 rounded-md object-cover" />
<div className="mt-1 line-clamp-1 w-24 text-ellipsis text-center text-[11px] text-neutral-500">
{attachment.name}
</div>
</button>
)
const name = attachment?.name ?? ""
const url = attachment?.url
const type = attachment?.type ?? ""
const isImage =
(!!type && type.startsWith("image/")) ||
/\.(png|jpe?g|gif|webp|svg)$/i.test(name) ||
/\.(png|jpe?g|gif|webp|svg)$/i.test(url ?? "")
const openRemovalModal = (event: React.MouseEvent) => {
event.preventDefault()
event.stopPropagation()
setAttachmentToRemove({ commentId: comment.id, attachmentId: attachment.id, name })
}
return (
<a
<div
key={attachment.id}
href={attachment.url}
download={attachment.name}
target="_blank"
className="flex items-center gap-2 rounded-md border border-slate-200 px-2 py-1 text-xs text-neutral-800 hover:border-slate-400"
className="group relative overflow-hidden rounded-lg border border-slate-200 bg-white p-0.5 shadow-sm"
>
<FileIcon className="size-3.5 text-neutral-700" /> {attachment.name}
{attachment.url ? <Download className="size-3.5 text-neutral-700" /> : null}
</a>
{isImage && url ? (
<button
type="button"
onClick={() => setPreview(url || null)}
className="block w-full overflow-hidden rounded-md"
>
<img src={url} alt={name} className="h-24 w-full rounded-md object-cover" />
</button>
) : (
<a
href={url ?? undefined}
download={name || undefined}
target="_blank"
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"
>
<FileIcon className="size-5 text-neutral-600" />
{url ? <span className="font-medium">Baixar</span> : <span>Pendente</span>}
</a>
)}
<button
type="button"
onClick={openRemovalModal}
aria-label={`Remover ${name}`}
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 opacity-0 transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black/30 focus-visible:opacity-100 group-hover:opacity-100"
>
<X className="size-3.5" />
</button>
<div className="mt-1 line-clamp-1 w-full text-ellipsis text-center text-[11px] text-neutral-500">
{name}
</div>
</div>
)
})}
</div>
@ -178,6 +240,55 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
<form onSubmit={handleSubmit} className="mt-4 space-y-3">
<RichTextEditor value={body} onChange={setBody} placeholder="Escreva um comentário..." />
<Dropzone onUploaded={(files) => setAttachmentsToSend((prev) => [...prev, ...files])} />
{attachmentsToSend.length > 0 ? (
<div className="grid max-w-xl grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-3">
{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 (
<div key={`${attachment.storageId}-${index}`} className="group relative overflow-hidden rounded-lg border border-slate-200 bg-white p-0.5">
{isImage && previewUrl ? (
<button
type="button"
onClick={() => setPreview(previewUrl || null)}
className="block w-full overflow-hidden rounded-md"
>
<img src={previewUrl} alt={name} className="h-24 w-full rounded-md object-cover" />
</button>
) : (
<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">{name}</span>
</div>
)}
<button
type="button"
onClick={() =>
setAttachmentsToSend((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 ${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">
{name}
</div>
</div>
)
})}
</div>
) : null}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-xs text-neutral-600">
Visibilidade:
@ -196,6 +307,36 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
</Button>
</div>
</form>
<Dialog open={!!attachmentToRemove} onOpenChange={(open) => { if (!open && !removingAttachment) setAttachmentToRemove(null) }}>
<DialogContent className="max-w-sm space-y-4">
<DialogHeader>
<DialogTitle>Remover anexo</DialogTitle>
<DialogDescription>
Tem certeza de que deseja remover "{attachmentToRemove?.name}" deste comentário?
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => setAttachmentToRemove(null)}
className="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-slate-100"
disabled={removingAttachment}
>
Cancelar
</Button>
<Button
type="button"
onClick={handleRemoveAttachment}
disabled={removingAttachment}
className="inline-flex items-center gap-2 rounded-lg border border-black bg-black px-4 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 disabled:opacity-60"
>
<Trash2 className="size-4" />
{removingAttachment ? "Removendo..." : "Excluir"}
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog open={!!preview} onOpenChange={(open) => !open && setPreview(null)}>
<DialogContent className="max-w-3xl p-0">
{preview ? <img src={preview} alt="Preview" className="h-auto w-full rounded-xl" /> : null}