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:
parent
27b103cb46
commit
90c3c8e4d6
12 changed files with 415 additions and 50 deletions
|
|
@ -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 />}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
|
|
|||
133
web/src/components/tickets/new-ticket-dialog.tsx
Normal file
133
web/src/components/tickets/new-ticket-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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[] = []
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
67
web/src/components/ui/dialog.tsx
Normal file
67
web/src/components/ui/dialog.tsx
Normal 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 }
|
||||
|
||||
16
web/src/components/ui/spinner.tsx
Normal file
16
web/src/components/ui/spinner.tsx
Normal 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 }
|
||||
|
||||
52
web/src/lib/mappers/__tests__/ticket.test.ts
Normal file
52
web/src/lib/mappers/__tests__/ticket.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue