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:
parent
9e676b06f9
commit
3b1cde79df
11 changed files with 782 additions and 77 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue