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

@ -25,20 +25,20 @@ async function validateMachineToken(
.first()
if (!tokenRecord) {
throw new ConvexError("Token de maquina invalido")
throw new ConvexError("Token de máquina inválido")
}
if (tokenRecord.revoked) {
throw new ConvexError("Token de maquina revogado")
throw new ConvexError("Token de máquina revogado")
}
if (tokenRecord.expiresAt < Date.now()) {
throw new ConvexError("Token de maquina expirado")
throw new ConvexError("Token de máquina expirado")
}
const machine = await ctx.db.get(tokenRecord.machineId)
if (!machine) {
throw new ConvexError("Maquina nao encontrada")
throw new ConvexError("Máquina não encontrada")
}
return { machine, tenantId: tokenRecord.tenantId }
@ -57,11 +57,11 @@ export const startSession = mutation({
handler: async (ctx, { ticketId, actorId }) => {
const ticket = await ctx.db.get(ticketId)
if (!ticket) {
throw new ConvexError("Ticket nao encontrado")
throw new ConvexError("Ticket não encontrado")
}
if (!ticket.machineId) {
throw new ConvexError("Este ticket nao esta vinculado a uma maquina")
throw new ConvexError("Este ticket não está vinculado a uma máquina")
}
// Verificar se agente tem permissao
@ -78,12 +78,12 @@ export const startSession = mutation({
// Verificar se maquina esta online (heartbeat nos ultimos 5 minutos)
const machine = await ctx.db.get(ticket.machineId)
if (!machine) {
throw new ConvexError("Maquina nao encontrada")
throw new ConvexError("Máquina não encontrada")
}
const fiveMinutesAgo = Date.now() - 5 * 60 * 1000
if (!machine.lastHeartbeatAt || machine.lastHeartbeatAt < fiveMinutesAgo) {
throw new ConvexError("Maquina offline. A maquina precisa estar online para iniciar o chat.")
throw new ConvexError("Máquina offline. A máquina precisa estar online para iniciar o chat.")
}
// Verificar se ja existe sessao ativa para este ticket
@ -123,6 +123,19 @@ export const startSession = mutation({
await ctx.db.patch(ticketId, { chatEnabled: true })
}
// Registrar evento na timeline
await ctx.db.insert("ticketEvents", {
ticketId,
type: "LIVE_CHAT_STARTED",
payload: {
sessionId,
agentId: actorId,
agentName: agent.name,
machineHostname: machine.hostname,
},
createdAt: now,
})
return { sessionId, isNew: true }
},
})
@ -136,7 +149,7 @@ export const endSession = mutation({
handler: async (ctx, { sessionId, actorId }) => {
const session = await ctx.db.get(sessionId)
if (!session) {
throw new ConvexError("Sessao nao encontrada")
throw new ConvexError("Sessão não encontrada")
}
// Verificar permissao
@ -146,12 +159,32 @@ export const endSession = mutation({
}
if (session.status !== "ACTIVE") {
throw new ConvexError("Sessao ja encerrada")
throw new ConvexError("Sessão já encerrada")
}
const now = Date.now()
await ctx.db.patch(sessionId, {
status: "ENDED",
endedAt: Date.now(),
endedAt: now,
})
// Calcular duracao da sessao
const durationMs = now - session.startedAt
// Registrar evento na timeline
await ctx.db.insert("ticketEvents", {
ticketId: session.ticketId,
type: "LIVE_CHAT_ENDED",
payload: {
sessionId,
agentId: actorId,
agentName: agent.name,
durationMs,
startedAt: session.startedAt,
endedAt: now,
},
createdAt: now,
})
return { ok: true }
@ -184,11 +217,11 @@ export const postMachineMessage = mutation({
const ticket = await ctx.db.get(args.ticketId)
if (!ticket || ticket.tenantId !== tenantId) {
throw new ConvexError("Ticket nao encontrado")
throw new ConvexError("Ticket não encontrado")
}
if (ticket.machineId?.toString() !== machine._id.toString()) {
throw new ConvexError("Esta maquina nao esta vinculada a este ticket")
throw new ConvexError("Esta máquina não está vinculada a este ticket")
}
// Verificar se existe sessao ativa
@ -199,7 +232,7 @@ export const postMachineMessage = mutation({
.first()
if (!session) {
throw new ConvexError("Nenhuma sessao de chat ativa para este ticket")
throw new ConvexError("Nenhuma sessão de chat ativa para este ticket")
}
// Obter usuario vinculado a maquina (ou usar nome do hostname)
@ -233,7 +266,7 @@ export const postMachineMessage = mutation({
// Limitar tamanho do body
if (args.body.length > 4000) {
throw new ConvexError("Mensagem muito longa (maximo 4000 caracteres)")
throw new ConvexError("Mensagem muito longa (máximo 4000 caracteres)")
}
// Inserir mensagem
@ -272,11 +305,11 @@ export const markMachineMessagesRead = mutation({
const ticket = await ctx.db.get(args.ticketId)
if (!ticket || ticket.tenantId !== tenantId) {
throw new ConvexError("Ticket nao encontrado")
throw new ConvexError("Ticket não encontrado")
}
if (ticket.machineId?.toString() !== machine._id.toString()) {
throw new ConvexError("Esta maquina nao esta vinculada a este ticket")
throw new ConvexError("Esta máquina não está vinculada a este ticket")
}
// Obter userId para marcar leitura
@ -367,11 +400,11 @@ export const listMachineMessages = query({
const ticket = await ctx.db.get(args.ticketId)
if (!ticket || ticket.tenantId !== tenantId) {
throw new ConvexError("Ticket nao encontrado")
throw new ConvexError("Ticket não encontrado")
}
if (ticket.machineId?.toString() !== machine._id.toString()) {
throw new ConvexError("Esta maquina nao esta vinculada a este ticket")
throw new ConvexError("Esta máquina não está vinculada a este ticket")
}
// Buscar sessao ativa

View file

@ -1237,10 +1237,10 @@ function getStatusVariant(status?: string | null) {
function OsIcon({ osName }: { osName?: string | null }) {
const name = (osName ?? "").toLowerCase()
if (name.includes("mac") || name.includes("darwin") || name.includes("macos")) return <Apple className="size-4 text-black" />
if (name.includes("linux")) return <Terminal className="size-4 text-black" />
if (name.includes("mac") || name.includes("darwin") || name.includes("macos")) return <Apple className="size-4 text-neutral-500" />
if (name.includes("linux")) return <Terminal className="size-4 text-neutral-500" />
// fallback para Windows/outros como monitor genérico
return <Monitor className="size-4 text-black" />
return <Monitor className="size-4 text-neutral-500" />
}
export function AdminDevicesOverview({
@ -3160,7 +3160,7 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
key: "activation",
label: "Licença",
value: windowsActivationStatus ? "Ativada" : "Não ativada",
icon: windowsActivationStatus ? <ShieldCheck className="size-4 text-emerald-500" /> : <ShieldAlert className="size-4 text-amber-500" />,
icon: windowsActivationStatus ? <ShieldCheck className="size-4 text-neutral-500" /> : <ShieldAlert className="size-4 text-amber-500" />,
tone: windowsActivationStatus ? undefined : "warning",
})
}
@ -4611,7 +4611,7 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
<div>
<h4 className="text-sm font-semibold">Inventário</h4>
<p className="text-xs text-muted-foreground">
Dados sincronizados via agente ou Fleet.
Informações coletadas automaticamente do dispositivo.
</p>
</div>
<div className="space-y-3 text-sm text-muted-foreground">
@ -6202,10 +6202,10 @@ function InfoChip({ label, value, icon, tone = "default", onClick, statusBadge }
const clickableClasses = onClick ? "cursor-pointer hover:ring-2 hover:ring-blue-200 transition-all" : ""
const badgeVariantClasses = {
default: "border-slate-200 bg-slate-100 text-slate-600",
success: "border-emerald-200 bg-emerald-50 text-emerald-700",
warning: "border-amber-200 bg-amber-50 text-amber-700",
error: "border-red-200 bg-red-50 text-red-700",
default: "border-slate-300 text-slate-500",
success: "border-emerald-500 text-emerald-600",
warning: "border-amber-500 text-amber-600",
error: "border-red-500 text-red-600",
}
return (

View file

@ -1,18 +1,40 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { useMutation, useQuery } from "convex/react"
import { useCallback, useEffect, useRef, useState } from "react"
import { useAction, useMutation, useQuery } from "convex/react"
import type { Id } from "@/convex/_generated/dataModel"
import { api } from "@/convex/_generated/api"
import { useAuth } from "@/lib/auth-client"
import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { cn } from "@/lib/utils"
import { toast } from "sonner"
import { MessageCircle, Send, X, Minimize2, User, Headphones, ChevronDown } from "lucide-react"
import {
MessageCircle,
Send,
X,
Minimize2,
User,
Headphones,
ChevronDown,
WifiOff,
PhoneOff,
Paperclip,
FileText,
Image as ImageIcon,
Download,
} from "lucide-react"
const MAX_MESSAGE_LENGTH = 4000
const MAX_ATTACHMENT_SIZE = 5 * 1024 * 1024 // 5MB
const MAX_ATTACHMENTS = 5
function formatTime(timestamp: number) {
return new Date(timestamp).toLocaleTimeString("pt-BR", {
@ -29,6 +51,134 @@ type ChatSession = {
unreadCount: number
}
type UploadedFile = {
storageId: string
name: string
size: number
type: string
previewUrl?: string
}
type ChatAttachment = {
storageId: Id<"_storage">
name: string
size: number | null
type: string | null
}
type ChatData = {
ticketId: string
chatEnabled: boolean
status: string
canPost: boolean
reopenDeadline: number | null
liveChat?: {
hasMachine: boolean
machineOnline: boolean
machineHostname: string | null
activeSession: {
sessionId: Id<"liveChatSessions">
agentId: Id<"users">
agentName: string | null
startedAt: number
unreadByAgent: number
} | null
}
messages: Array<{
id: Id<"ticketChatMessages">
body: string
createdAt: number
updatedAt: number
authorId: string
authorName: string | null
authorEmail: string | null
attachments: ChatAttachment[]
readBy: Array<{ userId: string; readAt: number }>
}>
}
// Componente de preview de anexo na mensagem
function MessageAttachment({ attachment }: { attachment: ChatAttachment }) {
const getFileUrl = useAction(api.files.getUrl)
const [url, setUrl] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
async function loadUrl() {
try {
const fileUrl = await getFileUrl({ storageId: attachment.storageId })
if (!cancelled && fileUrl) {
setUrl(fileUrl)
}
} catch (error) {
console.error("Erro ao carregar anexo:", error)
} finally {
if (!cancelled) setLoading(false)
}
}
loadUrl()
return () => { cancelled = true }
}, [attachment.storageId, getFileUrl])
const isImage = attachment.type?.startsWith("image/")
const handleDownload = async () => {
if (!url) return
try {
const response = await fetch(url)
const blob = await response.blob()
const downloadUrl = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = downloadUrl
a.download = attachment.name
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(downloadUrl)
} catch (error) {
toast.error("Erro ao baixar arquivo")
}
}
if (loading) {
return (
<div className="flex size-12 items-center justify-center rounded-lg border border-slate-200 bg-slate-50">
<Spinner className="size-4 text-slate-400" />
</div>
)
}
if (isImage && url) {
return (
<button
onClick={handleDownload}
className="group relative overflow-hidden rounded-lg border border-slate-200"
>
<img
src={url}
alt={attachment.name}
className="size-16 object-cover"
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 transition-opacity group-hover:opacity-100">
<Download className="size-4 text-white" />
</div>
</button>
)
}
return (
<button
onClick={handleDownload}
className="flex items-center gap-2 rounded-lg border border-slate-200 bg-slate-50 px-2 py-1.5 text-xs hover:bg-slate-100"
>
<FileText className="size-4 text-slate-500" />
<span className="max-w-[80px] truncate text-slate-700">{attachment.name}</span>
<Download className="size-3 text-slate-400" />
</button>
)
}
export function ChatWidget() {
const { convexUserId } = useAuth()
const viewerId = convexUserId ?? null
@ -38,11 +188,17 @@ export function ChatWidget() {
const [activeTicketId, setActiveTicketId] = useState<string | null>(null)
const [draft, setDraft] = useState("")
const [isSending, setIsSending] = useState(false)
const [isEndingChat, setIsEndingChat] = useState(false)
const [attachments, setAttachments] = useState<UploadedFile[]>([])
const [isUploading, setIsUploading] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const messagesEndRef = useRef<HTMLDivElement | null>(null)
const inputRef = useRef<HTMLTextAreaElement | null>(null)
const fileInputRef = useRef<HTMLInputElement | null>(null)
const dropAreaRef = useRef<HTMLDivElement | null>(null)
// Buscar sessoes de chat ativas do agente
// Buscar sessões de chat ativas do agente
const activeSessions = useQuery(
api.liveChat.listAgentSessions,
viewerId ? { agentId: viewerId as Id<"users"> } : "skip"
@ -54,22 +210,27 @@ export function ChatWidget() {
viewerId && activeTicketId
? { ticketId: activeTicketId as Id<"tickets">, viewerId: viewerId as Id<"users"> }
: "skip"
)
) as ChatData | null | undefined
const postChatMessage = useMutation(api.tickets.postChatMessage)
const markChatRead = useMutation(api.tickets.markChatRead)
const endLiveChat = useMutation(api.liveChat.endSession)
const generateUploadUrl = useAction(api.files.generateUploadUrl)
const messages = chat?.messages ?? []
const totalUnread = activeSessions?.reduce((sum, s) => sum + s.unreadCount, 0) ?? 0
const liveChat = chat?.liveChat
const machineOnline = liveChat?.machineOnline ?? false
const machineHostname = liveChat?.machineHostname
// Auto-selecionar primeira sessao se nenhuma selecionada
// Auto-selecionar primeira sessão se nenhuma selecionada
useEffect(() => {
if (!activeTicketId && activeSessions && activeSessions.length > 0) {
setActiveTicketId(activeSessions[0].ticketId)
}
}, [activeTicketId, activeSessions])
// Scroll para ultima mensagem
// Scroll para última mensagem
useEffect(() => {
if (messagesEndRef.current && isOpen && !isMinimized) {
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
@ -90,29 +251,157 @@ export function ChatWidget() {
}).catch(console.error)
}, [viewerId, chat, activeTicketId, isOpen, isMinimized, markChatRead])
// Upload de arquivos
const uploadFiles = useCallback(async (files: File[]) => {
const maxFiles = MAX_ATTACHMENTS - attachments.length
const validFiles = files
.filter(f => f.size <= MAX_ATTACHMENT_SIZE)
.slice(0, maxFiles)
if (validFiles.length < files.length) {
toast.error(`Alguns arquivos ignorados (máx. ${MAX_ATTACHMENTS} arquivos, 5MB cada)`)
}
if (validFiles.length === 0) return
setIsUploading(true)
try {
for (const file of validFiles) {
const uploadUrl = await generateUploadUrl()
const response = await fetch(uploadUrl, {
method: "POST",
headers: { "Content-Type": file.type },
body: file,
})
const { storageId } = await response.json()
const previewUrl = file.type.startsWith("image/")
? URL.createObjectURL(file)
: undefined
setAttachments(prev => [...prev, {
storageId,
name: file.name,
size: file.size,
type: file.type,
previewUrl,
}])
}
} catch (error) {
console.error("Erro no upload:", error)
toast.error("Erro ao enviar arquivo")
} finally {
setIsUploading(false)
}
}, [attachments.length, generateUploadUrl])
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? [])
if (files.length > 0) {
await uploadFiles(files)
}
e.target.value = ""
}
const removeAttachment = (index: number) => {
setAttachments(prev => {
const removed = prev[index]
if (removed?.previewUrl) {
URL.revokeObjectURL(removed.previewUrl)
}
return prev.filter((_, i) => i !== index)
})
}
// Drag and drop handlers
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(true)
}
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
if (!dropAreaRef.current?.contains(e.relatedTarget as Node)) {
setIsDragging(false)
}
}
const handleDrop = async (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
const files = Array.from(e.dataTransfer.files)
if (files.length > 0) {
await uploadFiles(files)
}
}
const handleSend = async () => {
if (!viewerId || !activeTicketId || !draft.trim()) return
if (!viewerId || !activeTicketId) return
if (!draft.trim() && attachments.length === 0) return
if (draft.length > MAX_MESSAGE_LENGTH) {
toast.error(`Mensagem muito longa (max. ${MAX_MESSAGE_LENGTH} caracteres).`)
toast.error(`Mensagem muito longa (máx. ${MAX_MESSAGE_LENGTH} caracteres).`)
return
}
setIsSending(true)
try {
await postChatMessage({
ticketId: activeTicketId as Id<"tickets">,
actorId: viewerId as Id<"users">,
body: draft.trim(),
attachments: attachments.map(a => ({
storageId: a.storageId as unknown as Id<"_storage">,
name: a.name,
size: a.size,
type: a.type,
})),
})
setDraft("")
// Limpar previews
attachments.forEach(a => {
if (a.previewUrl) URL.revokeObjectURL(a.previewUrl)
})
setAttachments([])
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)
}
}
const handleEndChat = async () => {
if (!viewerId || !liveChat?.activeSession?.sessionId || isEndingChat) return
setIsEndingChat(true)
toast.dismiss("live-chat")
try {
await endLiveChat({
sessionId: liveChat.activeSession.sessionId,
actorId: viewerId as Id<"users">,
})
toast.success("Chat ao vivo encerrado.", { id: "live-chat" })
if (activeSessions && activeSessions.length <= 1) {
setIsOpen(false)
setActiveTicketId(null)
} else {
const nextSession = activeSessions?.find((s) => s.ticketId !== activeTicketId)
if (nextSession) {
setActiveTicketId(nextSession.ticketId)
}
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Não foi possível encerrar o chat"
toast.error(message, { id: "live-chat" })
} finally {
setIsEndingChat(false)
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
@ -120,7 +409,7 @@ export function ChatWidget() {
}
}
// Nao mostrar se nao logado ou sem sessoes
// Não mostrar se não logado ou sem sessões
if (!viewerId) return null
if (!activeSessions || activeSessions.length === 0) return null
@ -130,51 +419,103 @@ export function ChatWidget() {
<div className="fixed bottom-4 right-4 z-50 flex flex-col items-end gap-2">
{/* Widget aberto */}
{isOpen && !isMinimized && (
<div className="flex h-[500px] w-[380px] flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between border-b border-slate-100 bg-black px-4 py-3 text-white">
<div className="flex h-[520px] w-[400px] flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-2xl">
{/* Header - Estilo card da aplicação */}
<div className="flex items-center justify-between border-b border-slate-200 bg-white px-4 py-3">
<div className="flex items-center gap-3">
<div className="flex size-8 items-center justify-center rounded-full bg-white/20">
<MessageCircle className="size-4" />
<div className="flex size-10 items-center justify-center rounded-full bg-black text-white">
<MessageCircle className="size-5" />
</div>
<div>
<p className="text-sm font-semibold">Chat Ativo</p>
<div className="flex items-center gap-2">
<p className="text-sm font-semibold text-slate-900">Chat Ativo</p>
{/* Indicador online/offline */}
{liveChat?.hasMachine && (
machineOnline ? (
<span className="flex items-center gap-1.5 text-xs text-emerald-600">
<span className="size-2 rounded-full bg-emerald-500 animate-pulse" />
Online
</span>
) : (
<span className="flex items-center gap-1.5 text-xs text-slate-400">
<WifiOff className="size-3" />
Offline
</span>
)
)}
</div>
{activeSession && (
<p className="text-xs text-white/70">#{activeSession.ticketRef}</p>
<p className="text-xs text-slate-500">
#{activeSession.ticketRef}
{machineHostname && ` - ${machineHostname}`}
</p>
)}
</div>
</div>
<div className="flex items-center gap-1">
{/* Botão encerrar chat */}
<Button
variant="ghost"
size="sm"
onClick={handleEndChat}
disabled={isEndingChat}
className="gap-1.5 text-red-600 hover:bg-red-50 hover:text-red-700"
>
{isEndingChat ? (
<Spinner className="size-3.5" />
) : (
<PhoneOff className="size-3.5" />
)}
<span className="hidden sm:inline">Encerrar</span>
</Button>
<button
onClick={() => setIsMinimized(true)}
className="rounded p-1.5 hover:bg-white/10"
className="rounded-md p-1.5 text-slate-500 hover:bg-slate-100"
title="Minimizar"
>
<Minimize2 className="size-4" />
</button>
<button
onClick={() => setIsOpen(false)}
className="rounded p-1.5 hover:bg-white/10"
className="rounded-md p-1.5 text-slate-500 hover:bg-slate-100"
title="Fechar"
>
<X className="size-4" />
</button>
</div>
</div>
{/* Seletor de sessoes (se mais de uma) */}
{/* Seletor de sessões (se mais de uma) */}
{activeSessions.length > 1 && (
<div className="border-b border-slate-100 bg-slate-50 px-3 py-2">
<select
<Select
value={activeTicketId ?? ""}
onChange={(e) => setActiveTicketId(e.target.value)}
className="w-full rounded border border-slate-200 bg-white px-2 py-1 text-sm"
onValueChange={setActiveTicketId}
>
{activeSessions.map((session) => (
<option key={session.ticketId} value={session.ticketId}>
#{session.ticketRef} - {session.ticketSubject.slice(0, 30)}
{session.unreadCount > 0 ? ` (${session.unreadCount})` : ""}
</option>
))}
</select>
<SelectTrigger className="h-8 w-full border-slate-200 bg-white text-sm">
<SelectValue placeholder="Selecione uma conversa" />
</SelectTrigger>
<SelectContent>
{activeSessions.map((session) => (
<SelectItem key={session.ticketId} value={session.ticketId}>
#{session.ticketRef} - {session.ticketSubject.slice(0, 25)}
{session.unreadCount > 0 && (
<span className="ml-1 text-red-500">({session.unreadCount})</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Aviso de máquina offline */}
{liveChat?.hasMachine && !machineOnline && (
<div className="border-b border-amber-200 bg-amber-50 px-3 py-2">
<p className="flex items-center gap-2 text-xs text-amber-700">
<WifiOff className="size-3" />
A máquina está offline. Mensagens serão entregues quando voltar.
</p>
</div>
)}
@ -211,7 +552,7 @@ export function ChatWidget() {
</div>
<div
className={cn(
"max-w-[70%] rounded-2xl px-3 py-2",
"max-w-[75%] rounded-2xl px-3 py-2",
isOwn
? "rounded-br-md bg-black text-white"
: "rounded-bl-md border border-slate-100 bg-white text-slate-900 shadow-sm"
@ -222,7 +563,17 @@ export function ChatWidget() {
{msg.authorName ?? "Cliente"}
</p>
)}
<p className="whitespace-pre-wrap text-sm">{msg.body}</p>
{msg.body && (
<p className="whitespace-pre-wrap text-sm">{msg.body}</p>
)}
{/* Anexos da mensagem */}
{msg.attachments && msg.attachments.length > 0 && (
<div className={cn("mt-2 flex flex-wrap gap-1.5", isOwn && "justify-end")}>
{msg.attachments.map((att, i) => (
<MessageAttachment key={i} attachment={att} />
))}
</div>
)}
<p className={cn("mt-1 text-right text-xs", isOwn ? "text-white/60" : "text-slate-400")}>
{formatTime(msg.createdAt)}
</p>
@ -235,8 +586,56 @@ export function ChatWidget() {
)}
</div>
{/* Input */}
<div className="border-t border-slate-200 bg-white p-3">
{/* Preview de anexos pendentes */}
{attachments.length > 0 && (
<div className="flex flex-wrap gap-2 border-t border-slate-100 bg-slate-50 p-2">
{attachments.map((file, index) => (
<div key={index} className="group relative">
{file.type?.startsWith("image/") && file.previewUrl ? (
<img
src={file.previewUrl}
alt={file.name}
className="size-14 rounded-lg border border-slate-200 object-cover"
/>
) : (
<div className="flex size-14 items-center justify-center rounded-lg border border-slate-200 bg-white">
<FileText className="size-5 text-slate-400" />
</div>
)}
<button
onClick={() => removeAttachment(index)}
className="absolute -right-1.5 -top-1.5 flex size-5 items-center justify-center rounded-full bg-red-500 text-white opacity-0 transition-opacity group-hover:opacity-100"
>
<X className="size-3" />
</button>
<p className="mt-0.5 max-w-[56px] truncate text-center text-[10px] text-slate-500">
{file.name}
</p>
</div>
))}
{isUploading && (
<div className="flex size-14 items-center justify-center rounded-lg border border-dashed border-slate-300 bg-slate-50">
<Spinner className="size-5 text-slate-400" />
</div>
)}
</div>
)}
{/* Input com dropzone */}
<div
ref={dropAreaRef}
className="relative border-t border-slate-200 bg-white p-3"
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* Overlay de drag */}
{isDragging && (
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-b-2xl border-2 border-dashed border-primary bg-primary/5">
<p className="text-sm font-medium text-primary">Solte os arquivos aqui</p>
</div>
)}
<div className="flex items-end gap-2">
<textarea
ref={inputRef}
@ -244,20 +643,46 @@ export function ChatWidget() {
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Digite sua mensagem..."
className="max-h-20 min-h-[36px] flex-1 resize-none rounded-lg border border-slate-200 px-3 py-2 text-sm focus:border-black focus:outline-none focus:ring-1 focus:ring-black"
className="max-h-20 min-h-[36px] flex-1 resize-none rounded-lg border border-slate-200 px-3 py-2 text-sm focus:border-slate-400 focus:outline-none focus:ring-1 focus:ring-slate-400"
rows={1}
disabled={isSending}
/>
{/* Botão de anexar */}
<Button
type="button"
variant="ghost"
size="icon"
className="size-9 text-slate-500 hover:bg-slate-100 hover:text-slate-700"
onClick={() => fileInputRef.current?.click()}
disabled={attachments.length >= MAX_ATTACHMENTS || isUploading}
title="Anexar arquivo"
>
{isUploading ? (
<Spinner className="size-4" />
) : (
<Paperclip className="size-4" />
)}
</Button>
{/* Botão de enviar */}
<Button
type="button"
onClick={handleSend}
disabled={!draft.trim() || isSending}
className="flex size-9 items-center justify-center rounded-lg bg-black text-white hover:bg-black/90"
disabled={(!draft.trim() && attachments.length === 0) || isSending}
className="size-9 bg-black text-white hover:bg-black/90"
size="icon"
>
{isSending ? <Spinner className="size-4" /> : <Send className="size-4" />}
</Button>
</div>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleFileSelect}
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt,.csv"
/>
</div>
</div>
)}
@ -272,6 +697,15 @@ export function ChatWidget() {
<span className="text-sm font-medium">
Chat #{activeSession?.ticketRef}
</span>
{/* Indicador online no minimizado */}
{liveChat?.hasMachine && (
<span
className={cn(
"size-2 rounded-full",
machineOnline ? "bg-emerald-400" : "bg-slate-400"
)}
/>
)}
{totalUnread > 0 && (
<span className="flex size-5 items-center justify-center rounded-full bg-red-500 text-xs font-bold">
{totalUnread}
@ -281,7 +715,7 @@ export function ChatWidget() {
</button>
)}
{/* Botao flutuante */}
{/* Botão flutuante */}
{!isOpen && (
<button
onClick={() => {

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 (

View file

@ -62,6 +62,9 @@ export const DEVICE_INVENTORY_COLUMN_METADATA: DeviceInventoryColumnMetadata[] =
{ key: "fleetTeam", label: "Equipe Fleet", width: 18, default: true },
{ key: "fleetUpdatedAt", label: "Fleet atualizado em", width: 20, default: true },
{ key: "managementMode", label: "Modo de gestão", width: 20, default: false },
{ key: "usbPolicy", label: "Política USB", width: 18, default: true },
{ key: "usbPolicyStatus", label: "Status política USB", width: 18, default: true },
{ key: "ticketCount", label: "Total de tickets", width: 16, default: true },
]
export type DeviceInventoryColumnKey = (typeof DEVICE_INVENTORY_COLUMN_METADATA)[number]["key"]

View file

@ -22,4 +22,6 @@ export const TICKET_TIMELINE_LABELS: Record<string, string> = {
TICKET_LINKED: "Chamado vinculado",
TICKET_REOPENED: "Chamado reaberto",
CUSTOM_FIELDS_UPDATED: "Campos personalizados atualizados",
LIVE_CHAT_STARTED: "Chat iniciado",
LIVE_CHAT_ENDED: "Chat finalizado",
};

View file

@ -52,6 +52,9 @@ export type MachineInventoryRecord = {
remoteAccess?: unknown
linkedUsers?: LinkedUser[]
customFields?: DeviceCustomField[]
usbPolicy?: string | null
usbPolicyStatus?: string | null
ticketCount?: number | null
}
type WorkbookOptions = {
@ -147,6 +150,9 @@ const COLUMN_VALUE_RESOLVERS: Record<string, (machine: MachineInventoryRecord, d
fleetUpdatedAt: (_machine, derived) =>
derived.fleetInfo?.updatedAt ? formatDateTime(derived.fleetInfo.updatedAt) : null,
managementMode: (machine) => describeManagementMode(machine.managementMode),
usbPolicy: (machine) => describeUsbPolicy(machine.usbPolicy),
usbPolicyStatus: (machine) => describeUsbPolicyStatus(machine.usbPolicyStatus),
ticketCount: (machine) => machine.ticketCount ?? 0,
}
const DEFAULT_COLUMN_CONFIG: DeviceInventoryColumnConfig[] = DEVICE_INVENTORY_COLUMN_METADATA.filter((meta) => meta.default !== false).map(
@ -336,6 +342,38 @@ function describeManagementMode(mode: string | null | undefined): string {
}
}
function describeUsbPolicy(policy: string | null | undefined): string {
if (!policy) return "—"
const normalized = policy.toUpperCase()
switch (normalized) {
case "ALLOW":
return "Permitido"
case "BLOCK_ALL":
return "Bloqueado"
case "READONLY":
return "Somente leitura"
default:
return policy
}
}
function describeUsbPolicyStatus(status: string | null | undefined): string {
if (!status) return "—"
const normalized = status.toUpperCase()
switch (normalized) {
case "PENDING":
return "Pendente"
case "APPLYING":
return "Aplicando"
case "APPLIED":
return "Aplicado"
case "FAILED":
return "Falhou"
default:
return status
}
}
const SOFTWARE_HEADERS = ["Hostname", "Aplicativo", "Versão", "Origem", "Publicador", "Instalado em"] as const
const SOFTWARE_COLUMN_WIDTHS = [22, 36, 18, 18, 22, 20] as const

View file

@ -359,6 +359,22 @@ function buildTimelineMessage(type: string, payload: Record<string, unknown> | n
return "CSAT recebido"
case "CSAT_RATED":
return "CSAT avaliado"
case "LIVE_CHAT_STARTED": {
const agentName = p.agentName as string | undefined
const machineHostname = p.machineHostname as string | undefined
const parts: string[] = ["Chat ao vivo iniciado"]
if (agentName) parts[0] += ` por ${agentName}`
if (machineHostname) parts.push(`Máquina: ${machineHostname}`)
return parts.join(" • ")
}
case "LIVE_CHAT_ENDED": {
const agentName = p.agentName as string | undefined
const durationMs = p.durationMs as number | undefined
const parts: string[] = ["Chat ao vivo finalizado"]
if (agentName) parts[0] += ` por ${agentName}`
if (durationMs) parts.push(`Duração: ${formatDurationMs(durationMs)}`)
return parts.join(" • ")
}
default:
return null
}
@ -397,7 +413,11 @@ function TicketPdfDocument({ ticket, logoDataUrl }: { ticket: TicketWithDetails;
safeBody: sanitizeToPlainText(comment.body) || "Sem texto",
})) as Array<TicketComment & { safeBody: string }>
// Tipos de eventos que não devem aparecer no PDF
const HIDDEN_EVENT_TYPES = ["CHAT_MESSAGE_ADDED"]
const timeline = [...ticket.timeline]
.filter((event) => !HIDDEN_EVENT_TYPES.includes(event.type))
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
.map((event) => {
const label = TICKET_TIMELINE_LABELS[event.type] ?? event.type