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:
parent
b916ce3083
commit
f89541c467
3 changed files with 165 additions and 25 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
<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>
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue