style: refresh ticket ui components
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
parent
5c16ab75a6
commit
744d5933d4
16 changed files with 718 additions and 650 deletions
|
|
@ -5,8 +5,7 @@ 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 { useAction, useMutation } from "convex/react"
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
import { useMutation } from "convex/react"
|
||||
// @ts-ignore
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
|
|
@ -27,59 +26,74 @@ 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-[#00e8ff] px-3 py-2 text-sm font-semibold text-black transition hover:bg-[#00d6eb]"
|
||||
|
||||
export function TicketComments({ ticket }: TicketCommentsProps) {
|
||||
const { userId } = useAuth()
|
||||
const addComment = useMutation(api.tickets.addComment)
|
||||
const generateUploadUrl = useAction(api.files.generateUploadUrl)
|
||||
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 [pending, setPending] = useState<Pick<TicketWithDetails["comments"][number], "id" | "author" | "visibility" | "body" | "attachments" | "createdAt" | "updatedAt">[]>([])
|
||||
const [visibility, setVisibility] = useState<"PUBLIC" | "INTERNAL">("PUBLIC")
|
||||
|
||||
const commentsAll = useMemo(() => {
|
||||
return [...pending, ...ticket.comments]
|
||||
}, [pending, ticket.comments])
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
if (!userId) return
|
||||
const attachments = attachmentsToSend
|
||||
const now = new Date()
|
||||
const attachments = attachmentsToSend
|
||||
const optimistic = {
|
||||
id: `temp-${now.getTime()}`,
|
||||
author: ticket.requester,
|
||||
visibility,
|
||||
body: sanitizeEditorHtml(body),
|
||||
attachments: attachments.map((a) => ({ id: a.storageId, name: a.name, url: a.previewUrl })),
|
||||
attachments: attachments.map((attachment) => ({
|
||||
id: attachment.storageId,
|
||||
name: attachment.name,
|
||||
url: attachment.previewUrl,
|
||||
})),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
setPending((p) => [optimistic, ...p])
|
||||
|
||||
setPending((current) => [optimistic, ...current])
|
||||
setBody("")
|
||||
setAttachmentsToSend([])
|
||||
toast.loading("Enviando comentário...", { id: "comment" })
|
||||
|
||||
try {
|
||||
const typedAttachments = attachments.map((a) => ({
|
||||
storageId: a.storageId as unknown as Id<"_storage">,
|
||||
name: a.name,
|
||||
size: a.size,
|
||||
type: a.type,
|
||||
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: userId as Id<"users">, visibility, body: optimistic.body, attachments: typedAttachments })
|
||||
await addComment({
|
||||
ticketId: ticket.id as Id<"tickets">,
|
||||
authorId: userId as Id<"users">,
|
||||
visibility,
|
||||
body: optimistic.body,
|
||||
attachments: payload,
|
||||
})
|
||||
setPending([])
|
||||
toast.success("Comentário enviado!", { id: "comment" })
|
||||
} catch (err) {
|
||||
} catch {
|
||||
setPending([])
|
||||
toast.error("Falha ao enviar comentário.", { id: "comment" })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<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
|
||||
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||
<CardHeader className="px-4 pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-lg font-semibold text-neutral-900">
|
||||
<IconMessage className="size-5 text-neutral-900" /> Conversa
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6 px-4 pb-6">
|
||||
|
|
@ -87,10 +101,10 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<IconMessage className="size-5" />
|
||||
<IconMessage className="size-5 text-neutral-900" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>Nenhum comentário ainda</EmptyTitle>
|
||||
<EmptyDescription>Registre o próximo passo abaixo.</EmptyDescription>
|
||||
<EmptyTitle className="text-neutral-900">Nenhum comentário ainda</EmptyTitle>
|
||||
<EmptyDescription className="text-neutral-600">Registre o próximo passo abaixo.</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
) : (
|
||||
|
|
@ -100,51 +114,57 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase())
|
||||
.join("")
|
||||
|
||||
return (
|
||||
<div key={comment.id} className="flex gap-3">
|
||||
<Avatar className="size-9">
|
||||
<Avatar className="size-9 border border-slate-200">
|
||||
<AvatarImage src={comment.author.avatarUrl} alt={comment.author.name} />
|
||||
<AvatarFallback>{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="font-medium text-foreground">{comment.author.name}</span>
|
||||
<span className="font-semibold text-neutral-900">{comment.author.name}</span>
|
||||
{comment.visibility === "INTERNAL" ? (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<IconLock className="size-3" /> Interno
|
||||
<Badge className={badgeInternal}>
|
||||
<IconLock className="size-3 text-[#00e8ff]" /> Interno
|
||||
</Badge>
|
||||
) : null}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<span className="text-xs text-neutral-500">
|
||||
{formatDistanceToNow(comment.createdAt, { addSuffix: true, locale: ptBR })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="break-words rounded-lg border border-dashed bg-card px-3 py-2 text-sm leading-relaxed text-muted-foreground">
|
||||
<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>
|
||||
{comment.attachments?.length ? (
|
||||
<div className="grid max-w-xl grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-3">
|
||||
{comment.attachments.map((att) => {
|
||||
const isImg = (att?.url ?? "").match(/\.(png|jpe?g|gif|webp|svg)$/i)
|
||||
if (isImg && att.url) {
|
||||
{comment.attachments.map((attachment) => {
|
||||
const isImage = (attachment?.url ?? "").match(/\.(png|jpe?g|gif|webp|svg)$/i)
|
||||
if (isImage && attachment.url) {
|
||||
return (
|
||||
<button
|
||||
key={att.id}
|
||||
key={attachment.id}
|
||||
type="button"
|
||||
onClick={() => setPreview(att.url || null)}
|
||||
className="group overflow-hidden rounded-lg border bg-card p-0.5 hover:shadow"
|
||||
onClick={() => setPreview(attachment.url || null)}
|
||||
className="group overflow-hidden rounded-lg border border-slate-200 bg-white p-0.5 hover:border-slate-400"
|
||||
>
|
||||
{/* 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}
|
||||
<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>
|
||||
)
|
||||
}
|
||||
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
|
||||
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"
|
||||
>
|
||||
<FileIcon className="size-3.5 text-neutral-700" /> {attachment.name}
|
||||
{attachment.url ? <Download className="size-3.5 text-neutral-700" /> : null}
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
|
|
@ -159,25 +179,26 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
<RichTextEditor value={body} onChange={setBody} placeholder="Escreva um comentário..." />
|
||||
<Dropzone onUploaded={(files) => setAttachmentsToSend((prev) => [...prev, ...files])} />
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2 text-xs text-neutral-600">
|
||||
Visibilidade:
|
||||
<Select value={visibility} onValueChange={(v) => setVisibility(v as "PUBLIC" | "INTERNAL")}>
|
||||
<SelectTrigger className="h-8 w-[140px]"><SelectValue placeholder="Visibilidade" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<Select value={visibility} onValueChange={(value) => setVisibility(value as "PUBLIC" | "INTERNAL")}>
|
||||
<SelectTrigger className={selectTriggerClass}>
|
||||
<SelectValue placeholder="Visibilidade" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
|
||||
<SelectItem value="PUBLIC">Pública</SelectItem>
|
||||
<SelectItem value="INTERNAL">Interna</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button type="submit" size="sm">Enviar</Button>
|
||||
<Button type="submit" size="sm" className={submitButtonClass}>
|
||||
Enviar
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<Dialog open={!!preview} onOpenChange={(o) => !o && setPreview(null)}>
|
||||
<Dialog open={!!preview} onOpenChange={(open) => !open && 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}
|
||||
{preview ? <img src={preview} alt="Preview" className="h-auto w-full rounded-xl" /> : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardContent>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue