feat: melhora visualizacao do historico de chat

- Remove fundo cinza/Card do historico de chat
- Agrupa sessoes por dia (Hoje, Ontem, data completa)
- Adiciona expansao/colapso por dia e por sessao
- Implementa paginacao de dias (5 por vez) e mensagens (20 por vez)
- Move historico para baixo da timeline (web e portal)

Estrutura escalavel para muitas interacoes de chat.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Seu Nome 2025-12-08 16:10:48 -03:00
parent b916ce3083
commit f89541c467
3 changed files with 165 additions and 25 deletions

View file

@ -505,7 +505,6 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
<TicketCsatCard ticket={ticket} /> <TicketCsatCard ticket={ticket} />
</div> </div>
) : null} ) : null}
<TicketChatHistory ticketId={ticketId} />
<div className="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]"> <div className="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm"> <Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardHeader className="flex flex-row items-center justify-between px-5 py-4"> <CardHeader className="flex flex-row items-center justify-between px-5 py-4">
@ -685,6 +684,7 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
<TicketChatHistory ticketId={ticketId} />
<Dialog open={!!previewAttachment} onOpenChange={(open) => { if (!open) setPreviewAttachment(null) }}> <Dialog open={!!previewAttachment} onOpenChange={(open) => { if (!open) setPreviewAttachment(null) }}>
<DialogContent className="max-w-3xl border border-slate-200 p-0"> <DialogContent className="max-w-3xl border border-slate-200 p-0">
<DialogHeader className="relative px-4 py-3"> <DialogHeader className="relative px-4 py-3">

View file

