From 3b1cde79df66cc739959eb225f72084398ac8f4e Mon Sep 17 00:00:00 2001 From: esdrasrenan Date: Sun, 7 Dec 2025 02:20:11 -0300 Subject: [PATCH] Melhora chat ao vivo com anexos e eventos de timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- convex/liveChat.ts | 71 ++- .../admin/devices/admin-devices-overview.tsx | 18 +- src/components/chat/chat-widget.tsx | 514 ++++++++++++++++-- src/components/tickets/ticket-chat-panel.tsx | 10 +- src/components/tickets/ticket-detail-view.tsx | 2 - .../tickets/ticket-summary-header.tsx | 128 ++++- src/components/tickets/ticket-timeline.tsx | 53 +- src/lib/device-inventory-columns.ts | 3 + src/lib/ticket-timeline-labels.ts | 2 + src/server/machines/inventory-export.ts | 38 ++ src/server/pdf/ticket-pdf-template.tsx | 20 + 11 files changed, 782 insertions(+), 77 deletions(-) diff --git a/convex/liveChat.ts b/convex/liveChat.ts index 6cc4c20..ae4910f 100644 --- a/convex/liveChat.ts +++ b/convex/liveChat.ts @@ -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 diff --git a/src/components/admin/devices/admin-devices-overview.tsx b/src/components/admin/devices/admin-devices-overview.tsx index 8a36ca6..60f8553 100644 --- a/src/components/admin/devices/admin-devices-overview.tsx +++ b/src/components/admin/devices/admin-devices-overview.tsx @@ -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 - if (name.includes("linux")) return + if (name.includes("mac") || name.includes("darwin") || name.includes("macos")) return + if (name.includes("linux")) return // fallback para Windows/outros como monitor genérico - return + return } 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 ? : , + icon: windowsActivationStatus ? : , tone: windowsActivationStatus ? undefined : "warning", }) } @@ -4611,7 +4611,7 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {

Inventário

- Dados sincronizados via agente ou Fleet. + Informações coletadas automaticamente do dispositivo.

@@ -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 ( diff --git a/src/components/chat/chat-widget.tsx b/src/components/chat/chat-widget.tsx index 30818e6..f973276 100644 --- a/src/components/chat/chat-widget.tsx +++ b/src/components/chat/chat-widget.tsx @@ -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(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 ( +
+ +
+ ) + } + + if (isImage && url) { + return ( + + ) + } + + return ( + + ) +} + export function ChatWidget() { const { convexUserId } = useAuth() const viewerId = convexUserId ?? null @@ -38,11 +188,17 @@ export function ChatWidget() { const [activeTicketId, setActiveTicketId] = useState(null) const [draft, setDraft] = useState("") const [isSending, setIsSending] = useState(false) + const [isEndingChat, setIsEndingChat] = useState(false) + const [attachments, setAttachments] = useState([]) + const [isUploading, setIsUploading] = useState(false) + const [isDragging, setIsDragging] = useState(false) const messagesEndRef = useRef(null) const inputRef = useRef(null) + const fileInputRef = useRef(null) + const dropAreaRef = useRef(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) => { + 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() {
{/* Widget aberto */} {isOpen && !isMinimized && ( -
- {/* Header */} -
+
+ {/* Header - Estilo card da aplicação */} +
-
- +
+
-

Chat Ativo

+
+

Chat Ativo

+ {/* Indicador online/offline */} + {liveChat?.hasMachine && ( + machineOnline ? ( + + + Online + + ) : ( + + + Offline + + ) + )} +
{activeSession && ( -

#{activeSession.ticketRef}

+

+ #{activeSession.ticketRef} + {machineHostname && ` - ${machineHostname}`} +

)}
+ {/* Botão encerrar chat */} +
- {/* Seletor de sessoes (se mais de uma) */} + {/* Seletor de sessões (se mais de uma) */} {activeSessions.length > 1 && (
- + + + + + {activeSessions.map((session) => ( + + #{session.ticketRef} - {session.ticketSubject.slice(0, 25)} + {session.unreadCount > 0 && ( + ({session.unreadCount}) + )} + + ))} + + +
+ )} + + {/* Aviso de máquina offline */} + {liveChat?.hasMachine && !machineOnline && ( +
+

+ + A máquina está offline. Mensagens serão entregues quando voltar. +

)} @@ -211,7 +552,7 @@ export function ChatWidget() {
)} -

{msg.body}

+ {msg.body && ( +

{msg.body}

+ )} + {/* Anexos da mensagem */} + {msg.attachments && msg.attachments.length > 0 && ( +
+ {msg.attachments.map((att, i) => ( + + ))} +
+ )}

{formatTime(msg.createdAt)}

@@ -235,8 +586,56 @@ export function ChatWidget() { )}
- {/* Input */} -
+ {/* Preview de anexos pendentes */} + {attachments.length > 0 && ( +
+ {attachments.map((file, index) => ( +
+ {file.type?.startsWith("image/") && file.previewUrl ? ( + {file.name} + ) : ( +
+ +
+ )} + +

+ {file.name} +

+
+ ))} + {isUploading && ( +
+ +
+ )} +
+ )} + + {/* Input com dropzone */} +
+ {/* Overlay de drag */} + {isDragging && ( +
+

Solte os arquivos aqui

+
+ )} +