From 97ca2b3b548db103b41d3ebdf97d335a8f524197 Mon Sep 17 00:00:00 2001 From: esdrasrenan Date: Sat, 4 Oct 2025 14:56:16 -0300 Subject: [PATCH 1/3] feat(ui): priority dropdown badge, delete ticket modal, Empty state component; Convex mutations (updatePriority/remove); layout polish - PrioritySelect with translucent badge + Select options - DeleteTicketDialog with confirmation and icons - Empty UI primitives and basic usage (kept simple to ensure build) - Convex: tickets.updatePriority & tickets.remove - Header: integrate priority select, delete action, avatar-rich assignee select --- web/convex/tickets.ts | 42 ++++++++++++ .../tickets/delete-ticket-dialog.tsx | 61 +++++++++++++++++ .../components/tickets/priority-select.tsx | 67 +++++++++++++++++++ .../tickets/ticket-summary-header.tsx | 19 +++++- web/src/components/ui/empty.tsx | 36 ++++++++++ 5 files changed, 222 insertions(+), 3 deletions(-) create mode 100644 web/src/components/tickets/delete-ticket-dialog.tsx create mode 100644 web/src/components/tickets/priority-select.tsx create mode 100644 web/src/components/ui/empty.tsx diff --git a/web/convex/tickets.ts b/web/convex/tickets.ts index 5bd8e32..7f2edcc 100644 --- a/web/convex/tickets.ts +++ b/web/convex/tickets.ts @@ -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 = { 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; + }, +}); diff --git a/web/src/components/tickets/delete-ticket-dialog.tsx b/web/src/components/tickets/delete-ticket-dialog.tsx new file mode 100644 index 0000000..75d1b31 --- /dev/null +++ b/web/src/components/tickets/delete-ticket-dialog.tsx @@ -0,0 +1,61 @@ +"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 { 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) + + async function confirm() { + setLoading(true) + toast.loading("Excluindo ticket...", { id: "del" }) + try { + await remove({ ticketId, actorId: undefined as unknown 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 ( + + + + + + + + Excluir ticket + + + Esta ação é permanente e removerá o ticket, comentários e eventos associados. Deseja continuar? + + +
+ + +
+
+
+ ) +} + diff --git a/web/src/components/tickets/priority-select.tsx b/web/src/components/tickets/priority-select.tsx new file mode 100644 index 0000000..d35d8f2 --- /dev/null +++ b/web/src/components/tickets/priority-select.tsx @@ -0,0 +1,67 @@ +"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 type { TicketStatus } from "@/lib/schemas/ticket" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Badge } from "@/components/ui/badge" +import { toast } from "sonner" + +const labels: Record = { + 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) + return ( + + ) +} + diff --git a/web/src/components/tickets/ticket-summary-header.tsx b/web/src/components/tickets/ticket-summary-header.tsx index 2ddb943..89edf2d 100644 --- a/web/src/components/tickets/ticket-summary-header.tsx +++ b/web/src/components/tickets/ticket-summary-header.tsx @@ -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) { #{ticket.reference} - + diff --git a/web/src/components/ui/empty.tsx b/web/src/components/ui/empty.tsx new file mode 100644 index 0000000..98b356a --- /dev/null +++ b/web/src/components/ui/empty.tsx @@ -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
+} + +export function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) { + return
+} + +export function EmptyMedia({ variant = "default", className, children }: { variant?: "default" | "icon"; className?: string; children?: React.ReactNode }) { + if (variant === "icon") { + return ( +
+ {children} +
+ ) + } + return
{children}
+} + +export function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) { + return
+} + +export function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) { + return

+} + +export function EmptyContent({ className, ...props }: React.ComponentProps<"div">) { + return

+} + From 65ccb987415b3dcab15ee3e404b66689228bcfe0 Mon Sep 17 00:00:00 2001 From: esdrasrenan Date: Sat, 4 Oct 2025 15:04:12 -0300 Subject: [PATCH 2/3] fix(priority/delete): pass actorId from useAuth to Convex mutations --- web/src/components/tickets/delete-ticket-dialog.tsx | 6 ++++-- web/src/components/tickets/new-ticket-dialog.tsx | 7 ++++++- web/src/components/tickets/priority-select.tsx | 7 ++++--- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/web/src/components/tickets/delete-ticket-dialog.tsx b/web/src/components/tickets/delete-ticket-dialog.tsx index 75d1b31..9cb51c5 100644 --- a/web/src/components/tickets/delete-ticket-dialog.tsx +++ b/web/src/components/tickets/delete-ticket-dialog.tsx @@ -6,6 +6,7 @@ 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" @@ -16,12 +17,14 @@ export function DeleteTicketDialog({ ticketId }: { ticketId: Id<"tickets"> }) { 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 { - await remove({ ticketId, actorId: undefined as unknown as Id<"users"> }) + 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") @@ -58,4 +61,3 @@ export function DeleteTicketDialog({ ticketId }: { ticketId: Id<"tickets"> }) { ) } - diff --git a/web/src/components/tickets/new-ticket-dialog.tsx b/web/src/components/tickets/new-ticket-dialog.tsx index e69f978..d52cd73 100644 --- a/web/src/components/tickets/new-ticket-dialog.tsx +++ b/web/src/components/tickets/new-ticket-dialog.tsx @@ -46,13 +46,18 @@ export function NewTicketDialog() { async function submit(values: z.infer) { 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, diff --git a/web/src/components/tickets/priority-select.tsx b/web/src/components/tickets/priority-select.tsx index d35d8f2..34e53ab 100644 --- a/web/src/components/tickets/priority-select.tsx +++ b/web/src/components/tickets/priority-select.tsx @@ -5,7 +5,7 @@ import { useMutation } from "convex/react" // @ts-ignore import { api } from "@/convex/_generated/api" import type { Id } from "@/convex/_generated/dataModel" -import type { TicketStatus } from "@/lib/schemas/ticket" +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" @@ -33,6 +33,7 @@ function badgeClass(p: string) { 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 ( ) } - From 881bb7bfdd8a56c869c6a7dc17b3db2266d433d0 Mon Sep 17 00:00:00 2001 From: esdrasrenan Date: Sat, 4 Oct 2025 15:15:50 -0300 Subject: [PATCH 3/3] =?UTF-8?q?docs(AGENTS):=20registrar=20progresso=20(ri?= =?UTF-8?q?ch=20text,=20tipagem,=20UI=20header)=20e=20pr=C3=B3ximos=20pass?= =?UTF-8?q?os=20+=20novas=20APIs=20Convex?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agents.md | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/agents.md b/agents.md index 26e5599..7aab815 100644 --- a/agents.md +++ b/agents.md @@ -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.