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}
+
+
+ ))}
+ {isUploading && (
+
+
+
+ )}
+
+ )}
+
+ {/* Input com dropzone */}
+
+ {/* Overlay de drag */}
+ {isDragging && (
+
+
Solte os arquivos aqui
+
+ )}
+
+
+
)}
@@ -272,6 +697,15 @@ export function ChatWidget() {
Chat #{activeSession?.ticketRef}
+ {/* Indicador online no minimizado */}
+ {liveChat?.hasMachine && (
+
+ )}
{totalUnread > 0 && (
{totalUnread}
@@ -281,7 +715,7 @@ export function ChatWidget() {
)}
- {/* Botao flutuante */}
+ {/* Botão flutuante */}
{!isOpen && (
)
}
+ if (entry.type === "LIVE_CHAT_STARTED") {
+ const agentName = (payload as { agentName?: string }).agentName
+ const machineHostname = (payload as { machineHostname?: string }).machineHostname
+ message = (
+
+
+ Chat ao vivo iniciado
+ {agentName && (
+ <>
+ {" "}por {agentName}
+ >
+ )}
+
+ {machineHostname && (
+
+ Máquina: {machineHostname}
+
+ )}
+
+ )
+ }
+ 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 = (
+
+
+ Chat ao vivo finalizado
+ {agentName && (
+ <>
+ {" "}por {agentName}
+ >
+ )}
+
+ {durationFormatted && (
+
+ Duração: {durationFormatted}
+
+ )}
+
+ )
+ }
if (!message) return null
return (
diff --git a/src/lib/device-inventory-columns.ts b/src/lib/device-inventory-columns.ts
index d03a806..95f620a 100644
--- a/src/lib/device-inventory-columns.ts
+++ b/src/lib/device-inventory-columns.ts
@@ -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"]
diff --git a/src/lib/ticket-timeline-labels.ts b/src/lib/ticket-timeline-labels.ts
index 024d846..520c688 100644
--- a/src/lib/ticket-timeline-labels.ts
+++ b/src/lib/ticket-timeline-labels.ts
@@ -22,4 +22,6 @@ export const TICKET_TIMELINE_LABELS: Record
= {
TICKET_LINKED: "Chamado vinculado",
TICKET_REOPENED: "Chamado reaberto",
CUSTOM_FIELDS_UPDATED: "Campos personalizados atualizados",
+ LIVE_CHAT_STARTED: "Chat iniciado",
+ LIVE_CHAT_ENDED: "Chat finalizado",
};
diff --git a/src/server/machines/inventory-export.ts b/src/server/machines/inventory-export.ts
index 62cee97..a0acc90 100644
--- a/src/server/machines/inventory-export.ts
+++ b/src/server/machines/inventory-export.ts
@@ -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
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
diff --git a/src/server/pdf/ticket-pdf-template.tsx b/src/server/pdf/ticket-pdf-template.tsx
index 2943099..b1ba290 100644
--- a/src/server/pdf/ticket-pdf-template.tsx
+++ b/src/server/pdf/ticket-pdf-template.tsx
@@ -359,6 +359,22 @@ function buildTimelineMessage(type: string, payload: Record | 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
+ // 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