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} />
|
<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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue