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
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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={() => {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue