Melhora chat ao vivo com anexos e eventos de timeline

- Reestrutura visual do widget de chat (header branco, status emerald)
- Adiciona sistema de anexos com upload e drag-and-drop
- Substitui select nativo por componente Select do shadcn
- Adiciona eventos LIVE_CHAT_STARTED e LIVE_CHAT_ENDED na timeline
- Traduz labels de chat para portugues (Chat iniciado/finalizado)
- Filtra CHAT_MESSAGE_ADDED da timeline (apenas inicio/fim aparecem)
- Restringe inicio de chat a tickets com responsavel atribuido
- Exibe duracao da sessao ao finalizar 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:
esdrasrenan 2025-12-07 02:20:11 -03:00
parent 9e676b06f9
commit 3b1cde79df
11 changed files with 782 additions and 77 deletions

View file

@ -133,12 +133,12 @@ export function TicketChatPanel({ ticketId }: TicketChatPanelProps) {
actorId: viewerId as Id<"users">,
})
if (result.isNew) {
toast.success("Chat ao vivo iniciado! O cliente sera notificado.", { id: "live-chat" })
toast.success("Chat ao vivo iniciado! O cliente será notificado.", { id: "live-chat" })
} else {
toast.info("Ja existe uma sessao de chat ativa.", { id: "live-chat" })
toast.info("Já existe uma sessão de chat ativa.", { id: "live-chat" })
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Nao foi possivel iniciar o chat"
const message = error instanceof Error ? error.message : "Não foi possível iniciar o chat"
toast.error(message, { id: "live-chat" })
} finally {
setIsStartingChat(false)
@ -156,7 +156,7 @@ export function TicketChatPanel({ ticketId }: TicketChatPanelProps) {
})
toast.success("Chat ao vivo encerrado.", { id: "live-chat" })
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Nao foi possivel encerrar o chat"
const message = error instanceof Error ? error.message : "Não foi possível encerrar o chat"
toast.error(message, { id: "live-chat" })
} finally {
setIsEndingChat(false)
@ -180,7 +180,7 @@ export function TicketChatPanel({ ticketId }: TicketChatPanelProps) {
inputRef.current?.focus()
} catch (error) {
console.error(error)
toast.error("Nao foi possivel enviar a mensagem.")
toast.error("Não foi possível enviar a mensagem.")
} finally {
setIsSending(false)
}

View file

@ -17,7 +17,6 @@ import { TicketDetailsPanel } from "@/components/tickets/ticket-details-panel";
import { TicketSummaryHeader } from "@/components/tickets/ticket-summary-header";
import { TicketTimeline } from "@/components/tickets/ticket-timeline";
import { TicketCsatCard } from "@/components/tickets/ticket-csat-card";
import { TicketChatPanel } from "@/components/tickets/ticket-chat-panel";
import { useAuth } from "@/lib/auth-client";
export function TicketDetailView({ id }: { id: string }) {
@ -108,7 +107,6 @@ export function TicketDetailView({ id }: { id: string }) {
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
<div className="space-y-6">
<TicketComments ticket={ticket} />
<TicketChatPanel ticketId={ticket.id as string} />
<TicketTimeline ticket={ticket} />
</div>
<TicketDetailsPanel ticket={ticket} />

View file

@ -20,7 +20,7 @@ import { DeleteTicketDialog } from "@/components/tickets/delete-ticket-dialog"
import { StatusSelect } from "@/components/tickets/status-select"
import { CloseTicketDialog, type AdjustWorkSummaryResult } from "@/components/tickets/close-ticket-dialog"
import { TicketCustomFieldsSection } from "@/components/tickets/ticket-custom-fields"
import { Calendar as CalendarIcon, CheckCircle2, MonitorSmartphone, RotateCcw } from "lucide-react"
import { Calendar as CalendarIcon, CheckCircle2, MessageCircle, MonitorSmartphone, RotateCcw, WifiOff } from "lucide-react"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@ -314,6 +314,54 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
| null
| undefined
// Live Chat
const liveChatSession = useQuery(
api.liveChat.getTicketSession,
convexUserId && ticket.machineId
? { ticketId: ticket.id as Id<"tickets">, viewerId: convexUserId as Id<"users"> }
: "skip"
) as {
sessionId: Id<"liveChatSessions">
agentId: Id<"users">
agentName: string | null
startedAt: number
lastActivityAt: number
unreadByAgent: number
machineOnline: boolean
} | null | undefined
const startLiveChat = useMutation(api.liveChat.startSession)
const [isStartingChat, setIsStartingChat] = useState(false)
// Verificar se máquina está online (para tickets com machineId)
const hasMachine = Boolean(ticket.machineId)
const machineOnline = liveChatSession?.machineOnline ?? false
const hasActiveSession = Boolean(liveChatSession?.sessionId)
const ticketHasAssignee = Boolean(ticket.assignee)
const canStartChat = hasMachine && !hasActiveSession && isStaff && ticketHasAssignee
const handleStartLiveChat = async () => {
if (!convexUserId || !ticket.id || isStartingChat) return
setIsStartingChat(true)
toast.dismiss("live-chat")
try {
const result = await startLiveChat({
ticketId: ticket.id as Id<"tickets">,
actorId: convexUserId as Id<"users">,
})
if (result.isNew) {
toast.success("Chat ao vivo iniciado! O cliente será notificado.", { id: "live-chat" })
} else {
toast.info("Já existe uma sessão de chat ativa.", { id: "live-chat" })
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Não foi possível iniciar o chat"
toast.error(message, { id: "live-chat" })
} finally {
setIsStartingChat(false)
}
}
const [assigneeState, setAssigneeState] = useState(ticket.assignee ?? null)
const [editing, setEditing] = useState(false)
const [subject, setSubject] = useState(ticket.subject)
@ -1397,6 +1445,84 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
</TooltipContent>
</Tooltip>
) : null}
{hasMachine && !hasActiveSession && isStaff ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="outline"
aria-label="Iniciar chat ao vivo"
className={cn(
"inline-flex items-center justify-center rounded-lg border border-slate-200 bg-white text-neutral-800 hover:bg-slate-50",
(!machineOnline || !ticketHasAssignee) && "cursor-not-allowed opacity-50"
)}
onClick={handleStartLiveChat}
disabled={isStartingChat || !machineOnline || !ticketHasAssignee}
>
<span className="relative inline-flex items-center justify-center">
<MessageCircle className="size-5" />
<span className="pointer-events-none absolute -right-0.5 -top-0.5 inline-flex">
<span className="relative inline-flex">
<span
className={cn(
"inline-flex size-2 rounded-full border border-white",
machineOnline ? "bg-green-500" : "bg-slate-400"
)}
/>
</span>
</span>
</span>
</Button>
</TooltipTrigger>
<TooltipContent className="max-w-xs space-y-1 rounded-xl border border-slate-200 bg-white px-3 py-2 text-left text-xs text-neutral-900 shadow-lg">
<p className="text-sm font-semibold text-neutral-900">Iniciar chat ao vivo</p>
<p className="text-xs text-neutral-700">
<span className="font-semibold text-neutral-900">Status:</span>{" "}
{machineOnline ? "Máquina online" : "Máquina offline"}
</p>
{!ticketHasAssignee && (
<p className="text-xs text-amber-600">
Atribua um responsável ao ticket para iniciar o chat.
</p>
)}
{ticketHasAssignee && !machineOnline && (
<p className="text-xs text-amber-600">
A máquina precisa estar online para iniciar o chat.
</p>
)}
</TooltipContent>
</Tooltip>
) : hasMachine && hasActiveSession ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="outline"
aria-label="Chat ativo"
className="inline-flex items-center justify-center rounded-lg border border-green-200 bg-green-50 text-green-700 hover:bg-green-100"
onClick={() => {
// Não faz nada - sessão já ativa (widget flutuante mostra)
}}
>
<span className="relative inline-flex items-center justify-center">
<MessageCircle className="size-5" />
<span className="pointer-events-none absolute -right-0.5 -top-0.5 inline-flex">
<span className="relative inline-flex">
<span className="absolute left-1/2 top-1/2 size-4 -translate-x-1/2 -translate-y-1/2 rounded-full bg-green-400 animate-ping" />
<span className="inline-flex size-2 rounded-full border border-white bg-green-500" />
</span>
</span>
</span>
</Button>
</TooltipTrigger>
<TooltipContent className="max-w-xs space-y-1 rounded-xl border border-slate-200 bg-white px-3 py-2 text-left text-xs text-neutral-900 shadow-lg">
<p className="text-sm font-semibold text-green-700">Chat em andamento</p>
<p className="text-xs text-neutral-700">
Use o widget flutuante no canto inferior direito para continuar a conversa.
</p>
</TooltipContent>
</Tooltip>
) : null}
<DeleteTicketDialog ticketId={ticket.id as Id<"tickets">} />
</div>
{status === "RESOLVED" && canReopenTicket && reopenDeadlineLabel ? (

View file

@ -6,6 +6,7 @@ import {
IconCalendar,
IconClockHour4,
IconFolders,
IconMessage,
IconNote,
IconPaperclip,
IconSquareCheck,
@ -51,6 +52,8 @@ const timelineIcons: Record<string, ComponentType<{ className?: string }>> = {
CSAT_RECEIVED: IconStar,
CSAT_RATED: IconStar,
TICKET_LINKED: IconLink,
LIVE_CHAT_STARTED: IconMessage,
LIVE_CHAT_ENDED: IconMessage,
}
const timelineLabels: Record<string, string> = TICKET_TIMELINE_LABELS
@ -79,8 +82,13 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
const [page, setPage] = useState(1)
// Tipos de eventos que não devem aparecer na timeline
const HIDDEN_EVENT_TYPES = ["CHAT_MESSAGE_ADDED"]
const sortedTimeline = useMemo(
() => [...ticket.timeline].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()),
() => [...ticket.timeline]
.filter((event) => !HIDDEN_EVENT_TYPES.includes(event.type))
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()),
[ticket.timeline]
)
@ -605,6 +613,49 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
</div>
)
}
if (entry.type === "LIVE_CHAT_STARTED") {
const agentName = (payload as { agentName?: string }).agentName
const machineHostname = (payload as { machineHostname?: string }).machineHostname
message = (
<div className="space-y-1">
<span className="block text-sm text-neutral-600">
<span className="font-semibold text-neutral-800">Chat ao vivo iniciado</span>
{agentName && (
<>
{" "}por <span className="font-semibold text-neutral-900">{agentName}</span>
</>
)}
</span>
{machineHostname && (
<span className="block text-xs text-neutral-500">
Máquina: {machineHostname}
</span>
)}
</div>
)
}
if (entry.type === "LIVE_CHAT_ENDED") {
const agentName = (payload as { agentName?: string }).agentName
const durationMs = (payload as { durationMs?: number }).durationMs
const durationFormatted = typeof durationMs === "number" ? formatDuration(durationMs) : null
message = (
<div className="space-y-1">
<span className="block text-sm text-neutral-600">
<span className="font-semibold text-neutral-800">Chat ao vivo finalizado</span>
{agentName && (
<>
{" "}por <span className="font-semibold text-neutral-900">{agentName}</span>
</>
)}
</span>
{durationFormatted && (
<span className="block text-xs text-neutral-500">
Duração: {durationFormatted}
</span>
)}
</div>
)
}
if (!message) return null
return (