diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 6015beb..7de2054 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -25,7 +25,11 @@ "Bash(\"\"\" OWNER TO renan; FROM pg_tables WHERE schemaname = public;\"\" | docker exec -i c95ebc27eb82 psql -U sistema -d strapi_blog\")", "Bash(sequence_name)", "Bash(\"\"\" OWNER TO renan; FROM information_schema.sequences WHERE sequence_schema = public;\"\" | docker exec -i c95ebc27eb82 psql -U sistema -d strapi_blog\")", - "Bash(git add:*)" + "Bash(git add:*)", + "Bash(git commit:*)", + "Bash(git push:*)", + "Bash(cargo check:*)", + "Bash(bun run:*)" ] } } diff --git a/apps/desktop/src-tauri/src/chat.rs b/apps/desktop/src-tauri/src/chat.rs index 92d4b4e..864e829 100644 --- a/apps/desktop/src-tauri/src/chat.rs +++ b/apps/desktop/src-tauri/src/chat.rs @@ -674,16 +674,25 @@ async fn process_chat_update( }), ); - // Mostrar janela de chat minimizada (menos intrusivo que abrir completo) - // A janela ja abre minimizada por padrao (start_minimized=true) + // Mostrar janela de chat (se nao existe, cria minimizada; se existe, apenas mostra) if let Some(session) = current_sessions.first() { let label = format!("chat-{}", session.ticket_id); if let Some(window) = app.get_webview_window(&label) { - // Janela ja existe - apenas mostrar e garantir que esta minimizada + // Janela ja existe - apenas mostrar (NAO minimizar se estiver expandida) + // Isso permite que o usuario mantenha o chat aberto enquanto recebe mensagens let _ = window.show(); - let _ = set_chat_minimized(app, &session.ticket_id, true); + // Verificar se esta expandida (altura > 100px significa expandido) + // Se estiver expandida, NAO minimizar - usuario esta usando o chat + if let Ok(size) = window.inner_size() { + let is_expanded = size.height > 100; + if !is_expanded { + // Janela esta minimizada, manter minimizada + let _ = set_chat_minimized(app, &session.ticket_id, true); + } + // Se esta expandida, nao faz nada - deixa o usuario continuar usando + } } else { - // Criar nova janela ja minimizada (sem necessidade de chamar set_chat_minimized depois) + // Criar nova janela ja minimizada (menos intrusivo) let _ = open_chat_window(app, &session.ticket_id, session.ticket_ref); } } diff --git a/convex/liveChat.ts b/convex/liveChat.ts index e781be7..836f9c9 100644 --- a/convex/liveChat.ts +++ b/convex/liveChat.ts @@ -753,43 +753,78 @@ export const getTicketChatHistory = query({ // ENCERRAMENTO AUTOMATICO POR INATIVIDADE // ============================================ -// Timeout de inatividade: 5 minutos -const INACTIVITY_TIMEOUT_MS = 5 * 60 * 1000 +// Timeout de maquina offline: 5 minutos sem heartbeat +const MACHINE_OFFLINE_TIMEOUT_MS = 5 * 60 * 1000 -// Mutation interna para encerrar sessões inativas (chamada pelo cron) -// Otimizada com paginação para evitar timeout +// Mutation interna para encerrar sessões de máquinas offline (chamada pelo cron) +// Nova lógica: só encerra se a MÁQUINA estiver offline, não por inatividade de chat +// Isso permite que usuários mantenham o chat aberto sem precisar enviar mensagens export const autoEndInactiveSessions = mutation({ args: {}, handler: async (ctx) => { - // Log obrigatorio para evitar shape_inference errors com logLines vazios - console.log("cron: autoEndInactiveSessions iniciado") + console.log("cron: autoEndInactiveSessions iniciado (verificando maquinas offline)") const now = Date.now() - const cutoffTime = now - INACTIVITY_TIMEOUT_MS + const offlineCutoff = now - MACHINE_OFFLINE_TIMEOUT_MS // Limitar a 50 sessões por execução para evitar timeout do cron (30s) const maxSessionsPerRun = 50 - // Buscar sessões ativas com inatividade > 5 minutos (usando índice otimizado) - const inactiveSessions = await ctx.db + // Buscar todas as sessões ativas + const activeSessions = await ctx.db .query("liveChatSessions") - .withIndex("by_status_lastActivity", (q) => - q.eq("status", "ACTIVE").lt("lastActivityAt", cutoffTime) - ) + .withIndex("by_status_lastActivity", (q) => q.eq("status", "ACTIVE")) .take(maxSessionsPerRun) let endedCount = 0 + let checkedCount = 0 - for (const session of inactiveSessions) { - // Encerrar a sessão + for (const session of activeSessions) { + checkedCount++ + + // Buscar o ticket para obter a máquina + const ticket = await ctx.db.get(session.ticketId) + if (!ticket || !ticket.machineId) { + // Ticket sem máquina - encerrar sessão órfã + await ctx.db.patch(session._id, { + status: "ENDED", + endedAt: now, + }) + await ctx.db.insert("ticketEvents", { + ticketId: session.ticketId, + type: "LIVE_CHAT_ENDED", + payload: { + sessionId: session._id, + agentId: session.agentId, + agentName: session.agentSnapshot?.name ?? "Sistema", + durationMs: now - session.startedAt, + startedAt: session.startedAt, + endedAt: now, + autoEnded: true, + reason: "ticket_sem_maquina", + }, + createdAt: now, + }) + endedCount++ + continue + } + + // Verificar heartbeat da máquina + const lastHeartbeatAt = await getLastHeartbeatAt(ctx, ticket.machineId) + const machineIsOnline = lastHeartbeatAt !== null && lastHeartbeatAt > offlineCutoff + + // Se máquina está online, manter sessão ativa + if (machineIsOnline) { + continue + } + + // Máquina está offline - encerrar sessão await ctx.db.patch(session._id, { status: "ENDED", endedAt: now, }) - // Calcular duração da sessão const durationMs = now - session.startedAt - // Registrar evento na timeline await ctx.db.insert("ticketEvents", { ticketId: session.ticketId, type: "LIVE_CHAT_ENDED", @@ -800,8 +835,8 @@ export const autoEndInactiveSessions = mutation({ durationMs, startedAt: session.startedAt, endedAt: now, - autoEnded: true, // Flag para indicar encerramento automático - reason: "inatividade", + autoEnded: true, + reason: "maquina_offline", }, createdAt: now, }) @@ -809,7 +844,8 @@ export const autoEndInactiveSessions = mutation({ endedCount++ } - return { endedCount, hasMore: inactiveSessions.length === maxSessionsPerRun } + console.log(`cron: verificadas ${checkedCount} sessoes, encerradas ${endedCount} (maquinas offline)`) + return { endedCount, checkedCount, hasMore: activeSessions.length === maxSessionsPerRun } }, }) diff --git a/src/components/admin/devices/device-tickets-history.client.tsx b/src/components/admin/devices/device-tickets-history.client.tsx index 13aaa60..6419bcf 100644 --- a/src/components/admin/devices/device-tickets-history.client.tsx +++ b/src/components/admin/devices/device-tickets-history.client.tsx @@ -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("ALL") const [requesterFilter, setRequesterFilter] = useState("ALL") @@ -373,10 +375,10 @@ export function DeviceTicketsHistoryClient({ tenantId: _tenantId, deviceId }: { Prioridade - Ultima atualizacao + Última atualização - Responsavel + Responsável @@ -387,7 +389,11 @@ export function DeviceTicketsHistoryClient({ tenantId: _tenantId, deviceId }: { const updatedLabel = formatRelativeTime(ticket.updatedAt) const updatedAbsolute = formatAbsoluteTime(ticket.updatedAt) return ( - + router.push(`/tickets/${ticket.id}`)} + >
) { 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 ( diff --git a/src/components/chat/chat-widget.tsx b/src/components/chat/chat-widget.tsx index 0081e26..ac80b5f 100644 --- a/src/components/chat/chat-widget.tsx +++ b/src/components/chat/chat-widget.tsx @@ -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(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(() => { + 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(null) const [isSending, setIsSending] = useState(false) const [isEndingChat, setIsEndingChat] = useState(false) const [attachments, setAttachments] = useState([]) @@ -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) => { + 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) diff --git a/src/components/tickets/ticket-chat-panel.tsx b/src/components/tickets/ticket-chat-panel.tsx index b1d2ea2..258f261 100644 --- a/src/components/tickets/ticket-chat-panel.tsx +++ b/src/components/tickets/ticket-chat-panel.tsx @@ -82,7 +82,9 @@ export function TicketChatPanel({ ticketId }: TicketChatPanelProps) { const messagesEndRef = useRef(null) const inputRef = useRef(null) + const containerRef = useRef(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 ( - + {/* Header */}