Implementa sistema de chat em tempo real entre agente e cliente

- Adiciona tabela liveChatSessions no schema Convex
- Cria convex/liveChat.ts com mutations e queries para chat
- Adiciona API routes para maquinas (sessions, messages, poll)
- Cria modulo chat.rs no Tauri com ChatRuntime e polling
- Adiciona comandos de chat no lib.rs (start/stop polling, open/close window)
- Cria componentes React do chat widget (ChatWidget, types)
- Adiciona botao "Iniciar Chat" no dashboard (ticket-chat-panel)
- Implementa menu de chat no system tray
- Polling de 2 segundos para maior responsividade
- Janela de chat flutuante, frameless, always-on-top

🤖 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:00:27 -03:00
parent 0c8d53c0b6
commit ba91c1e0f5
15 changed files with 2004 additions and 15 deletions

View file

@ -13,6 +13,7 @@ import { cn } from "@/lib/utils"
import { toast } from "sonner"
import { formatDistanceToNowStrict } from "date-fns"
import { ptBR } from "date-fns/locale"
import { MessageCircle, MonitorSmartphone, WifiOff, X } from "lucide-react"
const MAX_MESSAGE_LENGTH = 4000
@ -41,6 +42,18 @@ export function TicketChatPanel({ ticketId }: TicketChatPanelProps) {
status: string
canPost: boolean
reopenDeadline: number | null
liveChat: {
hasMachine: boolean
machineOnline: boolean
machineHostname: string | null
activeSession: {
sessionId: Id<"liveChatSessions">
agentId: Id<"users">
agentName: string | null
startedAt: number
unreadByAgent: number
} | null
}
messages: Array<{
id: Id<"ticketChatMessages">
body: string
@ -58,9 +71,13 @@ export function TicketChatPanel({ ticketId }: TicketChatPanelProps) {
const markChatRead = useMutation(api.tickets.markChatRead)
const postChatMessage = useMutation(api.tickets.postChatMessage)
const startLiveChat = useMutation(api.liveChat.startSession)
const endLiveChat = useMutation(api.liveChat.endSession)
const messagesEndRef = useRef<HTMLDivElement | null>(null)
const [draft, setDraft] = useState("")
const [isSending, setIsSending] = useState(false)
const [isStartingChat, setIsStartingChat] = useState(false)
const [isEndingChat, setIsEndingChat] = useState(false)
const messages = chat?.messages ?? []
const canPost = Boolean(chat?.canPost && viewerId)
@ -96,10 +113,50 @@ export function TicketChatPanel({ ticketId }: TicketChatPanelProps) {
return null
}, [canPost, chatEnabled])
const handleStartLiveChat = async () => {
if (!viewerId) return
setIsStartingChat(true)
toast.dismiss("live-chat")
try {
const result = await startLiveChat({
ticketId: ticketId as Id<"tickets">,
actorId: viewerId as Id<"users">,
})
if (result.isNew) {
toast.success("Chat ao vivo iniciado! O cliente sera notificado.", { id: "live-chat" })
} else {
toast.info("Ja existe uma sessao de chat ativa.", { id: "live-chat" })
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Nao foi possivel iniciar o chat"
toast.error(message, { id: "live-chat" })
} finally {
setIsStartingChat(false)
}
}
const handleEndLiveChat = async () => {
if (!viewerId || !chat?.liveChat?.activeSession) return
setIsEndingChat(true)
toast.dismiss("live-chat")
try {
await endLiveChat({
sessionId: chat.liveChat.activeSession.sessionId,
actorId: viewerId as Id<"users">,
})
toast.success("Chat ao vivo encerrado.", { id: "live-chat" })
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Nao foi possivel encerrar o chat"
toast.error(message, { id: "live-chat" })
} finally {
setIsEndingChat(false)
}
}
const handleSend = async () => {
if (!viewerId || !canPost || draft.trim().length === 0) return
if (draft.length > MAX_MESSAGE_LENGTH) {
toast.error(`Mensagem muito longa (máx. ${MAX_MESSAGE_LENGTH} caracteres).`)
toast.error(`Mensagem muito longa (max. ${MAX_MESSAGE_LENGTH} caracteres).`)
return
}
setIsSending(true)
@ -115,7 +172,7 @@ export function TicketChatPanel({ ticketId }: TicketChatPanelProps) {
toast.success("Mensagem enviada!", { id: "ticket-chat" })
} catch (error) {
console.error(error)
toast.error("Não foi possível enviar a mensagem.", { id: "ticket-chat" })
toast.error("Nao foi possivel enviar a mensagem.", { id: "ticket-chat" })
} finally {
setIsSending(false)
}
@ -125,15 +182,85 @@ export function TicketChatPanel({ ticketId }: TicketChatPanelProps) {
return null
}
const liveChat = chat?.liveChat
const hasActiveSession = Boolean(liveChat?.activeSession)
return (
<Card className="border-slate-200">
<CardHeader className="flex flex-row items-center justify-between gap-2">
<CardTitle className="text-base font-semibold text-neutral-800">Chat do atendimento</CardTitle>
{!chatEnabled ? (
<span className="text-xs font-medium text-neutral-500">Chat desativado</span>
) : null}
<div className="flex items-center gap-2">
<CardTitle className="text-base font-semibold text-neutral-800">Chat do atendimento</CardTitle>
{liveChat?.hasMachine && (
<div className="flex items-center gap-1.5">
{liveChat.machineOnline ? (
<span className="flex items-center gap-1 rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700">
<span className="size-1.5 rounded-full bg-green-500" />
Online
</span>
) : (
<span className="flex items-center gap-1 rounded-full bg-neutral-100 px-2 py-0.5 text-xs font-medium text-neutral-500">
<WifiOff className="size-3" />
Offline
</span>
)}
</div>
)}
</div>
<div className="flex items-center gap-2">
{!chatEnabled ? (
<span className="text-xs font-medium text-neutral-500">Chat desativado</span>
) : null}
{liveChat?.hasMachine && (
<>
{hasActiveSession ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleEndLiveChat}
disabled={isEndingChat}
className="gap-1.5 border-red-200 text-red-600 hover:bg-red-50 hover:text-red-700"
>
{isEndingChat ? <Spinner className="size-3" /> : <X className="size-3" />}
Encerrar Chat
</Button>
) : (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleStartLiveChat}
disabled={isStartingChat || !liveChat.machineOnline}
className="gap-1.5"
title={!liveChat.machineOnline ? "A maquina precisa estar online para iniciar o chat" : undefined}
>
{isStartingChat ? <Spinner className="size-3" /> : <MessageCircle className="size-3" />}
Iniciar Chat
</Button>
)}
</>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
{hasActiveSession && liveChat?.activeSession && (
<div className="flex items-center gap-2 rounded-lg border border-green-200 bg-green-50 px-3 py-2">
<div className="flex size-6 items-center justify-center rounded-full bg-green-100">
<MessageCircle className="size-3.5 text-green-600" />
</div>
<div className="flex-1">
<p className="text-sm font-medium text-green-800">Chat ao vivo ativo</p>
<p className="text-xs text-green-600">
Iniciado por {liveChat.activeSession.agentName ?? "agente"} {formatRelative(liveChat.activeSession.startedAt)}
{liveChat.activeSession.unreadByAgent > 0 && (
<span className="ml-2 rounded-full bg-green-600 px-1.5 py-0.5 text-white">
{liveChat.activeSession.unreadByAgent} nao lidas
</span>
)}
</p>
</div>
</div>
)}
{chat === undefined ? (
<div className="flex items-center justify-center gap-2 rounded-lg border border-dashed border-slate-200 py-6 text-sm text-neutral-500">
<Spinner className="size-4" /> Carregando mensagens...