@ -1,13 +1,12 @@
"use client" "use client"
import { useState } from "react" import { useState, useMemo } from "react"
import { useQuery, useAction } from "convex/react" import { useQuery, useAction } from "convex/react"
import { formatDistanceToNow, format } from "date-fns" import { format, isToday, isYesterday, startOfDay } from "date-fns"
import { ptBR } from "date-fns/locale" import { ptBR } from "date-fns/locale"
import { api } from "@/convex/_generated/api" import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel" import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client" import { useAuth } from "@/lib/auth-client"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
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 { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@ -20,6 +19,7 @@ import {
Download, Download,
FileText, FileText,
Paperclip, Paperclip,
Calendar,
} from "lucide-react" } from "lucide-react"
type ChatHistoryProps = { type ChatHistoryProps = {
@ -52,7 +52,16 @@ type ChatSession = {
messages: ChatMessage[] messages: ChatMessage[]
} }
type DayGroup = {
date: Date
dateKey: string
label: string
sessions: ChatSession[]
totalMessages: number
}
const MESSAGES_PER_PAGE = 20 const MESSAGES_PER_PAGE = 20
const DAYS_PER_PAGE = 5
function formatDuration(minutes: number): string { function formatDuration(minutes: number): string {
if (minutes < 60) { if (minutes < 60) {
@ -66,6 +75,16 @@ function formatDuration(minutes: number): string {
return `${hours}h ${mins}min` return `${hours}h ${mins}min`
} }
function formatDayLabel(date: Date): string {
if (isToday(date)) {
return "Hoje"
}
if (isYesterday(date)) {
return "Ontem"
}
return format(date, "dd 'de' MMMM 'de' yyyy", { locale: ptBR })
}
function MessageAttachmentPreview({ attachment }: { attachment: { storageId: string; name: string; type: string | null } }) { function MessageAttachmentPreview({ attachment }: { attachment: { storageId: string; name: string; type: string | null } }) {
const getFileUrl = useAction(api.files.getUrl) const getFileUrl = useAction(api.files.getUrl)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@ -155,7 +174,7 @@ function ChatSessionCard({ session, isExpanded, onToggle }: { session: ChatSessi
)} )}
</div> </div>
<div className="flex items-center gap-2 text-xs text-slate-500"> <div className="flex items-center gap-2 text-xs text-slate-500">
<span>{format(session.startedAt, "dd/MM/yyyy 'as' HH:mm", { locale: ptBR })}</span> <span>{format(session.startedAt, "HH:mm", { locale: ptBR })}</span>
<span>-</span> <span>-</span>
<span>{session.messageCount} mensagens</span> <span>{session.messageCount} mensagens</span>
<span>-</span> <span>-</span>
@ -255,9 +274,62 @@ function ChatSessionCard({ session, isExpanded, onToggle }: { session: ChatSessi
) )
} }
function DayGroupCard({
dayGroup,
isExpanded,
onToggle,
expandedSessions,
onToggleSession
}: {
dayGroup: DayGroup
isExpanded: boolean
onToggle: () => void
expandedSessions: Set<string>
onToggleSession: (sessionId: string) => void
}) {
return (
<div className="space-y-2">
{/* Header do dia */}
<button
onClick={onToggle}
className="flex w-full items-center justify-between gap-3 rounded-lg px-3 py-2 text-left hover:bg-slate-50 transition-colors"
>
<div className="flex items-center gap-2">
<Calendar className="size-4 text-slate-400" />
<span className="text-sm font-medium text-slate-700">{dayGroup.label}</span>
<span className="text-xs text-slate-500">
({dayGroup.sessions.length} {dayGroup.sessions.length === 1 ? "sessao" : "sessoes"} - {dayGroup.totalMessages} mensagens)
</span>
</div>
{isExpanded ? (
<ChevronDown className="size-4 text-slate-400" />
) : (
<ChevronRight className="size-4 text-slate-400" />
)}
</button>
{/* Sessoes do dia */}
{isExpanded && (
<div className="space-y-2 pl-2">
{dayGroup.sessions.map((session) => (
<ChatSessionCard
key={session.sessionId}
session={session}
isExpanded={expandedSessions.has(session.sessionId)}
onToggle={() => onToggleSession(session.sessionId)}
/>
))}
</div>
)}
</div>
)
}
export function TicketChatHistory({ ticketId }: ChatHistoryProps) { export function TicketChatHistory({ ticketId }: ChatHistoryProps) {
const { convexUserId } = useAuth() const { convexUserId } = useAuth()
const [expandedDays, setExpandedDays] = useState<Set<string>>(new Set())
const [expandedSessions, setExpandedSessions] = useState<Set<string>>(new Set()) const [expandedSessions, setExpandedSessions] = useState<Set<string>>(new Set())
const [visibleDays, setVisibleDays] = useState(DAYS_PER_PAGE)
const chatHistory = useQuery( const chatHistory = useQuery(
api.liveChat.getTicketChatHistory, api.liveChat.getTicketChatHistory,
@ -266,6 +338,56 @@ export function TicketChatHistory({ ticketId }: ChatHistoryProps) {
: "skip" : "skip"
) )
// Agrupar sessoes por dia
const dayGroups = useMemo(() => {
if (!chatHistory?.sessions) return []
const groups = new Map<string, DayGroup>()
chatHistory.sessions.forEach((session: ChatSession) => {
const sessionDate = new Date(session.startedAt)
const dayStart = startOfDay(sessionDate)
const dateKey = dayStart.toISOString()
if (!groups.has(dateKey)) {
groups.set(dateKey, {
date: dayStart,
dateKey,
label: formatDayLabel(dayStart),
sessions: [],
totalMessages: 0,
})
}
const group = groups.get(dateKey)!
group.sessions.push(session)
group.totalMessages += session.messageCount
})
// Ordenar sessoes dentro de cada grupo por hora (mais recente primeiro)
groups.forEach((group) => {
group.sessions.sort((a, b) => b.startedAt - a.startedAt)
})
// Retornar grupos ordenados por data (mais recente primeiro)
return Array.from(groups.values()).sort((a, b) => b.date.getTime() - a.date.getTime())
}, [chatHistory?.sessions])
const displayedDays = dayGroups.slice(0, visibleDays)
const hasMoreDays = dayGroups.length > visibleDays
const toggleDay = (dateKey: string) => {
setExpandedDays((prev) => {
const next = new Set(prev)
if (next.has(dateKey)) {
next.delete(dateKey)
} else {
next.add(dateKey)
}
return next
})
}
const toggleSession = (sessionId: string) => { const toggleSession = (sessionId: string) => {
setExpandedSessions((prev) => { setExpandedSessions((prev) => {
const next = new Set(prev) const next = new Set(prev)
@ -278,32 +400,50 @@ export function TicketChatHistory({ ticketId }: ChatHistoryProps) {
}) })
} }
const loadMoreDays = () => {
setVisibleDays((prev) => prev + DAYS_PER_PAGE)
}
// Nao mostrar se nao ha historico // Nao mostrar se nao ha historico
if (!chatHistory || chatHistory.sessions.length === 0) { if (!chatHistory || chatHistory.sessions.length === 0) {
return null return null
} }
return ( return (
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm"> <div className="space-y-3">
<CardHeader className="border-b border-slate-100 bg-slate-50/50 px-4 py-3"> {/* Header */}
<CardTitle className="flex items-center gap-2 text-base font-semibold text-slate-900"> <div className="flex items-center justify-between">
<MessageCircle className="size-4" /> <div className="flex items-center gap-2">
Histórico de chat <MessageCircle className="size-4 text-slate-500" />
<span className="ml-auto text-xs font-normal text-slate-500"> <h3 className="text-sm font-semibold text-slate-900">Historico de chat</h3>
{chatHistory.sessions.length} {chatHistory.sessions.length === 1 ? "sessão" : "sessões"} - {chatHistory.totalMessages} mensagens </div>
</span> <span className="text-xs text-slate-500">
</CardTitle> {chatHistory.sessions.length} {chatHistory.sessions.length === 1 ? "sessao" : "sessoes"} - {chatHistory.totalMessages} mensagens
</CardHeader> </span>
<CardContent className="p-4 space-y-3"> </div>
{chatHistory.sessions.map((session: ChatSession) => (
<ChatSessionCard {/* Grupos por dia */}
key={session.sessionId} <div className="space-y-2">
session={session} {displayedDays.map((dayGroup) => (
isExpanded={expandedSessions.has(session.sessionId)} <DayGroupCard
onToggle={() => toggleSession(session.sessionId)} key={dayGroup.dateKey}
dayGroup={dayGroup}
isExpanded={expandedDays.has(dayGroup.dateKey)}
onToggle={() => toggleDay(dayGroup.dateKey)}
expandedSessions={expandedSessions}
onToggleSession={toggleSession}
/> />
))} ))}
</CardContent> </div>
</Card>
{/* Carregar mais dias */}
{hasMoreDays && (
<div className="text-center pt-2">
<Button variant="ghost" size="sm" onClick={loadMoreDays}>
Carregar mais ({dayGroups.length - visibleDays} dias restantes)
</Button>
</div>
)}
</div>
) )
} }

View file

@ -108,8 +108,8 @@ export function TicketDetailView({ id }: { id: string }) {
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]"> <div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
<div className="space-y-6"> <div className="space-y-6">
<TicketComments ticket={ticket} /> <TicketComments ticket={ticket} />
<TicketChatHistory ticketId={id} />
<TicketTimeline ticket={ticket} /> <TicketTimeline ticket={ticket} />
<TicketChatHistory ticketId={id} />
</div> </div>
<TicketDetailsPanel ticket={ticket} /> <TicketDetailsPanel ticket={ticket} />
</div> </div>