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:
parent
0c8d53c0b6
commit
ba91c1e0f5
15 changed files with 2004 additions and 15 deletions
|
|
@ -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...
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue