Adiciona widget de chat flutuante global

- Widget no canto inferior direito em todas as paginas
- Mostra sessoes de chat ativas do agente
- Suporta multiplas sessoes com seletor
- Badge com contador de mensagens nao lidas
- Pode minimizar ou fechar
- Query listAgentSessions para buscar sessoes ativas

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
esdrasrenan 2025-12-07 01:31:00 -03:00
parent 2a78d14a74
commit 8c465008bf
4 changed files with 360 additions and 1 deletions

View file

@ -510,3 +510,45 @@ export const getTicketSession = query({
} }
}, },
}) })
// Listar sessoes ativas de um agente (para widget flutuante)
export const listAgentSessions = query({
args: {
agentId: v.id("users"),
},
handler: async (ctx, { agentId }) => {
const agent = await ctx.db.get(agentId)
if (!agent) {
return []
}
// Buscar todas as sessoes ativas do tenant do agente
const sessions = await ctx.db
.query("liveChatSessions")
.withIndex("by_tenant_status", (q) =>
q.eq("tenantId", agent.tenantId).eq("status", "ACTIVE")
)
.collect()
// Buscar detalhes dos tickets
const result = await Promise.all(
sessions.map(async (session) => {
const ticket = await ctx.db.get(session.ticketId)
return {
ticketId: session.ticketId,
ticketRef: ticket?.reference ?? 0,
ticketSubject: ticket?.subject ?? "",
sessionId: session._id,
agentId: session.agentId,
agentName: session.agentSnapshot?.name ?? "Agente",
unreadCount: session.unreadByAgent ?? 0,
lastActivityAt: session.lastActivityAt,
startedAt: session.startedAt,
}
})
)
// Ordenar por ultima atividade (mais recente primeiro)
return result.sort((a, b) => b.lastActivityAt - a.lastActivityAt)
},
})

View file

