feat: preview de imagens com modal, download com nome correto; cartões (Conversa/Detalhes/Timeline) com sombra e padding; alias '@/convex/_generated/api'; payloads legíveis (nome de fila/responsável, label de status) e timeline amigável; Dropzone no 'Novo ticket' com comentário inicial; microtipografia refinada

This commit is contained in:
esdrasrenan 2025-10-04 01:23:34 -03:00
parent 90c3c8e4d6
commit 44c98fec4a
24 changed files with 1409 additions and 301 deletions

View file

@ -4,10 +4,11 @@ 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, ImageIcon, FileIcon } from "lucide-react"
import { useAction, useMutation } from "convex/react"
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import { api } from "../../../convex/_generated/api"
import { api } from "@/convex/_generated/api"
import { useAuth } from "@/lib/auth-client"
import type { TicketWithDetails } from "@/lib/schemas/ticket"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
@ -15,6 +16,8 @@ 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 { Dialog, DialogContent } from "@/components/ui/dialog"
interface TicketCommentsProps {
ticket: TicketWithDetails
@ -25,7 +28,8 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
const addComment = useMutation(api.tickets.addComment)
const generateUploadUrl = useAction(api.files.generateUploadUrl)
const [body, setBody] = useState("")
const [files, setFiles] = useState<File[]>([])
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 commentsAll = useMemo(() => {
@ -35,30 +39,20 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!userId) return
let attachments: Array<{ storageId: string; name: string; size?: number; type?: string }> = []
if (files.length) {
const url = await generateUploadUrl({})
for (const file of files) {
const form = new FormData()
form.append("file", file)
const res = await fetch(url, { method: "POST", body: form })
const { storageId } = await res.json()
attachments.push({ storageId, name: file.name, size: file.size, type: file.type })
}
}
const attachments = attachmentsToSend
const now = new Date()
const optimistic = {
id: `temp-${now.getTime()}`,
author: ticket.requester, // placeholder; poderia buscar o próprio usuário se necessário
visibility: "PUBLIC" as const,
body,
attachments: attachments.map((a) => ({ id: a.storageId, name: a.name } as any)),
attachments: attachments.map((a) => ({ id: a.storageId, name: a.name, url: a.previewUrl } as any)),
createdAt: now,
updatedAt: now,
}
setPending((p) => [optimistic, ...p])
setBody("")
setFiles([])
setAttachmentsToSend([])
toast.loading("Enviando comentário…", { id: "comment" })
try {
await addComment({ ticketId: ticket.id as any, authorId: userId as any, visibility: "PUBLIC", body: optimistic.body, attachments })
@ -71,13 +65,13 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
}
return (
<Card className="border-none shadow-none">
<CardHeader className="px-0">
<Card className="rounded-xl border bg-card shadow-sm">
<CardHeader className="px-4">
<CardTitle className="flex items-center gap-2 text-lg font-semibold">
<IconMessage className="size-5" /> Conversa
</CardTitle>
</CardHeader>
<CardContent className="space-y-6 px-0">
<CardContent className="space-y-6 px-4 pb-6">
{commentsAll.length === 0 ? (
<p className="text-sm text-muted-foreground">
Ainda sem comentarios. Que tal registrar o proximo passo?
@ -110,15 +104,36 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
<div className="rounded-lg border border-dashed bg-card px-3 py-2 text-sm leading-relaxed text-muted-foreground break-words whitespace-pre-wrap">
{comment.body}
</div>
{comment.attachments?.length ? (
<div className="flex flex-wrap gap-2">
{comment.attachments.map((a) => (
<a key={(a as any).id} href={(a as any).url} target="_blank" className="text-xs underline">
{(a as any).name}
</a>
))}
</div>
) : null}
{comment.attachments?.length ? (
<div className="flex flex-wrap gap-3">
{comment.attachments.map((a) => {
const att = a as any
const isImg = (att?.url ?? "").match(/\.(png|jpe?g|gif|webp|svg)$/i)
if (isImg && att.url) {
return (
<button
key={att.id}
type="button"
onClick={() => setPreview(att.url)}
className="group overflow-hidden rounded-lg border bg-card p-0.5 hover:shadow"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={att.url} alt={att.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-muted-foreground">
{att.name}
</div>
</button>
)
}
return (
<a key={att.id} href={att.url} download={att.name} target="_blank" className="flex items-center gap-2 rounded-md border px-2 py-1 text-xs hover:bg-muted">
<FileIcon className="size-3.5" /> {att.name}
{att.url ? <Download className="size-3.5" /> : null}
</a>
)
})}
</div>
) : null}
</div>
</div>
)
@ -132,11 +147,19 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
value={body}
onChange={(e) => setBody(e.target.value)}
/>
<div className="flex items-center justify-between">
<input type="file" multiple onChange={(e) => setFiles(Array.from(e.target.files ?? []))} />
<Dropzone onUploaded={(files) => setAttachmentsToSend((prev) => [...prev, ...files])} />
<div className="flex items-center justify-end">
<Button type="submit" size="sm">Enviar</Button>
</div>
</form>
<Dialog open={!!preview} onOpenChange={(o) => !o && setPreview(null)}>
<DialogContent className="max-w-3xl p-0">
{preview ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={preview} alt="Preview" className="h-auto w-full rounded-xl" />
) : null}
</DialogContent>
</Dialog>
</CardContent>
</Card>
)