diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..72693fc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +node_modules +dist +.git +.github +.env +.env.local +.env.development +.env.production +npm-debug.log +Dockerfile +docker-compose*.yml +README.md \ No newline at end of file diff --git a/.env.example b/.env.example index a9932a3..339c769 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,3 @@ -VITE_API_URL=www.apiexample.com +VITE_API_URL=www.apiexample.com/api +VITE_WS_URL=ws://apiexample.com/api/live_chat VITE_APP_NAME=SyncDesk \ No newline at end of file diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..7e7c0f0 --- /dev/null +++ b/.env.production @@ -0,0 +1,3 @@ +VITE_API_URL=http://api.syncdesk.pro/api +VITE_WS_URL=ws://api.syncdesk.pro/api/live_chat +VITE_APP_NAME=SyncDesk \ No newline at end of file diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml new file mode 100644 index 0000000..cac4fde --- /dev/null +++ b/.github/workflows/deploy-web.yml @@ -0,0 +1,83 @@ +name: Deploy SyncDesk Web + +on: + push: + branches: + - main + +permissions: + contents: read + packages: write + +env: + IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/syncdesk-web + +jobs: + build-and-deploy: + name: Build and deploy web + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Normalize image name + run: | + echo "IMAGE_NAME_LOWER=$(echo '${{ env.IMAGE_NAME }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_ENV" + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: true + tags: | + ${{ env.IMAGE_NAME_LOWER }}:latest + ${{ env.IMAGE_NAME_LOWER }}:${{ github.sha }} + build-args: | + VITE_API_URL=${{ secrets.VITE_API_URL }} + VITE_WS_URL=${{ secrets.VITE_WS_URL }} + VITE_APP_NAME=${{ secrets.VITE_APP_NAME }} + + - name: Prepare SSH key + run: | + mkdir -p ~/.ssh + echo "${{ secrets.VM_SSH_KEY }}" > ~/.ssh/syncdesk_vm_key + chmod 600 ~/.ssh/syncdesk_vm_key + ssh-keyscan -H ${{ secrets.VM_HOST }} >> ~/.ssh/known_hosts + + - name: Deploy on VM + run: | + ssh -i ~/.ssh/syncdesk_vm_key ${{ secrets.VM_USER }}@${{ secrets.VM_HOST }} << EOF + set -e + + mkdir -p /opt/syncdesk/syncdesk-web + cd /opt/syncdesk/syncdesk-web + + cat > docker-compose.prod.yml << 'COMPOSE' + services: + syncdesk-web: + image: ${IMAGE_NAME_LOWER}:latest + container_name: syncdesk_web + restart: unless-stopped + ports: + - "80:80" + environment: + TZ: America/Sao_Paulo + COMPOSE + + if [ -n "${{ secrets.GHCR_TOKEN }}" ]; then + echo "${{ secrets.GHCR_TOKEN }}" | docker login ghcr.io -u "${{ secrets.GHCR_USERNAME }}" --password-stdin + fi + + docker compose -f docker-compose.prod.yml pull + docker compose -f docker-compose.prod.yml up -d + docker image prune -f + EOF \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6d7c3fa --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +FROM node:22-alpine AS builder + +WORKDIR /app + +ARG VITE_API_URL +ARG VITE_WS_URL +ARG VITE_APP_NAME + +ENV VITE_API_URL=$VITE_API_URL +ENV VITE_WS_URL=$VITE_WS_URL +ENV VITE_APP_NAME=$VITE_APP_NAME + +COPY package*.json ./ + +RUN npm ci + +COPY . . + +RUN npm run build + +FROM nginx:1.27-alpine AS runner + +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=builder /app/dist /usr/share/nginx/html + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..aa11091 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,16 @@ +services: + syncdesk-web: + build: + context: . + dockerfile: Dockerfile + args: + VITE_API_URL: ${VITE_API_URL} + VITE_WS_URL: ${VITE_WS_URL} + VITE_APP_NAME: ${VITE_APP_NAME} + image: syncdesk-web:latest + container_name: syncdesk_web + restart: unless-stopped + ports: + - "80:80" + environment: + TZ: America/Sao_Paulo diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..ae97302 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,25 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + client_max_body_size 20m; + + location / { + try_files $uri $uri/ /index.html; + } + + location /health { + access_log off; + add_header Content-Type text/plain; + return 200 "ok"; + } + + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|webp|woff|woff2|ttf|eot)$ { + expires 30d; + add_header Cache-Control "public, max-age=2592000, immutable"; + try_files $uri =404; + } +} \ No newline at end of file diff --git a/src/app/router.jsx b/src/app/router.jsx index a5389bf..b37d30e 100644 --- a/src/app/router.jsx +++ b/src/app/router.jsx @@ -10,10 +10,12 @@ import Dashboard from '@/features/dashboard/pages/Dashboard' import Chat from '@/features/chat/pages/Chat' import Usuarios from '@/features/users/pages/Usuarios' import CadastrarUsuario from '@/features/users/pages/CadastrarUsuario' -import EditarUsuario from '@/features/users/pages/EditarUsuario' +import EditarCliente from '@/features/users/pages/EditarCliente' +import EditarAtendente from '@/features/users/pages/EditarAtendente' import Chamados from '@/features/ticket/pages/Chamados' import AberturaChamado from '@/features/ticket/pages/AberturaChamado' import ModificarChamado from '@/features/ticket/pages/ModificarChamado' +import Configuracoes from '@/features/settings/pages/Configuracoes' export function AppRouter() { return ( @@ -33,11 +35,13 @@ export function AppRouter() { } /> } /> } /> + } /> }> } /> } /> - } /> + } /> + } /> diff --git a/src/features/auth/hooks/useLoginMutation.js b/src/features/auth/hooks/useLoginMutation.js index 0819f0c..da3e720 100644 --- a/src/features/auth/hooks/useLoginMutation.js +++ b/src/features/auth/hooks/useLoginMutation.js @@ -2,13 +2,14 @@ import { useMutation } from '@tanstack/react-query' import { login } from '@/features/auth/api/auth-service' import { useAuthStore } from '@/stores/auth-stores' import { decodeJwtPayload } from '@/shared/utils/jwt' +import { getUserById } from '@/features/users/api/user-service' export function useLoginMutation() { const setSession = useAuthStore((state) => state.setSession) return useMutation({ mutationFn: login, - onSuccess: (data) => { + onSuccess: async (data) => { const tokenPayload = decodeJwtPayload(data?.access_token) const derivedUser = tokenPayload?.sub @@ -18,18 +19,29 @@ export function useLoginMutation() { } : null - const user = data?.user - ? { - ...derivedUser, - ...data.user - } + const partialUser = data?.user + ? { ...derivedUser, ...data.user } : derivedUser setSession({ - user, + user: partialUser, accessToken: data?.access_token || null, refreshToken: data?.refresh_token || null }) + + if (partialUser?.id) { + try { + const fullUser = await getUserById(partialUser.id) + if (fullUser) { + setSession({ + user: { ...partialUser, ...fullUser }, + accessToken: data?.access_token || null, + refreshToken: data?.refresh_token || null + }) + } + } catch { + } + } } }) } \ No newline at end of file diff --git a/src/features/chat/api/chat-service.js b/src/features/chat/api/chat-service.js index 80f609f..4b0551a 100644 --- a/src/features/chat/api/chat-service.js +++ b/src/features/chat/api/chat-service.js @@ -9,6 +9,10 @@ function normalizeListResponse(data) { return data.items } + if (Array.isArray(data?.data?.items)) { + return data.data.items + } + if (Array.isArray(data?.data)) { return data.data } @@ -20,6 +24,10 @@ function normalizeObjectResponse(data) { return data?.data ?? data } +function isNotFoundError(error) { + return error?.response?.status === 404 +} + export async function getActiveConversations(search = '') { const params = search ? { search } : undefined const { data } = await http.get('/conversations/active', { params }) @@ -35,9 +43,9 @@ export async function getPaginatedMessages(ticketId, { page = 1, limit = 20 } = return { messages: Array.isArray(payload?.messages) ? payload.messages : [], - total: payload?.total ?? 0, - page: payload?.page ?? page, - limit: payload?.limit ?? limit, + total: Number(payload?.total ?? 0), + page: Number(payload?.page ?? page), + limit: Number(payload?.limit ?? limit), has_next: Boolean(payload?.has_next) } } @@ -53,6 +61,35 @@ export async function takeTicket(ticketId) { } export async function getAttendanceById(triageId) { - const { data } = await http.get(`/chatbot/${triageId}`) - return normalizeObjectResponse(data) + if (!triageId) { + return null + } + + try { + const { data } = await http.get(`/chatbot/${triageId}`) + return normalizeObjectResponse(data) + } catch (error) { + if (isNotFoundError(error)) { + return null + } + + throw error + } +} + +export async function getChatSessions(search = '') { + return getActiveConversations(search) +} + +export async function getChatMessages(ticketId) { + const result = await getPaginatedMessages(ticketId, { page: 1, limit: 100 }) + return result.messages +} + +export async function flagChatSession() { + throw new Error('A API atual do backend não possui endpoint REST para sinalizar sessão de chat.') +} + +export async function sendChatMessage() { + throw new Error('O envio de mensagens do chat ao vivo deve ser feito via WebSocket.') } \ No newline at end of file diff --git a/src/features/chat/hooks/useAssumeChatSessionMutation.js b/src/features/chat/hooks/useAssumeChatSessionMutation.js index 30ab1fe..d28bd4f 100644 --- a/src/features/chat/hooks/useAssumeChatSessionMutation.js +++ b/src/features/chat/hooks/useAssumeChatSessionMutation.js @@ -6,9 +6,13 @@ export function useAssumeChatSessionMutation() { return useMutation({ mutationFn: assumeConversation, - onSuccess: () => { + onSuccess: (_, chatId) => { queryClient.invalidateQueries({ queryKey: ['chat', 'active-conversations'] }) - queryClient.invalidateQueries({ queryKey: ['chat', 'messages'] }) + queryClient.invalidateQueries({ queryKey: ['tickets'] }) + + if (chatId) { + queryClient.invalidateQueries({ queryKey: ['chat', 'conversation', chatId] }) + } } }) } \ No newline at end of file diff --git a/src/features/chat/hooks/useAttendanceQuery.js b/src/features/chat/hooks/useAttendanceQuery.js index 5bcb626..a6818e0 100644 --- a/src/features/chat/hooks/useAttendanceQuery.js +++ b/src/features/chat/hooks/useAttendanceQuery.js @@ -1,10 +1,15 @@ import { useQuery } from '@tanstack/react-query' import { getAttendanceById } from '@/features/chat/api/chat-service' -export function useAttendanceQuery(triageId) { +export function useAttendanceQuery(triageId, options = {}) { + const { enabled: optionEnabled = true, ...queryOptions } = options + return useQuery({ queryKey: ['chatbot', 'attendance', triageId], queryFn: () => getAttendanceById(triageId), - enabled: Boolean(triageId) + retry: false, + staleTime: 30000, + ...queryOptions, + enabled: Boolean(triageId) && optionEnabled }) } \ No newline at end of file diff --git a/src/features/chat/hooks/useGetPaginatedMessages.js b/src/features/chat/hooks/useGetPaginatedMessages.js index accfddc..8b523b6 100644 --- a/src/features/chat/hooks/useGetPaginatedMessages.js +++ b/src/features/chat/hooks/useGetPaginatedMessages.js @@ -2,13 +2,19 @@ import { useInfiniteQuery } from '@tanstack/react-query' import { getPaginatedMessages } from '@/features/chat/api/chat-service' export function useGetPaginatedMessages(ticketId, limit = 20, options = {}) { - const enabled = Boolean(ticketId) && (options.enabled ?? true) + const { enabled: optionEnabled = true, ...queryOptions } = options + const enabled = Boolean(ticketId) && optionEnabled return useInfiniteQuery({ queryKey: ['chat', 'messages', ticketId, limit], queryFn: ({ pageParam = 1 }) => getPaginatedMessages(ticketId, { page: pageParam, limit }), initialPageParam: 1, getNextPageParam: (lastPage) => (lastPage?.has_next ? lastPage.page + 1 : undefined), - enabled + enabled, + retry: false, + staleTime: 30000, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + ...queryOptions }) } \ No newline at end of file diff --git a/src/features/chat/hooks/useLiveChatWebSocket.js b/src/features/chat/hooks/useLiveChatWebSocket.js index 098f59e..5bb84b3 100644 --- a/src/features/chat/hooks/useLiveChatWebSocket.js +++ b/src/features/chat/hooks/useLiveChatWebSocket.js @@ -12,6 +12,28 @@ function buildLiveChatWsUrl(chatId) { return url.toString() } +function getWebSocketProtocols(accessToken) { + if (!accessToken) { + return undefined + } + + return ['access_token', accessToken] +} + +function getSafeWsUrl(wsUrl) { + if (!wsUrl) { + return '' + } + + try { + const url = new URL(wsUrl) + url.search = '' + return url.toString() + } catch { + return wsUrl + } +} + function getMessageId(message) { return message?.id ?? `${message?.conversation_id}-${message?.timestamp}-${message?.content}` } @@ -27,23 +49,49 @@ function shouldIgnoreSystemJoinMessage(message) { return content.includes('joined to chat room') || content.includes('joined chat room') } +function extractMessageFromPayload(payload) { + if (!payload) { + return null + } + + if (payload?.meta?.success === false || payload?.status >= 400) { + return null + } + + if (payload?.data?.id || payload?.data?.content) { + return payload.data + } + + if (payload?.message?.id || payload?.message?.content) { + return payload.message + } + + if (payload?.id || payload?.content) { + return payload + } + + return null +} + export function useLiveChatWebSocket({ chatId, enabled = true }) { const accessToken = useAuthStore((state) => state.accessToken) const socketRef = useRef(null) + const [connectionStatus, setConnectionStatus] = useState('idle') const [liveMessages, setLiveMessages] = useState([]) const [lastError, setLastError] = useState(null) const wsUrl = useMemo(() => { - if (!chatId) { + if (!chatId || !accessToken) { return null } return buildLiveChatWsUrl(chatId) - }, [chatId]) + }, [accessToken, chatId]) useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect setLiveMessages([]) setLastError(null) @@ -52,60 +100,108 @@ export function useLiveChatWebSocket({ chatId, enabled = true }) { return undefined } + let active = true + let opened = false + let socket + setConnectionStatus('connecting') - const socket = new WebSocket(wsUrl, ['access_token', accessToken]) + try { + const protocols = getWebSocketProtocols(accessToken) + socket = protocols ? new WebSocket(wsUrl, protocols) : new WebSocket(wsUrl) + } catch { + setConnectionStatus('error') + setLastError('Não foi possível iniciar a conexão WebSocket.') + return undefined + } + socketRef.current = socket socket.onopen = () => { + if (!active) { + return + } + + opened = true setConnectionStatus('connected') setLastError(null) } socket.onmessage = (event) => { + if (!active) { + return + } + try { const payload = JSON.parse(event.data) - if (payload?.meta?.success && payload?.data) { - const message = payload.data - - if (shouldIgnoreSystemJoinMessage(message)) { - return - } - - setLiveMessages((current) => { - const nextId = getMessageId(message) - - if (current.some((item) => getMessageId(item) === nextId)) { - return current - } + if (payload?.meta?.success === false || payload?.status >= 400) { + setLastError(payload?.detail || 'Erro na comunicação em tempo real.') + return + } - return [...current, message] - }) + const message = extractMessageFromPayload(payload) + if (!message || shouldIgnoreSystemJoinMessage(message)) { return } - if (payload?.meta?.success === false || payload?.status) { - setLastError(payload?.detail || 'Erro na comunicação em tempo real.') - } + setLiveMessages((current) => { + const nextId = getMessageId(message) + + if (current.some((item) => getMessageId(item) === nextId)) { + return current + } + + return [...current, message] + }) } catch { setLastError('Não foi possível interpretar a mensagem recebida.') } } socket.onerror = () => { + if (!active) { + return + } + setConnectionStatus('error') - setLastError('Falha na conexão WebSocket.') + setLastError(`Falha na conexão WebSocket em ${getSafeWsUrl(wsUrl)}`) } - socket.onclose = () => { + socket.onclose = (event) => { + if (!active) { + return + } + + socketRef.current = null + + if (!opened) { + setConnectionStatus('error') + setLastError( + `Handshake WebSocket recusado em ${getSafeWsUrl(wsUrl)}. Verifique autenticação, permissão e vínculo do usuário com a conversa.` + ) + return + } + setConnectionStatus('disconnected') + + if (!event.wasClean) { + setLastError( + `WebSocket encerrado de forma inesperada. Código: ${event.code || 'sem código'}` + ) + } } return () => { - socket.close() - socketRef.current = null + active = false + + if (socketRef.current) { + socketRef.current.close(1000, 'Chat changed or component unmounted') + socketRef.current = null + } + + setConnectionStatus('idle') } }, [accessToken, chatId, enabled, wsUrl]) diff --git a/src/features/chat/pages/Chat.jsx b/src/features/chat/pages/Chat.jsx index cf20332..b239a03 100644 --- a/src/features/chat/pages/Chat.jsx +++ b/src/features/chat/pages/Chat.jsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { TrendingUp, Search, @@ -9,7 +9,10 @@ import { Paperclip, LayoutGrid, History, - LogOut + LogOut, + Settings, + Hand, + ShieldAlert } from 'lucide-react' import { useNavigate } from 'react-router-dom' import { useAuthStore } from '@/stores/auth-stores' @@ -17,17 +20,20 @@ import { useActiveConversationsQuery } from '@/features/chat/hooks/useActiveConv import { useGetPaginatedMessages } from '@/features/chat/hooks/useGetPaginatedMessages' import { useLiveChatWebSocket } from '@/features/chat/hooks/useLiveChatWebSocket' import { useAttendanceQuery } from '@/features/chat/hooks/useAttendanceQuery' +import { useAssumeChatSessionMutation } from '@/features/chat/hooks/useAssumeChatSessionMutation' import { decodeJwtPayload } from '@/shared/utils/jwt' import { useDebouncedValue } from '@/shared/hooks/useDebouncedValue' import { matchesConversationSearch } from '@/features/chat/utils/searchConversations' const VIEW_FILTERS = [ + { key: 'queue', label: 'Fila disponível' }, { key: 'mine', label: 'Meus atuais' }, { key: 'all', label: 'Todos os atuais' } ] export default function Chat() { const navigate = useNavigate() + const clearSession = useAuthStore((state) => state.clearSession) const authUser = useAuthStore((state) => state.user) const accessToken = useAuthStore((state) => state.accessToken) @@ -37,46 +43,81 @@ export default function Chat() { const [selectedChatId, setSelectedChatId] = useState(null) const [messageInput, setMessageInput] = useState('') const [viewFilter, setViewFilter] = useState('mine') + const [assumeError, setAssumeError] = useState(null) + const [pendingAssumeChatId, setPendingAssumeChatId] = useState(null) + const [optimisticMessages, setOptimisticMessages] = useState([]) const menuRef = useRef(null) const messagesViewportRef = useRef(null) + const shouldStickToBottomRef = useRef(true) + const lastActiveChatIdRef = useRef(null) + const previousMessagesSignatureRef = useRef('') + const liveArrivalOrderRef = useRef(new Map()) + const nextLiveArrivalOrderRef = useRef(1) + const nextOptimisticOrderRef = useRef(1) const debouncedSearch = useDebouncedValue(search, 300) - const tokenPayload = useMemo(() => decodeJwtPayload(accessToken), [accessToken]) - const currentUserId = String(authUser?.id ?? tokenPayload?.sub ?? '') + const tokenPayload = useMemo(() => { + if (!accessToken) { + return null + } + + return decodeJwtPayload(accessToken) + }, [accessToken]) + + const currentUserId = String( + authUser?.id ?? + tokenPayload?.sub ?? + tokenPayload?.user_id ?? + tokenPayload?.userId ?? + '' + ) + const currentRoleNames = useMemo( () => getCurrentRoleNames(authUser, tokenPayload), [authUser, tokenPayload] ) + const isAdmin = currentRoleNames.includes('admin') const conversationsQuery = useActiveConversationsQuery('', { - refetchInterval: 5000 + enabled: Boolean(accessToken), + refetchInterval: 10000, + staleTime: 5000, + retry: false }) - const allConversations = conversationsQuery.data ?? [] + const assumeConversationMutation = useAssumeChatSessionMutation() + + const allConversations = useMemo(() => { + return conversationsQuery.data ?? [] + }, [conversationsQuery.data]) - const currentConversations = useMemo( - () => allConversations.filter((conversation) => !conversation?.needs_assume), + const queueConversations = useMemo( + () => allConversations.filter((conversation) => isConversationAvailable(conversation)), [allConversations] ) const myCurrentConversations = useMemo( () => - currentConversations.filter((conversation) => + allConversations.filter((conversation) => isConversationAssignedToUser(conversation, currentUserId) ), - [currentConversations, currentUserId] + [allConversations, currentUserId] ) const sourceConversations = useMemo(() => { + if (viewFilter === 'queue') { + return queueConversations + } + if (viewFilter === 'all') { - return currentConversations + return allConversations } return myCurrentConversations - }, [currentConversations, myCurrentConversations, viewFilter]) + }, [allConversations, myCurrentConversations, queueConversations, viewFilter]) const visibleConversations = useMemo(() => { return sourceConversations.filter((conversation) => @@ -84,19 +125,6 @@ export default function Chat() { ) }, [sourceConversations, debouncedSearch]) - useEffect(() => { - function handleClickOutside(event) { - if (menuRef.current && !menuRef.current.contains(event.target)) { - setMenuPerfilAberto(false) - } - } - - document.addEventListener('mousedown', handleClickOutside) - return () => { - document.removeEventListener('mousedown', handleClickOutside) - } - }, []) - const activeChatId = useMemo(() => { if (!visibleConversations.length) { return null @@ -122,12 +150,15 @@ export default function Chat() { ) const assignedAgentId = getAssignedAgentId(activeConversation) + const activeConversationClosed = isConversationClosed(activeConversation) + const isAssignedToCurrentUser = Boolean( activeConversation && assignedAgentId && currentUserId && assignedAgentId === currentUserId ) + const isAssignedToAnotherAgent = Boolean( activeConversation && assignedAgentId && @@ -135,14 +166,33 @@ export default function Chat() { assignedAgentId !== currentUserId ) - const canReadHistory = Boolean(activeConversation?.ticket_id && (isAssignedToCurrentUser || isAdmin)) + const activeConversationIsAvailable = Boolean( + activeConversation && isConversationAvailable(activeConversation) + ) + + const canAssumeConversation = Boolean( + getConversationId(activeConversation) && + activeConversationIsAvailable && + !isAssignedToCurrentUser && + !isAssignedToAnotherAgent && + !pendingAssumeChatId + ) + + const canReadHistory = Boolean( + activeConversation?.ticket_id && + (isAssignedToCurrentUser || isAdmin) + ) + const canConnectLive = Boolean( - activeConversation?.chat_id && + getConversationId(activeConversation) && activeConversation?.can_join_live && - isAssignedToCurrentUser + isAssignedToCurrentUser && + !activeConversationClosed ) - const attendanceQuery = useAttendanceQuery(activeConversation?.triage_id) + const attendanceQuery = useAttendanceQuery(activeConversation?.triage_id, { + enabled: canReadHistory + }) const paginatedMessagesQuery = useGetPaginatedMessages( activeConversation?.ticket_id ?? null, @@ -155,7 +205,7 @@ export default function Chat() { const historyMessages = useMemo(() => { const pages = paginatedMessagesQuery.data?.pages ?? [] - return dedupeMessages( + return prepareHistoryMessages( pages .slice() .reverse() @@ -169,12 +219,56 @@ export default function Chat() { sendMessage, lastError } = useLiveChatWebSocket({ - chatId: activeConversation?.chat_id ?? null, + chatId: getConversationId(activeConversation), enabled: canConnectLive }) + const liveMessagesWithArrivalOrder = useMemo(() => { + return (liveMessages ?? []).map((message) => { + const key = getMessageIdentityKey(message) + + if (!liveArrivalOrderRef.current.has(key)) { + liveArrivalOrderRef.current.set(key, nextLiveArrivalOrderRef.current) + nextLiveArrivalOrderRef.current += 1 + } + + return { + ...message, + __source: 'live', + __arrivalOrder: liveArrivalOrderRef.current.get(key) + } + }) + }, [liveMessages]) + + useEffect(() => { + if (!optimisticMessages.length) { + return + } + + setOptimisticMessages((currentOptimisticMessages) => { + const nextOptimisticMessages = reconcileOptimisticMessages({ + confirmedMessages: [ + ...historyMessages, + ...liveMessagesWithArrivalOrder + ], + optimisticMessages: currentOptimisticMessages + }) + + const unchanged = + nextOptimisticMessages.length === currentOptimisticMessages.length && + nextOptimisticMessages.every( + (message, index) => + getMessageId(message) === getMessageId(currentOptimisticMessages[index]) + ) + + return unchanged ? currentOptimisticMessages : nextOptimisticMessages + }) + }, [historyMessages, liveMessagesWithArrivalOrder, optimisticMessages.length]) + const triageTimeline = useMemo(() => { - const triage = attendanceQuery.data?.triage ?? [] + const triage = Array.isArray(attendanceQuery.data?.triage) + ? attendanceQuery.data.triage + : [] return triage.flatMap((item, index) => { const timeline = [ @@ -193,45 +287,166 @@ export default function Chat() { }) } - return timeline + return timeline.filter((timelineItem) => Boolean(timelineItem.content)) }) }, [attendanceQuery.data]) const messages = useMemo(() => { - return dedupeMessages([...historyMessages, ...liveMessages]).filter(shouldRenderMessage) - }, [historyMessages, liveMessages]) + return buildChatTimeline({ + historyMessages, + liveMessages: liveMessagesWithArrivalOrder, + optimisticMessages + }).filter(shouldRenderMessage) + }, [historyMessages, liveMessagesWithArrivalOrder, optimisticMessages]) + + const messagesSignature = useMemo(() => { + return messages.map((message) => getMessageId(message)).join('|') + }, [messages]) const canSendMessage = Boolean( canConnectLive && isAssignedToCurrentUser && - connectionStatus === 'connected' + connectionStatus === 'connected' && + !activeConversationClosed ) + const totalCurrentCount = allConversations.length + const queueCount = queueConversations.length + const myCurrentCount = myCurrentConversations.length + + const scrollMessagesToBottom = useCallback((behavior = 'auto') => { + window.requestAnimationFrame(() => { + const viewport = messagesViewportRef.current + + if (!viewport) { + return + } + + viewport.scrollTo({ + top: viewport.scrollHeight, + behavior + }) + }) + }, []) + + const handleMessagesViewportScroll = useCallback(() => { + const viewport = messagesViewportRef.current + + if (!viewport) { + return + } + + shouldStickToBottomRef.current = isViewportNearBottom(viewport) + }, []) + + useEffect(() => { + function handleClickOutside(event) { + if (menuRef.current && !menuRef.current.contains(event.target)) { + setMenuPerfilAberto(false) + } + } + + document.addEventListener('mousedown', handleClickOutside) + + return () => { + document.removeEventListener('mousedown', handleClickOutside) + } + }, []) + useEffect(() => { setMessageInput('') + setAssumeError(null) + setOptimisticMessages([]) + shouldStickToBottomRef.current = true + liveArrivalOrderRef.current = new Map() + nextLiveArrivalOrderRef.current = 1 + nextOptimisticOrderRef.current = 1 + previousMessagesSignatureRef.current = '' }, [activeChatId]) useEffect(() => { - if (!messagesViewportRef.current) { + if (activeConversation && !isAssignedToCurrentUser) { + setMessageInput('') + } + }, [activeConversation, isAssignedToCurrentUser]) + + useLayoutEffect(() => { + const viewport = messagesViewportRef.current + + if (!viewport) { return } - messagesViewportRef.current.scrollTop = messagesViewportRef.current.scrollHeight - }, [activeChatId, liveMessages.length, triageTimeline.length]) + const chatChanged = lastActiveChatIdRef.current !== activeChatId + const messagesChanged = previousMessagesSignatureRef.current !== messagesSignature + + const shouldScroll = + chatChanged || + shouldStickToBottomRef.current || + previousMessagesSignatureRef.current === '' - function handleLogout() { + if (messagesChanged && shouldScroll) { + scrollMessagesToBottom(chatChanged ? 'auto' : 'smooth') + } + + if (chatChanged) { + scrollMessagesToBottom('auto') + } + + lastActiveChatIdRef.current = activeChatId + previousMessagesSignatureRef.current = messagesSignature + }, [activeChatId, messagesSignature, scrollMessagesToBottom]) + + const handleLogout = useCallback(() => { clearSession() navigate('/login', { replace: true }) - } + }, [clearSession, navigate]) - function handleNavigateHome() { + const handleNavigateHome = useCallback(() => { navigate('/') - } + }, [navigate]) + + const handleAssumeConversation = useCallback(async () => { + const chatId = getConversationId(activeConversation) - function handleSendMessage() { + if (!chatId || !canAssumeConversation) { + return + } + + try { + setPendingAssumeChatId(chatId) + setAssumeError(null) + + await assumeConversationMutation.mutateAsync(chatId) + await conversationsQuery.refetch() + + setSelectedChatId(chatId) + setViewFilter('mine') + shouldStickToBottomRef.current = true + scrollMessagesToBottom('auto') + } catch (error) { + setAssumeError( + error?.response?.data?.detail || + 'Não foi possível assumir este atendimento.' + ) + + await conversationsQuery.refetch() + } finally { + setPendingAssumeChatId(null) + } + }, [ + activeConversation, + assumeConversationMutation, + canAssumeConversation, + conversationsQuery, + scrollMessagesToBottom + ]) + + const handleSendMessage = useCallback(() => { const content = messageInput.trim() + const chatId = getConversationId(activeConversation) - if (!activeConversation || !content || !canSendMessage) { + if (!activeConversation || !chatId || !content || !canSendMessage) { return } @@ -240,13 +455,38 @@ export default function Chat() { content }) - if (sent) { - setMessageInput('') + if (!sent) { + return } - } - const totalCurrentCount = currentConversations.length - const myCurrentCount = myCurrentConversations.length + const optimisticOrder = nextOptimisticOrderRef.current + nextOptimisticOrderRef.current += 1 + + setOptimisticMessages((currentMessages) => [ + ...currentMessages, + { + id: `optimistic-${chatId}-${Date.now()}-${optimisticOrder}`, + conversation_id: chatId, + sender_id: currentUserId, + type: 'text', + content, + timestamp: new Date().toISOString(), + __source: 'optimistic', + __arrivalOrder: optimisticOrder + } + ]) + + setMessageInput('') + shouldStickToBottomRef.current = true + scrollMessagesToBottom('smooth') + }, [ + activeConversation, + canSendMessage, + currentUserId, + messageInput, + sendMessage, + scrollMessagesToBottom + ]) return (
@@ -261,7 +501,9 @@ export default function Chat() {
- SyncDesk + + SyncDesk +
@@ -300,25 +542,21 @@ export default function Chat() { {menuPerfilAberto && ( -
-
- -
-
+ { + setMenuPerfilAberto(false) + navigate('/configuracoes') + }} + onLogout={handleLogout} + /> )}
-
+ + {activeConversation && canAssumeConversation && ( + + )}
- {!activeConversation && !conversationsQuery.isLoading && ( - - )} - - {activeConversation && Boolean(triageTimeline.length) && ( -
-
- Histórico da triagem -
- -
- {triageTimeline.map((item) => ( - - ))} -
-
- )} - - {activeConversation && attendanceQuery.isLoading && ( - - )} +
+
+ {!activeConversation && !conversationsQuery.isLoading && ( + + )} - {activeConversation && !canReadHistory && ( - - )} + {activeConversation && activeConversationIsAvailable && !isAssignedToCurrentUser && ( + } + title="Atendimento disponível na fila" + text="Este atendimento foi aberto pela URA e ainda não possui responsável. Assuma o atendimento para responder em tempo real." + /> + )} - {activeConversation && canReadHistory && ( - <> - {paginatedMessagesQuery.hasNextPage && ( -
- -
+ {activeConversation && isAssignedToAnotherAgent && ( + } + title="Atendimento em andamento" + text="Este atendimento já está atribuído a outro atendente. Você pode localizá-lo nesta tela, mas apenas o responsável consegue responder em tempo real." + /> )} - {paginatedMessagesQuery.isLoading && !messages.length && ( - + {activeConversation && activeConversationClosed && ( + } + title="Atendimento encerrado" + text="Este atendimento foi encerrado. O histórico permanece disponível, mas novas mensagens não podem ser enviadas." + /> )} - {paginatedMessagesQuery.isError && ( - + {activeConversation && Boolean(triageTimeline.length) && canReadHistory && ( +
+
+ Histórico da triagem +
+ +
+ {triageTimeline.map((item) => ( + + ))} +
+
)} - {!paginatedMessagesQuery.isLoading && - !messages.length && - !paginatedMessagesQuery.isError && ( - + {activeConversation && + canReadHistory && + Boolean(triageTimeline.length) && + !attendanceQuery.isLoading && ( + )} - {messages.map((message) => ( - - ))} - - )} -
+ {activeConversation && attendanceQuery.isLoading && canReadHistory && ( + + )} -
-
- setMessageInput(event.target.value)} - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault() - handleSendMessage() - } - }} - disabled={!canSendMessage} - placeholder={getInputPlaceholder({ - activeConversation, - connectionStatus, - isAssignedToCurrentUser, - isAssignedToAnotherAgent - })} - className="flex-1 px-4 py-2 text-sm text-gray-600 focus:outline-none placeholder:text-gray-400 disabled:bg-white" - /> - - -
+ {activeConversation && attendanceQuery.isError && canReadHistory && ( + + )} - {(lastError || (activeConversation && !isAssignedToCurrentUser)) && ( -

- {lastError || getFooterHelperText({ activeConversation, isAssignedToAnotherAgent })} -

- )} + {activeConversation && canReadHistory && ( + <> + {paginatedMessagesQuery.hasNextPage && ( +
+ +
+ )} + + {paginatedMessagesQuery.isLoading && !messages.length && ( + + )} + + {paginatedMessagesQuery.isError && ( + + )} + + {!paginatedMessagesQuery.isLoading && + !messages.length && + !paginatedMessagesQuery.isError && ( + + )} + + {messages.map((message) => ( + + ))} + + )} +
+
+ + @@ -127,6 +153,7 @@ export default function AberturaChamado() {
+
+
)} @@ -155,8 +203,12 @@ export default function AberturaChamado() {
-

Novo Ticket

-

Preencha os dados para abertura de chamado técnico.

+

+ Novo Ticket +

+

+ Preencha os dados para abertura manual de chamado técnico. +

@@ -174,7 +226,17 @@ export default function AberturaChamado() { disabled={createTicketMutation.isPending} className="bg-[#BD3B0F] hover:bg-[#9a2f0d] text-white text-xs font-bold py-3 px-8 rounded-lg shadow-lg flex items-center gap-2 uppercase tracking-widest disabled:opacity-50 transition-all active:scale-95" > - {createTicketMutation.isPending ? : <> Abrir Chamado} + {createTicketMutation.isPending ? ( + <> + + Abrindo... + + ) : ( + <> + + Abrir Chamado + + )}
@@ -186,27 +248,39 @@ export default function AberturaChamado() {
- + + + {usersQuery.isError && ( +

+ Não foi possível carregar os usuários. +

+ )}
- +
- +
- +