feat: núcleo de tickets com Convex (CRUD, play, comentários com anexos) + auth placeholder; docs em AGENTS.md; toasts e updates otimistas; mapeadores Zod; refinos PT-BR e layout do painel de detalhes

This commit is contained in:
esdrasrenan 2025-10-04 00:31:44 -03:00
parent 2230590e57
commit 27b103cb46
97 changed files with 15117 additions and 15715 deletions

View file

@ -1,7 +1,14 @@
import Link from "next/link"
import { IconArrowRight, IconPlayerPlayFilled } from "@tabler/icons-react"
"use client"
import { playContext } from "@/lib/mocks/tickets"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { IconArrowRight, IconPlayerPlayFilled } from "@tabler/icons-react"
import { useMutation, useQuery } from "convex/react"
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import { api } from "../../../convex/_generated/api"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { useAuth } from "@/lib/auth-client"
import type { TicketPlayContext } from "@/lib/schemas/ticket"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
@ -9,13 +16,29 @@ 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"
interface PlayNextTicketCardProps {
context?: TicketPlayContext
}
export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) {
const router = useRouter()
const { userId } = useAuth()
const queueSummary = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) ?? []
const playNext = useMutation(api.tickets.playNext)
interface PlayNextTicketCardProps {
context?: TicketPlayContext
}
const nextTicketFromServer = useQuery(api.tickets.list, {
tenantId: DEFAULT_TENANT_ID,
status: undefined,
priority: undefined,
channel: undefined,
queueId: undefined,
limit: 1,
})?.[0]
export function PlayNextTicketCard({ context = playContext }: PlayNextTicketCardProps) {
if (!context.nextTicket) {
const cardContext: TicketPlayContext | null = context ?? (nextTicketFromServer ? { queue: { id: "default", name: "Geral", pending: queueSummary.reduce((a: number, b: any) => a + b.pending, 0), waiting: queueSummary.reduce((a: number, b: any) => a + b.waiting, 0), breached: 0 }, nextTicket: nextTicketFromServer } : null)
if (!cardContext || !cardContext.nextTicket) {
return (
<Card className="border-dashed">
<CardHeader>
@ -28,54 +51,59 @@ export function PlayNextTicketCard({ context = playContext }: PlayNextTicketCard
)
}
const ticket = context.nextTicket
return (
<Card className="border-dashed">
<CardHeader className="flex flex-row items-center justify-between gap-2">
<CardTitle className="text-lg font-semibold">
Proximo ticket #{ticket.reference}
</CardTitle>
<TicketPriorityPill priority={ticket.priority} />
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="space-y-1">
<h2 className="text-xl font-medium text-foreground">{ticket.subject}</h2>
<p className="text-sm text-muted-foreground">{ticket.summary}</p>
</div>
<div className="flex flex-wrap gap-2 text-sm text-muted-foreground">
<Badge variant="outline">{ticket.queue ?? "Sem fila"}</Badge>
<TicketStatusBadge status={ticket.status} />
<span>Solicitante: {ticket.requester.name}</span>
</div>
<Separator />
const ticket = cardContext.nextTicket
return (
<Card className="border-dashed">
<CardHeader className="flex flex-row items-center justify-between gap-2">
<CardTitle className="text-lg font-semibold">
Proximo ticket #{ticket.reference}
</CardTitle>
<TicketPriorityPill priority={ticket.priority} />
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="space-y-1">
<h2 className="text-xl font-medium text-foreground">{ticket.subject}</h2>
<p className="text-sm text-muted-foreground">{ticket.summary}</p>
</div>
<div className="flex flex-wrap gap-2 text-sm text-muted-foreground">
<Badge variant="outline">{ticket.queue ?? "Sem fila"}</Badge>
<TicketStatusBadge status={ticket.status} />
<span>Solicitante: {ticket.requester.name}</span>
</div>
<Separator />
<div className="flex flex-col gap-3 text-sm text-muted-foreground">
<div className="flex items-center justify-between">
<span>Pendentes na fila</span>
<span className="font-medium text-foreground">{context.queue.pending}</span>
<span className="font-medium text-foreground">{cardContext.queue.pending}</span>
</div>
<div className="flex items-center justify-between">
<span>Em espera</span>
<span className="font-medium text-foreground">{context.queue.waiting}</span>
<span className="font-medium text-foreground">{cardContext.queue.waiting}</span>
</div>
<div className="flex items-center justify-between">
<span>SLA violado</span>
<span className="font-medium text-destructive">{context.queue.breached}</span>
<span className="font-medium text-destructive">{cardContext.queue.breached}</span>
</div>
</div>
<Button asChild className="gap-2">
<Link href={`/tickets/${ticket.id}`}>
Iniciar atendimento
<IconPlayerPlayFilled className="size-4" />
</Link>
<Button
className="gap-2"
onClick={async () => {
if (!userId) return
const chosen = await playNext({ tenantId: DEFAULT_TENANT_ID, queueId: undefined, agentId: userId as any })
if (chosen?.id) router.push(`/tickets/${chosen.id}`)
}}
>
Iniciar atendimento
<IconPlayerPlayFilled className="size-4" />
</Button>
<Button variant="ghost" asChild className="gap-2 text-sm">
<Link href="/tickets">
Ver lista completa
<IconArrowRight className="size-4" />
</Link>
</Button>
</CardContent>
</Card>
)
}
<Button variant="ghost" asChild className="gap-2 text-sm">
<Link href="/tickets">
Ver lista completa
<IconArrowRight className="size-4" />
</Link>
</Button>
</CardContent>
</Card>
)
}

View file

@ -1,40 +1,40 @@
import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
const priorityConfig = {
LOW: {
label: "Baixa",
className: "bg-slate-100 text-slate-600 border-transparent",
},
MEDIUM: {
label: "Media",
className: "bg-blue-100 text-blue-600 border-transparent",
},
HIGH: {
label: "Alta",
className: "bg-amber-100 text-amber-700 border-transparent",
},
URGENT: {
label: "Urgente",
className: "bg-red-100 text-red-700 border-transparent",
},
} satisfies Record<string, { label: string; className: string }>
type TicketPriorityPillProps = {
priority: keyof typeof priorityConfig
}
export function TicketPriorityPill({ priority }: TicketPriorityPillProps) {
const config = priorityConfig[priority]
return (
<Badge
variant="outline"
className={cn(
"rounded-full px-2.5 py-1 text-xs font-medium",
config?.className ?? ""
)}
>
{config?.label ?? priority}
</Badge>
)
}
import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
const priorityConfig = {
LOW: {
label: "Baixa",
className: "bg-slate-100 text-slate-600 border-transparent",
},
MEDIUM: {
label: "Media",
className: "bg-blue-100 text-blue-600 border-transparent",
},
HIGH: {
label: "Alta",
className: "bg-amber-100 text-amber-700 border-transparent",
},
URGENT: {
label: "Urgente",
className: "bg-red-100 text-red-700 border-transparent",
},
} satisfies Record<string, { label: string; className: string }>
type TicketPriorityPillProps = {
priority: keyof typeof priorityConfig
}
export function TicketPriorityPill({ priority }: TicketPriorityPillProps) {
const config = priorityConfig[priority]
return (
<Badge
variant="outline"
className={cn(
"rounded-full px-2.5 py-1 text-xs font-medium",
config?.className ?? ""
)}
>
{config?.label ?? priority}
</Badge>
)
}

View file

@ -1,29 +1,29 @@
"use client"
import { ticketStatusSchema } from "@/lib/schemas/ticket"
import { Badge } from "@/components/ui/badge"
const statusConfig = {
NEW: { label: "Novo", className: "bg-slate-100 text-slate-700 border-transparent" },
OPEN: { label: "Aberto", className: "bg-blue-100 text-blue-700 border-transparent" },
PENDING: { label: "Pendente", className: "bg-amber-100 text-amber-700 border-transparent" },
ON_HOLD: { label: "Em espera", className: "bg-purple-100 text-purple-700 border-transparent" },
RESOLVED: { label: "Resolvido", className: "bg-emerald-100 text-emerald-700 border-transparent" },
CLOSED: { label: "Fechado", className: "bg-slate-100 text-slate-700 border-transparent" },
} satisfies Record<(typeof ticketStatusSchema)["_type"], { label: string; className: string }>
type TicketStatusBadgeProps = {
status: (typeof ticketStatusSchema)["_type"]
}
export function TicketStatusBadge({ status }: TicketStatusBadgeProps) {
const config = statusConfig[status]
return (
<Badge
variant="outline"
className={`rounded-full px-2.5 py-1 text-xs font-medium ${config?.className ?? ""}`}
>
{config?.label ?? status}
</Badge>
)
}
"use client"
import { ticketStatusSchema } from "@/lib/schemas/ticket"
import { Badge } from "@/components/ui/badge"
const statusConfig = {
NEW: { label: "Novo", className: "bg-slate-100 text-slate-700 border-transparent" },
OPEN: { label: "Aberto", className: "bg-blue-100 text-blue-700 border-transparent" },
PENDING: { label: "Pendente", className: "bg-amber-100 text-amber-700 border-transparent" },
ON_HOLD: { label: "Em espera", className: "bg-purple-100 text-purple-700 border-transparent" },
RESOLVED: { label: "Resolvido", className: "bg-emerald-100 text-emerald-700 border-transparent" },
CLOSED: { label: "Fechado", className: "bg-slate-100 text-slate-700 border-transparent" },
} satisfies Record<(typeof ticketStatusSchema)["_type"], { label: string; className: string }>
type TicketStatusBadgeProps = {
status: (typeof ticketStatusSchema)["_type"]
}
export function TicketStatusBadge({ status }: TicketStatusBadgeProps) {
const config = statusConfig[status]
return (
<Badge
variant="outline"
className={`rounded-full px-2.5 py-1 text-xs font-medium ${config?.className ?? ""}`}
>
{config?.label ?? status}
</Badge>
)
}

View file

@ -1,17 +1,75 @@
"use client"
import { useMemo, useState } from "react"
import { formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale"
import { IconLock, IconMessage } from "@tabler/icons-react"
import { useAction, useMutation } from "convex/react"
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import { api } from "../../../convex/_generated/api"
import { useAuth } from "@/lib/auth-client"
import type { TicketWithDetails } from "@/lib/schemas/ticket"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
interface TicketCommentsProps {
ticket: TicketWithDetails
}
import { Button } from "@/components/ui/button"
import { toast } from "sonner"
interface TicketCommentsProps {
ticket: TicketWithDetails
}
export function TicketComments({ ticket }: TicketCommentsProps) {
const { userId } = useAuth()
const addComment = useMutation(api.tickets.addComment)
const generateUploadUrl = useAction(api.files.generateUploadUrl)
const [body, setBody] = useState("")
const [files, setFiles] = useState<File[]>([])
const [pending, setPending] = useState<Pick<TicketWithDetails["comments"][number], "id"|"author"|"visibility"|"body"|"attachments"|"createdAt"|"updatedAt">[]>([])
const commentsAll = useMemo(() => {
return [...pending, ...ticket.comments]
}, [pending, ticket.comments])
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!userId) return
let attachments: Array<{ storageId: string; name: string; size?: number; type?: string }> = []
if (files.length) {
const url = await generateUploadUrl({})
for (const file of files) {
const form = new FormData()
form.append("file", file)
const res = await fetch(url, { method: "POST", body: form })
const { storageId } = await res.json()
attachments.push({ storageId, name: file.name, size: file.size, type: file.type })
}
}
const now = new Date()
const optimistic = {
id: `temp-${now.getTime()}`,
author: ticket.requester, // placeholder; poderia buscar o próprio usuário se necessário
visibility: "PUBLIC" as const,
body,
attachments: attachments.map((a) => ({ id: a.storageId, name: a.name } as any)),
createdAt: now,
updatedAt: now,
}
setPending((p) => [optimistic, ...p])
setBody("")
setFiles([])
toast.loading("Enviando comentário…", { id: "comment" })
try {
await addComment({ ticketId: ticket.id as any, authorId: userId as any, visibility: "PUBLIC", body: optimistic.body, attachments })
setPending([])
toast.success("Comentário enviado!", { id: "comment" })
} catch (err) {
setPending([])
toast.error("Falha ao enviar comentário.", { id: "comment" })
}
}
return (
<Card className="border-none shadow-none">
<CardHeader className="px-0">
@ -20,12 +78,12 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
</CardTitle>
</CardHeader>
<CardContent className="space-y-6 px-0">
{ticket.comments.length === 0 ? (
{commentsAll.length === 0 ? (
<p className="text-sm text-muted-foreground">
Ainda sem comentarios. Que tal registrar o proximo passo?
</p>
) : (
ticket.comments.map((comment) => {
commentsAll.map((comment) => {
const initials = comment.author.name
.split(" ")
.slice(0, 2)
@ -49,14 +107,36 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
{formatDistanceToNow(comment.createdAt, { addSuffix: true, locale: ptBR })}
</span>
</div>
<div className="rounded-lg border border-dashed bg-card px-3 py-2 text-sm leading-relaxed text-muted-foreground">
<div className="rounded-lg border border-dashed bg-card px-3 py-2 text-sm leading-relaxed text-muted-foreground break-words whitespace-pre-wrap">
{comment.body}
</div>
{comment.attachments?.length ? (
<div className="flex flex-wrap gap-2">
{comment.attachments.map((a) => (
<a key={(a as any).id} href={(a as any).url} target="_blank" className="text-xs underline">
{(a as any).name}
</a>
))}
</div>
) : null}
</div>
</div>
)
})
)}
<form onSubmit={handleSubmit} className="mt-4 space-y-3">
<textarea
className="w-full rounded-md border bg-background p-3 text-sm"
placeholder="Escreva um comentario..."
rows={3}
value={body}
onChange={(e) => setBody(e.target.value)}
/>
<div className="flex items-center justify-between">
<input type="file" multiple onChange={(e) => setFiles(Array.from(e.target.files ?? []))} />
<Button type="submit" size="sm">Enviar</Button>
</div>
</form>
</CardContent>
</Card>
)

View file

@ -0,0 +1,30 @@
"use client";
import { useQuery } from "convex/react";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import { api } from "../../../convex/_generated/api";
import { DEFAULT_TENANT_ID } from "@/lib/constants";
import { mapTicketWithDetailsFromServer } from "@/lib/mappers/ticket";
import { TicketComments } from "@/components/tickets/ticket-comments";
import { TicketDetailsPanel } from "@/components/tickets/ticket-details-panel";
import { TicketSummaryHeader } from "@/components/tickets/ticket-summary-header";
import { TicketTimeline } from "@/components/tickets/ticket-timeline";
export function TicketDetailView({ id }: { id: string }) {
const t = useQuery(api.tickets.getById, { tenantId: DEFAULT_TENANT_ID, id: id as any });
if (!t) return <div className="px-4 py-8 text-sm text-muted-foreground">Carregando ticket...</div>;
const ticket = mapTicketWithDetailsFromServer(t as any)
return (
<div className="flex flex-col gap-6 px-4 lg:px-6">
<TicketSummaryHeader ticket={ticket as any} />
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
<div className="space-y-6">
<TicketComments ticket={ticket as any} />
<TicketTimeline ticket={ticket as any} />
</div>
<TicketDetailsPanel ticket={ticket as any} />
</div>
</div>
);
}

View file

@ -1,53 +1,53 @@
import { format } from "date-fns"
import { ptBR } from "date-fns/locale"
import { IconAlertTriangle, IconClockHour4, IconTags } from "@tabler/icons-react"
import type { TicketWithDetails } from "@/lib/schemas/ticket"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Separator } from "@/components/ui/separator"
interface TicketDetailsPanelProps {
ticket: TicketWithDetails
}
import { format } from "date-fns"
import { ptBR } from "date-fns/locale"
import { IconAlertTriangle, IconClockHour4, IconTags } from "@tabler/icons-react"
import type { TicketWithDetails } from "@/lib/schemas/ticket"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Separator } from "@/components/ui/separator"
interface TicketDetailsPanelProps {
ticket: TicketWithDetails
}
export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
return (
<Card className="border-none shadow-none">
<CardHeader className="px-0">
<CardTitle className="text-lg font-semibold">Detalhes</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4 px-0 text-sm text-muted-foreground">
<div className="space-y-1">
<p className="text-xs font-semibold uppercase">Fila</p>
<Badge variant="outline">{ticket.queue ?? "Sem fila"}</Badge>
<CardContent className="flex flex-col gap-5 px-0 text-sm text-muted-foreground">
<div className="space-y-1 break-words">
<p className="text-xs font-semibold uppercase tracking-wide">Fila</p>
<Badge variant="outline" className="max-w-full truncate">{ticket.queue ?? "Sem fila"}</Badge>
</div>
<Separator />
<div className="space-y-2">
<p className="text-xs font-semibold uppercase">SLA</p>
<div className="space-y-2 break-words">
<p className="text-xs font-semibold uppercase tracking-wide">SLA</p>
{ticket.slaPolicy ? (
<div className="flex flex-col gap-2 rounded-lg border border-dashed bg-card px-3 py-2">
<span className="text-foreground">{ticket.slaPolicy.name}</span>
<span className="text-foreground text-sm font-medium leading-tight">{ticket.slaPolicy.name}</span>
<div className="flex flex-col gap-1 text-xs text-muted-foreground">
{ticket.slaPolicy.targetMinutesToFirstResponse ? (
<span>
<span className="leading-normal">
Resposta inicial: {ticket.slaPolicy.targetMinutesToFirstResponse} min
</span>
) : null}
{ticket.slaPolicy.targetMinutesToResolution ? (
<span>
Resolucao: {ticket.slaPolicy.targetMinutesToResolution} min
<span className="leading-normal">
Resolução: {ticket.slaPolicy.targetMinutesToResolution} min
</span>
) : null}
</div>
</div>
) : (
<span>Sem politica atribuida.</span>
<span>Sem política atribuída.</span>
)}
</div>
<Separator />
<div className="space-y-2">
<p className="text-xs font-semibold uppercase">Metricas</p>
<p className="text-xs font-semibold uppercase tracking-wide">tricas</p>
{ticket.metrics ? (
<div className="flex flex-col gap-2 text-xs text-muted-foreground">
<span className="flex items-center gap-2">
@ -62,8 +62,8 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
)}
</div>
<Separator />
<div className="space-y-2">
<p className="text-xs font-semibold uppercase">Tags</p>
<div className="space-y-2 break-words">
<p className="text-xs font-semibold uppercase tracking-wide">Tags</p>
<div className="flex flex-wrap gap-2">
{ticket.tags?.length ? (
ticket.tags.map((tag) => (
@ -78,7 +78,7 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
</div>
<Separator />
<div className="space-y-1">
<p className="text-xs font-semibold uppercase">Historico</p>
<p className="text-xs font-semibold uppercase tracking-wide">Histórico</p>
<div className="flex flex-col gap-1 text-xs text-muted-foreground">
<span>Criado: {format(ticket.createdAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
<span>Atualizado: {format(ticket.updatedAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>

View file

@ -1,47 +1,55 @@
import { queueSummaries } from "@/lib/mocks/tickets"
"use client"
import { useQuery } from "convex/react"
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import { api } from "../../../convex/_generated/api"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import type { TicketQueueSummary } from "@/lib/schemas/ticket"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Progress } from "@/components/ui/progress"
interface TicketQueueSummaryProps {
queues?: TicketQueueSummary[]
}
export function TicketQueueSummaryCards({ queues = queueSummaries }: TicketQueueSummaryProps) {
interface TicketQueueSummaryProps {
queues?: TicketQueueSummary[]
}
export function TicketQueueSummaryCards({ queues }: TicketQueueSummaryProps) {
const fromServer = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) ?? []
const data: TicketQueueSummary[] = (queues ?? fromServer) as any
return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{queues.map((queue) => {
{data.map((queue) => {
const total = queue.pending + queue.waiting
const breachPercent = total === 0 ? 0 : Math.round((queue.breached / total) * 100)
return (
<Card key={queue.id} className="border border-border/60 bg-gradient-to-br from-background to-card p-4">
<CardHeader className="pb-2">
<CardDescription>Fila</CardDescription>
<CardTitle className="text-lg font-semibold">{queue.name}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
<div className="flex justify-between text-muted-foreground">
<span>Pendentes</span>
<span className="font-medium text-foreground">{queue.pending}</span>
</div>
<div className="flex justify-between text-muted-foreground">
<span>Aguardando resposta</span>
<span className="font-medium text-foreground">{queue.waiting}</span>
</div>
<div className="flex items-center justify-between text-muted-foreground">
<span>Violados</span>
<span className="font-medium text-destructive">{queue.breached}</span>
</div>
<div className="pt-1.5">
<Progress value={breachPercent} className="h-1.5" />
<span className="mt-2 block text-xs text-muted-foreground">
{breachPercent}% com SLA violado nesta fila
</span>
</div>
</CardContent>
</Card>
)
})}
</div>
)
}
<Card key={queue.id} className="border border-border/60 bg-gradient-to-br from-background to-card p-4">
<CardHeader className="pb-2">
<CardDescription>Fila</CardDescription>
<CardTitle className="text-lg font-semibold">{queue.name}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
<div className="flex justify-between text-muted-foreground">
<span>Pendentes</span>
<span className="font-medium text-foreground">{queue.pending}</span>
</div>
<div className="flex justify-between text-muted-foreground">
<span>Aguardando resposta</span>
<span className="font-medium text-foreground">{queue.waiting}</span>
</div>
<div className="flex items-center justify-between text-muted-foreground">
<span>Violados</span>
<span className="font-medium text-destructive">{queue.breached}</span>
</div>
<div className="pt-1.5">
<Progress value={breachPercent} className="h-1.5" />
<span className="mt-2 block text-xs text-muted-foreground">
{breachPercent}% com SLA violado nesta fila
</span>
</div>
</CardContent>
</Card>
)
})}
</div>
)
}

View file

@ -1,18 +1,39 @@
"use client"
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 { toast } from "sonner"
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import { api } from "../../../convex/_generated/api"
import { useAuth } from "@/lib/auth-client"
import type { TicketWithDetails } from "@/lib/schemas/ticket"
import { Badge } from "@/components/ui/badge"
import { Separator } from "@/components/ui/separator"
import { TicketPriorityPill } from "@/components/tickets/priority-pill"
import { TicketStatusBadge } from "@/components/tickets/status-badge"
interface TicketHeaderProps {
ticket: TicketWithDetails
}
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
interface TicketHeaderProps {
ticket: TicketWithDetails
}
export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const { userId } = useAuth()
const updateStatus = useMutation(api.tickets.updateStatus)
const [status, setStatus] = useState(ticket.status)
const statusPt: Record<string, string> = {
NEW: "Novo",
OPEN: "Aberto",
PENDING: "Pendente",
ON_HOLD: "Em espera",
RESOLVED: "Resolvido",
CLOSED: "Fechado",
}
return (
<div className="space-y-4 rounded-xl border bg-card p-6 shadow-sm">
<div className="flex flex-wrap items-start justify-between gap-3">
@ -22,57 +43,82 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
#{ticket.reference}
</Badge>
<TicketPriorityPill priority={ticket.priority} />
<TicketStatusBadge status={ticket.status} />
<TicketStatusBadge status={status as any} />
<Select
value={status}
onValueChange={async (value) => {
const prev = status
setStatus(value) // otimista
if (!userId) return
toast.loading("Atualizando status…", { id: "status" })
try {
await updateStatus({ ticketId: ticket.id as any, status: value as any, actorId: userId as any })
toast.success(`Status alterado para ${statusPt[value]}.`, { id: "status" })
} catch (e) {
setStatus(prev)
toast.error("Não foi possível alterar o status.", { id: "status" })
}
}}
>
<SelectTrigger className="h-8 w-[150px]">
<SelectValue placeholder="Alterar status" />
</SelectTrigger>
<SelectContent>
{(["NEW","OPEN","PENDING","ON_HOLD","RESOLVED","CLOSED"]).map((s) => (
<SelectItem key={s} value={s}>{statusPt[s]}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<h1 className="text-2xl font-semibold text-foreground">{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">
<IconClock className="size-4" />
Criado em:
<span className="font-medium text-foreground">
{format(ticket.createdAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}
</span>
</div>
{ticket.dueAt ? (
<div className="flex items-center gap-2">
<IconClock className="size-4" />
SLA ate:
<span className="font-medium text-foreground">
{format(ticket.dueAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}
</span>
</div>
) : null}
{ticket.slaPolicy ? (
<div className="flex items-center gap-2">
<IconClock className="size-4" />
Politica:
<span className="font-medium text-foreground">{ticket.slaPolicy.name}</span>
</div>
) : null}
</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">
<IconClock className="size-4" />
Criado em:
<span className="font-medium text-foreground">
{format(ticket.createdAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}
</span>
</div>
{ticket.dueAt ? (
<div className="flex items-center gap-2">
<IconClock className="size-4" />
SLA ate:
<span className="font-medium text-foreground">
{format(ticket.dueAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}
</span>
</div>
) : null}
{ticket.slaPolicy ? (
<div className="flex items-center gap-2">
<IconClock className="size-4" />
Politica:
<span className="font-medium text-foreground">{ticket.slaPolicy.name}</span>
</div>
) : null}
</div>
</div>
)
}

View file

@ -1,17 +1,18 @@
import { format } from "date-fns"\nimport type { ComponentType } from "react"
import { format } from "date-fns"
import type { ComponentType } from "react"
import { ptBR } from "date-fns/locale"
import {
IconClockHour4,
IconNote,
IconSquareCheck,
IconUserCircle,
} from "@tabler/icons-react"
import type { TicketWithDetails } from "@/lib/schemas/ticket"
import { cn } from "@/lib/utils"
import { Card, CardContent } from "@/components/ui/card"
import { Separator } from "@/components/ui/separator"
import {
IconClockHour4,
IconNote,
IconSquareCheck,
IconUserCircle,
} from "@tabler/icons-react"
import type { TicketWithDetails } from "@/lib/schemas/ticket"
import { cn } from "@/lib/utils"
import { Card, CardContent } from "@/components/ui/card"
import { Separator } from "@/components/ui/separator"
const timelineIcons: Record<string, ComponentType<{ className?: string }>> = {
CREATED: IconUserCircle,
STATUS_CHANGED: IconSquareCheck,
@ -19,47 +20,54 @@ const timelineIcons: Record<string, ComponentType<{ className?: string }>> = {
COMMENT_ADDED: IconNote,
}
interface TicketTimelineProps {
ticket: TicketWithDetails
const timelineLabels: Record<string, string> = {
CREATED: "Criado",
STATUS_CHANGED: "Status alterado",
ASSIGNEE_CHANGED: "Responsável alterado",
COMMENT_ADDED: "Comentário adicionado",
}
export function TicketTimeline({ ticket }: TicketTimelineProps) {
return (
<Card className="border-none shadow-none">
<CardContent className="space-y-6">
{ticket.timeline.map((entry, index) => {
const Icon = timelineIcons[entry.type] ?? IconClockHour4
const isLast = index === ticket.timeline.length - 1
return (
<div key={entry.id} className="relative pl-10">
{!isLast && (
<span className="absolute left-[17px] top-6 h-full w-px bg-border" aria-hidden />
)}
<span className="absolute left-0 top-0 flex size-8 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Icon className="size-4" />
</span>
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
interface TicketTimelineProps {
ticket: TicketWithDetails
}
export function TicketTimeline({ ticket }: TicketTimelineProps) {
return (
<Card className="border-none shadow-none">
<CardContent className="space-y-6">
{ticket.timeline.map((entry, index) => {
const Icon = timelineIcons[entry.type] ?? IconClockHour4
const isLast = index === ticket.timeline.length - 1
return (
<div key={entry.id} className="relative pl-10">
{!isLast && (
<span className="absolute left-[17px] top-6 h-full w-px bg-border" aria-hidden />
)}
<span className="absolute left-0 top-0 flex size-8 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Icon className="size-4" />
</span>
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="text-sm font-medium text-foreground">
{entry.type.replaceAll("_", " ")}
{timelineLabels[entry.type] ?? entry.type}
</span>
<span className="text-xs text-muted-foreground">
{format(entry.createdAt, "dd MMM yyyy HH:mm", { locale: ptBR })}
</span>
</div>
{entry.payload ? (
<div className="rounded-lg border border-dashed bg-card px-3 py-2 text-sm text-muted-foreground">
<pre className="whitespace-pre-wrap leading-relaxed">
{JSON.stringify(entry.payload, null, 2)}
</pre>
</div>
) : null}
</div>
</div>
)
})}
</CardContent>
</Card>
)
}
<span className="text-xs text-muted-foreground">
{format(entry.createdAt, "dd MMM yyyy HH:mm", { locale: ptBR })}
</span>
</div>
{entry.payload ? (
<div className="rounded-lg border border-dashed bg-card px-3 py-2 text-sm text-muted-foreground">
<pre className="whitespace-pre-wrap leading-relaxed">
{JSON.stringify(entry.payload, null, 2)}
</pre>
</div>
) : null}
</div>
</div>
)
})}
</CardContent>
</Card>
)
}

View file

@ -1,125 +1,127 @@
"use client"
import { useMemo, useState } from "react"
import { IconFilter, IconRefresh } from "@tabler/icons-react"
import { tickets } from "@/lib/mocks/tickets"
import {
ticketChannelSchema,
ticketPrioritySchema,
ticketStatusSchema,
} from "@/lib/schemas/ticket"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
const statusOptions = ticketStatusSchema.options.map((status) => ({
value: status,
label: {
NEW: "Novo",
OPEN: "Aberto",
PENDING: "Pendente",
ON_HOLD: "Em espera",
RESOLVED: "Resolvido",
CLOSED: "Fechado",
}[status],
}))
const priorityOptions = ticketPrioritySchema.options.map((priority) => ({
value: priority,
label: {
LOW: "Baixa",
MEDIUM: "Media",
HIGH: "Alta",
URGENT: "Urgente",
}[priority],
}))
const channelOptions = ticketChannelSchema.options.map((channel) => ({
value: channel,
label: {
EMAIL: "E-mail",
WHATSAPP: "WhatsApp",
CHAT: "Chat",
PHONE: "Telefone",
API: "API",
MANUAL: "Manual",
}[channel],
}))
const queues = Array.from(new Set(tickets.map((ticket) => ticket.queue).filter(Boolean)))
export type TicketFiltersState = {
search: string
status: string | null
priority: string | null
queue: string | null
channel: string | null
}
export const defaultTicketFilters: TicketFiltersState = {
search: "",
status: null,
priority: null,
queue: null,
channel: null,
}
"use client"
import { useMemo, useState } from "react"
import { IconFilter, IconRefresh } from "@tabler/icons-react"
import {
ticketChannelSchema,
ticketPrioritySchema,
ticketStatusSchema,
} from "@/lib/schemas/ticket"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
const statusOptions = ticketStatusSchema.options.map((status) => ({
value: status,
label: {
NEW: "Novo",
OPEN: "Aberto",
PENDING: "Pendente",
ON_HOLD: "Em espera",
RESOLVED: "Resolvido",
CLOSED: "Fechado",
}[status],
}))
const priorityOptions = ticketPrioritySchema.options.map((priority) => ({
value: priority,
label: {
LOW: "Baixa",
MEDIUM: "Media",
HIGH: "Alta",
URGENT: "Urgente",
}[priority],
}))
const channelOptions = ticketChannelSchema.options.map((channel) => ({
value: channel,
label: {
EMAIL: "E-mail",
WHATSAPP: "WhatsApp",
CHAT: "Chat",
PHONE: "Telefone",
API: "API",
MANUAL: "Manual",
}[channel],
}))
type QueueOption = string
export type TicketFiltersState = {
search: string
status: string | null
priority: string | null
queue: string | null
channel: string | null
}
export const defaultTicketFilters: TicketFiltersState = {
search: "",
status: null,
priority: null,
queue: null,
channel: null,
}
interface TicketsFiltersProps {
onChange?: (filters: TicketFiltersState) => void
queues?: QueueOption[]
}
const ALL_VALUE = "ALL"
export function TicketsFilters({ onChange }: TicketsFiltersProps) {
const [filters, setFilters] = useState<TicketFiltersState>(defaultTicketFilters)
function setPartial(partial: Partial<TicketFiltersState>) {
setFilters((prev) => {
const next = { ...prev, ...partial }
onChange?.(next)
return next
})
}
const activeFilters = useMemo(() => {
const chips: string[] = []
if (filters.status) chips.push(`Status: ${filters.status}`)
if (filters.priority) chips.push(`Prioridade: ${filters.priority}`)
if (filters.queue) chips.push(`Fila: ${filters.queue}`)
if (filters.channel) chips.push(`Canal: ${filters.channel}`)
return chips
}, [filters])
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="flex flex-1 flex-col gap-2 md:flex-row">
<Input
placeholder="Buscar por assunto ou #ID"
value={filters.search}
onChange={(event) => setPartial({ search: event.target.value })}
className="md:max-w-sm"
/>
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 activeFilters = useMemo(() => {
const chips: string[] = []
if (filters.status) chips.push(`Status: ${filters.status}`)
if (filters.priority) chips.push(`Prioridade: ${filters.priority}`)
if (filters.queue) chips.push(`Fila: ${filters.queue}`)
if (filters.channel) chips.push(`Canal: ${filters.channel}`)
return chips
}, [filters])
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="flex flex-1 flex-col gap-2 md:flex-row">
<Input
placeholder="Buscar por assunto ou #ID"
value={filters.search}
onChange={(event) => setPartial({ search: event.target.value })}
className="md:max-w-sm"
/>
<Select
value={filters.queue ?? ""}
onValueChange={(value) => setPartial({ queue: value || null })}
value={filters.queue ?? ALL_VALUE}
onValueChange={(value) => setPartial({ queue: value === ALL_VALUE ? null : value })}
>
<SelectTrigger className="md:w-[180px]">
<SelectValue placeholder="Fila" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Todas as filas</SelectItem>
<SelectItem value={ALL_VALUE}>Todas as filas</SelectItem>
{queues.map((queue) => (
<SelectItem key={queue!} value={queue!}>
{queue}
@ -127,29 +129,29 @@ export function TicketsFilters({ onChange }: TicketsFiltersProps) {
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<IconFilter className="size-4" />
Filtros
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 space-y-4">
<div className="space-y-2">
<p className="text-xs font-semibold uppercase text-muted-foreground">
Status
</p>
</div>
<div className="flex items-center gap-2">
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<IconFilter className="size-4" />
Filtros
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 space-y-4">
<div className="space-y-2">
<p className="text-xs font-semibold uppercase text-muted-foreground">
Status
</p>
<Select
value={filters.status ?? ""}
onValueChange={(value) => setPartial({ status: value || null })}
value={filters.status ?? ALL_VALUE}
onValueChange={(value) => setPartial({ status: value === ALL_VALUE ? null : value })}
>
<SelectTrigger>
<SelectValue placeholder="Todos" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Todos</SelectItem>
<SelectItem value={ALL_VALUE}>Todos</SelectItem>
{statusOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
@ -157,20 +159,20 @@ export function TicketsFilters({ onChange }: TicketsFiltersProps) {
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<p className="text-xs font-semibold uppercase text-muted-foreground">
Prioridade
</p>
</div>
<div className="space-y-2">
<p className="text-xs font-semibold uppercase text-muted-foreground">
Prioridade
</p>
<Select
value={filters.priority ?? ""}
onValueChange={(value) => setPartial({ priority: value || null })}
value={filters.priority ?? ALL_VALUE}
onValueChange={(value) => setPartial({ priority: value === ALL_VALUE ? null : value })}
>
<SelectTrigger>
<SelectValue placeholder="Todas" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Todas</SelectItem>
<SelectItem value={ALL_VALUE}>Todas</SelectItem>
{priorityOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
@ -178,20 +180,20 @@ export function TicketsFilters({ onChange }: TicketsFiltersProps) {
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<p className="text-xs font-semibold uppercase text-muted-foreground">
Canal
</p>
</div>
<div className="space-y-2">
<p className="text-xs font-semibold uppercase text-muted-foreground">
Canal
</p>
<Select
value={filters.channel ?? ""}
onValueChange={(value) => setPartial({ channel: value || null })}
value={filters.channel ?? ALL_VALUE}
onValueChange={(value) => setPartial({ channel: value === ALL_VALUE ? null : value })}
>
<SelectTrigger>
<SelectValue placeholder="Todos" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Todos</SelectItem>
<SelectItem value={ALL_VALUE}>Todos</SelectItem>
{channelOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
@ -199,29 +201,29 @@ export function TicketsFilters({ onChange }: TicketsFiltersProps) {
))}
</SelectContent>
</Select>
</div>
</PopoverContent>
</Popover>
<Button
variant="ghost"
size="sm"
className="gap-2"
onClick={() => setPartial(defaultTicketFilters)}
>
<IconRefresh className="size-4" />
Resetar
</Button>
</div>
</div>
{activeFilters.length > 0 && (
<div className="flex flex-wrap gap-2">
{activeFilters.map((chip) => (
<Badge key={chip} variant="secondary" className="rounded-full px-3 py-1 text-xs">
{chip}
</Badge>
))}
</div>
)}
</div>
)
}
</div>
</PopoverContent>
</Popover>
<Button
variant="ghost"
size="sm"
className="gap-2"
onClick={() => setPartial(defaultTicketFilters)}
>
<IconRefresh className="size-4" />
Resetar
</Button>
</div>
</div>
{activeFilters.length > 0 && (
<div className="flex flex-wrap gap-2">
{activeFilters.map((chip) => (
<Badge key={chip} variant="secondary" className="rounded-full px-3 py-1 text-xs">
{chip}
</Badge>
))}
</div>
)}
</div>
)
}

View file

@ -1,172 +1,172 @@
import Link from "next/link"
import { formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale"
import { tickets as ticketsMock } from "@/lib/mocks/tickets"
import type { Ticket } from "@/lib/schemas/ticket"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent } from "@/components/ui/card"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { TicketPriorityPill } from "@/components/tickets/priority-pill"
import { TicketStatusBadge } from "@/components/tickets/status-badge"
const channelLabel: Record<string, string> = {
EMAIL: "E-mail",
WHATSAPP: "WhatsApp",
CHAT: "Chat",
PHONE: "Telefone",
API: "API",
MANUAL: "Manual",
}
const cellClass = "py-4 align-top"
function AssigneeCell({ ticket }: { ticket: Ticket }) {
if (!ticket.assignee) {
return <span className="text-sm text-muted-foreground">Sem responsável</span>
}
const initials = ticket.assignee.name
.split(" ")
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join("")
return (
<div className="flex items-center gap-2">
<Avatar className="size-8">
<AvatarImage src={ticket.assignee.avatarUrl} alt={ticket.assignee.name} />
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<span className="text-sm font-medium leading-none text-foreground">
{ticket.assignee.name}
</span>
<span className="text-xs text-muted-foreground">
{ticket.assignee.teams?.[0] ?? "Agente"}
</span>
</div>
</div>
)
}
type TicketsTableProps = {
tickets?: Ticket[]
}
export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
return (
<Card className="border-none shadow-none">
<CardContent className="px-4 py-4 sm:px-6">
<Table className="min-w-full">
<TableHeader>
<TableRow className="text-xs uppercase text-muted-foreground">
<TableHead className="w-[110px]">Ticket</TableHead>
<TableHead>Assunto</TableHead>
<TableHead className="hidden lg:table-cell">Fila</TableHead>
<TableHead className="hidden md:table-cell">Canal</TableHead>
<TableHead className="hidden md:table-cell">Prioridade</TableHead>
<TableHead>Status</TableHead>
<TableHead className="hidden xl:table-cell">Responsável</TableHead>
<TableHead className="w-[140px]">Atualizado</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tickets.map((ticket) => (
<TableRow key={ticket.id} className="group">
<TableCell className={cellClass}>
<div className="flex flex-col gap-0.5">
<Link
href={`/tickets/${ticket.id}`}
className="font-semibold tracking-tight text-primary hover:underline"
>
#{ticket.reference}
</Link>
<span className="text-xs text-muted-foreground">
{ticket.queue ?? "Sem fila"}
</span>
</div>
</TableCell>
<TableCell className={cellClass}>
<div className="flex flex-col gap-1">
<Link
href={`/tickets/${ticket.id}`}
className="line-clamp-1 font-medium text-foreground hover:underline"
>
{ticket.subject}
</Link>
<span className="line-clamp-1 text-sm text-muted-foreground">
{ticket.summary ?? "Sem resumo"}
</span>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span>{ticket.requester.name}</span>
{ticket.tags?.map((tag) => (
<Badge
key={tag}
variant="outline"
className="rounded-full border-transparent bg-slate-100 px-2 py-1 text-xs text-slate-600"
>
{tag}
</Badge>
))}
</div>
</div>
</TableCell>
<TableCell className={`${cellClass} hidden lg:table-cell`}>
<Badge className="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-600">
{ticket.queue ?? "Sem fila"}
</Badge>
</TableCell>
<TableCell className={`${cellClass} hidden md:table-cell`}>
<Badge className="rounded-full bg-blue-50 px-2.5 py-1 text-xs font-medium text-blue-600">
{channelLabel[ticket.channel] ?? ticket.channel}
</Badge>
</TableCell>
<TableCell className={`${cellClass} hidden md:table-cell`}>
<TicketPriorityPill priority={ticket.priority} />
</TableCell>
<TableCell className={cellClass}>
<div className="flex flex-col gap-1">
<TicketStatusBadge status={ticket.status} />
{ticket.metrics?.timeWaitingMinutes ? (
<span className="text-xs text-muted-foreground">
Espera {ticket.metrics.timeWaitingMinutes} min
</span>
) : null}
</div>
</TableCell>
<TableCell className={`${cellClass} hidden xl:table-cell`}>
<AssigneeCell ticket={ticket} />
</TableCell>
<TableCell className={cellClass}>
<span className="text-sm text-muted-foreground">
{formatDistanceToNow(ticket.updatedAt, {
addSuffix: true,
locale: ptBR,
})}
</span>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{tickets.length === 0 && (
<div className="flex flex-col items-center justify-center gap-2 py-10 text-center">
<p className="text-sm font-medium">Nenhum ticket encontrado</p>
<p className="text-sm text-muted-foreground">
Ajuste os filtros ou selecione outra fila.
</p>
</div>
)}
</CardContent>
</Card>
)
}
import Link from "next/link"
import { formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale"
import { tickets as ticketsMock } from "@/lib/mocks/tickets"
import type { Ticket } from "@/lib/schemas/ticket"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent } from "@/components/ui/card"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { TicketPriorityPill } from "@/components/tickets/priority-pill"
import { TicketStatusBadge } from "@/components/tickets/status-badge"
const channelLabel: Record<string, string> = {
EMAIL: "E-mail",
WHATSAPP: "WhatsApp",
CHAT: "Chat",
PHONE: "Telefone",
API: "API",
MANUAL: "Manual",
}
const cellClass = "py-4 align-top"
function AssigneeCell({ ticket }: { ticket: Ticket }) {
if (!ticket.assignee) {
return <span className="text-sm text-muted-foreground">Sem responsável</span>
}
const initials = ticket.assignee.name
.split(" ")
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join("")
return (
<div className="flex items-center gap-2">
<Avatar className="size-8">
<AvatarImage src={ticket.assignee.avatarUrl} alt={ticket.assignee.name} />
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<span className="text-sm font-medium leading-none text-foreground">
{ticket.assignee.name}
</span>
<span className="text-xs text-muted-foreground">
{ticket.assignee.teams?.[0] ?? "Agente"}
</span>
</div>
</div>
)
}
type TicketsTableProps = {
tickets?: Ticket[]
}
export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
return (
<Card className="border-none shadow-none">
<CardContent className="px-4 py-4 sm:px-6">
<Table className="min-w-full">
<TableHeader>
<TableRow className="text-xs uppercase text-muted-foreground">
<TableHead className="w-[110px]">Ticket</TableHead>
<TableHead>Assunto</TableHead>
<TableHead className="hidden lg:table-cell">Fila</TableHead>
<TableHead className="hidden md:table-cell">Canal</TableHead>
<TableHead className="hidden md:table-cell">Prioridade</TableHead>
<TableHead>Status</TableHead>
<TableHead className="hidden xl:table-cell">Responsável</TableHead>
<TableHead className="w-[140px]">Atualizado</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tickets.map((ticket) => (
<TableRow key={ticket.id} className="group">
<TableCell className={cellClass}>
<div className="flex flex-col gap-0.5">
<Link
href={`/tickets/${ticket.id}`}
className="font-semibold tracking-tight text-primary hover:underline"
>
#{ticket.reference}
</Link>
<span className="text-xs text-muted-foreground">
{ticket.queue ?? "Sem fila"}
</span>
</div>
</TableCell>
<TableCell className={cellClass}>
<div className="flex flex-col gap-1">
<Link
href={`/tickets/${ticket.id}`}
className="line-clamp-1 font-medium text-foreground hover:underline"
>
{ticket.subject}
</Link>
<span className="line-clamp-1 text-sm text-muted-foreground">
{ticket.summary ?? "Sem resumo"}
</span>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span>{ticket.requester.name}</span>
{ticket.tags?.map((tag) => (
<Badge
key={tag}
variant="outline"
className="rounded-full border-transparent bg-slate-100 px-2 py-1 text-xs text-slate-600"
>
{tag}
</Badge>
))}
</div>
</div>
</TableCell>
<TableCell className={`${cellClass} hidden lg:table-cell`}>
<Badge className="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-600">
{ticket.queue ?? "Sem fila"}
</Badge>
</TableCell>
<TableCell className={`${cellClass} hidden md:table-cell`}>
<Badge className="rounded-full bg-blue-50 px-2.5 py-1 text-xs font-medium text-blue-600">
{channelLabel[ticket.channel] ?? ticket.channel}
</Badge>
</TableCell>
<TableCell className={`${cellClass} hidden md:table-cell`}>
<TicketPriorityPill priority={ticket.priority} />
</TableCell>
<TableCell className={cellClass}>
<div className="flex flex-col gap-1">
<TicketStatusBadge status={ticket.status} />
{ticket.metrics?.timeWaitingMinutes ? (
<span className="text-xs text-muted-foreground">
Espera {ticket.metrics.timeWaitingMinutes} min
</span>
) : null}
</div>
</TableCell>
<TableCell className={`${cellClass} hidden xl:table-cell`}>
<AssigneeCell ticket={ticket} />
</TableCell>
<TableCell className={cellClass}>
<span className="text-sm text-muted-foreground">
{formatDistanceToNow(ticket.updatedAt, {
addSuffix: true,
locale: ptBR,
})}
</span>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{tickets.length === 0 && (
<div className="flex flex-col items-center justify-center gap-2 py-10 text-center">
<p className="text-sm font-medium">Nenhum ticket encontrado</p>
<p className="text-sm text-muted-foreground">
Ajuste os filtros ou selecione outra fila.
</p>
</div>
)}
</CardContent>
</Card>
)
}

View file

@ -1,41 +1,40 @@
"use client"
import { useMemo, useState } from "react"
import { tickets } from "@/lib/mocks/tickets"
import { useQuery } from "convex/react"
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import { api } from "../../../convex/_generated/api"
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"
function applyFilters(base: typeof tickets, filters: TicketFiltersState) {
return base.filter((ticket) => {
if (filters.status && ticket.status !== filters.status) return false
if (filters.priority && ticket.priority !== filters.priority) return false
if (filters.queue && ticket.queue !== filters.queue) return false
if (filters.channel && ticket.channel !== filters.channel) return false
if (filters.search) {
const term = filters.search.toLowerCase()
const reference = `#${ticket.reference}`.toLowerCase()
if (
!ticket.subject.toLowerCase().includes(term) &&
!reference.includes(term)
) {
return false
}
}
return true
})
}
export function TicketsView() {
const [filters, setFilters] = useState<TicketFiltersState>(defaultTicketFilters)
const filteredTickets = useMemo(() => applyFilters(tickets, filters), [filters])
const queues = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) ?? []
const ticketsRaw = useQuery(api.tickets.list, {
tenantId: DEFAULT_TENANT_ID,
status: filters.status ?? undefined,
priority: filters.priority ?? undefined,
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 filteredTickets = useMemo(() => {
if (!filters.queue) return tickets
return tickets.filter((t: any) => t.queue === filters.queue)
}, [tickets, filters.queue])
return (
<div className="flex flex-col gap-6 px-4 lg:px-6">
<TicketsFilters onChange={setFilters} />
<TicketsTable tickets={filteredTickets} />
<TicketsFilters onChange={setFilters} queues={queues.map((q: any) => q.name)} />
<TicketsTable tickets={filteredTickets as any} />
</div>
)
}