feat: scaffold tickets experience

This commit is contained in:
esdrasrenan 2025-09-18 23:30:50 -03:00
commit 2230590e57
79 changed files with 16055 additions and 0 deletions

View file

@ -0,0 +1,81 @@
import Link from "next/link"
import { IconArrowRight, IconPlayerPlayFilled } from "@tabler/icons-react"
import { playContext } from "@/lib/mocks/tickets"
import type { TicketPlayContext } from "@/lib/schemas/ticket"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
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 = playContext }: PlayNextTicketCardProps) {
if (!context.nextTicket) {
return (
<Card className="border-dashed">
<CardHeader>
<CardTitle>Fila sem tickets pendentes</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
Nenhum ticket disponivel no momento. Excelente trabalho!
</CardContent>
</Card>
)
}
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 />
<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>
</div>
<div className="flex items-center justify-between">
<span>Em espera</span>
<span className="font-medium text-foreground">{context.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>
</div>
</div>
<Button asChild className="gap-2">
<Link href={`/tickets/${ticket.id}`}>
Iniciar atendimento
<IconPlayerPlayFilled className="size-4" />
</Link>
</Button>
<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

@ -0,0 +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>
)
}

View file

@ -0,0 +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>
)
}

View file

@ -0,0 +1,63 @@
import { formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale"
import { IconLock, IconMessage } from "@tabler/icons-react"
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
}
export function TicketComments({ ticket }: TicketCommentsProps) {
return (
<Card className="border-none shadow-none">
<CardHeader className="px-0">
<CardTitle className="flex items-center gap-2 text-lg font-semibold">
<IconMessage className="size-5" /> Conversa
</CardTitle>
</CardHeader>
<CardContent className="space-y-6 px-0">
{ticket.comments.length === 0 ? (
<p className="text-sm text-muted-foreground">
Ainda sem comentarios. Que tal registrar o proximo passo?
</p>
) : (
ticket.comments.map((comment) => {
const initials = comment.author.name
.split(" ")
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join("")
return (
<div key={comment.id} className="flex gap-3">
<Avatar className="size-9">
<AvatarImage src={comment.author.avatarUrl} alt={comment.author.name} />
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="font-medium text-foreground">{comment.author.name}</span>
{comment.visibility === "INTERNAL" ? (
<Badge variant="outline" className="gap-1">
<IconLock className="size-3" /> Interno
</Badge>
) : null}
<span className="text-xs text-muted-foreground">
{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">
{comment.body}
</div>
</div>
</div>
)
})
)}
</CardContent>
</Card>
)
}

View file

@ -0,0 +1,93 @@
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>
</div>
<Separator />
<div className="space-y-2">
<p className="text-xs font-semibold uppercase">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>
<div className="flex flex-col gap-1 text-xs text-muted-foreground">
{ticket.slaPolicy.targetMinutesToFirstResponse ? (
<span>
Resposta inicial: {ticket.slaPolicy.targetMinutesToFirstResponse} min
</span>
) : null}
{ticket.slaPolicy.targetMinutesToResolution ? (
<span>
Resolucao: {ticket.slaPolicy.targetMinutesToResolution} min
</span>
) : null}
</div>
</div>
) : (
<span>Sem politica atribuida.</span>
)}
</div>
<Separator />
<div className="space-y-2">
<p className="text-xs font-semibold uppercase">Metricas</p>
{ticket.metrics ? (
<div className="flex flex-col gap-2 text-xs text-muted-foreground">
<span className="flex items-center gap-2">
<IconClockHour4 className="size-3" /> Tempo aguardando: {ticket.metrics.timeWaitingMinutes ?? "-"} min
</span>
<span className="flex items-center gap-2">
<IconAlertTriangle className="size-3" /> Tempo aberto: {ticket.metrics.timeOpenedMinutes ?? "-"} min
</span>
</div>
) : (
<span>Sem dados de SLA ainda.</span>
)}
</div>
<Separator />
<div className="space-y-2">
<p className="text-xs font-semibold uppercase">Tags</p>
<div className="flex flex-wrap gap-2">
{ticket.tags?.length ? (
ticket.tags.map((tag) => (
<Badge key={tag} variant="outline" className="gap-1">
<IconTags className="size-3" /> {tag}
</Badge>
))
) : (
<span>Sem tags.</span>
)}
</div>
</div>
<Separator />
<div className="space-y-1">
<p className="text-xs font-semibold uppercase">Historico</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>
{ticket.resolvedAt ? (
<span>Resolvido: {format(ticket.resolvedAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
) : null}
</div>
</div>
</CardContent>
</Card>
)
}

View file

@ -0,0 +1,47 @@
import { queueSummaries } from "@/lib/mocks/tickets"
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) {
return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{queues.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>
)
}

View file

@ -0,0 +1,78 @@
import { format } from "date-fns"
import { ptBR } from "date-fns/locale"
import { IconClock, IconUserCircle } from "@tabler/icons-react"
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
}
export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
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">
<div className="space-y-2">
<div className="flex items-center gap-3">
<Badge variant="outline" className="rounded-full px-3 py-1 text-xs uppercase tracking-wide">
#{ticket.reference}
</Badge>
<TicketPriorityPill priority={ticket.priority} />
<TicketStatusBadge status={ticket.status} />
</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>
)
}

View file

@ -0,0 +1,65 @@
import { format } from "date-fns"\nimport 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"
const timelineIcons: Record<string, ComponentType<{ className?: string }>> = {
CREATED: IconUserCircle,
STATUS_CHANGED: IconSquareCheck,
ASSIGNEE_CHANGED: IconUserCircle,
COMMENT_ADDED: IconNote,
}
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("_", " ")}
</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>
)
}

View file

@ -0,0 +1,227 @@
"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,
}
interface TicketsFiltersProps {
onChange?: (filters: TicketFiltersState) => void
}
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"
/>
<Select
value={filters.queue ?? ""}
onValueChange={(value) => setPartial({ queue: value || null })}
>
<SelectTrigger className="md:w-[180px]">
<SelectValue placeholder="Fila" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Todas as filas</SelectItem>
{queues.map((queue) => (
<SelectItem key={queue!} value={queue!}>
{queue}
</SelectItem>
))}
</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>
<Select
value={filters.status ?? ""}
onValueChange={(value) => setPartial({ status: value || null })}
>
<SelectTrigger>
<SelectValue placeholder="Todos" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Todos</SelectItem>
{statusOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</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 })}
>
<SelectTrigger>
<SelectValue placeholder="Todas" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Todas</SelectItem>
{priorityOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</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 })}
>
<SelectTrigger>
<SelectValue placeholder="Todos" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Todos</SelectItem>
{channelOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</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>
)
}

View file

@ -0,0 +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>
)
}

View file

@ -0,0 +1,41 @@
"use client"
import { useMemo, useState } from "react"
import { tickets } from "@/lib/mocks/tickets"
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])
return (
<div className="flex flex-col gap-6 px-4 lg:px-6">
<TicketsFilters onChange={setFilters} />
<TicketsTable tickets={filteredTickets} />
</div>
)
}