feat: núcleo de tickets com Convex (CRUD, play, comentários com anexos) + auth placeholder; docs em AGENTS.md; toasts e updates otimistas; mapeadores Zod; refinos PT-BR e layout do painel de detalhes
This commit is contained in:
parent
2230590e57
commit
27b103cb46
97 changed files with 15117 additions and 15715 deletions
|
|
@ -1,17 +1,75 @@
|
|||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { formatDistanceToNow } from "date-fns"
|
||||
import { ptBR } from "date-fns/locale"
|
||||
import { IconLock, IconMessage } from "@tabler/icons-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 { useAuth } from "@/lib/auth-client"
|
||||
import type { TicketWithDetails } from "@/lib/schemas/ticket"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
|
||||
interface TicketCommentsProps {
|
||||
ticket: TicketWithDetails
|
||||
}
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { toast } from "sonner"
|
||||
|
||||
interface TicketCommentsProps {
|
||||
ticket: TicketWithDetails
|
||||
}
|
||||
|
||||
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 [files, setFiles] = useState<File[]>([])
|
||||
const [pending, setPending] = useState<Pick<TicketWithDetails["comments"][number], "id"|"author"|"visibility"|"body"|"attachments"|"createdAt"|"updatedAt">[]>([])
|
||||
|
||||
const commentsAll = useMemo(() => {
|
||||
return [...pending, ...ticket.comments]
|
||||
}, [pending, ticket.comments])
|
||||
|
||||
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 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)),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
setPending((p) => [optimistic, ...p])
|
||||
setBody("")
|
||||
setFiles([])
|
||||
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 })
|
||||
setPending([])
|
||||
toast.success("Comentário enviado!", { id: "comment" })
|
||||
} catch (err) {
|
||||
setPending([])
|
||||
toast.error("Falha ao enviar comentário.", { id: "comment" })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border-none shadow-none">
|
||||
<CardHeader className="px-0">
|
||||
|
|
@ -20,12 +78,12 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6 px-0">
|
||||
{ticket.comments.length === 0 ? (
|
||||
{commentsAll.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ainda sem comentarios. Que tal registrar o proximo passo?
|
||||
</p>
|
||||
) : (
|
||||
ticket.comments.map((comment) => {
|
||||
commentsAll.map((comment) => {
|
||||
const initials = comment.author.name
|
||||
.split(" ")
|
||||
.slice(0, 2)
|
||||
|
|
@ -49,14 +107,36 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
{formatDistanceToNow(comment.createdAt, { addSuffix: true, locale: ptBR })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="rounded-lg border border-dashed bg-card px-3 py-2 text-sm leading-relaxed text-muted-foreground">
|
||||
<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}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className="mt-4 space-y-3">
|
||||
<textarea
|
||||
className="w-full rounded-md border bg-background p-3 text-sm"
|
||||
placeholder="Escreva um comentario..."
|
||||
rows={3}
|
||||
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 ?? []))} />
|
||||
<Button type="submit" size="sm">Enviar</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue