Merge pull request #5 from esdrasrenan/feat/convex-tickets-core

Feat/convex tickets core
This commit is contained in:
esdrasrenan 2025-10-04 20:10:44 -03:00 committed by GitHub
commit f6026f83a5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1104 additions and 799 deletions

View file

@ -265,3 +265,35 @@ Médio prazo:
- Prioridade: alterar no cabeçalho; observar evento de timeline e toasts. - Prioridade: alterar no cabeçalho; observar evento de timeline e toasts.
- Exclusão: acionar modal no cabeçalho e confirmar; conferir redirecionamento para `/tickets`. - Exclusão: acionar modal no cabeçalho e confirmar; conferir redirecionamento para `/tickets`.
- Novo ticket: usar Dialog; assunto com menos de 3 chars deve bloquear submit com erro no campo. - Novo ticket: usar Dialog; assunto com menos de 3 chars deve bloquear submit com erro no campo.
---
## Atualizações recentes (abr/2025)
Resumo do que foi integrado nesta rodada para o núcleo de tickets e UX:
- Header do ticket
- Status como dropdownbadge (padrão visual alinhado às badges existentes).
- Edição inline de Assunto/Resumo com Cancelar/Salvar e toasts.
- Ação de Play/Pause (toggle de atendimento) com eventos WORK_STARTED/WORK_PAUSED na timeline.
- Layout dos campos reorganizado: labels acima e controles abaixo (evita redundância do valor + dropdown lado a lado).
- Tabela e comentários
- Empty states padronizados com Empty + CTA de novo ticket.
- Notificações
- Toaster centralizado no rodapé (bottomcenter) com estilo consistente.
- Título do app
- Atualizado para “Sistema de chamados”.
Backend Convex
- ickets.updateSubject e ickets.updateSummary adicionadas para edição do cabeçalho.
- ickets.toggleWork adicionada; campo opcional working no schema de ickets.
Próximos passos sugeridos
- Status dropdownbadge também na tabela (edição rápida opcional com confirmação).
- Combobox (command) para busca de responsável no select.
- Tokens de cor: manter badges padrão do design atual; quando migração completa para paleta Rever estiver definida, aplicar via globals.css para herdar em todos os componentes.
- Testes (Vitest): adicionar casos de mappers e smoke tests de páginas.
Observações de codificação
- Evitar ny; usar TicketStatus/TicketPriority e Id<>/Doc<> do Convex.
- Não retornar Date do Convex; sempre epoch (number) e converter via mappers Zod.

View file

