Merge pull request #4 from esdrasrenan/feat/convex-tickets-core

Core UI & Rich Text: Tiptap, Priority dropdown, Delete modal, Convex APIs + typing
This commit is contained in:
esdrasrenan 2025-10-04 15:17:50 -03:00 committed by GitHub
commit 2479d2c5be
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 292 additions and 4 deletions

View file

@ -176,6 +176,8 @@ Este repositório foi atualizado para usar Convex como backend em tempo real par
- `tickets.changeAssignee({ ticketId, assigneeId, actorId })` — gera evento com `assigneeName`.
- `tickets.changeQueue({ ticketId, queueId, actorId })` — gera evento com `queueName`.
- `tickets.playNext({ tenantId, queueId?, agentId })` — atribui ticket e registra evento.
- `tickets.updatePriority({ ticketId, priority, actorId })` — altera prioridade e registra `PRIORITY_CHANGED`.
- `tickets.remove({ ticketId, actorId })` — remove ticket, eventos e comentários (tenta excluir anexos do storage).
- `queues.summary({ tenantId })`
- `files.generateUploadUrl()` — usar via `useAction`.
- `users.ensureUser({ tenantId, email, name, avatarUrl?, role?, teams? })`
@ -204,3 +206,62 @@ Observações:
- [ ] Skeleton/Loading onde couber.
- [ ] Mappers atualizados se tocar em payloads.
- [ ] AGENTS.md atualizado se houver mudança de padrões.
---
## Progresso recente (mar/2025)
Resumo do que foi implementado desde o último marco:
- Rich text (Tiptap) com SSR seguro para comentários e descrição inicial do ticket
- Componente: `web/src/components/ui/rich-text-editor.tsx`
- Comentários: `web/src/components/tickets/ticket-comments.rich.tsx` (visibilidade Público/Interno, anexos tipados)
- Novo ticket (Dialog + Página): campos de descrição usam rich text; primeiro comentário é registrado quando houver conteúdo.
- Tipagem estrita (remoção de `any`) no front e no Convex
- Uso consistente de `Id<>` e `Doc<>` (Convex) e schemas Zod (record tipado em v4).
- Queries `useQuery` com "skip" quando necessário; mapeadores atualizados.
- Filtros server-side
- `tickets.list` agora escolhe o melhor índice (por `status`, `queueId` ou `tenant`) e só então aplica filtros complementares.
- UI do detalhe do ticket (Header)
- Prioridade como dropdown-badge translúcida: `web/src/components/tickets/priority-select.tsx` (nova Convex `tickets.updatePriority`).
- Seleção de responsável com avatar no menu.
- Ação de exclusão com modal (ícones, confirmação): `web/src/components/tickets/delete-ticket-dialog.tsx` (Convex `tickets.remove`).
- Correções e DX
- Tiptap: `immediatelyRender: false` + `setContent({ emitUpdate: false })` para evitar mismatch de hidratação.
- Validação de assunto no Dialog “Novo ticket” (trim + `setError`) para prevenir `ZodError` em runtime.
Arquivos principais tocados:
- Convex: `web/convex/schema.ts`, `web/convex/tickets.ts` (novas mutations + tipagem `Doc/Id`).
- UI: `ticket-summary-header.tsx`, `ticket-detail-view.tsx`, `ticket-comments.rich.tsx`, `new-ticket-dialog.tsx`, `play-next-ticket-card.tsx`.
- Tipos e mapeadores: `web/src/lib/schemas/ticket.ts`, `web/src/lib/mappers/ticket.ts`.
## Guia de layout/UX aplicado
- Header do ticket
- Ordem: `#ref` • PrioritySelect (badge) • Status (badge/select) • Ações (Excluir)
- Tipografia: título forte, resumo como texto auxiliar, metadados em texto pequeno.
- Comentários
- Composer com rich text + Dropzone; seletor de visibilidade.
- Lista com avatar, nome, carimbo relativo e conteúdo rich text.
- Prioridades (labels)
- LOW (cinza), MEDIUM (azul), HIGH (âmbar), URGENT (vermelho) — badge translúcida no trigger do select.
## Próximos passos sugeridos (UI/Funcionais)
Curto prazo (incremental):
- [ ] Transformar Status em dropdown-badge (mesmo padrão de Prioridade).
- [ ] Estados vazios com `Empty` (ícone, título, descrição, CTA) na lista de comentários e tabela.
- [ ] Edição inline no header (Assunto/Resumo) com botões Reset/Salvar (mutations dedicadas).
- [ ] Polir cards (bordas/padding/sombra) nas telas Play/Tickets para padronizar com Header/Conversa.
Médio prazo:
- [ ] Combobox (command) para responsável com busca.
- [ ] Paginação/ordenção server-side em `tickets.list`.
- [ ] Unificar mensagens de timeline e payloads (sempre `actorName`/`actorAvatar`).
- [ ] Testes Vitest para mapeadores e smoke tests básicos das páginas.
## Como validar manualmente
- Rich text: comentar em `/tickets/[id]` com formatação, anexos e alternando visibilidade.
- Prioridade: alterar no cabeçalho; observar evento de timeline e toasts.
- Exclusão: acionar modal no cabeçalho e confirmar; conferir redirecionamento para `/tickets`.
- Novo ticket: usar Dialog; assunto com menos de 3 chars deve bloquear submit com erro no campo.

View file

@ -333,6 +333,21 @@ export const changeQueue = mutation({
},
});
export const updatePriority = mutation({
args: { ticketId: v.id("tickets"), priority: v.string(), actorId: v.id("users") },
handler: async (ctx, { ticketId, priority, actorId }) => {
const now = Date.now();
await ctx.db.patch(ticketId, { priority, updatedAt: now });
const pt: Record<string, string> = { LOW: "Baixa", MEDIUM: "Média", HIGH: "Alta", URGENT: "Urgente" };
await ctx.db.insert("ticketEvents", {
ticketId,
type: "PRIORITY_CHANGED",
payload: { to: priority, toLabel: pt[priority] ?? priority, actorId },
createdAt: now,
});
},
});
export const playNext = mutation({
args: {
tenantId: v.string(),
@ -422,3 +437,30 @@ export const playNext = mutation({
}
},
});
export const remove = mutation({
args: { ticketId: v.id("tickets"), actorId: v.id("users") },
handler: async (ctx, { ticketId, actorId }) => {
// delete comments (and attachments)
const comments = await ctx.db
.query("ticketComments")
.withIndex("by_ticket", (q) => q.eq("ticketId", ticketId))
.collect();
for (const c of comments) {
for (const att of c.attachments ?? []) {
try { await ctx.storage.delete(att.storageId); } catch {}
}
await ctx.db.delete(c._id);
}
// delete events
const events = await ctx.db
.query("ticketEvents")
.withIndex("by_ticket", (q) => q.eq("ticketId", ticketId))
.collect();
for (const ev of events) await ctx.db.delete(ev._id);
// delete ticket
await ctx.db.delete(ticketId);
// (optional) event is moot after deletion
return true;
},
});

View file

@ -0,0 +1,63 @@
"use client"
import { useRouter } from "next/navigation"
import { useState } from "react"
import { useMutation } from "convex/react"
// @ts-ignore
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogTrigger } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { AlertTriangle, Trash2 } from "lucide-react"
import { toast } from "sonner"
export function DeleteTicketDialog({ ticketId }: { ticketId: Id<"tickets"> }) {
const router = useRouter()
const remove = useMutation(api.tickets.remove)
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const { userId } = useAuth()
async function confirm() {
setLoading(true)
toast.loading("Excluindo ticket...", { id: "del" })
try {
if (!userId) throw new Error("No user")
await remove({ ticketId, actorId: userId as Id<"users"> })
toast.success("Ticket excluído.", { id: "del" })
setOpen(false)
router.push("/tickets")
} catch {
toast.error("Não foi possível excluir o ticket.", { id: "del" })
} finally {
setLoading(false)
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className="gap-2 text-destructive hover:bg-destructive/10">
<Trash2 className="size-4" /> Excluir
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-destructive">
<AlertTriangle className="size-5" /> Excluir ticket
</DialogTitle>
<DialogDescription>
Esta ação é permanente e removerá o ticket, comentários e eventos associados. Deseja continuar?
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setOpen(false)}>Cancelar</Button>
<Button variant="destructive" onClick={confirm} disabled={loading} className="gap-2">
{loading ? "Excluindo..." : (<><Trash2 className="size-4" /> Excluir</>)}
</Button>
</div>
</DialogContent>
</Dialog>
)
}

View file

