feat: scaffold tickets experience
This commit is contained in:
commit
2230590e57
79 changed files with 16055 additions and 0 deletions
81
web/src/components/tickets/play-next-ticket-card.tsx
Normal file
81
web/src/components/tickets/play-next-ticket-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
40
web/src/components/tickets/priority-pill.tsx
Normal file
40
web/src/components/tickets/priority-pill.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
29
web/src/components/tickets/status-badge.tsx
Normal file
29
web/src/components/tickets/status-badge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
63
web/src/components/tickets/ticket-comments.tsx
Normal file
63
web/src/components/tickets/ticket-comments.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
93
web/src/components/tickets/ticket-details-panel.tsx
Normal file
93
web/src/components/tickets/ticket-details-panel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
47
web/src/components/tickets/ticket-queue-summary.tsx
Normal file
47
web/src/components/tickets/ticket-queue-summary.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
78
web/src/components/tickets/ticket-summary-header.tsx
Normal file
78
web/src/components/tickets/ticket-summary-header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
65
web/src/components/tickets/ticket-timeline.tsx
Normal file
65
web/src/components/tickets/ticket-timeline.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
227
web/src/components/tickets/tickets-filters.tsx
Normal file
227
web/src/components/tickets/tickets-filters.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
172
web/src/components/tickets/tickets-table.tsx
Normal file
172
web/src/components/tickets/tickets-table.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
41
web/src/components/tickets/tickets-view.tsx
Normal file
41
web/src/components/tickets/tickets-view.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue