feat(chat): adiciona interface de lista de chats estilo WhatsApp

- Cria ChatSessionList e ChatSessionItem para listar sessões ativas
- Refatora ChatWidget para usar viewMode (list/chat)
- Ordena por não lidos primeiro, depois por última atividade
- Adiciona botão de voltar quando há múltiplos chats
- Persiste viewMode no localStorage

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rever-tecnologia 2025-12-15 12:03:40 -03:00
parent bc5ba0c73a
commit 95ab1b5f0c
3 changed files with 359 additions and 107 deletions

View file

@ -0,0 +1,107 @@
"use client"
import { cn } from "@/lib/utils"
import { MessageCircle, WifiOff } from "lucide-react"
type ChatSession = {
ticketId: string
ticketRef: number
ticketSubject: string
sessionId: string
agentId: string
unreadCount: number
lastActivityAt: number
machineHostname?: string | null
machineOnline?: boolean
}
type ChatSessionItemProps = {
session: ChatSession
isActive?: boolean
onClick: () => void
}
function formatTime(timestamp: number) {
const now = Date.now()
const diff = now - timestamp
const minutes = Math.floor(diff / 60000)
const hours = Math.floor(diff / 3600000)
const days = Math.floor(diff / 86400000)
if (minutes < 1) return "Agora"
if (minutes < 60) return `${minutes}min`
if (hours < 24) return `${hours}h`
if (days === 1) return "Ontem"
return new Date(timestamp).toLocaleDateString("pt-BR", {
day: "2-digit",
month: "2-digit",
})
}
export function ChatSessionItem({ session, isActive, onClick }: ChatSessionItemProps) {
const hasUnread = session.unreadCount > 0
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex w-full items-start gap-3 border-b border-slate-100 px-4 py-3 text-left transition-colors hover:bg-slate-50",
isActive && "bg-slate-100",
hasUnread && "bg-red-50/50 hover:bg-red-50"
)}
>
{/* Avatar/Icone */}
<div
className={cn(
"flex size-10 shrink-0 items-center justify-center rounded-full",
hasUnread ? "bg-red-100 text-red-600" : "bg-slate-100 text-slate-600"
)}
>
<MessageCircle className="size-5" />
</div>
{/* Conteudo */}
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className={cn("text-sm font-semibold", hasUnread ? "text-red-700" : "text-slate-900")}>
#{session.ticketRef}
</span>
{/* Indicador online/offline */}
{session.machineOnline !== undefined && (
session.machineOnline ? (
<span className="size-2 rounded-full bg-emerald-500" title="Online" />
) : (
<WifiOff className="size-3 text-slate-400" title="Offline" />
)
)}
</div>
<span className="text-xs text-slate-400">
{formatTime(session.lastActivityAt)}
</span>
</div>
<p className="mt-0.5 truncate text-sm text-slate-600">
{session.ticketSubject}
</p>
{session.machineHostname && (
<p className="mt-0.5 truncate text-xs text-slate-400">
{session.machineHostname}
</p>
)}
</div>
{/* Badge de nao lidos */}
{hasUnread && (
<div className="flex shrink-0 items-center justify-center">
<span className="flex size-5 items-center justify-center rounded-full bg-red-500 text-xs font-bold text-white">
{session.unreadCount > 9 ? "9+" : session.unreadCount}
</span>
</div>
)}
</button>
)
}

View file

@ -0,0 +1,112 @@
"use client"
import { useMemo } from "react"
import { MessageCircle, X } from "lucide-react"
import { ChatSessionItem } from "./chat-session-item"
type ChatSession = {
ticketId: string
ticketRef: number
ticketSubject: string
sessionId: string
agentId: string
unreadCount: number
lastActivityAt: number
machineHostname?: string | null
machineOnline?: boolean
}
type ChatSessionListProps = {
sessions: ChatSession[]
activeTicketId?: string | null
onSelectSession: (ticketId: string) => void
onClose: () => void
onMinimize: () => void
}
export function ChatSessionList({
sessions,
activeTicketId,
onSelectSession,
onClose,
onMinimize,
}: ChatSessionListProps) {
// Ordenar: nao lidos primeiro, depois por ultima atividade (desc)
const sortedSessions = useMemo(() => {
return [...sessions].sort((a, b) => {
// Nao lidos primeiro
if (a.unreadCount > 0 && b.unreadCount === 0) return -1
if (a.unreadCount === 0 && b.unreadCount > 0) return 1
// Depois por ultima atividade (mais recente primeiro)
return b.lastActivityAt - a.lastActivityAt
})
}, [sessions])
const totalUnread = sessions.reduce((sum, s) => sum + s.unreadCount, 0)
return (
<div className="flex h-full flex-col">
{/* Header */}
<div className="flex items-center justify-between border-b border-slate-200 bg-white px-4 py-3">
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-full bg-black text-white">
<MessageCircle className="size-5" />
</div>
<div>
<p className="text-sm font-semibold text-slate-900">Chats</p>
<p className="text-xs text-slate-500">
{sessions.length} conversa{sessions.length !== 1 ? "s" : ""} ativa{sessions.length !== 1 ? "s" : ""}
{totalUnread > 0 && (
<span className="ml-1 text-red-500">
({totalUnread} nao lida{totalUnread !== 1 ? "s" : ""})
</span>
)}
</p>
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={onMinimize}
className="rounded-md p-1.5 text-slate-500 hover:bg-slate-100"
title="Minimizar"
>
<svg className="size-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
</svg>
</button>
<button
onClick={onClose}
className="rounded-md p-1.5 text-slate-500 hover:bg-slate-100"
title="Fechar"
>
<X className="size-4" />
</button>
</div>
</div>
{/* Lista de sessoes */}
<div className="flex-1 overflow-y-auto bg-white">
{sortedSessions.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center p-4 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">Nenhum chat ativo</p>
<p className="mt-1 text-xs text-slate-400">
Inicie um chat em um ticket para comecar
</p>
</div>
) : (
sortedSessions.map((session) => (
<ChatSessionItem
key={session.ticketId}
session={session}
isActive={session.ticketId === activeTicketId}
onClick={() => onSelectSession(session.ticketId)}
/>
))
)}
</div>
</div>
)
}

View file

@ -8,13 +8,6 @@ import { api } from "@/convex/_generated/api"
import { useAuth } from "@/lib/auth-client" import { useAuth } from "@/lib/auth-client"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner" import { Spinner } from "@/components/ui/spinner"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { toast } from "sonner" import { toast } from "sonner"
import { import {
@ -24,6 +17,7 @@ import {
Minimize2, Minimize2,
User, User,
ChevronDown, ChevronDown,
ChevronLeft,
WifiOff, WifiOff,
XCircle, XCircle,
Paperclip, Paperclip,
@ -34,16 +28,20 @@ import {
Eye, Eye,
Check, Check,
} from "lucide-react" } from "lucide-react"
import { ChatSessionList } from "./chat-session-list"
const MAX_MESSAGE_LENGTH = 4000 const MAX_MESSAGE_LENGTH = 4000
const MAX_ATTACHMENT_SIZE = 5 * 1024 * 1024 // 5MB const MAX_ATTACHMENT_SIZE = 5 * 1024 * 1024 // 5MB
const MAX_ATTACHMENTS = 5 const MAX_ATTACHMENTS = 5
const STORAGE_KEY = "chat-widget-state" const STORAGE_KEY = "chat-widget-state"
type ViewMode = "list" | "chat"
type ChatWidgetState = { type ChatWidgetState = {
isOpen: boolean isOpen: boolean
isMinimized: boolean isMinimized: boolean
activeTicketId: string | null activeTicketId: string | null
viewMode: ViewMode
} }
function formatTime(timestamp: number) { function formatTime(timestamp: number) {
@ -315,6 +313,17 @@ export function ChatWidget() {
} catch {} } catch {}
return null return null
}) })
const [viewMode, setViewMode] = useState<ViewMode>(() => {
if (typeof window === "undefined") return "list"
try {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved) {
const state = JSON.parse(saved) as ChatWidgetState
return state.viewMode ?? "list"
}
} catch {}
return "list"
})
const [draft, setDraft] = useState("") const [draft, setDraft] = useState("")
const [isSending, setIsSending] = useState(false) const [isSending, setIsSending] = useState(false)
const [isEndingChat, setIsEndingChat] = useState(false) const [isEndingChat, setIsEndingChat] = useState(false)
@ -369,6 +378,7 @@ export function ChatWidget() {
const state = JSON.parse(event.newValue) as ChatWidgetState const state = JSON.parse(event.newValue) as ChatWidgetState
setIsOpen(state.isOpen) setIsOpen(state.isOpen)
setIsMinimized(state.isMinimized) setIsMinimized(state.isMinimized)
setViewMode(state.viewMode ?? "list")
if (state.activeTicketId) { if (state.activeTicketId) {
setActiveTicketId(state.activeTicketId) setActiveTicketId(state.activeTicketId)
} }
@ -387,20 +397,32 @@ export function ChatWidget() {
isOpen, isOpen,
isMinimized, isMinimized,
activeTicketId, activeTicketId,
viewMode,
} }
// Salvar no localStorage (isso dispara evento storage em outras abas automaticamente) // Salvar no localStorage (isso dispara evento storage em outras abas automaticamente)
try { try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state)) localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
} catch {} } catch {}
}, [isOpen, isMinimized, activeTicketId]) }, [isOpen, isMinimized, activeTicketId, viewMode])
// Auto-selecionar primeira sessão se nenhuma selecionada // Auto-selecionar modo e sessao baseado na quantidade de sessoes
useEffect(() => { useEffect(() => {
if (!activeTicketId && activeSessions && activeSessions.length > 0) { if (!activeSessions) return
if (activeSessions.length === 0) {
// Sem sessoes, limpar estado
setActiveTicketId(null)
setViewMode("list")
} else if (activeSessions.length === 1) {
// Apenas 1 sessao, ir direto para chat
setActiveTicketId(activeSessions[0].ticketId) setActiveTicketId(activeSessions[0].ticketId)
setViewMode("chat")
} else if (!activeTicketId) {
// Multiplas sessoes mas nenhuma selecionada, mostrar lista
setViewMode("list")
} }
}, [activeTicketId, activeSessions]) }, [activeSessions, activeTicketId])
// Auto-abrir o widget quando ESTE agente iniciar uma nova sessão de chat. // Auto-abrir o widget quando ESTE agente iniciar uma nova sessão de chat.
// Nao roda na montagem inicial para nao sobrescrever o estado do localStorage. // Nao roda na montagem inicial para nao sobrescrever o estado do localStorage.
@ -648,6 +670,16 @@ export function ChatWidget() {
} }
} }
// Handlers para navegacao lista/chat
const handleSelectSession = (ticketId: string) => {
setActiveTicketId(ticketId)
setViewMode("chat")
}
const handleBackToList = () => {
setViewMode("list")
}
// Nao mostrar se esta no Tauri (usa o chat nativo) // Nao mostrar se esta no Tauri (usa o chat nativo)
if (isTauriContext) return null if (isTauriContext) return null
@ -670,15 +702,38 @@ export function ChatWidget() {
{/* Widget aberto */} {/* Widget aberto */}
{isOpen && !isMinimized && ( {isOpen && !isMinimized && (
<div className="flex h-[520px] w-[400px] flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-2xl"> <div className="flex h-[520px] w-[400px] flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-2xl">
{/* Header - Estilo card da aplicação */} {/* Modo Lista - mostra quando viewMode === "list" e ha multiplas sessoes */}
{viewMode === "list" && activeSessions.length > 1 ? (
<ChatSessionList
sessions={activeSessions}
activeTicketId={activeTicketId}
onSelectSession={handleSelectSession}
onClose={() => setIsOpen(false)}
onMinimize={() => setIsMinimized(true)}
/>
) : (
<>
{/* Header - Modo Chat */}
<div className="flex items-center justify-between border-b border-slate-200 bg-white px-4 py-3"> <div className="flex items-center justify-between border-b border-slate-200 bg-white px-4 py-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{/* Botao voltar para lista (quando ha multiplas sessoes) */}
{activeSessions.length > 1 && (
<button
onClick={handleBackToList}
className="flex size-8 items-center justify-center rounded-lg text-slate-500 hover:bg-slate-100"
title="Voltar para lista"
>
<ChevronLeft className="size-5" />
</button>
)}
<div className="flex size-10 items-center justify-center rounded-full bg-black text-white"> <div className="flex size-10 items-center justify-center rounded-full bg-black text-white">
<MessageCircle className="size-5" /> <MessageCircle className="size-5" />
</div> </div>
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<p className="text-sm font-semibold text-slate-900">Chat</p> <p className="text-sm font-semibold text-slate-900">
{activeSession ? `#${activeSession.ticketRef}` : "Chat"}
</p>
{/* Indicador online/offline */} {/* Indicador online/offline */}
{liveChat?.hasMachine && ( {liveChat?.hasMachine && (
machineOnline ? ( machineOnline ? (
@ -702,7 +757,7 @@ export function ChatWidget() {
rel="noopener noreferrer" rel="noopener noreferrer"
className="group flex items-center gap-1 text-xs text-slate-500 hover:text-slate-900 transition-colors" className="group flex items-center gap-1 text-xs text-slate-500 hover:text-slate-900 transition-colors"
> >
<span>#{activeSession.ticketRef}</span> <span className="max-w-[160px] truncate">{activeSession.ticketSubject}</span>
<ExternalLink className="size-3 opacity-0 group-hover:opacity-100 transition-opacity" /> <ExternalLink className="size-3 opacity-0 group-hover:opacity-100 transition-opacity" />
</a> </a>
{machineHostname && ( {machineHostname && (
@ -715,7 +770,7 @@ export function ChatWidget() {
</div> </div>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{/* Botão encerrar chat */} {/* Botao encerrar chat */}
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@ -747,30 +802,6 @@ export function ChatWidget() {
</div> </div>
</div> </div>
{/* Seletor de sessões (se mais de uma) */}
{activeSessions.length > 1 && (
<div className="border-b border-slate-100 bg-slate-50 px-3 py-2">
<Select
value={activeTicketId ?? ""}
onValueChange={setActiveTicketId}
>
<SelectTrigger className="h-8 w-full border-slate-200 bg-white text-sm">
<SelectValue placeholder="Selecione uma conversa" />
</SelectTrigger>
<SelectContent>
{activeSessions.map((session) => (
<SelectItem key={session.ticketId} value={session.ticketId}>
#{session.ticketRef} - {session.ticketSubject.slice(0, 25)}
{session.unreadCount > 0 && (
<span className="ml-1 text-red-500">({session.unreadCount})</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Aviso de máquina offline */} {/* Aviso de máquina offline */}
{liveChat?.hasMachine && !machineOnline && ( {liveChat?.hasMachine && !machineOnline && (
<div className="border-b border-amber-200 bg-amber-50 px-3 py-2"> <div className="border-b border-amber-200 bg-amber-50 px-3 py-2">
@ -956,6 +987,8 @@ export function ChatWidget() {
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt,.csv" accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt,.csv"
/> />
</div> </div>
</>
)}
</div> </div>
)} )}