feat(ui): Spinner e Dialog shadcn; Novo ticket em Dialog com RHF+Zod; selects de responsável e fila com updates otimistas; spinners e toasts; fix setState durante render nos filtros; loading states\n\nchore(test): Vitest + testes de mapeadores\n

This commit is contained in:
esdrasrenan 2025-10-04 00:52:56 -03:00
parent 27b103cb46
commit 90c3c8e4d6
12 changed files with 415 additions and 50 deletions

View file

@ -1,8 +1,8 @@
import Link from "next/link"
import { AppShell } from "@/components/app-shell"
import { SiteHeader } from "@/components/site-header"
import { TicketQueueSummaryCards } from "@/components/tickets/ticket-queue-summary"
import { TicketsView } from "@/components/tickets/tickets-view"
import { NewTicketDialog } from "@/components/tickets/new-ticket-dialog"
export default function TicketsPage() {
return (
@ -12,7 +12,7 @@ export default function TicketsPage() {
title="Tickets"
lead="Visão consolidada de filas e SLAs"
secondaryAction={<SiteHeader.SecondaryButton>Exportar CSV</SiteHeader.SecondaryButton>}
primaryAction={<SiteHeader.PrimaryButton asChild><Link href="/tickets/new">Novo ticket</Link></SiteHeader.PrimaryButton>}
primaryAction={<NewTicketDialog />}
/>
}
>

View file

@ -0,0 +1,133 @@
"use client"
import { z } from "zod"
import { useState } from "react"
import { useMutation, useQuery } from "convex/react"
// @ts-ignore
import { api } from "../../../convex/_generated/api"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { useAuth } from "@/lib/auth-client"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { toast } from "sonner"
import { Spinner } from "@/components/ui/spinner"
const schema = z.object({
subject: z.string().min(3, "Informe um assunto"),
summary: z.string().optional(),
priority: z.enum(["LOW", "MEDIUM", "HIGH", "URGENT"]).default("MEDIUM"),
channel: z.enum(["EMAIL", "WHATSAPP", "CHAT", "PHONE", "API", "MANUAL"]).default("MANUAL"),
queueName: z.string().nullable().optional(),
})
export function NewTicketDialog() {
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const [values, setValues] = useState<z.infer<typeof schema>>({ subject: "", summary: "", priority: "MEDIUM", channel: "MANUAL", queueName: null })
const { userId } = useAuth()
const queues = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) ?? []
const create = useMutation(api.tickets.create)
async function submit(e: React.FormEvent) {
e.preventDefault()
const parsed = schema.safeParse(values)
if (!parsed.success) {
toast.error(parsed.error.issues[0]?.message ?? "Preencha o formulário")
return
}
if (!userId) return
setLoading(true)
toast.loading("Criando ticket…", { id: "new-ticket" })
try {
const sel = queues.find((q: any) => q.name === values.queueName)
const id = await create({
tenantId: DEFAULT_TENANT_ID,
subject: values.subject,
summary: values.summary,
priority: values.priority,
channel: values.channel,
queueId: sel?.id,
requesterId: userId as any,
})
toast.success("Ticket criado!", { id: "new-ticket" })
setOpen(false)
setValues({ subject: "", summary: "", priority: "MEDIUM", channel: "MANUAL", queueName: null })
// Navegar para o ticket recém-criado
window.location.href = `/tickets/${id}`
} catch (err) {
toast.error("Não foi possível criar o ticket.", { id: "new-ticket" })
} finally {
setLoading(false)
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm">Novo ticket</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Novo ticket</DialogTitle>
<DialogDescription>Preencha as informações básicas para abrir um chamado.</DialogDescription>
</DialogHeader>
<form className="space-y-4" onSubmit={submit}>
<div className="space-y-2">
<label className="text-sm" htmlFor="subject">Assunto</label>
<Input id="subject" value={values.subject} onChange={(e) => setValues((v) => ({ ...v, subject: e.target.value }))} required />
</div>
<div className="space-y-2">
<label className="text-sm" htmlFor="summary">Resumo</label>
<textarea id="summary" className="w-full rounded-md border bg-background p-2 text-sm" rows={3} value={values.summary} onChange={(e) => setValues((v) => ({ ...v, summary: e.target.value }))} />
</div>
<div className="grid gap-3 sm:grid-cols-3">
<div className="space-y-2">
<label className="text-sm">Prioridade</label>
<Select value={values.priority} onValueChange={(v) => setValues((s) => ({ ...s, priority: v as any }))}>
<SelectTrigger><SelectValue placeholder="Prioridade" /></SelectTrigger>
<SelectContent>
<SelectItem value="LOW">Baixa</SelectItem>
<SelectItem value="MEDIUM">Média</SelectItem>
<SelectItem value="HIGH">Alta</SelectItem>
<SelectItem value="URGENT">Urgente</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm">Canal</label>
<Select value={values.channel} onValueChange={(v) => setValues((s) => ({ ...s, channel: v as any }))}>
<SelectTrigger><SelectValue placeholder="Canal" /></SelectTrigger>
<SelectContent>
<SelectItem value="EMAIL">E-mail</SelectItem>
<SelectItem value="WHATSAPP">WhatsApp</SelectItem>
<SelectItem value="CHAT">Chat</SelectItem>
<SelectItem value="PHONE">Telefone</SelectItem>
<SelectItem value="API">API</SelectItem>
<SelectItem value="MANUAL">Manual</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm">Fila</label>
<Select value={values.queueName ?? ""} onValueChange={(v) => setValues((s) => ({ ...s, queueName: v || null }))}>
<SelectTrigger><SelectValue placeholder="Sem fila" /></SelectTrigger>
<SelectContent>
<SelectItem value="">Sem fila</SelectItem>
{queues.map((q: any) => (
<SelectItem key={q.id} value={q.name}>{q.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={loading}>{loading ? (<><Spinner className="me-2" /> Criando</>) : "Criar"}</Button>
</div>
</form>
</DialogContent>
</Dialog>
)
}

View file

@ -16,6 +16,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Separator } from "@/components/ui/separator"
import { TicketPriorityPill } from "@/components/tickets/priority-pill"
import { TicketStatusBadge } from "@/components/tickets/status-badge"
import { Spinner } from "@/components/ui/spinner"
interface PlayNextTicketCardProps {
context?: TicketPlayContext
@ -94,8 +95,7 @@ export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) {
if (chosen?.id) router.push(`/tickets/${chosen.id}`)
}}
>
Iniciar atendimento
<IconPlayerPlayFilled className="size-4" />
{userId ? (<><IconPlayerPlayFilled className="size-4" /> Iniciar atendimento</>) : (<><Spinner className="me-2" /> Carregando</>)}
</Button>
<Button variant="ghost" asChild className="gap-2 text-sm">
<Link href="/tickets">

View file

@ -4,7 +4,7 @@ import { useState } from "react"
import { format } from "date-fns"
import { ptBR } from "date-fns/locale"
import { IconClock, IconUserCircle } from "@tabler/icons-react"
import { useMutation } from "convex/react"
import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner"
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
@ -25,6 +25,10 @@ interface TicketHeaderProps {
export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const { userId } = useAuth()
const updateStatus = useMutation(api.tickets.updateStatus)
const changeAssignee = useMutation(api.tickets.changeAssignee)
const changeQueue = useMutation(api.tickets.changeQueue)
const agents = useQuery(api.users.listAgents, { tenantId: ticket.tenantId }) ?? []
const queues = useQuery(api.queues.summary, { tenantId: ticket.tenantId }) ?? []
const [status, setStatus] = useState(ticket.status)
const statusPt: Record<string, string> = {
NEW: "Novo",
@ -70,32 +74,77 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
</SelectContent>
</Select>
</div>
<h1 className="text-2xl font-semibold text-foreground">{ticket.subject}</h1>
<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>
</div>
<Separator />
<div className="grid gap-4 text-sm text-muted-foreground sm:grid-cols-2 lg:grid-cols-3">
<div className="flex items-center gap-2">
<IconUserCircle className="size-4" />
Solicitante:
<span className="font-medium text-foreground">{ticket.requester.name}</span>
</div>
<div className="flex items-center gap-2">
<IconUserCircle className="size-4" />
Responsavel:
<span className="font-medium text-foreground">
{ticket.assignee?.name ?? "Aguardando atribuicao"}
</span>
</div>
<div className="flex items-center gap-2">
<IconClock className="size-4" />
Atualizado em:
<span className="font-medium text-foreground">
{format(ticket.updatedAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}
</span>
</div>
<div className="flex items-center gap-2">
<Separator />
<div className="grid gap-4 text-sm text-muted-foreground sm:grid-cols-2 lg:grid-cols-3">
<div className="flex items-center gap-2">
<IconUserCircle className="size-4" />
Solicitante:
<span className="font-medium text-foreground">{ticket.requester.name}</span>
</div>
<div className="flex items-center gap-2">
<IconUserCircle className="size-4" />
Responsável:
<span className="font-medium text-foreground">{ticket.assignee?.name ?? "Aguardando atribuição"}</span>
<Select
value={ticket.assignee?.id ?? ""}
onValueChange={async (value) => {
if (!userId) return
toast.loading("Atribuindo responsável…", { id: "assignee" })
try {
await changeAssignee({ ticketId: ticket.id as any, assigneeId: value as any, actorId: userId as any })
toast.success("Responsável atualizado!", { id: "assignee" })
} catch {
toast.error("Não foi possível atribuir.", { id: "assignee" })
}
}}
>
<SelectTrigger className="h-8 w-[180px]"><SelectValue placeholder="Selecionar" /></SelectTrigger>
<SelectContent>
{agents.map((a: any) => (
<SelectItem key={a._id} value={a._id}>{a.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<IconClock className="size-4" />
Fila:
<span className="font-medium text-foreground">{ticket.queue ?? "Sem fila"}</span>
<Select
value={ticket.queue ?? ""}
onValueChange={async (value) => {
if (!userId) return
const q = queues.find((qq: any) => qq.name === value)
if (!q) return
toast.loading("Atualizando fila…", { id: "queue" })
try {
await changeQueue({ ticketId: ticket.id as any, queueId: q.id as any, actorId: userId as any })
toast.success("Fila atualizada!", { id: "queue" })
} catch {
toast.error("Não foi possível atualizar a fila.", { id: "queue" })
}
}}
>
<SelectTrigger className="h-8 w-[180px]"><SelectValue placeholder="Selecionar" /></SelectTrigger>
<SelectContent>
{queues.map((q: any) => (
<SelectItem key={q.id} value={q.name}>{q.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<IconClock className="size-4" />
Atualizado em:
<span className="font-medium text-foreground">
{format(ticket.updatedAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}
</span>
</div>
<div className="flex items-center gap-2">
<IconClock className="size-4" />
Criado em:
<span className="font-medium text-foreground">

View file

@ -1,6 +1,6 @@
"use client"
import { useMemo, useState } from "react"
import { useEffect, useMemo, useState } from "react"
import { IconFilter, IconRefresh } from "@tabler/icons-react"
import {
@ -84,15 +84,16 @@ interface TicketsFiltersProps {
const ALL_VALUE = "ALL"
export function TicketsFilters({ onChange, queues = [] }: TicketsFiltersProps) {
const [filters, setFilters] = useState<TicketFiltersState>(defaultTicketFilters)
function setPartial(partial: Partial<TicketFiltersState>) {
setFilters((prev) => {
const next = { ...prev, ...partial }
onChange?.(next)
return next
})
}
const [filters, setFilters] = useState<TicketFiltersState>(defaultTicketFilters)
function setPartial(partial: Partial<TicketFiltersState>) {
setFilters((prev) => ({ ...prev, ...partial }))
}
// Propaga as mudanças de filtros para o pai sem disparar durante render
useEffect(() => {
onChange?.(filters)
}, [filters, onChange])
const activeFilters = useMemo(() => {
const chips: string[] = []

View file

@ -9,11 +9,12 @@ import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { mapTicketsFromServerList } from "@/lib/mappers/ticket"
import { TicketsFilters, TicketFiltersState, defaultTicketFilters } from "@/components/tickets/tickets-filters"
import { TicketsTable } from "@/components/tickets/tickets-table"
import { Spinner } from "@/components/ui/spinner"
export function TicketsView() {
const [filters, setFilters] = useState<TicketFiltersState>(defaultTicketFilters)
const queues = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) ?? []
const queues = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID })
const ticketsRaw = useQuery(api.tickets.list, {
tenantId: DEFAULT_TENANT_ID,
status: filters.status ?? undefined,
@ -21,9 +22,9 @@ export function TicketsView() {
channel: filters.channel ?? undefined,
queueId: undefined, // simplified: filter by queue name on client
search: filters.search || undefined,
}) ?? []
})
const tickets = useMemo(() => mapTicketsFromServerList(ticketsRaw as any[]), [ticketsRaw])
const tickets = useMemo(() => mapTicketsFromServerList((ticketsRaw ?? []) as any[]), [ticketsRaw])
const filteredTickets = useMemo(() => {
if (!filters.queue) return tickets
@ -32,8 +33,12 @@ export function TicketsView() {
return (
<div className="flex flex-col gap-6 px-4 lg:px-6">
<TicketsFilters onChange={setFilters} queues={queues.map((q: any) => q.name)} />
<TicketsTable tickets={filteredTickets as any} />
<TicketsFilters onChange={setFilters} queues={(queues ?? []).map((q: any) => q.name)} />
{ticketsRaw === undefined ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground"><Spinner /> Carregando tickets</div>
) : (
<TicketsTable tickets={filteredTickets as any} />
)}
</div>
)
}

View file

@ -0,0 +1,67 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/40 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-[95vw] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 rounded-xl border bg-popover p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col gap-1", className)} {...props} />
)
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold", className)} {...props} />
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription }

View file

@ -0,0 +1,16 @@
import { LoaderIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<LoaderIcon
role="status"
aria-label="Carregando"
className={cn("size-4 animate-spin", className)}
{...props}
/>
)
}
export { Spinner }

View file

@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import { mapTicketFromServer, mapTicketWithDetailsFromServer } from "@/lib/mappers/ticket";
describe("ticket mappers", () => {
it("converte ticket básico (epoch -> Date)", () => {
const now = Date.now();
const ui = mapTicketFromServer({
id: "t1",
reference: 1,
tenantId: "tenant",
subject: "Teste",
status: "OPEN",
priority: "MEDIUM",
channel: "EMAIL",
queue: null,
requester: { id: "u1", name: "Ana", email: "a@a.com", teams: [] },
assignee: null,
updatedAt: now,
createdAt: now,
tags: [],
lastTimelineEntry: null,
});
expect(ui.createdAt).toBeInstanceOf(Date);
expect(ui.updatedAt).toBeInstanceOf(Date);
expect(ui.lastTimelineEntry).toBeUndefined();
});
it("converte ticket com detalhes", () => {
const now = Date.now();
const ui = mapTicketWithDetailsFromServer({
id: "t1",
reference: 1,
tenantId: "tenant",
subject: "Teste",
status: "OPEN",
priority: "MEDIUM",
channel: "EMAIL",
queue: "Suporte N1",
requester: { id: "u1", name: "Ana", email: "a@a.com", teams: [] },
assignee: { id: "u2", name: "Bruno", email: "b@b.com", teams: [] },
updatedAt: now,
createdAt: now,
tags: [],
lastTimelineEntry: null,
timeline: [{ id: "e1", type: "CREATED", createdAt: now }],
comments: [{ id: "c1", author: { id: "u1", name: "Ana", email: "a@a.com", teams: [] }, visibility: "PUBLIC", body: "Oi", createdAt: now, updatedAt: now }],
});
expect(ui.timeline[0]!.createdAt).toBeInstanceOf(Date);
expect(ui.comments[0]!.createdAt).toBeInstanceOf(Date);
});
});