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

@ -281,6 +281,34 @@ export const updateStatus = mutation({
}, },
}); });
export const changeAssignee = mutation({
args: { ticketId: v.id("tickets"), assigneeId: v.id("users"), actorId: v.id("users") },
handler: async (ctx, { ticketId, assigneeId, actorId }) => {
const now = Date.now();
await ctx.db.patch(ticketId, { assigneeId, updatedAt: now });
await ctx.db.insert("ticketEvents", {
ticketId,
type: "ASSIGNEE_CHANGED",
payload: { assigneeId, actorId },
createdAt: now,
});
},
});
export const changeQueue = mutation({
args: { ticketId: v.id("tickets"), queueId: v.id("queues"), actorId: v.id("users") },
handler: async (ctx, { ticketId, queueId, actorId }) => {
const now = Date.now();
await ctx.db.patch(ticketId, { queueId, updatedAt: now });
await ctx.db.insert("ticketEvents", {
ticketId,
type: "QUEUE_CHANGED",
payload: { queueId, actorId },
createdAt: now,
});
},
});
export const playNext = mutation({ export const playNext = mutation({
args: { args: {
tenantId: v.string(), tenantId: v.string(),

View file

@ -8,7 +8,8 @@
"start": "next start", "start": "next start",
"lint": "eslint", "lint": "eslint",
"prisma:generate": "prisma generate", "prisma:generate": "prisma generate",
"convex:dev": "convex dev" "convex:dev": "convex dev",
"test": "vitest"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
@ -40,13 +41,15 @@
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"recharts": "^2.15.4", "recharts": "^2.15.4",
"sonner": "^2.0.7", "react-hook-form": "^7.53.0",
"@hookform/resolvers": "^3.9.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"zod": "^4.1.9" "zod": "^4.1.9"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/node": "^20", "@types/node": "^20",
@ -57,6 +60,7 @@
"prisma": "^6.16.2", "prisma": "^6.16.2",
"tailwindcss": "^4", "tailwindcss": "^4",
"tw-animate-css": "^1.3.8", "tw-animate-css": "^1.3.8",
"typescript": "^5" "typescript": "^5",
} "vitest": "^2.1.4"
} }
}

View file

@ -1,8 +1,8 @@
import Link from "next/link"
import { AppShell } from "@/components/app-shell" import { AppShell } from "@/components/app-shell"
import { SiteHeader } from "@/components/site-header" import { SiteHeader } from "@/components/site-header"
import { TicketQueueSummaryCards } from "@/components/tickets/ticket-queue-summary" import { TicketQueueSummaryCards } from "@/components/tickets/ticket-queue-summary"
import { TicketsView } from "@/components/tickets/tickets-view" import { TicketsView } from "@/components/tickets/tickets-view"
import { NewTicketDialog } from "@/components/tickets/new-ticket-dialog"
export default function TicketsPage() { export default function TicketsPage() {
return ( return (
@ -12,7 +12,7 @@ export default function TicketsPage() {
title="Tickets" title="Tickets"
lead="Visão consolidada de filas e SLAs" lead="Visão consolidada de filas e SLAs"
secondaryAction={<SiteHeader.SecondaryButton>Exportar CSV</SiteHeader.SecondaryButton>} 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 { Separator } from "@/components/ui/separator"
import { TicketPriorityPill } from "@/components/tickets/priority-pill" import { TicketPriorityPill } from "@/components/tickets/priority-pill"
import { TicketStatusBadge } from "@/components/tickets/status-badge" import { TicketStatusBadge } from "@/components/tickets/status-badge"
import { Spinner } from "@/components/ui/spinner"
interface PlayNextTicketCardProps { interface PlayNextTicketCardProps {
context?: TicketPlayContext context?: TicketPlayContext
@ -94,8 +95,7 @@ export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) {
if (chosen?.id) router.push(`/tickets/${chosen.id}`) if (chosen?.id) router.push(`/tickets/${chosen.id}`)
}} }}
> >
Iniciar atendimento {userId ? (<><IconPlayerPlayFilled className="size-4" /> Iniciar atendimento</>) : (<><Spinner className="me-2" /> Carregando</>)}
<IconPlayerPlayFilled className="size-4" />
</Button> </Button>
<Button variant="ghost" asChild className="gap-2 text-sm"> <Button variant="ghost" asChild className="gap-2 text-sm">
<Link href="/tickets"> <Link href="/tickets">

View file

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

View file

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

View file

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

10
web/vitest.config.ts Normal file
View file

@ -0,0 +1,10 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
globals: true,
include: ["src/**/*.test.ts"],
},
});