Corrige comportamentos do chat e melhora UX

- Corrige contador de mensagens resetando sozinho (web)
  - Adiciona verificacao de visibilidade antes de marcar como lido
  - Verifica se aba esta ativa antes de marcar como lido

- Adiciona sincronizacao de estado do chat entre abas (web)
  - Usa BroadcastChannel para sincronizar aberto/fechado/minimizado
  - Persiste estado no localStorage

- Corrige chat minimizando sozinho no desktop (Rust)
  - Verifica se chat esta expandido antes de minimizar
  - Mantem chat aberto quando usuario esta usando

- Melhora encerramento automatico de sessoes de chat
  - Muda criterio de inatividade de chat para maquina offline
  - Sessao permanece ativa enquanto Raven estiver aberto
  - Encerra apenas quando maquina fica offline por 5+ min

- Corrige tabela de tickets em /devices
  - Adiciona acentuacao correta (Ultima atualizacao, Responsavel)
  - Torna linha inteira clicavel para abrir ticket

- Ajusta sidebar
  - Menu Tickets agora expande ao clicar (igual Cadastros)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rever-tecnologia 2025-12-11 15:21:24 -03:00
parent 2682b6e8ac
commit f4880f32d2
7 changed files with 219 additions and 37 deletions

View file

@ -2,6 +2,7 @@
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { usePaginatedQuery, useQuery } from "convex/react"
import { format, formatDistanceToNowStrict } from "date-fns"
import { ptBR } from "date-fns/locale"
@ -145,6 +146,7 @@ function getPriorityMeta(priority: TicketPriority | string | null | undefined) {
}
export function DeviceTicketsHistoryClient({ tenantId: _tenantId, deviceId }: { tenantId: string; deviceId: string }) {
const router = useRouter()
const [statusFilter, setStatusFilter] = useState<"all" | "open" | "resolved">("all")
const [priorityFilter, setPriorityFilter] = useState<string>("ALL")
const [requesterFilter, setRequesterFilter] = useState<string>("ALL")
@ -373,10 +375,10 @@ export function DeviceTicketsHistoryClient({ tenantId: _tenantId, deviceId }: {
Prioridade
</TableHead>
<TableHead className="border-l border-slate-200 px-3 py-3 text-center text-[11px] font-semibold uppercase tracking-wide text-neutral-600">
Ultima atualizacao
Última atualização
</TableHead>
<TableHead className="border-l border-slate-200 px-3 py-3 text-center text-[11px] font-semibold uppercase tracking-wide text-neutral-600">
Responsavel
Responsável
</TableHead>
</TableRow>
</TableHeader>
@ -387,7 +389,11 @@ export function DeviceTicketsHistoryClient({ tenantId: _tenantId, deviceId }: {
const updatedLabel = formatRelativeTime(ticket.updatedAt)
const updatedAbsolute = formatAbsoluteTime(ticket.updatedAt)
return (
<TableRow key={ticket.id} className="border-b border-slate-100 hover:bg-slate-50/70">
<TableRow
key={ticket.id}
className="cursor-pointer border-b border-slate-100 hover:bg-slate-50/70"
onClick={() => router.push(`/tickets/${ticket.id}`)}
>
<TableCell className="px-4 py-3 text-center align-middle">
<div className="flex flex-col items-center gap-1">
<Link

View file

@ -269,8 +269,8 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const childItems = item.children.filter((child) => !child.hidden && canAccess(child.requiredRole))
const isExpanded = expanded.has(item.title)
const isChildActive = childItems.some((child) => isActive(child))
const parentActive = item.title === "Tickets" ? isActive(item) || isChildActive : isChildActive
const isToggleOnly = item.title !== "Tickets"
const parentActive = isChildActive
const isToggleOnly = true // Todos os menus com filhos expandem ao clicar, nao navegam
return (
<React.Fragment key={item.title}>

View file

@ -38,6 +38,14 @@ import {
const MAX_MESSAGE_LENGTH = 4000
const MAX_ATTACHMENT_SIZE = 5 * 1024 * 1024 // 5MB
const MAX_ATTACHMENTS = 5
const CHAT_WIDGET_CHANNEL = "chat-widget-sync"
const STORAGE_KEY = "chat-widget-state"
type ChatWidgetState = {
isOpen: boolean
isMinimized: boolean
activeTicketId: string | null
}
function formatTime(timestamp: number) {
return new Date(timestamp).toLocaleTimeString("pt-BR", {
@ -238,10 +246,42 @@ export function ChatWidget() {
const { convexUserId } = useAuth()
const viewerId = convexUserId ?? null
const [isOpen, setIsOpen] = useState(false)
const [isMinimized, setIsMinimized] = useState(false)
const [activeTicketId, setActiveTicketId] = useState<string | null>(null)
// Inicializar estado a partir do localStorage (para persistir entre reloads)
const [isOpen, setIsOpen] = useState(() => {
if (typeof window === "undefined") return false
try {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved) {
const state = JSON.parse(saved) as ChatWidgetState
return state.isOpen
}
} catch {}
return false
})
const [isMinimized, setIsMinimized] = useState(() => {
if (typeof window === "undefined") return false
try {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved) {
const state = JSON.parse(saved) as ChatWidgetState
return state.isMinimized
}
} catch {}
return false
})
const [activeTicketId, setActiveTicketId] = useState<string | null>(() => {
if (typeof window === "undefined") return null
try {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved) {
const state = JSON.parse(saved) as ChatWidgetState
return state.activeTicketId
}
} catch {}
return null
})
const [draft, setDraft] = useState("")
const broadcastChannelRef = useRef<BroadcastChannel | null>(null)
const [isSending, setIsSending] = useState(false)
const [isEndingChat, setIsEndingChat] = useState(false)
const [attachments, setAttachments] = useState<UploadedFile[]>([])
@ -279,6 +319,51 @@ export function ChatWidget() {
const machineOnline = liveChat?.machineOnline ?? false
const machineHostname = liveChat?.machineHostname
// Sincronizar estado entre abas usando BroadcastChannel
useEffect(() => {
if (typeof window === "undefined") return
// Criar canal de broadcast
const channel = new BroadcastChannel(CHAT_WIDGET_CHANNEL)
broadcastChannelRef.current = channel
// Ouvir mensagens de outras abas
channel.onmessage = (event: MessageEvent<ChatWidgetState>) => {
const state = event.data
setIsOpen(state.isOpen)
setIsMinimized(state.isMinimized)
if (state.activeTicketId) {
setActiveTicketId(state.activeTicketId)
}
}
return () => {
channel.close()
broadcastChannelRef.current = null
}
}, [])
// Salvar estado no localStorage e broadcast para outras abas quando muda
useEffect(() => {
if (typeof window === "undefined") return
const state: ChatWidgetState = {
isOpen,
isMinimized,
activeTicketId,
}
// Salvar no localStorage para persistir entre reloads
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
} catch {}
// Broadcast para outras abas
if (broadcastChannelRef.current) {
broadcastChannelRef.current.postMessage(state)
}
}, [isOpen, isMinimized, activeTicketId])
// Auto-selecionar primeira sessão se nenhuma selecionada
useEffect(() => {
if (!activeTicketId && activeSessions && activeSessions.length > 0) {
@ -316,8 +401,10 @@ export function ChatWidget() {
// Marcar mensagens como lidas ao abrir/mostrar chat
useEffect(() => {
if (!viewerId || !chat || !activeTicketId) return
// Só marca quando o widget está aberto e visível
// Só marca quando o widget está aberto, expandido e a aba está ativa
if (!isOpen || isMinimized) return
if (typeof document !== "undefined" && document.visibilityState === "hidden") return
const unreadIds = chat.messages
?.filter((msg) => !msg.readBy?.some((r) => r.userId === viewerId))
.map((msg) => msg.id) ?? []
@ -492,7 +579,15 @@ export function ChatWidget() {
// Nao mostrar se nao logado ou sem sessoes
if (!viewerId) return null
if (!activeSessions || activeSessions.length === 0) return null
if (!activeSessions || activeSessions.length === 0) {
// Limpar estado salvo quando nao ha sessoes
if (typeof window !== "undefined") {
try {
localStorage.removeItem(STORAGE_KEY)
} catch {}
}
return null
}
const activeSession = activeSessions.find((s) => s.ticketId === activeTicketId)

View file

@ -82,7 +82,9 @@ export function TicketChatPanel({ ticketId }: TicketChatPanelProps) {
const messagesEndRef = useRef<HTMLDivElement | null>(null)
const inputRef = useRef<HTMLTextAreaElement | null>(null)
const containerRef = useRef<HTMLDivElement | null>(null)
const [draft, setDraft] = useState("")
const [isVisible, setIsVisible] = useState(false)
const [isSending, setIsSending] = useState(false)
const [isStartingChat, setIsStartingChat] = useState(false)
const [isEndingChat, setIsEndingChat] = useState(false)
@ -93,8 +95,38 @@ export function TicketChatPanel({ ticketId }: TicketChatPanelProps) {
const liveChat = chat?.liveChat
const hasActiveSession = Boolean(liveChat?.activeSession)
// Detectar visibilidade do componente na viewport
useEffect(() => {
const container = containerRef.current
if (!container) return
const observer = new IntersectionObserver(
([entry]) => {
setIsVisible(entry.isIntersecting)
},
{ threshold: 0.1 }
)
observer.observe(container)
return () => observer.disconnect()
}, [])
// Detectar se a aba esta ativa
useEffect(() => {
const handleVisibilityChange = () => {
if (document.visibilityState === "hidden") {
setIsVisible(false)
}
}
document.addEventListener("visibilitychange", handleVisibilityChange)
return () => document.removeEventListener("visibilitychange", handleVisibilityChange)
}, [])
// Marcar como lido apenas quando o componente esta visivel E a aba esta ativa
useEffect(() => {
if (!viewerId || !chat || !Array.isArray(chat.messages) || chat.messages.length === 0) return
// Verificar se o componente esta visivel e a aba esta ativa
if (!isVisible || document.visibilityState === "hidden") return
const unreadIds = chat.messages
.filter((message) => {
const alreadyRead = (message.readBy ?? []).some((entry) => entry.userId === viewerId)
@ -109,7 +141,7 @@ export function TicketChatPanel({ ticketId }: TicketChatPanelProps) {
}).catch((error) => {
console.error("Failed to mark chat messages as read", error)
})
}, [markChatRead, chat, ticketId, viewerId])
}, [markChatRead, chat, ticketId, viewerId, isVisible])
useEffect(() => {
if (messagesEndRef.current) {
@ -230,7 +262,7 @@ export function TicketChatPanel({ ticketId }: TicketChatPanelProps) {
}
return (
<Card className="flex flex-col overflow-hidden border-slate-200">
<Card ref={containerRef} className="flex flex-col overflow-hidden border-slate-200">
{/* Header */}
<CardHeader className="flex flex-row items-center justify-between gap-2 border-b border-slate-100 bg-slate-50 px-4 pr-3 py-3">
<div className="flex items-center gap-3">