@ -46,13 +46,18 @@ export function NewTicketDialog() {
async function submit(values: z.infer<typeof schema>) {
if (!userId) return
const subjectTrimmed = (values.subject ?? "").trim()
if (subjectTrimmed.length < 3) {
form.setError("subject", { type: "min", message: "Informe um assunto" })
return
}
setLoading(true)
toast.loading("Criando ticket…", { id: "new-ticket" })
try {
const sel = queues.find((q) => q.name === values.queueName)
const id = await create({
tenantId: DEFAULT_TENANT_ID,
subject: values.subject,
subject: subjectTrimmed,
summary: values.summary,
priority: values.priority,
channel: values.channel,

View file

@ -0,0 +1,68 @@
"use client"
import { useState } from "react"
import { useMutation } from "convex/react"
// @ts-ignore
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Badge } from "@/components/ui/badge"
import { toast } from "sonner"
const labels: Record<string, string> = {
LOW: "Baixa",
MEDIUM: "Média",
HIGH: "Alta",
URGENT: "Urgente",
}
function badgeClass(p: string) {
switch (p) {
case "URGENT":
return "bg-red-100 text-red-700"
case "HIGH":
return "bg-amber-100 text-amber-700"
case "MEDIUM":
return "bg-blue-100 text-blue-700"
default:
return "bg-slate-100 text-slate-700"
}
}
export function PrioritySelect({ ticketId, value }: { ticketId: Id<"tickets">; value: "LOW" | "MEDIUM" | "HIGH" | "URGENT" }) {
const updatePriority = useMutation(api.tickets.updatePriority)
const [priority, setPriority] = useState(value)
const { userId } = useAuth()
return (
<Select
value={priority}
onValueChange={async (val) => {
const prev = priority
setPriority(val as typeof priority)
toast.loading("Atualizando prioridade...", { id: "prio" })
try {
if (!userId) throw new Error("No user")
await updatePriority({ ticketId, priority: val as any, actorId: userId as Id<"users"> })
toast.success("Prioridade atualizada!", { id: "prio" })
} catch {
setPriority(prev)
toast.error("Não foi possível atualizar a prioridade.", { id: "prio" })
}
}}
>
<SelectTrigger className="h-7 w-[140px] border-transparent bg-muted/50 px-2">
<SelectValue>
<Badge className={`rounded-full px-2 py-0.5 ${badgeClass(priority)}`}>{labels[priority]}</Badge>
</SelectValue>
</SelectTrigger>
<SelectContent>
{(["LOW","MEDIUM","HIGH","URGENT"] as const).map((p) => (
<SelectItem key={p} value={p}>
<span className="inline-flex items-center gap-2"><span className={`h-2 w-2 rounded-full ${p==="URGENT"?"bg-red-500":p==="HIGH"?"bg-amber-500":p==="MEDIUM"?"bg-blue-500":"bg-slate-400"}`}></span>{labels[p]}</span>
</SelectItem>
))}
</SelectContent>
</Select>
)
}

View file

@ -16,6 +16,8 @@ import type { TicketWithDetails, TicketQueueSummary, TicketStatus } from "@/lib/
import { Badge } from "@/components/ui/badge"
import { Separator } from "@/components/ui/separator"
import { TicketPriorityPill } from "@/components/tickets/priority-pill"
import { PrioritySelect } from "@/components/tickets/priority-select"
import { DeleteTicketDialog } from "@/components/tickets/delete-ticket-dialog"
import { TicketStatusBadge } from "@/components/tickets/status-badge"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
@ -47,7 +49,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
<Badge variant="outline" className="rounded-full px-3 py-1 text-xs uppercase tracking-wide">
#{ticket.reference}
</Badge>
<TicketPriorityPill priority={ticket.priority} />
<PrioritySelect ticketId={ticket.id as any} value={ticket.priority as any} />
<TicketStatusBadge status={status} />
<Select
value={status}
@ -77,6 +79,9 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
</div>
<h1 className="text-2xl font-semibold text-foreground break-words">{ticket.subject}</h1>
<p className="max-w-2xl text-sm text-muted-foreground">{ticket.summary}</p>
<div className="ms-auto flex items-center gap-2">
<DeleteTicketDialog ticketId={ticket.id as any} />
</div>
</div>
</div>
<Separator />
@ -103,10 +108,18 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
}
}}
>
<SelectTrigger className="h-8 w-[180px]"><SelectValue placeholder="Selecionar" /></SelectTrigger>
<SelectTrigger className="h-8 w-[220px]"><SelectValue placeholder="Selecionar" /></SelectTrigger>
<SelectContent>
{agents.map((a) => (
<SelectItem key={a._id} value={a._id}>{a.name}</SelectItem>
<SelectItem key={a._id} value={a._id}>
<span className="inline-flex items-center gap-2">
<span className="inline-flex size-6 items-center justify-center overflow-hidden rounded-full bg-muted">
{/* eslint-disable-next-line @next/next/no-img-element */}
{a.avatarUrl ? <img src={a.avatarUrl} alt={a.name} className="h-6 w-6 rounded-full object-cover" /> : <span className="text-[10px] font-medium">{a.name.split(' ').slice(0,2).map(p=>p[0]).join('').toUpperCase()}</span>}
</span>
{a.name}
</span>
</SelectItem>
))}
</SelectContent>
</Select>

View file

@ -0,0 +1,36 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
export function Empty({ className, ...props }: React.ComponentProps<"div">) {
return <div className={cn("flex flex-col items-center gap-4 rounded-xl border bg-card p-8 text-center", className)} {...props} />
}
export function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return <div className={cn("flex flex-col items-center gap-2", className)} {...props} />
}
export function EmptyMedia({ variant = "default", className, children }: { variant?: "default" | "icon"; className?: string; children?: React.ReactNode }) {
if (variant === "icon") {
return (
<div className={cn("flex size-12 items-center justify-center rounded-full bg-muted text-muted-foreground", className)}>
{children}
</div>
)
}
return <div className={className}>{children}</div>
}
export function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return <div className={cn("text-lg font-semibold", className)} {...props} />
}
export function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return <p className={cn("max-w-sm text-sm text-muted-foreground", className)} {...props} />
}
export function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return <div className={cn("mt-2 flex items-center gap-2", className)} {...props} />
}