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:
parent
bc5ba0c73a
commit
95ab1b5f0c
3 changed files with 359 additions and 107 deletions
107
src/components/chat/chat-session-item.tsx
Normal file
107
src/components/chat/chat-session-item.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
112
src/components/chat/chat-session-list.tsx
Normal file
112
src/components/chat/chat-session-list.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -8,13 +8,6 @@ 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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
|
|
@ -24,6 +17,7 @@ import {
|
|||
Minimize2,
|
||||
User,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
WifiOff,
|
||||
XCircle,
|
||||
Paperclip,
|
||||
|
|
@ -34,16 +28,20 @@ import {
|
|||
Eye,
|
||||
Check,
|
||||
} from "lucide-react"
|
||||
import { ChatSessionList } from "./chat-session-list"
|
||||
|
||||
const MAX_MESSAGE_LENGTH = 4000
|
||||
const MAX_ATTACHMENT_SIZE = 5 * 1024 * 1024 // 5MB
|
||||
const MAX_ATTACHMENTS = 5
|
||||
const STORAGE_KEY = "chat-widget-state"
|
||||
|
||||
type ViewMode = "list" | "chat"
|
||||
|
||||
type ChatWidgetState = {
|
||||
isOpen: boolean
|
||||
isMinimized: boolean
|
||||
activeTicketId: string | null
|
||||
viewMode: ViewMode
|
||||
}
|
||||
|
||||
function formatTime(timestamp: number) {
|
||||
|
|
@ -315,6 +313,17 @@ export function ChatWidget() {
|
|||
} catch {}
|
||||
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 [isSending, setIsSending] = useState(false)
|
||||
const [isEndingChat, setIsEndingChat] = useState(false)
|
||||
|
|
@ -369,6 +378,7 @@ export function ChatWidget() {
|
|||
const state = JSON.parse(event.newValue) as ChatWidgetState
|
||||
setIsOpen(state.isOpen)
|
||||
setIsMinimized(state.isMinimized)
|
||||
setViewMode(state.viewMode ?? "list")
|
||||
if (state.activeTicketId) {
|
||||
setActiveTicketId(state.activeTicketId)
|
||||
}
|
||||
|
|
@ -387,20 +397,32 @@ export function ChatWidget() {
|
|||
isOpen,
|
||||
isMinimized,
|
||||
activeTicketId,
|
||||
viewMode,
|
||||
}
|
||||
|
||||
// Salvar no localStorage (isso dispara evento storage em outras abas automaticamente)
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
|
||||
} 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(() => {
|
||||
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)
|
||||
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.
|
||||
// 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)
|
||||
if (isTauriContext) return null
|
||||
|
||||
|
|
@ -670,15 +702,38 @@ export function ChatWidget() {
|
|||
{/* Widget aberto */}
|
||||
{isOpen && !isMinimized && (
|
||||
<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 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">
|
||||
<MessageCircle className="size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<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 */}
|
||||
{liveChat?.hasMachine && (
|
||||
machineOnline ? (
|
||||
|
|
@ -702,7 +757,7 @@ export function ChatWidget() {
|
|||
rel="noopener noreferrer"
|
||||
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" />
|
||||
</a>
|
||||
{machineHostname && (
|
||||
|
|
@ -715,7 +770,7 @@ export function ChatWidget() {
|
|||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Botão encerrar chat */}
|
||||
{/* Botao encerrar chat */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
|
@ -747,30 +802,6 @@ export function ChatWidget() {
|
|||
</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 */}
|
||||
{liveChat?.hasMachine && !machineOnline && (
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue