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 { useRouter } from "next/navigation"
import { IconArrowRight, IconPlayerPlayFilled } from "@tabler/icons-react" import { IconArrowRight, IconPlayerPlayFilled } from "@tabler/icons-react"
import { useMutation, useQuery } from "convex/react" import { useMutation, useQuery } from "convex/react"
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
import { api } from "@/convex/_generated/api" import { api } from "@/convex/_generated/api"
import { DEFAULT_TENANT_ID } from "@/lib/constants" import { DEFAULT_TENANT_ID } from "@/lib/constants"
@ -26,6 +25,10 @@ interface PlayNextTicketCardProps {
context?: TicketPlayContext 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) { export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) {
const router = useRouter() const router = useRouter()
const { userId } = useAuth() const { userId } = useAuth()
@ -43,16 +46,29 @@ export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) {
})?.[0] })?.[0]
const nextTicketUi = nextTicketFromServer ? mapTicketFromServer(nextTicketFromServer as unknown) : null 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) { if (!cardContext || !cardContext.nextTicket) {
return ( return (
<Card className="rounded-xl border bg-card shadow-sm"> <Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardHeader> <CardHeader>
<CardTitle>Fila sem tickets pendentes</CardTitle> <CardTitle className="text-lg font-semibold text-neutral-900">Fila sem tickets pendentes</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="text-sm text-muted-foreground"> <CardContent className="text-sm text-neutral-600">
Nenhum ticket disponivel no momento. Excelente trabalho! Nenhum ticket disponível no momento. Excelente trabalho!
</CardContent> </CardContent>
</Card> </Card>
) )
@ -61,61 +77,73 @@ export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) {
const ticket = cardContext.nextTicket const ticket = cardContext.nextTicket
return ( return (
<Card className="rounded-xl border bg-card shadow-sm"> <Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardHeader className="flex flex-row items-center justify-between gap-2"> <CardHeader className="flex flex-row items-center justify-between gap-2">
<CardTitle className="text-lg font-semibold"> <CardTitle className="text-lg font-semibold text-neutral-900">
Proximo ticket #{ticket.reference} Próximo ticket #{ticket.reference}
</CardTitle> </CardTitle>
<TicketPriorityPill priority={ticket.priority} /> <TicketPriorityPill priority={ticket.priority} />
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-4"> <CardContent className="flex flex-col gap-4 text-sm text-neutral-700">
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
<span className="text-sm text-muted-foreground">Fila:</span> <span className="text-xs uppercase tracking-wide text-neutral-500">Fila</span>
<Select value={selectedQueueId ?? "ALL"} onValueChange={(v) => setSelectedQueueId(v === "ALL" ? undefined : v)}> <Select value={selectedQueueId ?? "ALL"} onValueChange={(value) => setSelectedQueueId(value === "ALL" ? undefined : value)}>
<SelectTrigger className="h-8 w-[180px]"><SelectValue placeholder="Todas" /></SelectTrigger> <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]">
<SelectContent> <SelectValue placeholder="Todas" />
</SelectTrigger>
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
<SelectItem value="ALL">Todas</SelectItem> <SelectItem value="ALL">Todas</SelectItem>
{queueSummary.map((q) => ( {queueSummary.map((queue) => (
<SelectItem key={q.id} value={q.id}>{q.name}</SelectItem> <SelectItem key={queue.id} value={queue.id}>
{queue.name}
</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<h2 className="text-xl font-medium text-foreground">{ticket.subject}</h2> <h2 className="text-xl font-semibold text-neutral-900">{ticket.subject}</h2>
<p className="text-sm text-muted-foreground">{ticket.summary}</p> <p className="text-sm text-neutral-600">{ticket.summary}</p>
</div> </div>
<div className="flex flex-wrap gap-2 text-sm text-muted-foreground"> <div className="flex flex-wrap gap-2 text-xs text-neutral-600">
<Badge variant="outline">{ticket.queue ?? "Sem fila"}</Badge> <Badge className={queueBadgeClass}>{ticket.queue ?? "Sem fila"}</Badge>
<TicketStatusBadge status={ticket.status} /> <TicketStatusBadge status={ticket.status} />
<span>Solicitante: {ticket.requester.name}</span> <span className="font-medium text-neutral-900">Solicitante: {ticket.requester.name}</span>
</div> </div>
<Separator /> <Separator className="bg-slate-200" />
<div className="flex flex-col gap-3 text-sm text-muted-foreground"> <div className="flex flex-col gap-3 text-sm text-neutral-700">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span>Pendentes na fila</span> <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>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span>Em espera</span> <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>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span>SLA violado</span> <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>
</div> </div>
<Button <Button
className="gap-2" className={startButtonClass}
onClick={async () => { onClick={async () => {
if (!userId) return if (!userId) return
const chosen = await playNext({ tenantId: DEFAULT_TENANT_ID, queueId: (selectedQueueId as Id<"queues">) || undefined, agentId: userId as Id<"users"> }) 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}`) 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>
<Button variant="ghost" asChild className="gap-2 text-sm"> <Button variant="ghost" asChild className={secondaryButtonClass}>
<Link href="/tickets"> <Link href="/tickets">
Ver lista completa Ver lista completa
<IconArrowRight className="size-4" /> <IconArrowRight className="size-4" />

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

View file

@ -11,72 +11,68 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { toast } from "sonner" import { toast } from "sonner"
import { ArrowDown, ArrowRight, ArrowUp, ChevronsUp } from "lucide-react" import { ArrowDown, ArrowRight, ArrowUp, ChevronsUp } from "lucide-react"
import { cn } from "@/lib/utils"
const labels: Record<TicketPriority, string> = { const priorityStyles: Record<TicketPriority, { label: string; badgeClass: string }> = {
LOW: "Baixa", LOW: { label: "Baixa", badgeClass: "border border-slate-200 bg-slate-100 text-slate-700" },
MEDIUM: "Média", MEDIUM: { label: "Média", badgeClass: "border border-slate-200 bg-[#dff1fb] text-[#0a4760]" },
HIGH: "Alta", HIGH: { label: "Alta", badgeClass: "border border-slate-200 bg-[#fde8d1] text-[#7d3b05]" },
URGENT: "Urgente", URGENT: { label: "Urgente", badgeClass: "border border-slate-200 bg-[#fbd9dd] text-[#8b0f1c]" },
} }
function badgeClass(p: TicketPriority) { 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]"
switch (p) { 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"
case "URGENT": const iconClass = "size-4 text-neutral-700"
return "bg-red-100 text-red-700" const baseBadgeClass = "inline-flex items-center gap-2 rounded-full px-3 py-0.5 text-xs font-semibold"
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"
}
}
function PriorityIcon({ p }: { p: TicketPriority }) { function PriorityIcon({ value }: { value: TicketPriority }) {
const cls = "size-3.5 text-cyan-600" if (value === "LOW") return <ArrowDown className={iconClass} />
if (p === "LOW") return <ArrowDown className={cls} /> if (value === "MEDIUM") return <ArrowRight className={iconClass} />
if (p === "MEDIUM") return <ArrowRight className={cls} /> if (value === "HIGH") return <ArrowUp className={iconClass} />
if (p === "HIGH") return <ArrowUp className={cls} /> return <ChevronsUp className={iconClass} />
return <ChevronsUp className={cls} />
} }
export function PrioritySelect({ ticketId, value }: { ticketId: string; value: TicketPriority }) { export function PrioritySelect({ ticketId, value }: { ticketId: string; value: TicketPriority }) {
const updatePriority = useMutation(api.tickets.updatePriority) const updatePriority = useMutation(api.tickets.updatePriority)
const [priority, setPriority] = useState<TicketPriority>(value) const [priority, setPriority] = useState<TicketPriority>(value)
const { userId } = useAuth() const { userId } = useAuth()
return ( return (
<Select <Select
value={priority} value={priority}
onValueChange={async (val) => { onValueChange={async (selected) => {
const prev = priority const previous = priority
const next = val as TicketPriority const next = selected as TicketPriority
setPriority(next) setPriority(next)
toast.loading("Atualizando prioridade...", { id: "prio" }) toast.loading("Atualizando prioridade...", { id: "priority" })
try { 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"> }) 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 { } catch {
setPriority(prev) setPriority(previous)
toast.error("Não foi possível atualizar a prioridade.", { id: "prio" }) 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> <SelectValue>
<Badge className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 ${badgeClass(priority)}`}> <Badge className={cn(baseBadgeClass, priorityStyles[priority]?.badgeClass)}>
<PriorityIcon p={priority} /> {labels[priority]} <PriorityIcon value={priority} />
{priorityStyles[priority]?.label ?? priority}
</Badge> </Badge>
</SelectValue> </SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
{(["LOW","MEDIUM","HIGH","URGENT"] as const).map((p) => ( {(["LOW", "MEDIUM", "HIGH", "URGENT"] as const).map((option) => (
<SelectItem key={p} value={p}> <SelectItem key={option} value={option} className={itemClass}>
<span className="inline-flex items-center gap-2"><PriorityIcon p={p} />{labels[p]}</span> <span className="inline-flex items-center gap-2">
<PriorityIcon value={option} />
{priorityStyles[option].label}
</span>
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
) )
} }

View file

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

View file

@ -2,26 +2,25 @@
import { ticketStatusSchema, type TicketStatus } from "@/lib/schemas/ticket" import { ticketStatusSchema, type TicketStatus } from "@/lib/schemas/ticket"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
const statusConfig = { const statusStyles: Record<TicketStatus, { label: string; className: string }> = {
NEW: { label: "Novo", className: "bg-slate-100 text-slate-700 border-transparent" }, NEW: { label: "Novo", className: "border border-slate-200 bg-slate-100 text-slate-700" },
OPEN: { label: "Aberto", className: "bg-blue-100 text-blue-700 border-transparent" }, OPEN: { label: "Aberto", className: "border border-slate-200 bg-[#dff1fb] text-[#0a4760]" },
PENDING: { label: "Pendente", className: "bg-amber-100 text-amber-700 border-transparent" }, PENDING: { label: "Pendente", className: "border border-slate-200 bg-[#fdebd6] text-[#7b4107]" },
ON_HOLD: { label: "Em espera", className: "bg-purple-100 text-purple-700 border-transparent" }, ON_HOLD: { label: "Em espera", className: "border border-slate-200 bg-[#ede8ff] text-[#4f2f96]" },
RESOLVED: { label: "Resolvido", className: "bg-emerald-100 text-emerald-700 border-transparent" }, RESOLVED: { label: "Resolvido", className: "border border-slate-200 bg-[#dcf4eb] text-[#1f6a45]" },
CLOSED: { label: "Fechado", className: "bg-slate-100 text-slate-700 border-transparent" }, CLOSED: { label: "Fechado", className: "border border-slate-200 bg-slate-200 text-slate-700" },
} satisfies Record<TicketStatus, { label: string; className: string }> }
type TicketStatusBadgeProps = { status: TicketStatus } type TicketStatusBadgeProps = { status: TicketStatus }
export function TicketStatusBadge({ status }: TicketStatusBadgeProps) { export function TicketStatusBadge({ status }: TicketStatusBadgeProps) {
const config = statusConfig[status] const parsed = ticketStatusSchema.parse(status)
const styles = statusStyles[parsed]
return ( return (
<Badge <Badge className={cn('inline-flex items-center rounded-full px-3 py-0.5 text-xs font-semibold', styles?.className)}>
variant="outline" {styles?.label ?? parsed}
className={`rounded-full px-2.5 py-1 text-xs font-medium ${config?.className ?? ""}`}
>
{config?.label ?? status}
</Badge> </Badge>
) )
} }

View file

@ -10,63 +10,56 @@ import { useAuth } from "@/lib/auth-client"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { toast } from "sonner" import { toast } from "sonner"
import { cn } from "@/lib/utils"
const labels: Record<TicketStatus, string> = { const statusStyles: Record<TicketStatus, { label: string; badgeClass: string }> = {
NEW: "Novo", NEW: { label: "Novo", badgeClass: "border border-slate-200 bg-slate-100 text-slate-700" },
OPEN: "Aberto", OPEN: { label: "Aberto", badgeClass: "border border-slate-200 bg-[#dff1fb] text-[#0a4760]" },
PENDING: "Pendente", PENDING: { label: "Pendente", badgeClass: "border border-slate-200 bg-[#fdebd6] text-[#7b4107]" },
ON_HOLD: "Em espera", ON_HOLD: { label: "Em espera", badgeClass: "border border-slate-200 bg-[#ede8ff] text-[#4f2f96]" },
RESOLVED: "Resolvido", RESOLVED: { label: "Resolvido", badgeClass: "border border-slate-200 bg-[#dcf4eb] text-[#1f6a45]" },
CLOSED: "Fechado", CLOSED: { label: "Fechado", badgeClass: "border border-slate-200 bg-slate-200 text-slate-700" },
} }
function badgeClass(s: TicketStatus) { 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]"
switch (s) { 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"
case "OPEN": const baseBadgeClass = "inline-flex items-center rounded-full px-3 py-0.5 text-xs font-semibold"
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"
}
}
export function StatusSelect({ ticketId, value }: { ticketId: string; value: TicketStatus }) { export function StatusSelect({ ticketId, value }: { ticketId: string; value: TicketStatus }) {
const updateStatus = useMutation(api.tickets.updateStatus) const updateStatus = useMutation(api.tickets.updateStatus)
const [status, setStatus] = useState<TicketStatus>(value) const [status, setStatus] = useState<TicketStatus>(value)
const { userId } = useAuth() const { userId } = useAuth()
return ( return (
<Select <Select
value={status} value={status}
onValueChange={async (val) => { onValueChange={async (selected) => {
const prev = status const previous = status
const next = val as TicketStatus const next = selected as TicketStatus
setStatus(next) setStatus(next)
toast.loading("Atualizando status...", { id: "status" }) toast.loading("Atualizando status...", { id: "status" })
try { 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"> }) 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 { } catch {
setStatus(prev) setStatus(previous)
toast.error("Não foi possível atualizar o status.", { id: "status" }) 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> <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> </SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <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((s) => ( {(["NEW", "OPEN", "PENDING", "ON_HOLD", "RESOLVED", "CLOSED"] as const).map((option) => (
<SelectItem key={s} value={s}>{labels[s as TicketStatus]}</SelectItem> <SelectItem key={option} value={option} className={itemClass}>
{statusStyles[option].label}
</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>

View file

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

View file

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

View file

@ -11,75 +11,75 @@ interface TicketDetailsPanelProps {
ticket: TicketWithDetails 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) { export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
return ( return (
<Card className="rounded-xl border bg-card shadow-sm"> <Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardHeader className="px-4"> <CardHeader className="px-4 pb-3">
<CardTitle className="text-lg font-semibold">Detalhes</CardTitle> <CardTitle className="text-lg font-semibold text-neutral-900">Detalhes</CardTitle>
</CardHeader> </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"> <div className="space-y-1 break-words">
<p className="text-xs font-semibold uppercase tracking-wide">Fila</p> <p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Fila</p>
<Badge variant="outline" className="max-w-full truncate">{ticket.queue ?? "Sem fila"}</Badge> <Badge className={queueBadgeClass}>{ticket.queue ?? "Sem fila"}</Badge>
</div> </div>
<Separator /> <Separator className="bg-slate-200" />
<div className="space-y-2 break-words"> <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 ? ( {ticket.slaPolicy ? (
<div className="flex flex-col gap-2 rounded-lg border border-dashed bg-card px-3 py-2"> <div className="flex flex-col gap-2 rounded-xl border border-slate-200 bg-white px-3 py-2 shadow-sm">
<span className="text-foreground text-sm font-medium leading-tight">{ticket.slaPolicy.name}</span> <span className="text-sm font-semibold text-neutral-900">{ticket.slaPolicy.name}</span>
<div className="flex flex-col gap-1 text-xs text-muted-foreground"> <div className="flex flex-col gap-1 text-xs text-neutral-600">
{ticket.slaPolicy.targetMinutesToFirstResponse ? ( {ticket.slaPolicy.targetMinutesToFirstResponse ? (
<span className="leading-normal"> <span>Resposta inicial: {ticket.slaPolicy.targetMinutesToFirstResponse} min</span>
Resposta inicial: {ticket.slaPolicy.targetMinutesToFirstResponse} min
</span>
) : null} ) : null}
{ticket.slaPolicy.targetMinutesToResolution ? ( {ticket.slaPolicy.targetMinutesToResolution ? (
<span className="leading-normal"> <span>Resolução: {ticket.slaPolicy.targetMinutesToResolution} min</span>
Resolução: {ticket.slaPolicy.targetMinutesToResolution} min
</span>
) : null} ) : null}
</div> </div>
</div> </div>
) : ( ) : (
<span>Sem política atribuída.</span> <span className="text-neutral-600">Sem política atribuída.</span>
)} )}
</div> </div>
<Separator /> <Separator className="bg-slate-200" />
<div className="space-y-2"> <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 ? ( {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"> <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>
<span className="flex items-center gap-2"> <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> </span>
</div> </div>
) : ( ) : (
<span>Sem dados de SLA ainda.</span> <span className="text-neutral-600">Sem dados de SLA ainda.</span>
)} )}
</div> </div>
<Separator /> <Separator className="bg-slate-200" />
<div className="space-y-2 break-words"> <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"> <div className="flex flex-wrap gap-2">
{ticket.tags?.length ? ( {ticket.tags?.length ? (
ticket.tags.map((tag) => ( ticket.tags.map((tag) => (
<Badge key={tag} variant="outline" className="gap-1"> <Badge key={tag} className={tagBadgeClass}>
<IconTags className="size-3" /> {tag} <IconTags className={iconAccentClass} /> {tag}
</Badge> </Badge>
)) ))
) : ( ) : (
<span>Sem tags.</span> <span className="text-neutral-600">Sem tags.</span>
)} )}
</div> </div>
</div> </div>
<Separator /> <Separator className="bg-slate-200" />
<div className="space-y-1"> <div className="space-y-1">
<p className="text-xs font-semibold uppercase tracking-wide">Histórico</p> <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-muted-foreground"> <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>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> <span>Atualizado: {format(ticket.updatedAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
{ticket.resolvedAt ? ( {ticket.resolvedAt ? (

View file

@ -1,7 +1,6 @@
"use client" "use client"
import { useQuery } from "convex/react" import { useQuery } from "convex/react"
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
import { api } from "@/convex/_generated/api" import { api } from "@/convex/_generated/api"
import { DEFAULT_TENANT_ID } from "@/lib/constants" import { DEFAULT_TENANT_ID } from "@/lib/constants"
@ -16,45 +15,47 @@ interface TicketQueueSummaryProps {
export function TicketQueueSummaryCards({ queues }: TicketQueueSummaryProps) { export function TicketQueueSummaryCards({ queues }: TicketQueueSummaryProps) {
const fromServer = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) const fromServer = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID })
const data: TicketQueueSummary[] = (queues ?? (fromServer as TicketQueueSummary[] | undefined) ?? []) const data: TicketQueueSummary[] = (queues ?? (fromServer as TicketQueueSummary[] | undefined) ?? [])
if (!queues && fromServer === undefined) { if (!queues && fromServer === undefined) {
return ( return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => ( {Array.from({ length: 3 }).map((_, index) => (
<div key={i} className="rounded-xl border bg-card p-4"> <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-muted" /> <div className="h-4 w-24 animate-pulse rounded bg-slate-100" />
<div className="mt-4 h-3 w-full animate-pulse rounded bg-muted" /> <div className="mt-4 h-3 w-full animate-pulse rounded bg-slate-100" />
</div> </div>
))} ))}
</div> </div>
) )
} }
return ( return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{data.map((queue) => { {data.map((queue) => {
const total = queue.pending + queue.waiting const total = queue.pending + queue.waiting
const breachPercent = total === 0 ? 0 : Math.round((queue.breached / total) * 100) const breachPercent = total === 0 ? 0 : Math.round((queue.breached / total) * 100)
return ( return (
<Card key={queue.id} className="border border-border/60 bg-gradient-to-br from-background to-card p-4"> <Card key={queue.id} className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardDescription>Fila</CardDescription> <CardDescription className="text-xs uppercase tracking-wide text-neutral-500">Fila</CardDescription>
<CardTitle className="text-lg font-semibold">{queue.name}</CardTitle> <CardTitle className="text-lg font-semibold text-neutral-900">{queue.name}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-3 text-sm"> <CardContent className="flex flex-col gap-3 text-sm text-neutral-600">
<div className="flex justify-between text-muted-foreground"> <div className="flex justify-between">
<span>Pendentes</span> <span>Pendentes</span>
<span className="font-medium text-foreground">{queue.pending}</span> <span className="font-semibold text-neutral-900">{queue.pending}</span>
</div> </div>
<div className="flex justify-between text-muted-foreground"> <div className="flex justify-between">
<span>Aguardando resposta</span> <span>Aguardando resposta</span>
<span className="font-medium text-foreground">{queue.waiting}</span> <span className="font-semibold text-neutral-900">{queue.waiting}</span>
</div> </div>
<div className="flex items-center justify-between text-muted-foreground"> <div className="flex items-center justify-between">
<span>Violados</span> <span>Violados</span>
<span className="font-medium text-destructive">{queue.breached}</span> <span className="font-semibold text-red-600">{queue.breached}</span>
</div> </div>
<div className="pt-1.5"> <div className="pt-1.5">
<Progress value={breachPercent} className="h-1.5" /> <Progress value={breachPercent} className="h-1.5 bg-slate-100" indicatorClassName="bg-[#00e8ff]" />
<span className="mt-2 block text-xs text-muted-foreground"> <span className="mt-2 block text-xs text-neutral-500">
{breachPercent}% com SLA violado nesta fila {breachPercent}% com SLA violado nesta fila
</span> </span>
</div> </div>

View file

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

View file

@ -9,7 +9,6 @@ import {
} from "@tabler/icons-react" } from "@tabler/icons-react"
import type { TicketWithDetails } from "@/lib/schemas/ticket" import type { TicketWithDetails } from "@/lib/schemas/ticket"
import { cn } from "@/lib/utils"
import { Card, CardContent } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
@ -23,6 +22,7 @@ const timelineIcons: Record<string, ComponentType<{ className?: string }>> = {
WORK_PAUSED: IconClockHour4, WORK_PAUSED: IconClockHour4,
SUBJECT_CHANGED: IconNote, SUBJECT_CHANGED: IconNote,
SUMMARY_CHANGED: IconNote, SUMMARY_CHANGED: IconNote,
QUEUE_CHANGED: IconSquareCheck,
} }
const timelineLabels: Record<string, string> = { const timelineLabels: Record<string, string> = {
@ -43,52 +43,79 @@ interface TicketTimelineProps {
export function TicketTimeline({ ticket }: TicketTimelineProps) { export function TicketTimeline({ ticket }: TicketTimelineProps) {
return ( return (
<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-6 px-4 pb-6"> <CardContent className="space-y-5 px-4 pb-6">
{ticket.timeline.map((entry, index) => { {ticket.timeline.map((entry, index) => {
const Icon = timelineIcons[entry.type] ?? IconClockHour4 const Icon = timelineIcons[entry.type] ?? IconClockHour4
const isLast = index === ticket.timeline.length - 1 const isLast = index === ticket.timeline.length - 1
return ( return (
<div key={entry.id} className="relative pl-10"> <div key={entry.id} className="relative pl-11">
{!isLast && ( {!isLast && (
<span className="absolute left-[17px] top-6 h-full w-px bg-border" aria-hidden /> <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 bg-muted text-muted-foreground"> <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" /> <Icon className="size-4" />
</span> </span>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1"> <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} {timelineLabels[entry.type] ?? entry.type}
</span> </span>
{entry.payload?.actorName ? ( {entry.payload?.actorName ? (
<span className="flex items-center gap-1 text-xs text-muted-foreground"> <span className="flex items-center gap-1 text-xs text-neutral-500">
<Avatar className="size-5"> <Avatar className="size-5 border border-slate-200">
<AvatarImage src={entry.payload?.actorAvatar as string | undefined} alt={String(entry.payload?.actorName ?? "")} /> <AvatarImage src={entry.payload?.actorAvatar as string | undefined} alt={String(entry.payload?.actorName ?? "")} />
<AvatarFallback> <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> </AvatarFallback>
</Avatar> </Avatar>
por {String(entry.payload?.actorName ?? '')} por {String(entry.payload?.actorName ?? "")}
</span> </span>
) : null} ) : 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 })} {format(entry.createdAt, "dd MMM yyyy HH:mm", { locale: ptBR })}
</span> </span>
</div> </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 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 === "STATUS_CHANGED" && (payload.toLabel || payload.to)) {
if (entry.type === "ASSIGNEE_CHANGED" && (p.assigneeName || p.assigneeId)) message = `Responsável alterado${p.assigneeName ? ` para ${p.assigneeName}` : ""}` message = "Status alterado para " + (payload.toLabel || payload.to)
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 === "ASSIGNEE_CHANGED" && (payload.assigneeName || payload.assigneeId)) {
if (entry.type === "COMMENT_ADDED" && (p.authorName || p.authorId)) message = `Comentário adicionado${p.authorName ? ` por ${p.authorName}` : ""}` message = "Responsável alterado" + (payload.assigneeName ? " para " + payload.assigneeName : "")
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 === "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 if (!message) return null
return ( 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} {message}
</div> </div>
) )
@ -97,9 +124,8 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
</div> </div>
) )
})} })}
<Separator className="bg-slate-200" />
</CardContent> </CardContent>
</Card> </Card>
) )
} }

View file

@ -40,7 +40,7 @@ const priorityOptions = ticketPrioritySchema.options.map((priority) => ({
value: priority, value: priority,
label: { label: {
LOW: "Baixa", LOW: "Baixa",
MEDIUM: "Media", MEDIUM: "Média",
HIGH: "Alta", HIGH: "Alta",
URGENT: "Urgente", URGENT: "Urgente",
}[priority], }[priority],
@ -90,7 +90,7 @@ export function TicketsFilters({ onChange, queues = [] }: TicketsFiltersProps) {
setFilters((prev) => ({ ...prev, ...partial })) 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(() => { useEffect(() => {
onChange?.(filters) onChange?.(filters)
}, [filters, onChange]) }, [filters, onChange])
@ -134,14 +134,18 @@ export function TicketsFilters({ onChange, queues = [] }: TicketsFiltersProps) {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button variant="outline" size="sm" className="gap-2"> <Button
<IconFilter className="size-4" /> 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 Filtros
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-64 space-y-4"> <PopoverContent className="w-64 space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs font-semibold uppercase text-muted-foreground"> <p className="text-xs font-semibold uppercase text-neutral-500">
Status Status
</p> </p>
<Select <Select
@ -162,7 +166,7 @@ export function TicketsFilters({ onChange, queues = [] }: TicketsFiltersProps) {
</Select> </Select>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs font-semibold uppercase text-muted-foreground"> <p className="text-xs font-semibold uppercase text-neutral-500">
Prioridade Prioridade
</p> </p>
<Select <Select
@ -183,7 +187,7 @@ export function TicketsFilters({ onChange, queues = [] }: TicketsFiltersProps) {
</Select> </Select>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs font-semibold uppercase text-muted-foreground"> <p className="text-xs font-semibold uppercase text-neutral-500">
Canal Canal
</p> </p>
<Select <Select
@ -208,7 +212,7 @@ export function TicketsFilters({ onChange, queues = [] }: TicketsFiltersProps) {
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="gap-2" className="gap-2 rounded-lg px-3 py-2 text-sm font-medium text-neutral-700 hover:bg-slate-100"
onClick={() => setPartial(defaultTicketFilters)} onClick={() => setPartial(defaultTicketFilters)}
> >
<IconRefresh className="size-4" /> <IconRefresh className="size-4" />
@ -219,7 +223,7 @@ export function TicketsFilters({ onChange, queues = [] }: TicketsFiltersProps) {
{activeFilters.length > 0 && ( {activeFilters.length > 0 && (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{activeFilters.map((chip) => ( {activeFilters.map((chip) => (
<Badge key={chip} variant="secondary" className="rounded-full px-3 py-1 text-xs"> <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} {chip}
</Badge> </Badge>
))} ))}

View file

@ -30,10 +30,13 @@ const channelLabel: Record<string, string> = {
} }
const cellClass = "py-4 align-top" 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 }) { function AssigneeCell({ ticket }: { ticket: Ticket }) {
if (!ticket.assignee) { 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 const initials = ticket.assignee.name
@ -44,15 +47,15 @@ function AssigneeCell({ ticket }: { ticket: Ticket }) {
return ( return (
<div className="flex items-center gap-2"> <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} /> <AvatarImage src={ticket.assignee.avatarUrl} alt={ticket.assignee.name} />
<AvatarFallback>{initials}</AvatarFallback> <AvatarFallback>{initials}</AvatarFallback>
</Avatar> </Avatar>
<div className="flex flex-col"> <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} {ticket.assignee.name}
</span> </span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-neutral-600">
{ticket.assignee.teams?.[0] ?? "Agente"} {ticket.assignee.teams?.[0] ?? "Agente"}
</span> </span>
</div> </div>
@ -60,17 +63,17 @@ function AssigneeCell({ ticket }: { ticket: Ticket }) {
) )
} }
type TicketsTableProps = { export type TicketsTableProps = {
tickets?: Ticket[] tickets?: Ticket[]
} }
export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) { export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
return ( 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"> <CardContent className="px-4 py-4 sm:px-6">
<Table className="min-w-full"> <Table className="min-w-full">
<TableHeader> <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 className="w-[110px]">Ticket</TableHead>
<TableHead>Assunto</TableHead> <TableHead>Assunto</TableHead>
<TableHead className="hidden lg:table-cell">Fila</TableHead> <TableHead className="hidden lg:table-cell">Fila</TableHead>
@ -83,16 +86,16 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{tickets.map((ticket) => ( {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}> <TableCell className={cellClass}>
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<Link <Link
href={`/tickets/${ticket.id}`} 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} #{ticket.reference}
</Link> </Link>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-neutral-500">
{ticket.queue ?? "Sem fila"} {ticket.queue ?? "Sem fila"}
</span> </span>
</div> </div>
@ -101,21 +104,17 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<Link <Link
href={`/tickets/${ticket.id}`} 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} {ticket.subject}
</Link> </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"} {ticket.summary ?? "Sem resumo"}
</span> </span>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground"> <div className="flex flex-wrap gap-2 text-xs text-neutral-500">
<span>{ticket.requester.name}</span> <span className="font-medium text-neutral-900">{ticket.requester.name}</span>
{ticket.tags?.map((tag) => ( {ticket.tags?.map((tag) => (
<Badge <Badge key={tag} className={tagBadgeClass}>
key={tag}
variant="outline"
className="rounded-full border-transparent bg-slate-100 px-2 py-1 text-xs text-slate-600"
>
{tag} {tag}
</Badge> </Badge>
))} ))}
@ -123,12 +122,11 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
</div> </div>
</TableCell> </TableCell>
<TableCell className={`${cellClass} hidden lg:table-cell`}> <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"> <Badge className={queueBadgeClass}>{ticket.queue ?? "Sem fila"}</Badge>
{ticket.queue ?? "Sem fila"}
</Badge>
</TableCell> </TableCell>
<TableCell className={`${cellClass} hidden md:table-cell`}> <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} {channelLabel[ticket.channel] ?? ticket.channel}
</Badge> </Badge>
</TableCell> </TableCell>
@ -139,8 +137,8 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<TicketStatusBadge status={ticket.status} /> <TicketStatusBadge status={ticket.status} />
{ticket.metrics?.timeWaitingMinutes ? ( {ticket.metrics?.timeWaitingMinutes ? (
<span className="text-xs text-muted-foreground"> <span className="text-xs text-neutral-500">
Espera {ticket.metrics.timeWaitingMinutes} min Em espera {ticket.metrics.timeWaitingMinutes} min
</span> </span>
) : null} ) : null}
</div> </div>
@ -149,11 +147,8 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
<AssigneeCell ticket={ticket} /> <AssigneeCell ticket={ticket} />
</TableCell> </TableCell>
<TableCell className={cellClass}> <TableCell className={cellClass}>
<span className="text-sm text-muted-foreground"> <span className="text-sm text-neutral-500">
{formatDistanceToNow(ticket.updatedAt, { {formatDistanceToNow(ticket.updatedAt, { addSuffix: true, locale: ptBR })}
addSuffix: true,
locale: ptBR,
})}
</span> </span>
</TableCell> </TableCell>
</TableRow> </TableRow>
@ -164,10 +159,10 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
<Empty className="my-6"> <Empty className="my-6">
<EmptyHeader> <EmptyHeader>
<EmptyMedia variant="icon"> <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> </EmptyMedia>
<EmptyTitle>Nenhum ticket encontrado</EmptyTitle> <EmptyTitle className="text-neutral-900">Nenhum ticket encontrado</EmptyTitle>
<EmptyDescription> <EmptyDescription className="text-neutral-600">
Ajuste os filtros ou crie um novo ticket. Ajuste os filtros ou crie um novo ticket.
</EmptyDescription> </EmptyDescription>
</EmptyHeader> </EmptyHeader>
@ -180,10 +175,3 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
</Card> </Card>
) )
} }

View file

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

View file

@ -4,10 +4,11 @@ import { cn } from "@/lib/utils"
type ProgressProps = React.HTMLAttributes<HTMLDivElement> & { type ProgressProps = React.HTMLAttributes<HTMLDivElement> & {
value?: number value?: number
indicatorClassName?: string
} }
export const Progress = React.forwardRef<HTMLDivElement, ProgressProps>( export const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
({ className, value = 0, ...props }, ref) => { ({ className, value = 0, indicatorClassName, ...props }, ref) => {
const clamped = Math.min(100, Math.max(0, value)) const clamped = Math.min(100, Math.max(0, value))
return ( return (
<div <div
@ -19,7 +20,7 @@ export const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
{...props} {...props}
> >
<div <div
className="h-full w-full flex-1 bg-primary transition-all" className={cn("h-full w-full flex-1 bg-primary transition-all", indicatorClassName)}
style={{ transform: `translateX(-${100 - clamped}%)` }} style={{ transform: `translateX(-${100 - clamped}%)` }}
/> />
</div> </div>