@ -48,6 +48,7 @@ export default defineSchema({
queueId: v.optional(v.id("queues")), queueId: v.optional(v.id("queues")),
requesterId: v.id("users"), requesterId: v.id("users"),
assigneeId: v.optional(v.id("users")), assigneeId: v.optional(v.id("users")),
working: v.optional(v.boolean()),
slaPolicyId: v.optional(v.id("slaPolicies")), slaPolicyId: v.optional(v.id("slaPolicies")),
dueAt: v.optional(v.number()), // ms since epoch dueAt: v.optional(v.number()), // ms since epoch
firstResponseAt: v.optional(v.number()), firstResponseAt: v.optional(v.number()),

View file

@ -220,6 +220,7 @@ export const create = mutation({
queueId: args.queueId, queueId: args.queueId,
requesterId: args.requesterId, requesterId: args.requesterId,
assigneeId: undefined, assigneeId: undefined,
working: false,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
firstResponseAt: undefined, firstResponseAt: undefined,
@ -348,6 +349,59 @@ export const updatePriority = mutation({
}, },
}); });
export const toggleWork = mutation({
args: { ticketId: v.id("tickets"), actorId: v.id("users") },
handler: async (ctx, { ticketId, actorId }) => {
const t = await ctx.db.get(ticketId)
if (!t) return
const now = Date.now()
const next = !(t.working ?? false)
await ctx.db.patch(ticketId, { working: next, updatedAt: now })
const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null
await ctx.db.insert("ticketEvents", {
ticketId,
type: next ? "WORK_STARTED" : "WORK_PAUSED",
payload: { actorId, actorName: actor?.name, actorAvatar: actor?.avatarUrl },
createdAt: now,
})
return next
},
})
export const updateSubject = mutation({
args: { ticketId: v.id("tickets"), subject: v.string(), actorId: v.id("users") },
handler: async (ctx, { ticketId, subject, actorId }) => {
const now = Date.now();
const t = await ctx.db.get(ticketId);
if (!t) return;
await ctx.db.patch(ticketId, { subject, updatedAt: now });
const actor = await ctx.db.get(actorId);
await ctx.db.insert("ticketEvents", {
ticketId,
type: "SUBJECT_CHANGED",
payload: { from: t.subject, to: subject, actorId, actorName: (actor as Doc<"users"> | null)?.name, actorAvatar: (actor as Doc<"users"> | null)?.avatarUrl },
createdAt: now,
});
},
});
export const updateSummary = mutation({
args: { ticketId: v.id("tickets"), summary: v.optional(v.string()), actorId: v.id("users") },
handler: async (ctx, { ticketId, summary, actorId }) => {
const now = Date.now();
const t = await ctx.db.get(ticketId);
if (!t) return;
await ctx.db.patch(ticketId, { summary, updatedAt: now });
const actor = await ctx.db.get(actorId);
await ctx.db.insert("ticketEvents", {
ticketId,
type: "SUMMARY_CHANGED",
payload: { actorId, actorName: (actor as Doc<"users"> | null)?.name, actorAvatar: (actor as Doc<"users"> | null)?.avatarUrl },
createdAt: now,
});
},
});
export const playNext = mutation({ export const playNext = mutation({
args: { args: {
tenantId: v.string(), tenantId: v.string(),

View file

@ -18,10 +18,10 @@ const jetBrainsMono = JetBrains_Mono({
display: "swap", display: "swap",
}) })
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Atlas Support", title: "Sistema de chamados",
description: "Plataforma omnichannel de gestão de chamados", description: "Plataforma de chamados da Rever",
} }
export default async function RootLayout({ export default async function RootLayout({
children, children,
@ -43,7 +43,7 @@ export default async function RootLayout({
<ConvexClientProvider> <ConvexClientProvider>
<AuthProvider demoUser={demoUser} tenantId={tenantId}> <AuthProvider demoUser={demoUser} tenantId={tenantId}>
{children} {children}
<Toaster position="top-right" richColors /> <Toaster position="bottom-center" richColors />
</AuthProvider> </AuthProvider>
</ConvexClientProvider> </ConvexClientProvider>
</body> </body>

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"
@ -21,11 +20,15 @@ import { TicketPriorityPill } from "@/components/tickets/priority-pill"
import { TicketStatusBadge } from "@/components/tickets/status-badge" import { TicketStatusBadge } from "@/components/tickets/status-badge"
import { Spinner } from "@/components/ui/spinner" import { Spinner } from "@/components/ui/spinner"
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select" import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"
interface PlayNextTicketCardProps { 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,85 +46,110 @@ 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="border-dashed"> <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>
) )
} }
const ticket = cardContext.nextTicket const ticket = cardContext.nextTicket
return ( return (
<Card className="border-dashed"> <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" />
</Link> </Link>
</Button> </Button>
</CardContent> </CardContent>
</Card> </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 { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
const priorityConfig = {
LOW: { const priorityStyles: Record<TicketPriority, { label: string; className: string }> = {
label: "Baixa", LOW: { label: "Baixa", className: "border border-slate-200 bg-slate-100 text-slate-700" },
className: "bg-slate-100 text-slate-600 border-transparent", 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]" },
MEDIUM: { URGENT: { label: "Urgente", className: "border border-slate-200 bg-[#fbd9dd] text-[#8b0f1c]" },
label: "Media", }
className: "bg-blue-100 text-blue-600 border-transparent",
}, const baseClass = "inline-flex items-center rounded-full px-3 py-0.5 text-xs font-semibold"
HIGH: {
label: "Alta", export function TicketPriorityPill({ priority }: { priority: TicketPriority }) {
className: "bg-amber-100 text-amber-700 border-transparent", const styles = priorityStyles[priority]
}, return (
URGENT: { <Badge className={cn(baseClass, styles?.className)}>
label: "Urgente", {styles?.label ?? priority}
className: "bg-red-100 text-red-700 border-transparent", </Badge>
}, )
} satisfies Record<string, { label: string; className: string }> }
type TicketPriorityPillProps = {
priority: keyof typeof priorityConfig
}
export function TicketPriorityPill({ priority }: TicketPriorityPillProps) {
const config = priorityConfig[priority]
return (
<Badge
variant="outline"
className={cn(
"rounded-full px-2.5 py-1 text-xs font-medium",
config?.className ?? ""
)}
>
{config?.label ?? priority}
</Badge>
)
}

View file

@ -5,61 +5,71 @@ import { useMutation } from "convex/react"
// @ts-ignore // @ts-ignore
import { api } from "@/convex/_generated/api" import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel" import type { Id } from "@/convex/_generated/dataModel"
import type { TicketPriority } from "@/lib/schemas/ticket"
import { useAuth } from "@/lib/auth-client" 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 { ArrowDown, ArrowRight, ArrowUp, ChevronsUp } from "lucide-react"
import { cn } from "@/lib/utils"
const labels: Record<string, 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: string) { 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" function PriorityIcon({ value }: { value: TicketPriority }) {
case "MEDIUM": if (value === "LOW") return <ArrowDown className={iconClass} />
return "bg-blue-100 text-blue-700" if (value === "MEDIUM") return <ArrowRight className={iconClass} />
default: if (value === "HIGH") return <ArrowUp className={iconClass} />
return "bg-slate-100 text-slate-700" return <ChevronsUp className={iconClass} />
}
} }
export function PrioritySelect({ ticketId, value }: { ticketId: Id<"tickets">; value: "LOW" | "MEDIUM" | "HIGH" | "URGENT" }) { 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(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
setPriority(val as typeof priority) const next = selected as TicketPriority
toast.loading("Atualizando prioridade...", { id: "prio" }) setPriority(next)
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, priority: val as any, 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={`rounded-full px-2 py-0.5 ${badgeClass(priority)}`}>{labels[priority]}</Badge> <Badge className={cn(baseBadgeClass, priorityStyles[priority]?.badgeClass)}>
<PriorityIcon value={priority} />
{priorityStyles[priority]?.label ?? priority}
</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"><span className={`h-2 w-2 rounded-full ${p==="URGENT"?"bg-red-500":p==="HIGH"?"bg-amber-500":p==="MEDIUM"?"bg-blue-500":"bg-slate-400"}`}></span>{labels[p]}</span> <span className="inline-flex items-center gap-2">
<PriorityIcon value={option} />
{priorityStyles[option].label}
</span>
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>

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

@ -1,27 +1,26 @@
"use client" "use client"
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 = {
NEW: { label: "Novo", className: "bg-slate-100 text-slate-700 border-transparent" }, const statusStyles: Record<TicketStatus, { label: string; className: string }> = {
OPEN: { label: "Aberto", className: "bg-blue-100 text-blue-700 border-transparent" }, NEW: { label: "Novo", className: "border border-slate-200 bg-slate-100 text-slate-700" },
PENDING: { label: "Pendente", className: "bg-amber-100 text-amber-700 border-transparent" }, OPEN: { label: "Aberto", className: "border border-slate-200 bg-[#dff1fb] text-[#0a4760]" },
ON_HOLD: { label: "Em espera", className: "bg-purple-100 text-purple-700 border-transparent" }, PENDING: { label: "Pendente", className: "border border-slate-200 bg-[#fdebd6] text-[#7b4107]" },
RESOLVED: { label: "Resolvido", className: "bg-emerald-100 text-emerald-700 border-transparent" }, ON_HOLD: { label: "Em espera", className: "border border-slate-200 bg-[#ede8ff] text-[#4f2f96]" },
CLOSED: { label: "Fechado", className: "bg-slate-100 text-slate-700 border-transparent" }, RESOLVED: { label: "Resolvido", className: "border border-slate-200 bg-[#dcf4eb] text-[#1f6a45]" },
} satisfies Record<TicketStatus, { label: string; className: string }> CLOSED: { label: "Fechado", className: "border border-slate-200 bg-slate-200 text-slate-700" },
}
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)
return ( const styles = statusStyles[parsed]
<Badge return (
variant="outline" <Badge className={cn('inline-flex items-center rounded-full px-3 py-0.5 text-xs font-semibold', styles?.className)}>
className={`rounded-full px-2.5 py-1 text-xs font-medium ${config?.className ?? ""}`} {styles?.label ?? parsed}
> </Badge>
{config?.label ?? status} )
</Badge> }
)
}

View file

@ -0,0 +1,67 @@
"use client"
import { useState } from "react"
import { useMutation } from "convex/react"
// @ts-ignore
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import type { TicketStatus } from "@/lib/schemas/ticket"
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 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" },
}
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 (selected) => {
const previous = status
const next = selected as TicketStatus
setStatus(next)
toast.loading("Atualizando status...", { id: "status" })
try {
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 " + (statusStyles[next]?.label ?? next) + ".", { id: "status" })
} catch {
setStatus(previous)
toast.error("Não foi possível atualizar o status.", { id: "status" })
}
}}
>
<SelectTrigger className={triggerClass}>
<SelectValue>
<Badge className={cn(baseBadgeClass, statusStyles[status]?.badgeClass)}>
{statusStyles[status]?.label ?? status}
</Badge>
</SelectValue>
</SelectTrigger>
<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 { 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"
@ -21,71 +20,93 @@ import { Dropzone } from "@/components/ui/dropzone"
import { RichTextEditor, RichTextContent, sanitizeEditorHtml } from "@/components/ui/rich-text-editor" import { RichTextEditor, RichTextContent, sanitizeEditorHtml } from "@/components/ui/rich-text-editor"
import { Dialog, DialogContent } from "@/components/ui/dialog" import { Dialog, DialogContent } from "@/components/ui/dialog"
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select" import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
interface TicketCommentsProps { 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)
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 [visibility, setVisibility] = useState<"PUBLIC" | "INTERNAL">("PUBLIC")
const commentsAll = useMemo(() => { const commentsAll = useMemo(() => {
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">
{commentsAll.length === 0 ? ( {commentsAll.length === 0 ? (
<p className="text-sm text-muted-foreground"> <Empty>
Ainda sem comentários. Que tal registrar o próximo passo? <EmptyHeader>
</p> <EmptyMedia variant="icon">
<IconMessage className="size-5 text-neutral-900" />
</EmptyMedia>
<EmptyTitle className="text-neutral-900">Nenhum comentário ainda</EmptyTitle>
<EmptyDescription className="text-neutral-600">Registre o próximo passo abaixo.</EmptyDescription>
</EmptyHeader>
</Empty>
) : ( ) : (
commentsAll.map((comment) => { commentsAll.map((comment) => {
const initials = comment.author.name const initials = comment.author.name
@ -93,56 +114,62 @@ 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="rounded-lg border border-dashed bg-card px-3 py-2 text-sm leading-relaxed text-muted-foreground break-words"> <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
<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"> key={attachment.id}
<FileIcon className="size-3.5" /> {att.name} href={attachment.url}
{att.url ? <Download className="size-3.5" /> : null} download={attachment.name}
</a> 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"
})} >
</div> <FileIcon className="size-3.5 text-neutral-700" /> {attachment.name}
) : null} {attachment.url ? <Download className="size-3.5 text-neutral-700" /> : null}
</a>
)
})}
</div>
) : null}
</div> </div>
</div> </div>
) )
@ -152,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

@ -1,85 +1,85 @@
import { format } from "date-fns" import { format } from "date-fns"
import { ptBR } from "date-fns/locale" import { ptBR } from "date-fns/locale"
import { IconAlertTriangle, IconClockHour4, IconTags } from "@tabler/icons-react" import { IconAlertTriangle, IconClockHour4, IconTags } from "@tabler/icons-react"
import type { TicketWithDetails } from "@/lib/schemas/ticket" import type { TicketWithDetails } from "@/lib/schemas/ticket"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
interface TicketDetailsPanelProps { 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 ? (
@ -90,4 +90,4 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
</CardContent> </CardContent>
</Card> </Card>
) )
} }

View file

@ -1,67 +1,68 @@
"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"
import type { TicketQueueSummary } from "@/lib/schemas/ticket" import type { TicketQueueSummary } from "@/lib/schemas/ticket"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Progress } from "@/components/ui/progress" import { Progress } from "@/components/ui/progress"
interface TicketQueueSummaryProps { interface TicketQueueSummaryProps {
queues?: TicketQueueSummary[] queues?: TicketQueueSummary[]
} }
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>
</CardContent> </CardContent>
</Card> </Card>
) )
})} })}
</div> </div>
) )
} }

View file

@ -1,9 +1,9 @@
"use client" "use client"
import { useState } from "react" import { useMemo, useState } from "react"
import { format } from "date-fns" import { format } from "date-fns"
import { ptBR } from "date-fns/locale" import { ptBR } from "date-fns/locale"
import { IconClock, IconUserCircle } from "@tabler/icons-react" import { IconPlayerPause, IconPlayerPlay } from "@tabler/icons-react"
import { useMutation, useQuery } from "convex/react" import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner" import { toast } from "sonner"
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
@ -15,91 +15,154 @@ import type { Doc, Id } from "@/convex/_generated/dataModel"
import type { TicketWithDetails, TicketQueueSummary, TicketStatus } from "@/lib/schemas/ticket" import type { TicketWithDetails, TicketQueueSummary, TicketStatus } from "@/lib/schemas/ticket"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { TicketPriorityPill } from "@/components/tickets/priority-pill"
import { PrioritySelect } from "@/components/tickets/priority-select" import { PrioritySelect } from "@/components/tickets/priority-select"
import { DeleteTicketDialog } from "@/components/tickets/delete-ticket-dialog" import { DeleteTicketDialog } from "@/components/tickets/delete-ticket-dialog"
import { TicketStatusBadge } from "@/components/tickets/status-badge" import { StatusSelect } from "@/components/tickets/status-select"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Button } from "@/components/ui/button"
interface TicketHeaderProps { import { Input } from "@/components/ui/input"
ticket: TicketWithDetails
} 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) { export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const { userId } = useAuth() const { userId } = useAuth()
const updateStatus = useMutation(api.tickets.updateStatus)
const changeAssignee = useMutation(api.tickets.changeAssignee) const changeAssignee = useMutation(api.tickets.changeAssignee)
const changeQueue = useMutation(api.tickets.changeQueue) const changeQueue = useMutation(api.tickets.changeQueue)
const updateSubject = useMutation(api.tickets.updateSubject)
const updateSummary = useMutation(api.tickets.updateSummary)
const toggleWork = useMutation(api.tickets.toggleWork)
const agents = (useQuery(api.users.listAgents, { tenantId: ticket.tenantId }) as Doc<"users">[] | undefined) ?? [] const agents = (useQuery(api.users.listAgents, { tenantId: ticket.tenantId }) as Doc<"users">[] | undefined) ?? []
const queues = (useQuery(api.queues.summary, { tenantId: ticket.tenantId }) as TicketQueueSummary[] | undefined) ?? [] const queues = (useQuery(api.queues.summary, { tenantId: ticket.tenantId }) as TicketQueueSummary[] | undefined) ?? []
const [status, setStatus] = useState<TicketStatus>(ticket.status) const [status] = useState<TicketStatus>(ticket.status)
const statusPt: Record<string, string> = {
NEW: "Novo", const [editing, setEditing] = useState(false)
OPEN: "Aberto", const [subject, setSubject] = useState(ticket.subject)
PENDING: "Pendente", const [summary, setSummary] = useState(ticket.summary ?? "")
ON_HOLD: "Em espera", const dirty = useMemo(
RESOLVED: "Resolvido", () => subject !== ticket.subject || (summary ?? "") !== (ticket.summary ?? ""),
CLOSED: "Fechado", [subject, summary, ticket.subject, ticket.summary]
)
async function handleSave() {
if (!userId) return
toast.loading("Salvando alterações...", { id: "save-header" })
try {
if (subject !== ticket.subject) {
await updateSubject({ ticketId: ticket.id as Id<"tickets">, subject: subject.trim(), actorId: userId as Id<"users"> })
}
if ((summary ?? "") !== (ticket.summary ?? "")) {
await updateSummary({ ticketId: ticket.id as Id<"tickets">, summary: (summary ?? "").trim(), actorId: userId as Id<"users"> })
}
toast.success("Cabeçalho atualizado!", { id: "save-header" })
setEditing(false)
} catch {
toast.error("Não foi possível salvar.", { id: "save-header" })
}
} }
function handleCancel() {
setSubject(ticket.subject)
setSummary(ticket.summary ?? "")
setEditing(false)
}
const lastWork = [...ticket.timeline].reverse().find((e) => e.type === "WORK_STARTED" || e.type === "WORK_PAUSED")
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} <PrioritySelect ticketId={ticket.id} value={ticket.priority} />
</Badge> <StatusSelect ticketId={ticket.id} value={status} />
<PrioritySelect ticketId={ticket.id as any} value={ticket.priority as any} /> <Button
<TicketStatusBadge status={status} /> size="sm"
<Select className={isPlaying ? pauseButtonClass : startButtonClass}
value={status} onClick={async () => {
onValueChange={async (value) => {
const prev = status
setStatus(value as import("@/lib/schemas/ticket").TicketStatus) // otimista
if (!userId) return if (!userId) return
toast.loading("Atualizando status…", { id: "status" }) const next = await toggleWork({ ticketId: ticket.id as Id<"tickets">, actorId: userId as Id<"users"> })
try { if (next) toast.success("Atendimento iniciado", { id: "work" })
await updateStatus({ ticketId: ticket.id as Id<"tickets">, status: value, actorId: userId as Id<"users"> }) else toast.success("Atendimento pausado", { id: "work" })
toast.success(`Status alterado para ${statusPt[value]}.`, { id: "status" })
} catch (e) {
setStatus(prev)
toast.error("Não foi possível alterar o status.", { id: "status" })
}
}} }}
> >
<SelectTrigger className="h-8 w-[150px]"> {isPlaying ? (
<SelectValue placeholder="Alterar status" /> <>
</SelectTrigger> <IconPlayerPause className="size-4 text-white" /> Pausar
<SelectContent> </>
{(["NEW","OPEN","PENDING","ON_HOLD","RESOLVED","CLOSED"]).map((s) => ( ) : (
<SelectItem key={s} value={s}>{statusPt[s]}</SelectItem> <>
))} <IconPlayerPlay className="size-4 text-black" /> Iniciar
</SelectContent> </>
</Select> )}
</Button>
</div> </div>
<h1 className="text-2xl font-semibold text-foreground break-words">{ticket.subject}</h1> {editing ? (
<p className="max-w-2xl text-sm text-muted-foreground">{ticket.summary}</p> <div className="space-y-2">
<div className="ms-auto flex items-center gap-2"> <Input
<DeleteTicketDialog ticketId={ticket.id as any} /> 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-lg border border-slate-300 bg-white p-3 text-sm text-neutral-800 shadow-sm"
placeholder="Adicione um resumo opcional"
/>
</div>
) : (
<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="flex items-center gap-2">
{editing ? (
<>
<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 size="sm" className={editButtonClass} onClick={() => setEditing(true)}>
Editar
</Button>
)}
<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 items-center gap-2"> <div className="flex flex-col gap-1">
<IconUserCircle className="size-4" /> <span className={sectionLabelClass}>Solicitante</span>
Solicitante: <span className={sectionValueClass}>{ticket.requester.name}</span>
<span className="font-medium text-foreground">{ticket.requester.name}</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex flex-col gap-1">
<IconUserCircle className="size-4" /> <span className={sectionLabelClass}>Responsável</span>
Responsável:
<span className="font-medium text-foreground">{ticket.assignee?.name ?? "Aguardando atribuição"}</span>
<Select <Select
value={ticket.assignee?.id ?? ""} value={ticket.assignee?.id ?? ""}
onValueChange={async (value) => { onValueChange={async (value) => {
if (!userId) return if (!userId) return
toast.loading("Atribuindo responsável…", { id: "assignee" }) toast.loading("Atribuindo responsável...", { id: "assignee" })
try { try {
await changeAssignee({ ticketId: ticket.id as Id<"tickets">, assigneeId: value as Id<"users">, actorId: userId as Id<"users"> }) await changeAssignee({ ticketId: ticket.id as Id<"tickets">, assigneeId: value as Id<"users">, actorId: userId as Id<"users"> })
toast.success("Responsável atualizado!", { id: "assignee" }) toast.success("Responsável atualizado!", { id: "assignee" })
@ -108,80 +171,68 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
} }
}} }}
> >
<SelectTrigger className="h-8 w-[220px]"><SelectValue placeholder="Selecionar" /></SelectTrigger> <SelectTrigger className={selectTriggerClass}>
<SelectContent> <SelectValue placeholder="Selecionar" />
{agents.map((a) => ( </SelectTrigger>
<SelectItem key={a._id} value={a._id}> <SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
<span className="inline-flex items-center gap-2"> {agents.map((agent) => (
<span className="inline-flex size-6 items-center justify-center overflow-hidden rounded-full bg-muted"> <SelectItem key={agent._id} value={agent._id}>
{/* eslint-disable-next-line @next/next/no-img-element */} {agent.name}
{a.avatarUrl ? <img src={a.avatarUrl} alt={a.name} className="h-6 w-6 rounded-full object-cover" /> : <span className="text-[10px] font-medium">{a.name.split(' ').slice(0,2).map(p=>p[0]).join('').toUpperCase()}</span>}
</span>
{a.name}
</span>
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex flex-col gap-1">
<IconClock className="size-4" /> <span className={sectionLabelClass}>Fila</span>
Fila:
<span className="font-medium text-foreground">{ticket.queue ?? "Sem 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]"><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 items-center gap-2"> <div className="flex flex-col gap-1">
<IconClock className="size-4" /> <span className={sectionLabelClass}>Atualizado em</span>
Atualizado em: <span className={sectionValueClass}>{format(ticket.updatedAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
<span className="font-medium text-foreground">
{format(ticket.updatedAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}
</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex flex-col gap-1">
<IconClock className="size-4" /> <span className={sectionLabelClass}>Criado em</span>
Criado em: <span className={sectionValueClass}>{format(ticket.createdAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
<span className="font-medium text-foreground"> </div>
{format(ticket.createdAt, "dd/MM/yyyy HH:mm", { locale: ptBR })} {ticket.dueAt ? (
</span> <div className="flex flex-col gap-1">
</div> <span className={sectionLabelClass}>SLA até</span>
{ticket.dueAt ? ( <span className={sectionValueClass}>{format(ticket.dueAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
<div className="flex items-center gap-2"> </div>
<IconClock className="size-4" /> ) : null}
SLA ate: {ticket.slaPolicy ? (
<span className="font-medium text-foreground"> <div className="flex flex-col gap-1">
{format(ticket.dueAt, "dd/MM/yyyy HH:mm", { locale: ptBR })} <span className={sectionLabelClass}>Política</span>
</span> <span className={sectionValueClass}>{ticket.slaPolicy.name}</span>
</div> </div>
) : null} ) : null}
{ticket.slaPolicy ? ( </div>
<div className="flex items-center gap-2"> </div>
<IconClock className="size-4" /> )
Politica: }
<span className="font-medium text-foreground">{ticket.slaPolicy.name}</span>
</div>
) : null}
</div>
</div>
)
}

View file

@ -1,24 +1,28 @@
import { format } from "date-fns" import { format } from "date-fns"
import type { ComponentType } from "react" import type { ComponentType } from "react"
import { ptBR } from "date-fns/locale" import { ptBR } from "date-fns/locale"
import { import {
IconClockHour4, IconClockHour4,
IconNote, IconNote,
IconSquareCheck, IconSquareCheck,
IconUserCircle, IconUserCircle,
} 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"
const timelineIcons: Record<string, ComponentType<{ className?: string }>> = { const timelineIcons: Record<string, ComponentType<{ className?: string }>> = {
CREATED: IconUserCircle, CREATED: IconUserCircle,
STATUS_CHANGED: IconSquareCheck, STATUS_CHANGED: IconSquareCheck,
ASSIGNEE_CHANGED: IconUserCircle, ASSIGNEE_CHANGED: IconUserCircle,
COMMENT_ADDED: IconNote, COMMENT_ADDED: IconNote,
WORK_STARTED: IconClockHour4,
WORK_PAUSED: IconClockHour4,
SUBJECT_CHANGED: IconNote,
SUMMARY_CHANGED: IconNote,
QUEUE_CHANGED: IconSquareCheck,
} }
const timelineLabels: Record<string, string> = { const timelineLabels: Record<string, string> = {
@ -26,69 +30,102 @@ const timelineLabels: Record<string, string> = {
STATUS_CHANGED: "Status alterado", STATUS_CHANGED: "Status alterado",
ASSIGNEE_CHANGED: "Responsável alterado", ASSIGNEE_CHANGED: "Responsável alterado",
COMMENT_ADDED: "Comentário adicionado", COMMENT_ADDED: "Comentário adicionado",
WORK_STARTED: "Atendimento iniciado",
WORK_PAUSED: "Atendimento pausado",
SUBJECT_CHANGED: "Assunto atualizado",
SUMMARY_CHANGED: "Resumo atualizado",
QUEUE_CHANGED: "Fila alterada", QUEUE_CHANGED: "Fila alterada",
} }
interface TicketTimelineProps { interface TicketTimelineProps {
ticket: TicketWithDetails ticket: TicketWithDetails
} }
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 } 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 === "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>
) )
})()} })()}
</div> </div>
</div> </div>
) )
})} })}
</CardContent> <Separator className="bg-slate-200" />
</Card> </CardContent>
) </Card>
)
} }

View file

@ -1,86 +1,86 @@
"use client" "use client"
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { IconFilter, IconRefresh } from "@tabler/icons-react" import { IconFilter, IconRefresh } from "@tabler/icons-react"
import { import {
ticketChannelSchema, ticketChannelSchema,
ticketPrioritySchema, ticketPrioritySchema,
ticketStatusSchema, ticketStatusSchema,
} from "@/lib/schemas/ticket" } from "@/lib/schemas/ticket"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover" } from "@/components/ui/popover"
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select"
const statusOptions = ticketStatusSchema.options.map((status) => ({ const statusOptions = ticketStatusSchema.options.map((status) => ({
value: status, value: status,
label: { label: {
NEW: "Novo", NEW: "Novo",
OPEN: "Aberto", OPEN: "Aberto",
PENDING: "Pendente", PENDING: "Pendente",
ON_HOLD: "Em espera", ON_HOLD: "Em espera",
RESOLVED: "Resolvido", RESOLVED: "Resolvido",
CLOSED: "Fechado", CLOSED: "Fechado",
}[status], }[status],
})) }))
const priorityOptions = ticketPrioritySchema.options.map((priority) => ({ 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],
})) }))
const channelOptions = ticketChannelSchema.options.map((channel) => ({ const channelOptions = ticketChannelSchema.options.map((channel) => ({
value: channel, value: channel,
label: { label: {
EMAIL: "E-mail", EMAIL: "E-mail",
WHATSAPP: "WhatsApp", WHATSAPP: "WhatsApp",
CHAT: "Chat", CHAT: "Chat",
PHONE: "Telefone", PHONE: "Telefone",
API: "API", API: "API",
MANUAL: "Manual", MANUAL: "Manual",
}[channel], }[channel],
})) }))
type QueueOption = string type QueueOption = string
export type TicketFiltersState = { export type TicketFiltersState = {
search: string search: string
status: string | null status: string | null
priority: string | null priority: string | null
queue: string | null queue: string | null
channel: string | null channel: string | null
} }
export const defaultTicketFilters: TicketFiltersState = { export const defaultTicketFilters: TicketFiltersState = {
search: "", search: "",
status: null, status: null,
priority: null, priority: null,
queue: null, queue: null,
channel: null, channel: null,
} }
interface TicketsFiltersProps { interface TicketsFiltersProps {
onChange?: (filters: TicketFiltersState) => void onChange?: (filters: TicketFiltersState) => void
queues?: QueueOption[] queues?: QueueOption[]
} }
const ALL_VALUE = "ALL" const ALL_VALUE = "ALL"
export function TicketsFilters({ onChange, queues = [] }: TicketsFiltersProps) { export function TicketsFilters({ onChange, queues = [] }: TicketsFiltersProps) {
@ -90,30 +90,30 @@ 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])
const activeFilters = useMemo(() => { const activeFilters = useMemo(() => {
const chips: string[] = [] const chips: string[] = []
if (filters.status) chips.push(`Status: ${filters.status}`) if (filters.status) chips.push(`Status: ${filters.status}`)
if (filters.priority) chips.push(`Prioridade: ${filters.priority}`) if (filters.priority) chips.push(`Prioridade: ${filters.priority}`)
if (filters.queue) chips.push(`Fila: ${filters.queue}`) if (filters.queue) chips.push(`Fila: ${filters.queue}`)
if (filters.channel) chips.push(`Canal: ${filters.channel}`) if (filters.channel) chips.push(`Canal: ${filters.channel}`)
return chips return chips
}, [filters]) }, [filters])
return ( return (
<div className="flex flex-col gap-4"> <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-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="flex flex-1 flex-col gap-2 md:flex-row"> <div className="flex flex-1 flex-col gap-2 md:flex-row">
<Input <Input
placeholder="Buscar por assunto ou #ID" placeholder="Buscar por assunto ou #ID"
value={filters.search} value={filters.search}
onChange={(event) => setPartial({ search: event.target.value })} onChange={(event) => setPartial({ search: event.target.value })}
className="md:max-w-sm" className="md:max-w-sm"
/> />
<Select <Select
value={filters.queue ?? ALL_VALUE} value={filters.queue ?? ALL_VALUE}
onValueChange={(value) => setPartial({ queue: value === ALL_VALUE ? null : value })} onValueChange={(value) => setPartial({ queue: value === ALL_VALUE ? null : value })}
@ -130,20 +130,24 @@ export function TicketsFilters({ onChange, queues = [] }: TicketsFiltersProps) {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<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"
Filtros size="sm"
</Button> 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"
</PopoverTrigger> >
<PopoverContent className="w-64 space-y-4"> <IconFilter className="size-4 text-neutral-800" />
<div className="space-y-2"> Filtros
<p className="text-xs font-semibold uppercase text-muted-foreground"> </Button>
Status </PopoverTrigger>
</p> <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 <Select
value={filters.status ?? ALL_VALUE} value={filters.status ?? ALL_VALUE}
onValueChange={(value) => setPartial({ status: value === ALL_VALUE ? null : value })} onValueChange={(value) => setPartial({ status: value === ALL_VALUE ? null : value })}
@ -160,11 +164,11 @@ export function TicketsFilters({ onChange, queues = [] }: TicketsFiltersProps) {
))} ))}
</SelectContent> </SelectContent>
</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
value={filters.priority ?? ALL_VALUE} value={filters.priority ?? ALL_VALUE}
onValueChange={(value) => setPartial({ priority: value === ALL_VALUE ? null : value })} onValueChange={(value) => setPartial({ priority: value === ALL_VALUE ? null : value })}
@ -181,11 +185,11 @@ export function TicketsFilters({ onChange, queues = [] }: TicketsFiltersProps) {
))} ))}
</SelectContent> </SelectContent>
</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
value={filters.channel ?? ALL_VALUE} value={filters.channel ?? ALL_VALUE}
onValueChange={(value) => setPartial({ channel: value === ALL_VALUE ? null : value })} onValueChange={(value) => setPartial({ channel: value === ALL_VALUE ? null : value })}
@ -202,29 +206,29 @@ export function TicketsFilters({ onChange, queues = [] }: TicketsFiltersProps) {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
<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" />
Resetar Resetar
</Button> </Button>
</div> </div>
</div> </div>
{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>
))} ))}
</div> </div>
)} )}
</div> </div>
) )
} }

View file

@ -1,74 +1,79 @@
import Link from "next/link" import Link from "next/link"
import { formatDistanceToNow } from "date-fns" import { formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale" import { ptBR } from "date-fns/locale"
import { tickets as ticketsMock } from "@/lib/mocks/tickets" import { tickets as ticketsMock } from "@/lib/mocks/tickets"
import type { Ticket } from "@/lib/schemas/ticket" import type { Ticket } from "@/lib/schemas/ticket"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Card, CardContent } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
import { import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
Table, import { NewTicketDialog } from "@/components/tickets/new-ticket-dialog"
TableBody, import {
TableCell, Table,
TableHead, TableBody,
TableHeader, TableCell,
TableRow, TableHead,
} from "@/components/ui/table" TableHeader,
import { TicketPriorityPill } from "@/components/tickets/priority-pill" TableRow,
import { TicketStatusBadge } from "@/components/tickets/status-badge" } from "@/components/ui/table"
import { TicketPriorityPill } from "@/components/tickets/priority-pill"
const channelLabel: Record<string, string> = { import { TicketStatusBadge } from "@/components/tickets/status-badge"
EMAIL: "E-mail",
WHATSAPP: "WhatsApp", const channelLabel: Record<string, string> = {
CHAT: "Chat", EMAIL: "E-mail",
PHONE: "Telefone", WHATSAPP: "WhatsApp",
API: "API", CHAT: "Chat",
MANUAL: "Manual", PHONE: "Telefone",
} API: "API",
MANUAL: "Manual",
const cellClass = "py-4 align-top" }
function AssigneeCell({ ticket }: { ticket: Ticket }) { const cellClass = "py-4 align-top"
if (!ticket.assignee) { 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"
return <span className="text-sm text-muted-foreground">Sem responsável</span> 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"
const initials = ticket.assignee.name function AssigneeCell({ ticket }: { ticket: Ticket }) {
.split(" ") if (!ticket.assignee) {
.slice(0, 2) return <span className="text-sm text-neutral-600">Sem responsável</span>
.map((part) => part[0]?.toUpperCase()) }
.join("")
const initials = ticket.assignee.name
return ( .split(" ")
<div className="flex items-center gap-2"> .slice(0, 2)
<Avatar className="size-8"> .map((part) => part[0]?.toUpperCase())
<AvatarImage src={ticket.assignee.avatarUrl} alt={ticket.assignee.name} /> .join("")
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<span className="text-sm font-medium leading-none text-foreground">
{ticket.assignee.name}
</span>
<span className="text-xs text-muted-foreground">
{ticket.assignee.teams?.[0] ?? "Agente"}
</span>
</div>
</div>
)
}
type TicketsTableProps = {
tickets?: Ticket[]
}
export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
return ( return (
<Card className="border bg-card/90 shadow-sm"> <div className="flex items-center gap-2">
<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-semibold leading-none text-neutral-900">
{ticket.assignee.name}
</span>
<span className="text-xs text-neutral-600">
{ticket.assignee.teams?.[0] ?? "Agente"}
</span>
</div>
</div>
)
}
export type TicketsTableProps = {
tickets?: Ticket[]
}
export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
return (
<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>
@ -81,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>
@ -99,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>
))} ))}
@ -121,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>
@ -137,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>
@ -147,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>
@ -159,14 +156,22 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
</TableBody> </TableBody>
</Table> </Table>
{tickets.length === 0 && ( {tickets.length === 0 && (
<div className="flex flex-col items-center justify-center gap-2 py-10 text-center text-sm"> <Empty className="my-6">
<p className="text-sm font-medium">Nenhum ticket encontrado</p> <EmptyHeader>
<p className="text-sm text-muted-foreground"> <EmptyMedia variant="icon">
Ajuste os filtros ou selecione outra fila. <span className="inline-block size-3 rounded-full border border-slate-300 bg-[#00e8ff]" />
</p> </EmptyMedia>
</div> <EmptyTitle className="text-neutral-900">Nenhum ticket encontrado</EmptyTitle>
<EmptyDescription className="text-neutral-600">
Ajuste os filtros ou crie um novo ticket.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<NewTicketDialog />
</EmptyContent>
</Empty>
)} )}
</CardContent> </CardContent>
</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>
@ -52,4 +51,4 @@ export function TicketsView() {
</div> </div>
) )
} }

View file

@ -4,26 +4,26 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const badgeVariants = cva( const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{ {
variants: { variants: {
variant: { variant: {
default: default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary: secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive: destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline: outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
}, },
} }
) )
function Badge({ function Badge({
className, className,

View file

@ -1,30 +1,31 @@
import * as React from "react" import * as React from "react"
import { cn } from "@/lib/utils" 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>(
({ className, value = 0, ...props }, ref) => { export const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
const clamped = Math.min(100, Math.max(0, value)) ({ className, value = 0, indicatorClassName, ...props }, ref) => {
return ( const clamped = Math.min(100, Math.max(0, value))
<div return (
ref={ref} <div
className={cn( ref={ref}
"relative h-2 w-full overflow-hidden rounded-full bg-muted", className={cn(
className "relative h-2 w-full overflow-hidden rounded-full bg-muted",
)} className
{...props} )}
> {...props}
<div >
className="h-full w-full flex-1 bg-primary transition-all" <div
style={{ transform: `translateX(-${100 - clamped}%)` }} className={cn("h-full w-full flex-1 bg-primary transition-all", indicatorClassName)}
/> style={{ transform: `translateX(-${100 - clamped}%)` }}
</div> />
) </div>
} )
) }
)
Progress.displayName = "Progress" Progress.displayName = "Progress"

View file

@ -6,20 +6,30 @@ import { Toaster as Sonner, ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => { const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme() const { theme = "system" } = useTheme()
return ( return (
<Sonner <Sonner
theme={theme as ToasterProps["theme"]} theme={theme as ToasterProps["theme"]}
className="toaster group" className="toaster group"
style={ toastOptions={{
{ classNames: {
"--normal-bg": "var(--popover)", toast: "border border-black bg-black text-white shadow-md",
"--normal-text": "var(--popover-foreground)", title: "font-medium",
"--normal-border": "var(--border)", description: "text-white/80",
} as React.CSSProperties icon: "text-cyan-400",
} actionButton: "bg-white text-black border border-black",
{...props} cancelButton: "bg-transparent text-white border border-white/40",
/> },
) }}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...props}
/>
)
} }
export { Toaster } export { Toaster }