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
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 />}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
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 { 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">
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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[] = []
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
10
web/vitest.config.ts
Normal file
10
web/vitest.config.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: "node",
|
||||||
|
globals: true,
|
||||||
|
include: ["src/**/*.test.ts"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue