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} />
</div>
) : null}
<TicketChatHistory ticketId={ticketId} />
<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">
<CardHeader className="flex flex-row items-center justify-between px-5 py-4">
@ -685,6 +684,7 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
</CardContent>
</Card>
</div>
<TicketChatHistory ticketId={ticketId} />
<Dialog open={!!previewAttachment} onOpenChange={(open) => { if (!open) setPreviewAttachment(null) }}>
<DialogContent className="max-w-3xl border border-slate-200 p-0">
<DialogHeader className="relative px-4 py-3">

View file

@ -1,13 +1,12 @@
"use client"
import { useState } from "react"
import { useState, useMemo } from "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 { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"
import { cn } from "@/lib/utils"
@ -20,6 +19,7 @@ import {
Download,
FileText,
Paperclip,
Calendar,
} from "lucide-react"
type ChatHistoryProps = {
@ -52,7 +52,16 @@ type ChatSession = {
messages: ChatMessage[]
}
type DayGroup = {
date: Date
dateKey: string
label: string
sessions: ChatSession[]
totalMessages: number
}
const MESSAGES_PER_PAGE = 20
const DAYS_PER_PAGE = 5
function formatDuration(minutes: number): string {
if (minutes < 60) {
@ -66,6 +75,16 @@ function formatDuration(minutes: number): string {
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 } }) {
const getFileUrl = useAction(api.files.getUrl)
const [loading, setLoading] = useState(false)
@ -155,7 +174,7 @@ function ChatSessionCard({ session, isExpanded, onToggle }: { session: ChatSessi
)}
</div>
<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>{session.messageCount} mensagens</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) {
const { convexUserId } = useAuth()
const [expandedDays, setExpandedDays] = useState<Set<string>>(new Set())
const [expandedSessions, setExpandedSessions] = useState<Set<string>>(new Set())
const [visibleDays, setVisibleDays] = useState(DAYS_PER_PAGE)
const chatHistory = useQuery(
api.liveChat.getTicketChatHistory,
@ -266,6 +338,56 @@ export function TicketChatHistory({ ticketId }: ChatHistoryProps) {
: "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) => {
setExpandedSessions((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
if (!chatHistory || chatHistory.sessions.length === 0) {
return null
}
return (
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardHeader className="border-b border-slate-100 bg-slate-50/50 px-4 py-3">
<CardTitle className="flex items-center gap-2 text-base font-semibold text-slate-900">
<MessageCircle className="size-4" />
Histórico de chat
<span className="ml-auto text-xs font-normal text-slate-500">
{chatHistory.sessions.length} {chatHistory.sessions.length === 1 ? "sessão" : "sessões"} - {chatHistory.totalMessages} mensagens
</span>
</CardTitle>
</CardHeader>
<CardContent className="p-4 space-y-3">
{chatHistory.sessions.map((session: ChatSession) => (
<ChatSessionCard
key={session.sessionId}
session={session}
isExpanded={expandedSessions.has(session.sessionId)}
onToggle={() => toggleSession(session.sessionId)}
<div className="space-y-3">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<MessageCircle className="size-4 text-slate-500" />
<h3 className="text-sm font-semibold text-slate-900">Historico de chat</h3>
</div>
<span className="text-xs text-slate-500">
{chatHistory.sessions.length} {chatHistory.sessions.length === 1 ? "sessao" : "sessoes"} - {chatHistory.totalMessages} mensagens
</span>
</div>
{/* Grupos por dia */}
<div className="space-y-2">
{displayedDays.map((dayGroup) => (
<DayGroupCard
key={dayGroup.dateKey}
dayGroup={dayGroup}
isExpanded={expandedDays.has(dayGroup.dateKey)}
onToggle={() => toggleDay(dayGroup.dateKey)}
expandedSessions={expandedSessions}
onToggleSession={toggleSession}
/>
))}
</CardContent>
</Card>
</div>
{/* 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="space-y-6">
<TicketComments ticket={ticket} />
<TicketChatHistory ticketId={id} />
<TicketTimeline ticket={ticket} />
<TicketChatHistory ticketId={id} />
</div>
<TicketDetailsPanel ticket={ticket} />
</div>