style: refresh ticket ui components

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
esdrasrenan 2025-10-04 20:13:06 -03:00
parent 5c16ab75a6
commit 744d5933d4
16 changed files with 718 additions and 650 deletions

View file

@ -5,7 +5,6 @@ import { useState } from "react"
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"
@ -21,11 +20,15 @@ import { TicketPriorityPill } from "@/components/tickets/priority-pill"
import { TicketStatusBadge } from "@/components/tickets/status-badge"
import { Spinner } from "@/components/ui/spinner"
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"
interface PlayNextTicketCardProps {
context?: TicketPlayContext
}
interface PlayNextTicketCardProps {
context?: TicketPlayContext
}
const queueBadgeClass = "inline-flex items-center rounded-full border border-slate-200 bg-white px-2.5 py-1 text-xs font-semibold text-neutral-700"
const startButtonClass = "inline-flex items-center gap-2 rounded-lg border border-black bg-[#00e8ff] px-3 py-2 text-sm font-semibold text-black transition hover:bg-[#00d6eb]"
const secondaryButtonClass = "inline-flex items-center gap-2 rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm font-semibold text-neutral-700 hover:bg-slate-100"
export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) {
const router = useRouter()
const { userId } = useAuth()
@ -43,85 +46,110 @@ export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) {
})?.[0]
const nextTicketUi = nextTicketFromServer ? mapTicketFromServer(nextTicketFromServer as unknown) : null
const cardContext: TicketPlayContext | null = context ?? (nextTicketUi ? { queue: { id: "default", name: "Geral", pending: queueSummary.reduce((a, b) => a + b.pending, 0), waiting: queueSummary.reduce((a, b) => a + b.waiting, 0), breached: 0 }, nextTicket: nextTicketUi } : null)
const cardContext: TicketPlayContext | null =
context ??
(nextTicketUi
? {
queue: {
id: "default",
name: "Geral",
pending: queueSummary.reduce((acc, item) => acc + item.pending, 0),
waiting: queueSummary.reduce((acc, item) => acc + item.waiting, 0),
breached: 0,
},
nextTicket: nextTicketUi,
}
: null)
if (!cardContext || !cardContext.nextTicket) {
return (
<Card className="rounded-xl border bg-card shadow-sm">
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardHeader>
<CardTitle>Fila sem tickets pendentes</CardTitle>
<CardTitle className="text-lg font-semibold text-neutral-900">Fila sem tickets pendentes</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
Nenhum ticket disponivel no momento. Excelente trabalho!
<CardContent className="text-sm text-neutral-600">
Nenhum ticket disponível no momento. Excelente trabalho!
</CardContent>
</Card>
)
}
const ticket = cardContext.nextTicket
return (
<Card className="rounded-xl border bg-card shadow-sm">
<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">
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardHeader className="flex flex-row items-center justify-between gap-2">
<CardTitle className="text-lg font-semibold text-neutral-900">
Próximo ticket #{ticket.reference}
</CardTitle>
<TicketPriorityPill priority={ticket.priority} />
</CardHeader>
<CardContent className="flex flex-col gap-4 text-sm text-neutral-700">
<div className="flex items-center justify-end gap-2">
<span className="text-sm text-muted-foreground">Fila:</span>
<Select value={selectedQueueId ?? "ALL"} onValueChange={(v) => setSelectedQueueId(v === "ALL" ? undefined : v)}>
<SelectTrigger className="h-8 w-[180px]"><SelectValue placeholder="Todas" /></SelectTrigger>
<SelectContent>
<span className="text-xs uppercase tracking-wide text-neutral-500">Fila</span>
<Select value={selectedQueueId ?? "ALL"} onValueChange={(value) => setSelectedQueueId(value === "ALL" ? undefined : value)}>
<SelectTrigger className="h-8 w-[180px] rounded-lg border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]">
<SelectValue placeholder="Todas" />
</SelectTrigger>
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
<SelectItem value="ALL">Todas</SelectItem>
{queueSummary.map((q) => (
<SelectItem key={q.id} value={q.id}>{q.name}</SelectItem>
{queueSummary.map((queue) => (
<SelectItem key={queue.id} value={queue.id}>
{queue.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<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>
<h2 className="text-xl font-semibold text-neutral-900">{ticket.subject}</h2>
<p className="text-sm text-neutral-600">{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 flex-wrap gap-2 text-xs text-neutral-600">
<Badge className={queueBadgeClass}>{ticket.queue ?? "Sem fila"}</Badge>
<TicketStatusBadge status={ticket.status} />
<span className="font-medium text-neutral-900">Solicitante: {ticket.requester.name}</span>
</div>
<Separator className="bg-slate-200" />
<div className="flex flex-col gap-3 text-sm text-neutral-700">
<div className="flex items-center justify-between">
<span>Pendentes na fila</span>
<span className="font-medium text-foreground">{cardContext.queue.pending}</span>
<span className="font-semibold text-neutral-900">{cardContext.queue.pending}</span>
</div>
<div className="flex items-center justify-between">
<span>Em espera</span>
<span className="font-medium text-foreground">{cardContext.queue.waiting}</span>
<span className="font-semibold text-neutral-900">{cardContext.queue.waiting}</span>
</div>
<div className="flex items-center justify-between">
<span>SLA violado</span>
<span className="font-medium text-destructive">{cardContext.queue.breached}</span>
<span className="font-semibold text-red-600">{cardContext.queue.breached}</span>
</div>
</div>
<Button
className="gap-2"
className={startButtonClass}
onClick={async () => {
if (!userId) return
const chosen = await playNext({ tenantId: DEFAULT_TENANT_ID, queueId: (selectedQueueId as Id<"queues">) || undefined, agentId: userId as Id<"users"> })
if (chosen?.id) router.push(`/tickets/${chosen.id}`)
}}
>
{userId ? (<><IconPlayerPlayFilled className="size-4" /> Iniciar atendimento</>) : (<><Spinner className="me-2" /> Carregando</>)}
{userId ? (
<>
<IconPlayerPlayFilled className="size-4 text-black" /> Iniciar atendimento
</>
) : (
<>
<Spinner className="me-2" /> Carregando...
</>
)}
</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={secondaryButtonClass}>
<Link href="/tickets">
Ver lista completa
<IconArrowRight className="size-4" />
</Link>
</Button>
</CardContent>
</Card>
)
}

View file

@ -1,40 +1,21 @@
import { cn } from "@/lib/utils"
import { type TicketPriority } from "@/lib/schemas/ticket"
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
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]
const priorityStyles: Record<TicketPriority, { label: string; className: string }> = {
LOW: { label: "Baixa", className: "border border-slate-200 bg-slate-100 text-slate-700" },
MEDIUM: { label: "Média", className: "border border-slate-200 bg-[#dff1fb] text-[#0a4760]" },
HIGH: { label: "Alta", className: "border border-slate-200 bg-[#fde8d1] text-[#7d3b05]" },
URGENT: { label: "Urgente", className: "border border-slate-200 bg-[#fbd9dd] text-[#8b0f1c]" },
}
const baseClass = "inline-flex items-center rounded-full px-3 py-0.5 text-xs font-semibold"
export function TicketPriorityPill({ priority }: { priority: TicketPriority }) {
const styles = priorityStyles[priority]
return (
<Badge
variant="outline"
className={cn(
"rounded-full px-2.5 py-1 text-xs font-medium",
config?.className ?? ""
)}
>
{config?.label ?? priority}
<Badge className={cn(baseClass, styles?.className)}>
{styles?.label ?? priority}
</Badge>
)
}

View file

@ -11,72 +11,68 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Badge } from "@/components/ui/badge"
import { toast } from "sonner"
import { ArrowDown, ArrowRight, ArrowUp, ChevronsUp } from "lucide-react"
import { cn } from "@/lib/utils"
const labels: Record<TicketPriority, string> = {
LOW: "Baixa",
MEDIUM: "Média",
HIGH: "Alta",
URGENT: "Urgente",
const priorityStyles: Record<TicketPriority, { label: string; badgeClass: string }> = {
LOW: { label: "Baixa", badgeClass: "border border-slate-200 bg-slate-100 text-slate-700" },
MEDIUM: { label: "Média", badgeClass: "border border-slate-200 bg-[#dff1fb] text-[#0a4760]" },
HIGH: { label: "Alta", badgeClass: "border border-slate-200 bg-[#fde8d1] text-[#7d3b05]" },
URGENT: { label: "Urgente", badgeClass: "border border-slate-200 bg-[#fbd9dd] text-[#8b0f1c]" },
}
function badgeClass(p: TicketPriority) {
switch (p) {
case "URGENT":
return "bg-red-100 text-red-700"
case "HIGH":
return "bg-amber-100 text-amber-700"
case "MEDIUM":
return "bg-blue-100 text-blue-700"
default:
return "bg-slate-100 text-slate-700"
}
}
const triggerClass = "h-8 w-[160px] rounded-full border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]"
const itemClass = "flex items-center gap-2 rounded-md px-2 py-2 text-sm text-neutral-800 transition data-[state=checked]:bg-[#00e8ff]/15 data-[state=checked]:text-neutral-900 focus:bg-[#00e8ff]/10"
const iconClass = "size-4 text-neutral-700"
const baseBadgeClass = "inline-flex items-center gap-2 rounded-full px-3 py-0.5 text-xs font-semibold"
function PriorityIcon({ p }: { p: TicketPriority }) {
const cls = "size-3.5 text-cyan-600"
if (p === "LOW") return <ArrowDown className={cls} />
if (p === "MEDIUM") return <ArrowRight className={cls} />
if (p === "HIGH") return <ArrowUp className={cls} />
return <ChevronsUp className={cls} />
function PriorityIcon({ value }: { value: TicketPriority }) {
if (value === "LOW") return <ArrowDown className={iconClass} />
if (value === "MEDIUM") return <ArrowRight className={iconClass} />
if (value === "HIGH") return <ArrowUp className={iconClass} />
return <ChevronsUp className={iconClass} />
}
export function PrioritySelect({ ticketId, value }: { ticketId: string; value: TicketPriority }) {
const updatePriority = useMutation(api.tickets.updatePriority)
const [priority, setPriority] = useState<TicketPriority>(value)
const { userId } = useAuth()
return (
<Select
value={priority}
onValueChange={async (val) => {
const prev = priority
const next = val as TicketPriority
onValueChange={async (selected) => {
const previous = priority
const next = selected as TicketPriority
setPriority(next)
toast.loading("Atualizando prioridade...", { id: "prio" })
toast.loading("Atualizando prioridade...", { id: "priority" })
try {
if (!userId) throw new Error("No user")
if (!userId) throw new Error("missing user")
await updatePriority({ ticketId: ticketId as unknown as Id<"tickets">, priority: next, actorId: userId as Id<"users"> })
toast.success("Prioridade atualizada!", { id: "prio" })
toast.success("Prioridade atualizada!", { id: "priority" })
} catch {
setPriority(prev)
toast.error("Não foi possível atualizar a prioridade.", { id: "prio" })
setPriority(previous)
toast.error("Não foi possível atualizar a prioridade.", { id: "priority" })
}
}}
>
<SelectTrigger className="h-7 w-[140px] border-transparent bg-muted/50 px-2">
<SelectTrigger className={triggerClass}>
<SelectValue>
<Badge className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 ${badgeClass(priority)}`}>
<PriorityIcon p={priority} /> {labels[priority]}
<Badge className={cn(baseBadgeClass, priorityStyles[priority]?.badgeClass)}>
<PriorityIcon value={priority} />
{priorityStyles[priority]?.label ?? priority}
</Badge>
</SelectValue>
</SelectTrigger>
<SelectContent>
{(["LOW","MEDIUM","HIGH","URGENT"] as const).map((p) => (
<SelectItem key={p} value={p}>
<span className="inline-flex items-center gap-2"><PriorityIcon p={p} />{labels[p]}</span>
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
{(["LOW", "MEDIUM", "HIGH", "URGENT"] as const).map((option) => (
<SelectItem key={option} value={option} className={itemClass}>
<span className="inline-flex items-center gap-2">
<PriorityIcon value={option} />
{priorityStyles[option].label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
)
}

View file

@ -6,19 +6,17 @@ import { api } from "@/convex/_generated/api";
import { DEFAULT_TENANT_ID } from "@/lib/constants";
import { mapTicketsFromServerList } from "@/lib/mappers/ticket";
import { TicketsTable } from "@/components/tickets/tickets-table";
import { Spinner } from "@/components/ui/spinner";
import type { Ticket } from "@/lib/schemas/ticket";
export function RecentTicketsPanel() {
const ticketsRaw = useQuery(api.tickets.list, { tenantId: DEFAULT_TENANT_ID, limit: 10 });
if (ticketsRaw === undefined) {
return (
<div className="rounded-xl border bg-card p-4">
<div className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
<div className="grid gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center justify-between gap-3">
<div className="h-4 w-56 animate-pulse rounded bg-muted" />
<div className="h-4 w-20 animate-pulse rounded bg-muted" />
<div className="h-4 w-56 animate-pulse rounded bg-slate-100" />
<div className="h-4 w-20 animate-pulse rounded bg-slate-100" />
</div>
))}
</div>
@ -27,7 +25,7 @@ export function RecentTicketsPanel() {
}
const tickets = mapTicketsFromServerList((ticketsRaw ?? []) as unknown[]);
return (
<div className="rounded-xl border bg-card">
<div className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<TicketsTable tickets={tickets} />
</div>
);

View file

@ -1,27 +1,26 @@
"use client"
import { ticketStatusSchema, type TicketStatus } 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<TicketStatus, { label: string; className: string }>
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
const statusStyles: Record<TicketStatus, { label: string; className: string }> = {
NEW: { label: "Novo", className: "border border-slate-200 bg-slate-100 text-slate-700" },
OPEN: { label: "Aberto", className: "border border-slate-200 bg-[#dff1fb] text-[#0a4760]" },
PENDING: { label: "Pendente", className: "border border-slate-200 bg-[#fdebd6] text-[#7b4107]" },
ON_HOLD: { label: "Em espera", className: "border border-slate-200 bg-[#ede8ff] text-[#4f2f96]" },
RESOLVED: { label: "Resolvido", className: "border border-slate-200 bg-[#dcf4eb] text-[#1f6a45]" },
CLOSED: { label: "Fechado", className: "border border-slate-200 bg-slate-200 text-slate-700" },
}
type TicketStatusBadgeProps = { status: TicketStatus }
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}
export function TicketStatusBadge({ status }: TicketStatusBadgeProps) {
const parsed = ticketStatusSchema.parse(status)
const styles = statusStyles[parsed]
return (
<Badge className={cn('inline-flex items-center rounded-full px-3 py-0.5 text-xs font-semibold', styles?.className)}>
{styles?.label ?? parsed}
</Badge>
)
}
)
}

View file

@ -10,63 +10,56 @@ import { useAuth } from "@/lib/auth-client"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Badge } from "@/components/ui/badge"
import { toast } from "sonner"
import { cn } from "@/lib/utils"
const labels: Record<TicketStatus, string> = {
NEW: "Novo",
OPEN: "Aberto",
PENDING: "Pendente",
ON_HOLD: "Em espera",
RESOLVED: "Resolvido",
CLOSED: "Fechado",
const statusStyles: Record<TicketStatus, { label: string; badgeClass: string }> = {
NEW: { label: "Novo", badgeClass: "border border-slate-200 bg-slate-100 text-slate-700" },
OPEN: { label: "Aberto", badgeClass: "border border-slate-200 bg-[#dff1fb] text-[#0a4760]" },
PENDING: { label: "Pendente", badgeClass: "border border-slate-200 bg-[#fdebd6] text-[#7b4107]" },
ON_HOLD: { label: "Em espera", badgeClass: "border border-slate-200 bg-[#ede8ff] text-[#4f2f96]" },
RESOLVED: { label: "Resolvido", badgeClass: "border border-slate-200 bg-[#dcf4eb] text-[#1f6a45]" },
CLOSED: { label: "Fechado", badgeClass: "border border-slate-200 bg-slate-200 text-slate-700" },
}
function badgeClass(s: TicketStatus) {
switch (s) {
case "OPEN":
return "bg-blue-100 text-blue-700"
case "PENDING":
return "bg-amber-100 text-amber-700"
case "ON_HOLD":
return "bg-purple-100 text-purple-700"
case "RESOLVED":
return "bg-emerald-100 text-emerald-700"
case "CLOSED":
return "bg-slate-100 text-slate-700"
default:
return "bg-slate-100 text-slate-700"
}
}
const triggerClass = "h-8 w-[180px] rounded-full border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]"
const itemClass = "rounded-md px-2 py-2 text-sm text-neutral-800 transition hover:bg-slate-100 data-[state=checked]:bg-[#00e8ff]/15 data-[state=checked]:text-neutral-900 focus:bg-[#00e8ff]/10"
const baseBadgeClass = "inline-flex items-center rounded-full px-3 py-0.5 text-xs font-semibold"
export function StatusSelect({ ticketId, value }: { ticketId: string; value: TicketStatus }) {
const updateStatus = useMutation(api.tickets.updateStatus)
const [status, setStatus] = useState<TicketStatus>(value)
const { userId } = useAuth()
return (
<Select
value={status}
onValueChange={async (val) => {
const prev = status
const next = val as TicketStatus
onValueChange={async (selected) => {
const previous = status
const next = selected as TicketStatus
setStatus(next)
toast.loading("Atualizando status...", { id: "status" })
try {
if (!userId) throw new Error("No user")
if (!userId) throw new Error("missing user")
await updateStatus({ ticketId: ticketId as unknown as Id<"tickets">, status: next, actorId: userId as Id<"users"> })
toast.success(`Status alterado para ${labels[next] ?? next}.`, { id: "status" })
toast.success("Status alterado para " + (statusStyles[next]?.label ?? next) + ".", { id: "status" })
} catch {
setStatus(prev)
setStatus(previous)
toast.error("Não foi possível atualizar o status.", { id: "status" })
}
}}
>
<SelectTrigger className="h-7 w-[160px] border-transparent bg-muted/50 px-2">
<SelectTrigger className={triggerClass}>
<SelectValue>
<Badge className={`rounded-full px-2 py-0.5 ${badgeClass(status)}`}>{labels[status]}</Badge>
<Badge className={cn(baseBadgeClass, statusStyles[status]?.badgeClass)}>
{statusStyles[status]?.label ?? status}
</Badge>
</SelectValue>
</SelectTrigger>
<SelectContent>
{(["NEW","OPEN","PENDING","ON_HOLD","RESOLVED","CLOSED"] as const).map((s) => (
<SelectItem key={s} value={s}>{labels[s as TicketStatus]}</SelectItem>
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
{(["NEW", "OPEN", "PENDING", "ON_HOLD", "RESOLVED", "CLOSED"] as const).map((option) => (
<SelectItem key={option} value={option} className={itemClass}>
{statusStyles[option].label}
</SelectItem>
))}
</SelectContent>
</Select>

View file

@ -5,8 +5,7 @@ import { formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale"
import { IconLock, IconMessage } from "@tabler/icons-react"
import { Download, FileIcon } from "lucide-react"
import { useAction, useMutation } from "convex/react"
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
import { useMutation } from "convex/react"
// @ts-ignore
import { api } from "@/convex/_generated/api"
import { useAuth } from "@/lib/auth-client"
@ -27,59 +26,74 @@ interface TicketCommentsProps {
ticket: TicketWithDetails
}
const badgeInternal = "gap-1 rounded-full border border-slate-300 bg-neutral-900 px-2 py-0.5 text-xs font-semibold uppercase tracking-wide text-white"
const selectTriggerClass = "h-8 w-[140px] rounded-lg border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]"
const submitButtonClass = "inline-flex items-center gap-2 rounded-lg border border-black bg-[#00e8ff] px-3 py-2 text-sm font-semibold text-black transition hover:bg-[#00d6eb]"
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 [attachmentsToSend, setAttachmentsToSend] = useState<Array<{ storageId: string; name: string; size?: number; type?: string; previewUrl?: string }>>([])
const [preview, setPreview] = useState<string | null>(null)
const [pending, setPending] = useState<Pick<TicketWithDetails["comments"][number], "id"|"author"|"visibility"|"body"|"attachments"|"createdAt"|"updatedAt">[]>([])
const [pending, setPending] = useState<Pick<TicketWithDetails["comments"][number], "id" | "author" | "visibility" | "body" | "attachments" | "createdAt" | "updatedAt">[]>([])
const [visibility, setVisibility] = useState<"PUBLIC" | "INTERNAL">("PUBLIC")
const commentsAll = useMemo(() => {
return [...pending, ...ticket.comments]
}, [pending, ticket.comments])
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
if (!userId) return
const attachments = attachmentsToSend
const now = new Date()
const attachments = attachmentsToSend
const optimistic = {
id: `temp-${now.getTime()}`,
author: ticket.requester,
visibility,
body: sanitizeEditorHtml(body),
attachments: attachments.map((a) => ({ id: a.storageId, name: a.name, url: a.previewUrl })),
attachments: attachments.map((attachment) => ({
id: attachment.storageId,
name: attachment.name,
url: attachment.previewUrl,
})),
createdAt: now,
updatedAt: now,
}
setPending((p) => [optimistic, ...p])
setPending((current) => [optimistic, ...current])
setBody("")
setAttachmentsToSend([])
toast.loading("Enviando comentário...", { id: "comment" })
try {
const typedAttachments = attachments.map((a) => ({
storageId: a.storageId as unknown as Id<"_storage">,
name: a.name,
size: a.size,
type: a.type,
const payload = attachments.map((attachment) => ({
storageId: attachment.storageId as unknown as Id<"_storage">,
name: attachment.name,
size: attachment.size,
type: attachment.type,
}))
await addComment({ ticketId: ticket.id as Id<"tickets">, authorId: userId as Id<"users">, visibility, body: optimistic.body, attachments: typedAttachments })
await addComment({
ticketId: ticket.id as Id<"tickets">,
authorId: userId as Id<"users">,
visibility,
body: optimistic.body,
attachments: payload,
})
setPending([])
toast.success("Comentário enviado!", { id: "comment" })
} catch (err) {
} catch {
setPending([])
toast.error("Falha ao enviar comentário.", { id: "comment" })
}
}
return (
<Card className="rounded-xl border bg-card shadow-sm">
<CardHeader className="px-4">
<CardTitle className="flex items-center gap-2 text-lg font-semibold">
<IconMessage className="size-5" /> Conversa
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardHeader className="px-4 pb-3">
<CardTitle className="flex items-center gap-2 text-lg font-semibold text-neutral-900">
<IconMessage className="size-5 text-neutral-900" /> Conversa
</CardTitle>
</CardHeader>
<CardContent className="space-y-6 px-4 pb-6">
@ -87,10 +101,10 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<IconMessage className="size-5" />
<IconMessage className="size-5 text-neutral-900" />
</EmptyMedia>
<EmptyTitle>Nenhum comentário ainda</EmptyTitle>
<EmptyDescription>Registre o próximo passo abaixo.</EmptyDescription>
<EmptyTitle className="text-neutral-900">Nenhum comentário ainda</EmptyTitle>
<EmptyDescription className="text-neutral-600">Registre o próximo passo abaixo.</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
@ -100,51 +114,57 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join("")
return (
<div key={comment.id} className="flex gap-3">
<Avatar className="size-9">
<Avatar className="size-9 border border-slate-200">
<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>
<span className="font-semibold text-neutral-900">{comment.author.name}</span>
{comment.visibility === "INTERNAL" ? (
<Badge variant="outline" className="gap-1">
<IconLock className="size-3" /> Interno
<Badge className={badgeInternal}>
<IconLock className="size-3 text-[#00e8ff]" /> Interno
</Badge>
) : null}
<span className="text-xs text-muted-foreground">
<span className="text-xs text-neutral-500">
{formatDistanceToNow(comment.createdAt, { addSuffix: true, locale: ptBR })}
</span>
</div>
<div className="break-words rounded-lg border border-dashed bg-card px-3 py-2 text-sm leading-relaxed text-muted-foreground">
<div className="break-words rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm leading-relaxed text-neutral-700">
<RichTextContent html={comment.body} />
</div>
{comment.attachments?.length ? (
<div className="grid max-w-xl grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-3">
{comment.attachments.map((att) => {
const isImg = (att?.url ?? "").match(/\.(png|jpe?g|gif|webp|svg)$/i)
if (isImg && att.url) {
{comment.attachments.map((attachment) => {
const isImage = (attachment?.url ?? "").match(/\.(png|jpe?g|gif|webp|svg)$/i)
if (isImage && attachment.url) {
return (
<button
key={att.id}
key={attachment.id}
type="button"
onClick={() => setPreview(att.url || null)}
className="group overflow-hidden rounded-lg border bg-card p-0.5 hover:shadow"
onClick={() => setPreview(attachment.url || null)}
className="group overflow-hidden rounded-lg border border-slate-200 bg-white p-0.5 hover:border-slate-400"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={att.url} alt={att.name} className="h-24 w-24 rounded-md object-cover" />
<div className="mt-1 line-clamp-1 w-24 text-ellipsis text-center text-[11px] text-muted-foreground">
{att.name}
<img src={attachment.url} alt={attachment.name} className="h-24 w-24 rounded-md object-cover" />
<div className="mt-1 line-clamp-1 w-24 text-ellipsis text-center text-[11px] text-neutral-500">
{attachment.name}
</div>
</button>
)
}
return (
<a key={att.id} href={att.url} download={att.name} target="_blank" className="flex items-center gap-2 rounded-md border px-2 py-1 text-xs hover:bg-muted">
<FileIcon className="size-3.5" /> {att.name}
{att.url ? <Download className="size-3.5" /> : null}
<a
key={attachment.id}
href={attachment.url}
download={attachment.name}
target="_blank"
className="flex items-center gap-2 rounded-md border border-slate-200 px-2 py-1 text-xs text-neutral-800 hover:border-slate-400"
>
<FileIcon className="size-3.5 text-neutral-700" /> {attachment.name}
{attachment.url ? <Download className="size-3.5 text-neutral-700" /> : null}
</a>
)
})}
@ -159,25 +179,26 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
<RichTextEditor value={body} onChange={setBody} placeholder="Escreva um comentário..." />
<Dropzone onUploaded={(files) => setAttachmentsToSend((prev) => [...prev, ...files])} />
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<div className="flex items-center gap-2 text-xs text-neutral-600">
Visibilidade:
<Select value={visibility} onValueChange={(v) => setVisibility(v as "PUBLIC" | "INTERNAL")}>
<SelectTrigger className="h-8 w-[140px]"><SelectValue placeholder="Visibilidade" /></SelectTrigger>
<SelectContent>
<Select value={visibility} onValueChange={(value) => setVisibility(value as "PUBLIC" | "INTERNAL")}>
<SelectTrigger className={selectTriggerClass}>
<SelectValue placeholder="Visibilidade" />
</SelectTrigger>
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
<SelectItem value="PUBLIC">Pública</SelectItem>
<SelectItem value="INTERNAL">Interna</SelectItem>
</SelectContent>
</Select>
</div>
<Button type="submit" size="sm">Enviar</Button>
<Button type="submit" size="sm" className={submitButtonClass}>
Enviar
</Button>
</div>
</form>
<Dialog open={!!preview} onOpenChange={(o) => !o && setPreview(null)}>
<Dialog open={!!preview} onOpenChange={(open) => !open && setPreview(null)}>
<DialogContent className="max-w-3xl p-0">
{preview ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={preview} alt="Preview" className="h-auto w-full rounded-xl" />
) : null}
{preview ? <img src={preview} alt="Preview" className="h-auto w-full rounded-xl" /> : null}
</DialogContent>
</Dialog>
</CardContent>

View file

@ -10,7 +10,6 @@ import type { Id } from "@/convex/_generated/dataModel";
import type { TicketWithDetails } from "@/lib/schemas/ticket";
import { getTicketById } from "@/lib/mocks/tickets";
import { Card, CardContent } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { TicketComments } from "@/components/tickets/ticket-comments.rich";
import { TicketDetailsPanel } from "@/components/tickets/ticket-details-panel";
@ -28,7 +27,7 @@ export function TicketDetailView({ id }: { id: string }) {
}
if (!ticket) return (
<div className="flex flex-col gap-6 px-4 lg:px-6">
<Card className="rounded-xl border bg-card shadow-sm">
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardContent className="space-y-3 p-6">
<div className="flex items-center gap-2"><Skeleton className="h-5 w-24" /><Skeleton className="h-5 w-20" /></div>
<Skeleton className="h-7 w-2/3" />
@ -36,7 +35,7 @@ export function TicketDetailView({ id }: { id: string }) {
</CardContent>
</Card>
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
<Card className="rounded-xl border bg-card shadow-sm">
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardContent className="space-y-4 p-6">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="space-y-2">
@ -46,7 +45,7 @@ export function TicketDetailView({ id }: { id: string }) {
))}
</CardContent>
</Card>
<Card className="rounded-xl border bg-card shadow-sm">
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardContent className="space-y-3 p-6">
{Array.from({ length: 5 }).map((_, i) => (<Skeleton key={i} className="h-3 w-full" />))}
</CardContent>

View file

@ -1,85 +1,85 @@
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
}
const queueBadgeClass = "inline-flex items-center rounded-full border border-slate-200 bg-white px-2.5 py-1 text-xs font-semibold text-neutral-700"
const tagBadgeClass = "inline-flex items-center gap-1 rounded-full border border-slate-200 bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-neutral-700"
const iconAccentClass = "size-3 text-neutral-700"
export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
return (
<Card className="rounded-xl border bg-card shadow-sm">
<CardHeader className="px-4">
<CardTitle className="text-lg font-semibold">Detalhes</CardTitle>
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardHeader className="px-4 pb-3">
<CardTitle className="text-lg font-semibold text-neutral-900">Detalhes</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-5 px-4 pb-6 text-sm text-muted-foreground">
<CardContent className="flex flex-col gap-5 px-4 pb-6 text-sm text-neutral-700">
<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>
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Fila</p>
<Badge className={queueBadgeClass}>{ticket.queue ?? "Sem fila"}</Badge>
</div>
<Separator />
<Separator className="bg-slate-200" />
<div className="space-y-2 break-words">
<p className="text-xs font-semibold uppercase tracking-wide">SLA</p>
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">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 text-sm font-medium leading-tight">{ticket.slaPolicy.name}</span>
<div className="flex flex-col gap-1 text-xs text-muted-foreground">
<div className="flex flex-col gap-2 rounded-xl border border-slate-200 bg-white px-3 py-2 shadow-sm">
<span className="text-sm font-semibold text-neutral-900">{ticket.slaPolicy.name}</span>
<div className="flex flex-col gap-1 text-xs text-neutral-600">
{ticket.slaPolicy.targetMinutesToFirstResponse ? (
<span className="leading-normal">
Resposta inicial: {ticket.slaPolicy.targetMinutesToFirstResponse} min
</span>
<span>Resposta inicial: {ticket.slaPolicy.targetMinutesToFirstResponse} min</span>
) : null}
{ticket.slaPolicy.targetMinutesToResolution ? (
<span className="leading-normal">
Resolução: {ticket.slaPolicy.targetMinutesToResolution} min
</span>
<span>Resolução: {ticket.slaPolicy.targetMinutesToResolution} min</span>
) : null}
</div>
</div>
) : (
<span>Sem política atribuída.</span>
<span className="text-neutral-600">Sem política atribuída.</span>
)}
</div>
<Separator />
<Separator className="bg-slate-200" />
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wide">Métricas</p>
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Métricas</p>
{ticket.metrics ? (
<div className="flex flex-col gap-2 text-xs text-muted-foreground">
<div className="flex flex-col gap-2 text-xs text-neutral-700">
<span className="flex items-center gap-2">
<IconClockHour4 className="size-3" /> Tempo aguardando: {ticket.metrics.timeWaitingMinutes ?? "-"} min
<IconClockHour4 className={iconAccentClass} /> Tempo aguardando: {ticket.metrics.timeWaitingMinutes ?? "-"} min
</span>
<span className="flex items-center gap-2">
<IconAlertTriangle className="size-3" /> Tempo aberto: {ticket.metrics.timeOpenedMinutes ?? "-"} min
<IconAlertTriangle className={iconAccentClass} /> Tempo aberto: {ticket.metrics.timeOpenedMinutes ?? "-"} min
</span>
</div>
) : (
<span>Sem dados de SLA ainda.</span>
<span className="text-neutral-600">Sem dados de SLA ainda.</span>
)}
</div>
<Separator />
<Separator className="bg-slate-200" />
<div className="space-y-2 break-words">
<p className="text-xs font-semibold uppercase tracking-wide">Tags</p>
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">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 key={tag} className={tagBadgeClass}>
<IconTags className={iconAccentClass} /> {tag}
</Badge>
))
) : (
<span>Sem tags.</span>
<span className="text-neutral-600">Sem tags.</span>
)}
</div>
</div>
<Separator />
<Separator className="bg-slate-200" />
<div className="space-y-1">
<p className="text-xs font-semibold uppercase tracking-wide">Histórico</p>
<div className="flex flex-col gap-1 text-xs text-muted-foreground">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Histórico</p>
<div className="flex flex-col gap-1 text-xs text-neutral-600">
<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 ? (
@ -90,4 +90,4 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
</CardContent>
</Card>
)
}
}

View file

@ -1,67 +1,68 @@
"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[]
}
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 TicketQueueSummary[] | undefined) ?? [])
if (!queues && fromServer === undefined) {
return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="rounded-xl border bg-card p-4">
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
<div className="mt-4 h-3 w-full animate-pulse rounded bg-muted" />
{Array.from({ length: 3 }).map((_, index) => (
<div key={index} className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
<div className="h-4 w-24 animate-pulse rounded bg-slate-100" />
<div className="mt-4 h-3 w-full animate-pulse rounded bg-slate-100" />
</div>
))}
</div>
)
}
return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{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="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
<CardHeader className="pb-2">
<CardDescription className="text-xs uppercase tracking-wide text-neutral-500">Fila</CardDescription>
<CardTitle className="text-lg font-semibold text-neutral-900">{queue.name}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm text-neutral-600">
<div className="flex justify-between">
<span>Pendentes</span>
<span className="font-semibold text-neutral-900">{queue.pending}</span>
</div>
<div className="flex justify-between">
<span>Aguardando resposta</span>
<span className="font-semibold text-neutral-900">{queue.waiting}</span>
</div>
<div className="flex items-center justify-between">
<span>Violados</span>
<span className="font-semibold text-red-600">{queue.breached}</span>
</div>
<div className="pt-1.5">
<Progress value={breachPercent} className="h-1.5 bg-slate-100" indicatorClassName="bg-[#00e8ff]" />
<span className="mt-2 block text-xs text-neutral-500">
{breachPercent}% com SLA violado nesta fila
</span>
</div>
</CardContent>
</Card>
)
})}
</div>
)
}

View file

@ -26,6 +26,16 @@ interface TicketHeaderProps {
ticket: TicketWithDetails
}
const cardClass = "space-y-4 rounded-2xl border border-slate-200 bg-white p-6 shadow-sm"
const referenceBadgeClass = "inline-flex items-center gap-1 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-semibold text-neutral-700"
const startButtonClass = "inline-flex items-center gap-1 rounded-lg border border-black bg-[#00e8ff] px-3 py-1.5 text-sm font-semibold text-black transition hover:bg-[#00d6eb]"
const pauseButtonClass = "inline-flex items-center gap-1 rounded-lg border border-black bg-black px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-black/90"
const editButtonClass = "inline-flex items-center gap-1 rounded-lg border border-black bg-black px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-black/90"
const selectTriggerClass = "h-8 w-[220px] rounded-lg border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]"
const smallSelectTriggerClass = "h-8 w-[180px] rounded-lg border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]"
const sectionLabelClass = "text-xs font-semibold uppercase tracking-wide text-neutral-500"
const sectionValueClass = "font-medium text-neutral-900"
export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const { userId } = useAuth()
const changeAssignee = useMutation(api.tickets.changeAssignee)
@ -40,7 +50,10 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const [editing, setEditing] = useState(false)
const [subject, setSubject] = useState(ticket.subject)
const [summary, setSummary] = useState(ticket.summary ?? "")
const dirty = useMemo(() => subject !== ticket.subject || (summary ?? "") !== (ticket.summary ?? ""), [subject, summary, ticket.subject, ticket.summary])
const dirty = useMemo(
() => subject !== ticket.subject || (summary ?? "") !== (ticket.summary ?? ""),
[subject, summary, ticket.subject, ticket.summary]
)
async function handleSave() {
if (!userId) return
@ -69,19 +82,16 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const isPlaying = lastWork?.type === "WORK_STARTED"
return (
<div className="space-y-4 rounded-xl border bg-card p-6 shadow-sm">
<div className={cardClass}>
<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>
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-3">
<Badge className={referenceBadgeClass}>#{ticket.reference}</Badge>
<PrioritySelect ticketId={ticket.id} value={ticket.priority} />
<StatusSelect ticketId={ticket.id} value={status} />
<Button
size="sm"
variant={isPlaying ? "default" : "outline"}
className={isPlaying ? "bg-black text-white border-black" : "border-black text-black"}
className={isPlaying ? pauseButtonClass : startButtonClass}
onClick={async () => {
if (!userId) return
const next = await toggleWork({ ticketId: ticket.id as Id<"tickets">, actorId: userId as Id<"users"> })
@ -89,49 +99,65 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
else toast.success("Atendimento pausado", { id: "work" })
}}
>
{isPlaying ? (<><IconPlayerPause className="mr-1 size-4" /> Pausar</>) : (<><IconPlayerPlay className="mr-1 size-4" /> Iniciar</>)}
{isPlaying ? (
<>
<IconPlayerPause className="size-4 text-white" /> Pausar
</>
) : (
<>
<IconPlayerPlay className="size-4 text-black" /> Iniciar
</>
)}
</Button>
</div>
{editing ? (
<div className="space-y-2">
<Input value={subject} onChange={(e) => setSubject(e.target.value)} className="h-9 text-base font-semibold" />
<Input
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="h-10 rounded-lg border border-slate-300 text-lg font-semibold text-neutral-900"
/>
<textarea
value={summary}
onChange={(e) => setSummary(e.target.value)}
rows={3}
className="w-full rounded-md border bg-background p-2 text-sm"
className="w-full rounded-lg border border-slate-300 bg-white p-3 text-sm text-neutral-800 shadow-sm"
placeholder="Adicione um resumo opcional"
/>
</div>
) : (
<>
<h1 className="break-words text-2xl font-semibold text-foreground">{subject}</h1>
{summary ? (
<p className="max-w-2xl text-sm text-muted-foreground">{summary}</p>
) : null}
</>
<div className="space-y-1">
<h1 className="break-words text-2xl font-semibold text-neutral-900">{subject}</h1>
{summary ? <p className="max-w-2xl text-sm text-neutral-600">{summary}</p> : null}
</div>
)}
<div className="ms-auto flex items-center gap-2">
<div className="flex items-center gap-2">
{editing ? (
<>
<Button variant="ghost" size="sm" onClick={handleCancel}>Cancelar</Button>
<Button size="sm" onClick={handleSave} disabled={!dirty}>Salvar</Button>
<Button variant="ghost" size="sm" className="text-sm font-semibold text-neutral-700" onClick={handleCancel}>
Cancelar
</Button>
<Button size="sm" className={startButtonClass} onClick={handleSave} disabled={!dirty}>
Salvar
</Button>
</>
) : (
<Button variant="outline" size="sm" onClick={() => setEditing(true)}>Editar</Button>
<Button size="sm" className={editButtonClass} onClick={() => setEditing(true)}>
Editar
</Button>
)}
<DeleteTicketDialog ticketId={ticket.id as Id<"tickets">} />
</div>
</div>
</div>
<Separator />
<div className="grid gap-4 text-sm text-muted-foreground sm:grid-cols-2 lg:grid-cols-3">
<Separator className="bg-slate-200" />
<div className="grid gap-4 text-sm text-neutral-600 sm:grid-cols-2 lg:grid-cols-3">
<div className="flex flex-col gap-1">
<span className="text-xs">Solicitante</span>
<span className="font-medium text-foreground">{ticket.requester.name}</span>
<span className={sectionLabelClass}>Solicitante</span>
<span className={sectionValueClass}>{ticket.requester.name}</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-xs">Responsável</span>
<span className={sectionLabelClass}>Responsável</span>
<Select
value={ticket.assignee?.id ?? ""}
onValueChange={async (value) => {
@ -145,57 +171,65 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
}
}}
>
<SelectTrigger className="h-8 w-[220px] border-black bg-white"><SelectValue placeholder="Selecionar" /></SelectTrigger>
<SelectContent>
{agents.map((a) => (
<SelectItem key={a._id} value={a._id}>{a.name}</SelectItem>
<SelectTrigger className={selectTriggerClass}>
<SelectValue placeholder="Selecionar" />
</SelectTrigger>
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
{agents.map((agent) => (
<SelectItem key={agent._id} value={agent._id}>
{agent.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1">
<span className="text-xs">Fila</span>
<span className={sectionLabelClass}>Fila</span>
<Select
value={ticket.queue ?? ""}
onValueChange={async (value) => {
if (!userId) return
const q = queues.find((qq) => qq.name === value)
if (!q) return
const queue = queues.find((item) => item.name === value)
if (!queue) return
toast.loading("Atualizando fila...", { id: "queue" })
try {
await changeQueue({ ticketId: ticket.id as Id<"tickets">, queueId: q.id as Id<"queues">, actorId: userId as Id<"users"> })
await changeQueue({ ticketId: ticket.id as Id<"tickets">, queueId: queue.id as Id<"queues">, actorId: userId as Id<"users"> })
toast.success("Fila atualizada!", { id: "queue" })
} catch {
toast.error("Não foi possível atualizar a fila.", { id: "queue" })
}
}}
>
<SelectTrigger className="h-8 w-[180px] border-black bg-white"><SelectValue placeholder="Selecionar" /></SelectTrigger>
<SelectContent>
{queues.map((q) => (
<SelectItem key={q.id} value={q.name}>{q.name}</SelectItem>
<SelectTrigger className={smallSelectTriggerClass}>
<SelectValue placeholder="Selecionar" />
</SelectTrigger>
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
{queues.map((queue) => (
<SelectItem key={queue.id} value={queue.name}>
{queue.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1">
<span className="text-xs">Atualizado em</span>
<span className="font-medium text-foreground">{format(ticket.updatedAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
<span className={sectionLabelClass}>Atualizado em</span>
<span className={sectionValueClass}>{format(ticket.updatedAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-xs">Criado em</span>
<span className="font-medium text-foreground">{format(ticket.createdAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
<span className={sectionLabelClass}>Criado em</span>
<span className={sectionValueClass}>{format(ticket.createdAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
</div>
{ticket.dueAt ? (
<div className="flex flex-col gap-1">
<span className="text-xs">SLA até</span>
<span className="font-medium text-foreground">{format(ticket.dueAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
<span className={sectionLabelClass}>SLA até</span>
<span className={sectionValueClass}>{format(ticket.dueAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
</div>
) : null}
{ticket.slaPolicy ? (
<div className="flex flex-col gap-1">
<span className="text-xs">Política</span>
<span className="font-medium text-foreground">{ticket.slaPolicy.name}</span>
<span className={sectionLabelClass}>Política</span>
<span className={sectionValueClass}>{ticket.slaPolicy.name}</span>
</div>
) : null}
</div>

View file

@ -1,19 +1,18 @@
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 {
IconClockHour4,
IconNote,
IconSquareCheck,
IconUserCircle,
} from "@tabler/icons-react"
import type { TicketWithDetails } from "@/lib/schemas/ticket"
import { Card, CardContent } from "@/components/ui/card"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Separator } from "@/components/ui/separator"
import { Separator } from "@/components/ui/separator"
const timelineIcons: Record<string, ComponentType<{ className?: string }>> = {
CREATED: IconUserCircle,
STATUS_CHANGED: IconSquareCheck,
@ -23,6 +22,7 @@ const timelineIcons: Record<string, ComponentType<{ className?: string }>> = {
WORK_PAUSED: IconClockHour4,
SUBJECT_CHANGED: IconNote,
SUMMARY_CHANGED: IconNote,
QUEUE_CHANGED: IconSquareCheck,
}
const timelineLabels: Record<string, string> = {
@ -36,70 +36,96 @@ const timelineLabels: Record<string, string> = {
SUMMARY_CHANGED: "Resumo atualizado",
QUEUE_CHANGED: "Fila alterada",
}
interface TicketTimelineProps {
ticket: TicketWithDetails
}
interface TicketTimelineProps {
ticket: TicketWithDetails
}
export function TicketTimeline({ ticket }: TicketTimelineProps) {
return (
<Card className="rounded-xl border bg-card shadow-sm">
<CardContent className="space-y-6 px-4 pb-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">
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardContent className="space-y-5 px-4 pb-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-11">
{!isLast && (
<span className="absolute left-[14px] top-6 h-full w-px bg-slate-200" aria-hidden />
)}
<span className="absolute left-0 top-0 flex size-8 items-center justify-center rounded-full border border-slate-200 bg-white text-neutral-700 shadow-sm">
<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">
<span className="text-sm font-semibold text-neutral-900">
{timelineLabels[entry.type] ?? entry.type}
</span>
{entry.payload?.actorName ? (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Avatar className="size-5">
<span className="flex items-center gap-1 text-xs text-neutral-500">
<Avatar className="size-5 border border-slate-200">
<AvatarImage src={entry.payload?.actorAvatar as string | undefined} alt={String(entry.payload?.actorName ?? "")} />
<AvatarFallback>
{String(entry.payload?.actorName ?? '').split(' ').slice(0,2).map((p:string)=>p[0]).join('').toUpperCase()}
{String(entry.payload?.actorName ?? "").split(" ").slice(0, 2).map((part: string) => part[0]).join("").toUpperCase()}
</AvatarFallback>
</Avatar>
por {String(entry.payload?.actorName ?? '')}
por {String(entry.payload?.actorName ?? "")}
</span>
) : null}
<span className="text-xs text-muted-foreground">
<span className="text-xs text-neutral-500">
{format(entry.createdAt, "dd MMM yyyy HH:mm", { locale: ptBR })}
</span>
</div>
{(() => {
const p = (entry.payload || {}) as { toLabel?: string; to?: string; assigneeName?: string; assigneeId?: string; queueName?: string; queueId?: string; requesterName?: string; authorName?: string; authorId?: string; from?: string }
const payload = (entry.payload || {}) as {
toLabel?: string
to?: string
assigneeName?: string
assigneeId?: string
queueName?: string
queueId?: string
requesterName?: string
authorName?: string
authorId?: string
from?: string
}
let message: string | null = null
if (entry.type === "STATUS_CHANGED" && (p.toLabel || p.to)) message = `Status alterado para ${p.toLabel || p.to}`
if (entry.type === "ASSIGNEE_CHANGED" && (p.assigneeName || p.assigneeId)) message = `Responsável alterado${p.assigneeName ? ` para ${p.assigneeName}` : ""}`
if (entry.type === "QUEUE_CHANGED" && (p.queueName || p.queueId)) message = `Fila alterada${p.queueName ? ` para ${p.queueName}` : ""}`
if (entry.type === "CREATED" && (p.requesterName)) message = `Criado por ${p.requesterName}`
if (entry.type === "COMMENT_ADDED" && (p.authorName || p.authorId)) message = `Comentário adicionado${p.authorName ? ` por ${p.authorName}` : ""}`
if (entry.type === "SUBJECT_CHANGED" && (p.to || p.toLabel)) message = `Assunto alterado${p.to ? ` para “${p.to}` : ""}`
if (entry.type === "SUMMARY_CHANGED") message = `Resumo atualizado`
if (entry.type === "STATUS_CHANGED" && (payload.toLabel || payload.to)) {
message = "Status alterado para " + (payload.toLabel || payload.to)
}
if (entry.type === "ASSIGNEE_CHANGED" && (payload.assigneeName || payload.assigneeId)) {
message = "Responsável alterado" + (payload.assigneeName ? " para " + payload.assigneeName : "")
}
if (entry.type === "QUEUE_CHANGED" && (payload.queueName || payload.queueId)) {
message = "Fila alterada" + (payload.queueName ? " para " + payload.queueName : "")
}
if (entry.type === "CREATED" && payload.requesterName) {
message = "Criado por " + payload.requesterName
}
if (entry.type === "COMMENT_ADDED" && (payload.authorName || payload.authorId)) {
message = "Comentário adicionado" + (payload.authorName ? " por " + payload.authorName : "")
}
if (entry.type === "SUBJECT_CHANGED" && (payload.to || payload.toLabel)) {
message = "Assunto alterado" + (payload.to ? " para \"" + payload.to + "\"" : "")
}
if (entry.type === "SUMMARY_CHANGED") {
message = "Resumo atualizado"
}
if (!message) return null
return (
<div className="rounded-lg border border-dashed bg-card px-3 py-2 text-sm text-muted-foreground">
<div className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-neutral-600">
{message}
</div>
)
})()}
</div>
</div>
)
})}
</CardContent>
</Card>
)
</div>
</div>
)
})}
<Separator className="bg-slate-200" />
</CardContent>
</Card>
)
}

View file

@ -1,86 +1,86 @@
"use client"
"use client"
import { useEffect, 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],
}))
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: "Média",
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,
}
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, queues = [] }: TicketsFiltersProps) {
@ -90,30 +90,30 @@ export function TicketsFilters({ onChange, queues = [] }: TicketsFiltersProps) {
setFilters((prev) => ({ ...prev, ...partial }))
}
// Propaga as mudanças de filtros para o pai sem disparar durante render
// Propaga as mudancas de filtros para o componente pai sem disparar durante a renderizacao
useEffect(() => {
onChange?.(filters)
}, [filters, onChange])
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"
/>
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 ?? ALL_VALUE}
onValueChange={(value) => setPartial({ queue: value === ALL_VALUE ? null : value })}
@ -130,20 +130,24 @@ export function TicketsFilters({ onChange, queues = [] }: 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 rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-neutral-800 shadow-sm hover:bg-slate-50"
>
<IconFilter className="size-4 text-neutral-800" />
Filtros
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 space-y-4">
<div className="space-y-2">
<p className="text-xs font-semibold uppercase text-neutral-500">
Status
</p>
<Select
value={filters.status ?? ALL_VALUE}
onValueChange={(value) => setPartial({ status: value === ALL_VALUE ? null : value })}
@ -160,11 +164,11 @@ export function TicketsFilters({ onChange, queues = [] }: 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-neutral-500">
Prioridade
</p>
<Select
value={filters.priority ?? ALL_VALUE}
onValueChange={(value) => setPartial({ priority: value === ALL_VALUE ? null : value })}
@ -181,11 +185,11 @@ export function TicketsFilters({ onChange, queues = [] }: 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-neutral-500">
Canal
</p>
<Select
value={filters.channel ?? ALL_VALUE}
onValueChange={(value) => setPartial({ channel: value === ALL_VALUE ? null : value })}
@ -202,29 +206,29 @@ export function TicketsFilters({ onChange, queues = [] }: 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 rounded-lg px-3 py-2 text-sm font-medium text-neutral-700 hover:bg-slate-100"
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} className="rounded-full border border-slate-300 bg-slate-100 px-3 py-1 text-xs font-medium text-neutral-700">
{chip}
</Badge>
))}
</div>
)}
</div>
)
}

View file

@ -30,10 +30,13 @@ const channelLabel: Record<string, string> = {
}
const cellClass = "py-4 align-top"
const queueBadgeClass = "inline-flex items-center rounded-full border border-slate-200 bg-white px-2.5 py-1 text-xs font-semibold text-neutral-700"
const channelBadgeClass = "inline-flex items-center gap-2 rounded-full border border-[#00c4d7]/40 bg-[#00e8ff]/15 px-2.5 py-1 text-xs font-semibold text-neutral-900"
const tagBadgeClass = "inline-flex items-center rounded-full border border-slate-200 bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-neutral-700"
function AssigneeCell({ ticket }: { ticket: Ticket }) {
if (!ticket.assignee) {
return <span className="text-sm text-muted-foreground">Sem responsável</span>
return <span className="text-sm text-neutral-600">Sem responsável</span>
}
const initials = ticket.assignee.name
@ -44,15 +47,15 @@ function AssigneeCell({ ticket }: { ticket: Ticket }) {
return (
<div className="flex items-center gap-2">
<Avatar className="size-8">
<Avatar className="size-8 border border-slate-200">
<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">
<span className="text-sm font-semibold leading-none text-neutral-900">
{ticket.assignee.name}
</span>
<span className="text-xs text-muted-foreground">
<span className="text-xs text-neutral-600">
{ticket.assignee.teams?.[0] ?? "Agente"}
</span>
</div>
@ -60,17 +63,17 @@ function AssigneeCell({ ticket }: { ticket: Ticket }) {
)
}
type TicketsTableProps = {
export type TicketsTableProps = {
tickets?: Ticket[]
}
export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
return (
<Card className="border bg-card/90 shadow-sm">
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardContent className="px-4 py-4 sm:px-6">
<Table className="min-w-full">
<TableHeader>
<TableRow className="text-xs uppercase tracking-wide text-muted-foreground">
<TableRow className="text-[11px] uppercase tracking-wide text-neutral-500">
<TableHead className="w-[110px]">Ticket</TableHead>
<TableHead>Assunto</TableHead>
<TableHead className="hidden lg:table-cell">Fila</TableHead>
@ -83,16 +86,16 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
</TableHeader>
<TableBody>
{tickets.map((ticket) => (
<TableRow key={ticket.id} className="group hover:bg-muted/40">
<TableRow key={ticket.id} className="group border-b border-slate-100 transition hover:bg-[#00e8ff]/8">
<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"
className="font-semibold tracking-tight text-neutral-900 hover:text-[#00b8ce]"
>
#{ticket.reference}
</Link>
<span className="text-xs text-muted-foreground">
<span className="text-xs text-neutral-500">
{ticket.queue ?? "Sem fila"}
</span>
</div>
@ -101,21 +104,17 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
<div className="flex flex-col gap-1">
<Link
href={`/tickets/${ticket.id}`}
className="line-clamp-1 font-medium text-foreground hover:underline"
className="line-clamp-1 font-semibold text-neutral-900 hover:text-[#00b8ce]"
>
{ticket.subject}
</Link>
<span className="line-clamp-1 text-sm text-muted-foreground">
<span className="line-clamp-1 text-sm text-neutral-600">
{ticket.summary ?? "Sem resumo"}
</span>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span>{ticket.requester.name}</span>
<div className="flex flex-wrap gap-2 text-xs text-neutral-500">
<span className="font-medium text-neutral-900">{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"
>
<Badge key={tag} className={tagBadgeClass}>
{tag}
</Badge>
))}
@ -123,12 +122,11 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
</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>
<Badge className={queueBadgeClass}>{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">
<Badge className={channelBadgeClass}>
<span className="inline-block size-2 rounded-full border border-[#009bb1] bg-[#00e8ff]" />
{channelLabel[ticket.channel] ?? ticket.channel}
</Badge>
</TableCell>
@ -139,8 +137,8 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
<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 className="text-xs text-neutral-500">
Em espera {ticket.metrics.timeWaitingMinutes} min
</span>
) : null}
</div>
@ -149,11 +147,8 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
<AssigneeCell ticket={ticket} />
</TableCell>
<TableCell className={cellClass}>
<span className="text-sm text-muted-foreground">
{formatDistanceToNow(ticket.updatedAt, {
addSuffix: true,
locale: ptBR,
})}
<span className="text-sm text-neutral-500">
{formatDistanceToNow(ticket.updatedAt, { addSuffix: true, locale: ptBR })}
</span>
</TableCell>
</TableRow>
@ -164,10 +159,10 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
<Empty className="my-6">
<EmptyHeader>
<EmptyMedia variant="icon">
<span className="inline-block size-3 rounded-full bg-muted-foreground/40" />
<span className="inline-block size-3 rounded-full border border-slate-300 bg-[#00e8ff]" />
</EmptyMedia>
<EmptyTitle>Nenhum ticket encontrado</EmptyTitle>
<EmptyDescription>
<EmptyTitle className="text-neutral-900">Nenhum ticket encontrado</EmptyTitle>
<EmptyDescription className="text-neutral-600">
Ajuste os filtros ou crie um novo ticket.
</EmptyDescription>
</EmptyHeader>
@ -179,11 +174,4 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
</CardContent>
</Card>
)
}
}

View file

@ -10,7 +10,6 @@ import { mapTicketsFromServerList } from "@/lib/mappers/ticket"
import type { Ticket, TicketQueueSummary } from "@/lib/schemas/ticket"
import { TicketsFilters, TicketFiltersState, defaultTicketFilters } from "@/components/tickets/tickets-filters"
import { TicketsTable } from "@/components/tickets/tickets-table"
import { Spinner } from "@/components/ui/spinner"
export function TicketsView() {
const [filters, setFilters] = useState<TicketFiltersState>(defaultTicketFilters)
@ -36,12 +35,12 @@ export function TicketsView() {
<div className="flex flex-col gap-6 px-4 lg:px-6">
<TicketsFilters onChange={setFilters} queues={(queues ?? []).map((q) => q.name)} />
{ticketsRaw === undefined ? (
<div className="rounded-xl border bg-card p-4">
<div className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
<div className="grid gap-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="flex items-center justify-between gap-3">
<div className="h-4 w-48 animate-pulse rounded bg-muted" />
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
<div className="h-4 w-48 animate-pulse rounded bg-slate-100" />
<div className="h-4 w-24 animate-pulse rounded bg-slate-100" />
</div>
))}
</div>
@ -52,4 +51,4 @@ export function TicketsView() {
</div>
)
}