@ -4,7 +4,8 @@ import "./globals.css"
import { ConvexClientProvider } from "./ConvexClientProvider" import { ConvexClientProvider } from "./ConvexClientProvider"
import { AuthProvider } from "@/lib/auth-client" import { AuthProvider } from "@/lib/auth-client"
import { Toaster } from "@/components/ui/sonner" import { Toaster } from "@/components/ui/sonner"
import { ChatWidgetProvider } from "@/components/chat/chat-widget-provider"
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Raven - Sistema de chamados", title: "Raven - Sistema de chamados",
description: "Plataforma Raven da Rever", description: "Plataforma Raven da Rever",
@ -35,6 +36,7 @@ export default async function RootLayout({
<ConvexClientProvider> <ConvexClientProvider>
<AuthProvider> <AuthProvider>
{children} {children}
<ChatWidgetProvider />
<Toaster position="bottom-center" richColors /> <Toaster position="bottom-center" richColors />
</AuthProvider> </AuthProvider>
</ConvexClientProvider> </ConvexClientProvider>

View file

@ -0,0 +1,13 @@
"use client"
import dynamic from "next/dynamic"
// Importacao dinamica para evitar problemas de SSR
const ChatWidget = dynamic(
() => import("./chat-widget").then((mod) => ({ default: mod.ChatWidget })),
{ ssr: false }
)
export function ChatWidgetProvider() {
return <ChatWidget />
}

View file

@ -0,0 +1,302 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { useMutation, useQuery } from "convex/react"
import type { Id } from "@/convex/_generated/dataModel"
import { api } from "@/convex/_generated/api"
import { useAuth } from "@/lib/auth-client"
import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"
import { cn } from "@/lib/utils"
import { toast } from "sonner"
import { MessageCircle, Send, X, Minimize2, User, Headphones, ChevronDown } from "lucide-react"
const MAX_MESSAGE_LENGTH = 4000
function formatTime(timestamp: number) {
return new Date(timestamp).toLocaleTimeString("pt-BR", {
hour: "2-digit",
minute: "2-digit",
})
}
type ChatSession = {
ticketId: string
ticketRef: number
ticketSubject: string
sessionId: string
unreadCount: number
}
export function ChatWidget() {
const { convexUserId } = useAuth()
const viewerId = convexUserId ?? null
const [isOpen, setIsOpen] = useState(false)
const [isMinimized, setIsMinimized] = useState(false)
const [activeTicketId, setActiveTicketId] = useState<string | null>(null)
const [draft, setDraft] = useState("")
const [isSending, setIsSending] = useState(false)
const messagesEndRef = useRef<HTMLDivElement | null>(null)
const inputRef = useRef<HTMLTextAreaElement | null>(null)
// Buscar sessoes de chat ativas do agente
const activeSessions = useQuery(
api.liveChat.listAgentSessions,
viewerId ? { agentId: viewerId as Id<"users"> } : "skip"
) as ChatSession[] | undefined
// Buscar mensagens do chat ativo
const chat = useQuery(
api.tickets.listChatMessages,
viewerId && activeTicketId
? { ticketId: activeTicketId as Id<"tickets">, viewerId: viewerId as Id<"users"> }
: "skip"
)
const postChatMessage = useMutation(api.tickets.postChatMessage)
const markChatRead = useMutation(api.tickets.markChatRead)
const messages = chat?.messages ?? []
const totalUnread = activeSessions?.reduce((sum, s) => sum + s.unreadCount, 0) ?? 0
// Auto-selecionar primeira sessao se nenhuma selecionada
useEffect(() => {
if (!activeTicketId && activeSessions && activeSessions.length > 0) {
setActiveTicketId(activeSessions[0].ticketId)
}
}, [activeTicketId, activeSessions])
// Scroll para ultima mensagem
useEffect(() => {
if (messagesEndRef.current && isOpen && !isMinimized) {
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
}
}, [messages.length, isOpen, isMinimized])
// Marcar mensagens como lidas
useEffect(() => {
if (!viewerId || !chat || !activeTicketId || !isOpen || isMinimized) return
const unreadIds = chat.messages
?.filter((msg) => !msg.readBy?.some((r) => r.userId === viewerId))
.map((msg) => msg.id) ?? []
if (unreadIds.length === 0) return
markChatRead({
ticketId: activeTicketId as Id<"tickets">,
actorId: viewerId as Id<"users">,
messageIds: unreadIds,
}).catch(console.error)
}, [viewerId, chat, activeTicketId, isOpen, isMinimized, markChatRead])
const handleSend = async () => {
if (!viewerId || !activeTicketId || !draft.trim()) return
if (draft.length > MAX_MESSAGE_LENGTH) {
toast.error(`Mensagem muito longa (max. ${MAX_MESSAGE_LENGTH} caracteres).`)
return
}
setIsSending(true)
try {
await postChatMessage({
ticketId: activeTicketId as Id<"tickets">,
actorId: viewerId as Id<"users">,
body: draft.trim(),
})
setDraft("")
inputRef.current?.focus()
} catch (error) {
console.error(error)
toast.error("Nao foi possivel enviar a mensagem.")
} finally {
setIsSending(false)
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
// Nao mostrar se nao logado ou sem sessoes
if (!viewerId) return null
if (!activeSessions || activeSessions.length === 0) return null
const activeSession = activeSessions.find((s) => s.ticketId === activeTicketId)
return (
<div className="fixed bottom-4 right-4 z-50 flex flex-col items-end gap-2">
{/* Widget aberto */}
{isOpen && !isMinimized && (
<div className="flex h-[500px] w-[380px] flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between border-b border-slate-100 bg-black px-4 py-3 text-white">
<div className="flex items-center gap-3">
<div className="flex size-8 items-center justify-center rounded-full bg-white/20">
<MessageCircle className="size-4" />
</div>
<div>
<p className="text-sm font-semibold">Chat Ativo</p>
{activeSession && (
<p className="text-xs text-white/70">#{activeSession.ticketRef}</p>
)}
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setIsMinimized(true)}
className="rounded p-1.5 hover:bg-white/10"
>
<Minimize2 className="size-4" />
</button>
<button
onClick={() => setIsOpen(false)}
className="rounded p-1.5 hover:bg-white/10"
>
<X className="size-4" />
</button>
</div>
</div>
{/* Seletor de sessoes (se mais de uma) */}
{activeSessions.length > 1 && (
<div className="border-b border-slate-100 bg-slate-50 px-3 py-2">
<select
value={activeTicketId ?? ""}
onChange={(e) => setActiveTicketId(e.target.value)}
className="w-full rounded border border-slate-200 bg-white px-2 py-1 text-sm"
>
{activeSessions.map((session) => (
<option key={session.ticketId} value={session.ticketId}>
#{session.ticketRef} - {session.ticketSubject.slice(0, 30)}
{session.unreadCount > 0 ? ` (${session.unreadCount})` : ""}
</option>
))}
</select>
</div>
)}
{/* Mensagens */}
<div className="flex-1 overflow-y-auto bg-slate-50/50 p-4">
{!chat ? (
<div className="flex h-full items-center justify-center">
<Spinner className="size-6 text-slate-400" />
</div>
) : messages.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center text-center">
<div className="flex size-12 items-center justify-center rounded-full bg-slate-100">
<MessageCircle className="size-6 text-slate-400" />
</div>
<p className="mt-3 text-sm font-medium text-slate-600">Nenhuma mensagem</p>
<p className="mt-1 text-xs text-slate-400">Envie uma mensagem para o cliente</p>
</div>
) : (
<div className="space-y-3">
{messages.map((msg) => {
const isOwn = String(msg.authorId) === String(viewerId)
return (
<div
key={msg.id}
className={cn("flex gap-2", isOwn ? "flex-row-reverse" : "flex-row")}
>
<div
className={cn(
"flex size-7 shrink-0 items-center justify-center rounded-full",
isOwn ? "bg-black text-white" : "bg-slate-200 text-slate-600"
)}
>
{isOwn ? <Headphones className="size-3.5" /> : <User className="size-3.5" />}
</div>
<div
className={cn(
"max-w-[70%] rounded-2xl px-3 py-2",
isOwn
? "rounded-br-md bg-black text-white"
: "rounded-bl-md border border-slate-100 bg-white text-slate-900 shadow-sm"
)}
>
{!isOwn && (
<p className="mb-0.5 text-xs font-medium text-slate-500">
{msg.authorName ?? "Cliente"}
</p>
)}
<p className="whitespace-pre-wrap text-sm">{msg.body}</p>
<p className={cn("mt-1 text-right text-xs", isOwn ? "text-white/60" : "text-slate-400")}>
{formatTime(msg.createdAt)}
</p>
</div>
</div>
)
})}
<div ref={messagesEndRef} />
</div>
)}
</div>
{/* Input */}
<div className="border-t border-slate-200 bg-white p-3">
<div className="flex items-end gap-2">
<textarea
ref={inputRef}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Digite sua mensagem..."
className="max-h-20 min-h-[36px] flex-1 resize-none rounded-lg border border-slate-200 px-3 py-2 text-sm focus:border-black focus:outline-none focus:ring-1 focus:ring-black"
rows={1}
disabled={isSending}
/>
<Button
type="button"
onClick={handleSend}
disabled={!draft.trim() || isSending}
className="flex size-9 items-center justify-center rounded-lg bg-black text-white hover:bg-black/90"
size="icon"
>
{isSending ? <Spinner className="size-4" /> : <Send className="size-4" />}
</Button>
</div>
</div>
</div>
)}
{/* Widget minimizado */}
{isOpen && isMinimized && (
<button
onClick={() => setIsMinimized(false)}
className="flex items-center gap-2 rounded-full bg-black px-4 py-2 text-white shadow-lg hover:bg-black/90"
>
<MessageCircle className="size-4" />
<span className="text-sm font-medium">
Chat #{activeSession?.ticketRef}
</span>
{totalUnread > 0 && (
<span className="flex size-5 items-center justify-center rounded-full bg-red-500 text-xs font-bold">
{totalUnread}
</span>
)}
<ChevronDown className="size-4" />
</button>
)}
{/* Botao flutuante */}
{!isOpen && (
<button
onClick={() => {
setIsOpen(true)
setIsMinimized(false)
}}
className="relative flex size-14 items-center justify-center rounded-full bg-black text-white shadow-lg transition-transform hover:scale-105 hover:bg-black/90"
>
<MessageCircle className="size-6" />
{totalUnread > 0 && (
<span className="absolute -right-1 -top-1 flex size-6 items-center justify-center rounded-full bg-red-500 text-xs font-bold">
{totalUnread}
</span>
)}
</button>
)}
</div>
)
}