From de1e4f518b0a7c2160ca27a651a2635ac680a29c Mon Sep 17 00:00:00 2001 From: Rafael Lopes Date: Mon, 18 May 2026 17:34:23 -0300 Subject: [PATCH] =?UTF-8?q?WIP:=20=20Reconstru=C3=A7=C3=A3o=20e=20refatora?= =?UTF-8?q?=C3=A7=C3=A3o=20do=20frontend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 +- docs/chat-whatsapp.md | 88 +++++ package-lock.json | 90 ++++- package.json | 3 +- src/modules/auth/services/sessionService.js | 135 ++++++++ src/modules/chat/components/ChatWindow.jsx | 307 +++++++++++++++-- src/modules/chat/hooks/useChat.js | 314 +++++++++++++++-- src/modules/chat/pages/ChatPage.jsx | 8 + .../home/components/AttendantOpsPanel.jsx | 120 +++++++ src/modules/home/components/HomeSidebar.jsx | 22 ++ src/modules/home/pages/HomePage.jsx | 88 ++--- src/modules/home/pages/ProfileHomePage.jsx | 23 ++ src/modules/home/pages/UnassignedHomePage.jsx | 91 +++++ .../management/components/DataPanel.jsx | 48 +++ .../components/ManagementLayout.jsx | 241 +++++++++++++ .../management/components/ManagementTable.jsx | 59 ++++ .../management/components/MetricGrid.jsx | 31 ++ src/modules/management/pages/AdminPage.jsx | 270 +++++++++++++++ .../management/pages/SupervisorPage.jsx | 320 ++++++++++++++++++ .../management/pages/WhatsappAdminPage.jsx | 61 ++++ .../management/services/adminAccessService.js | 33 ++ .../management/services/managementMocks.js | 106 ++++++ src/routes/router.jsx | 16 +- src/shared/hooks/useWhatsappSocket.js | 66 ++++ 24 files changed, 2436 insertions(+), 109 deletions(-) create mode 100644 docs/chat-whatsapp.md create mode 100644 src/modules/auth/services/sessionService.js create mode 100644 src/modules/home/components/AttendantOpsPanel.jsx create mode 100644 src/modules/home/pages/ProfileHomePage.jsx create mode 100644 src/modules/home/pages/UnassignedHomePage.jsx create mode 100644 src/modules/management/components/DataPanel.jsx create mode 100644 src/modules/management/components/ManagementLayout.jsx create mode 100644 src/modules/management/components/ManagementTable.jsx create mode 100644 src/modules/management/components/MetricGrid.jsx create mode 100644 src/modules/management/pages/AdminPage.jsx create mode 100644 src/modules/management/pages/SupervisorPage.jsx create mode 100644 src/modules/management/pages/WhatsappAdminPage.jsx create mode 100644 src/modules/management/services/adminAccessService.js create mode 100644 src/modules/management/services/managementMocks.js create mode 100644 src/shared/hooks/useWhatsappSocket.js diff --git a/.gitignore b/.gitignore index b512c09..f3fd91a 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ -node_modules \ No newline at end of file +node_modules +dist +.env.development +.env.production diff --git a/docs/chat-whatsapp.md b/docs/chat-whatsapp.md new file mode 100644 index 0000000..13c3a68 --- /dev/null +++ b/docs/chat-whatsapp.md @@ -0,0 +1,88 @@ +# Modulo de Chat WhatsApp (Frontend) + +## Visao geral + +O modulo de Chat no frontend integra as conversas em tempo real do WhatsApp diretamente na tela de atendimento do operador. + +A interface e altamente responsiva, provendo feedback instantaneo de envio (zero latencia) e sincronizando com o backend via WebSockets (Socket.io) para atualizar estados de de-duplicacao, novas mensagens, midias e controle de posse do atendimento. + +--- + +## Componentes Principais + +### 1. Hook de Negocio (`useChat.js`) +Centraliza todo o estado das conversas, conexao WebSocket e operacoes de rede: +* **`contacts`**: Lista de chats ativos sincronizados. Cada contato possui um objeto `assignment` (atribuicao) normalizado. +* **`messagesByContact`**: Map de historico de mensagens por JID/contato. +* **`takeChat()`**: Dispara a requisicao de rede `/whatsapp/assign` enviando o ID do atendente e o ID numerico da area do usuario logado (convertido com seguranca para inteiro). +* **`sendMessage()`**: Trata a de-duplicacao de mensagens em milissegundos e gerencia a concorrência (race condition). + +### 2. Painel de Atendimento (`ChatWindow.jsx`) +O container principal da conversa selecionada. Ele renderiza: +* **Header**: Mostra o nome resolvido do cliente, canal (WhatsApp) e o indicador de quem esta atendendo. +* **Historico**: Area de scroll contendo as bolhas de mensagens do atendente (`agent`) e do cliente (`customer`), incluindo visualizadores para imagens, audios e links de arquivos. +* **Footer de Input**: Caixa de texto com suporte a tecla Enter e icone de anexo de midia (com validacao automatica de tamanho). + +--- + +## Mecanismos de UX e Estabilidade + +### 1. Insercao Instantanea (UX Zero-Latency) +Para evitar que o atendente perceba qualquer latencia de rede, o envio e dividido em duas etapas: +1. **Fase Local**: A bolha de mensagem e inserida na tela imediatamente com um ID temporario (`temp-` + timestamp) e o texto digitado. O input de texto e arquivos e limpo na mesma hora. +2. **Fase de Disparo**: A requisicao HTTP POST e disparada para o backend em segundo plano. + +### 2. De-duplicacao de Mensagens (Prevecao de Race Condition) +Como o backend envia a mensagem recebida via WebSocket assim que o Puppeteer a dispara, a bolha poderia aparecer duplicada na tela se a requisição de envio original ainda estivesse processando. +* **A Solucao**: O hook de WebSocket compara as mensagens recebidas em tempo real. Se o texto bater e a diferenca temporal de timestamp for inferior a 4 segundos, ele identifica a bolha `temp-...` local, remove o prefixo temporario e atualiza-a com o ID oficial do WhatsApp gerado no servidor. **Zero duplicacoes, zero flashes na tela.** + +### 3. Validação de Posse (Type-Safe User IDs) +Para evitar conflitos na exibicao do banner *"⚠️ Atendido por outro colaborador"*, realizamos casting explicito dos IDs dos usuarios envolvidos: +```javascript +const isAssignedToMe = activeContact?.assignment?.userId && String(activeContact.assignment.userId) === String(currentUser.id); +const isAssignedToOthers = activeContact?.assignment && String(activeContact.assignment.userId) !== String(currentUser.id); +``` +Isso impede que comparacoes como `4 === "4"` (inteiro vindo do banco relacional vs string vindo do localStorage/JWT) avaliem incorretamente como falso, mantendo a tela bloqueada ou liberada com precisao. + +### 4. Layout e Rolagem Estrita (680px Scroll) +A interface de mensagens possui limitacoes verticais restritas para evitar que a tela se alongue infinitamente para baixo. +* A bolha de historico e fixada com altura proporcional (`height: 680px` ou `calc`) e controle de transbordo `overflow-y: auto`. +* O hook de chat escuta mudancas na lista de mensagens e realiza rolagem automatica suave (`smooth`) para o fim da tela sempre que uma nova bolha e adicionada. + +--- + +## Novos Fluxos Homologados (WhatsApp / Meta) + +### 1. Novo Atendimento Inteligente (`NewAttendancePage.jsx`) +* **Remoção do Seletor de Área**: O seletor manual foi removido da tela para simplificar a operação. O sistema resolve a área dinamicamente a partir do atendente logado (`currentUser.areaPrincipal` ou `areas[0]`). +* **Bloqueio de Campo**: Ao escolher um contato dos recentes ou da busca lateral, o input do telefone e do nome do cliente ficam bloqueados para escrita. +* **Modo "Novo Número"**: Ao clicar no botão, o operador habilita os inputs de nome e telefone. Caso inicie o chat sem digitar um nome personalizado, o sistema aplica um fallback limpo no formato `Contato Novo (+55...)`. + +### 2. Bloqueio e Envio de Templates Meta (`ChatWindow.jsx`) +Como a API oficial do WhatsApp/Meta exige uma mensagem pré-aprovada para iniciar conversas ativas (sem histórico prévio), a interface aplica travas estritas: +* **Travamento do Input**: Se a conversa selecionada possuir histórico de envio vazio (`!hasAgentMessages`), a caixa de texto principal e o botão "Enviar" ficam bloqueados. +* **Painel de Templates**: Logo acima do rodapé de digitação, renderiza-se um seletor horizontal com os templates oficiais Meta ativos no banco (buscados de `GET /whatsapp/templates`). +* **Substituição Dinâmica**: Ao clicar em um template, as variáveis `|NOME|`, `|DATA|` ou `|PROTOCOLO|` são interpoladas em tempo real com os dados do cliente, populando o input principal e liberando o fluxo de envio da primeira mensagem. + +### 3. Gerenciamento de Templates para Supervisores (`SupervisorPage.jsx`) +Supervisores possuem controle administrativo total sobre as mensagens homologadas: +* **CRUD de Modelos**: Exibe todos os templates de WhatsApp em formato de cards visuais. +* **Painel de Edição**: Permite criar novos templates ou editar identificadores/conteúdos de templates existentes. As alterações persistem imediatamente no banco PostgreSQL por meio dos endpoints `/whatsapp/templates`. + +--- + +## Como Integrar e Rodar + +### Variaveis de Ambiente +O frontend conecta no WebSocket e na API do backend usando a porta padrao do NestJS: +```env +VITE_API_URL=http://localhost:3001 +VITE_WS_URL=http://localhost:3001 +``` + +### Compilando e Rodando localmente +```bash +cd frontend +npm run dev +``` +Ao selecionar uma conversa de canal "WhatsApp" que esteja livre, basta digitar uma mensagem e pressionar Enter. O chat sera automaticamente assumido por voce em tempo real, gravando no PostgreSQL e desbloqueando a janela de chat de forma instantanea. diff --git a/package-lock.json b/package-lock.json index 3d5ff78..37d1d58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router-dom": "^6.30.1" + "react-router-dom": "^6.30.1", + "socket.io-client": "^4.8.3" }, "devDependencies": { "@vitejs/plugin-react": "^4.3.3", @@ -1107,6 +1108,12 @@ "win32" ] }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1260,7 +1267,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1281,6 +1287,28 @@ "dev": true, "license": "ISC" }, + "node_modules/engine.io-client": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz", + "integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.18.3", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -1413,7 +1441,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -1611,6 +1638,34 @@ "semver": "bin/semver.js" } }, + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1713,6 +1768,35 @@ } } }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index 8edf594..8e64848 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router-dom": "^6.30.1" + "react-router-dom": "^6.30.1", + "socket.io-client": "^4.8.3" }, "devDependencies": { "@vitejs/plugin-react": "^4.3.3", diff --git a/src/modules/auth/services/sessionService.js b/src/modules/auth/services/sessionService.js new file mode 100644 index 0000000..6adbcb8 --- /dev/null +++ b/src/modules/auth/services/sessionService.js @@ -0,0 +1,135 @@ +const PROFILE_ALIASES = { + admin: 'admin', + administrador: 'admin', + supervisor: 'supervisor', + gestor: 'supervisor', + agente: 'agent', + atendente: 'agent', + agent: 'agent', +}; + +const DEMO_PROFILE_BY_USERNAME = { + admin: 'admin', + 'lucas.admin': 'admin', + supervisor: 'supervisor', + 'marina.alves': 'supervisor', + 'rafael.nunes': 'supervisor', +}; + +function readStoredUser() { + const rawUser = window.localStorage.getItem('authUser'); + + if (!rawUser) { + return null; + } + + try { + return JSON.parse(rawUser); + } catch { + return null; + } +} + +function normalizeProfile(value) { + if (!value) { + return null; + } + + if (typeof value === 'string') { + return PROFILE_ALIASES[value.trim().toLowerCase()] || null; + } + + if (typeof value === 'object') { + return normalizeProfile(value.nome || value.name || value.role || value.perfil); + } + + return null; +} + +function resolveProfileFromList(values) { + if (!Array.isArray(values)) { + return normalizeProfile(values); + } + + const normalizedProfiles = values.map(normalizeProfile).filter(Boolean); + + if (normalizedProfiles.includes('admin')) { + return 'admin'; + } + + if (normalizedProfiles.includes('supervisor')) { + return 'supervisor'; + } + + return normalizedProfiles[0] || null; +} + +export function getCurrentUser() { + return readStoredUser(); +} + +export function getCurrentUserDisplay() { + const user = getCurrentUser(); + const fullName = user?.name || user?.nome || user?.username || 'Ana Camolesi'; + const nameParts = fullName.split(' ').filter(Boolean); + const name = + nameParts.length > 1 ? `${nameParts[0]} ${nameParts[nameParts.length - 1]}` : fullName; + const areas = Array.isArray(user?.areas) ? user.areas : []; + const profiles = Array.isArray(user?.perfis) + ? user.perfis + : Array.isArray(user?.profiles) + ? user.profiles + : []; + const area = user?.areaPrincipal || areas[0] || null; + const profile = profiles[0] || user?.perfil || user?.role || null; + const subtitle = [profile, area].filter(Boolean).join(' - ') || 'Atendimento omnichannel'; + const initials = name + .split(' ') + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]) + .join('') + .toUpperCase(); + + return { + name, + subtitle, + initials: initials || 'AM', + }; +} + +export function getCurrentUserProfile() { + const user = getCurrentUser(); + + if (!user) { + return 'agent'; + } + + if (user.accessStatus === 'unassigned') { + return 'unassigned'; + } + + const backendProfile = + resolveProfileFromList(user.role) || + resolveProfileFromList(user.perfil) || + resolveProfileFromList(user.perfis) || + resolveProfileFromList(user.profiles); + + if (backendProfile) { + return backendProfile; + } + + const username = String(user.username || user.email || user.name || '').trim().toLowerCase(); + const demoProfile = DEMO_PROFILE_BY_USERNAME[username]; + + if (demoProfile) { + return demoProfile; + } + + return 'agent'; +} + +export function clearSession() { + window.localStorage.removeItem('authToken'); + window.localStorage.removeItem('authUser'); +} diff --git a/src/modules/chat/components/ChatWindow.jsx b/src/modules/chat/components/ChatWindow.jsx index d7c0700..dcfe2fd 100644 --- a/src/modules/chat/components/ChatWindow.jsx +++ b/src/modules/chat/components/ChatWindow.jsx @@ -1,4 +1,183 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; + +function getMediaUrl(media) { + if (!media?.data || !media?.mimetype) return ''; + return `data:${media.mimetype};base64,${media.data}`; +} + +function MediaRenderer({ message, contactId, onLoadMedia, isAgent }) { + const mediaUrl = useMemo(() => getMediaUrl(message.media), [message.media]); + const mimetype = message.media?.mimetype || ''; + const filename = message.media?.filename || 'arquivo'; + + useEffect(() => { + if (!message.hasMedia || message.media?.data || message.mediaLoading || message.mediaError) { + return; + } + onLoadMedia?.(contactId, message.id); + }, [contactId, message, onLoadMedia]); + + if (!message.hasMedia && !message.media) return null; + + if (message.mediaLoading || (!message.media?.data && !message.mediaError)) { + return ( +
+ Carregando midia... +
+ ); + } + + if (message.mediaError) { + return ( + + Nao foi possivel carregar a midia. + + ); + } + + if (mimetype.startsWith('image/')) { + return ( + + {filename} { + event.currentTarget.style.transform = 'scale(1.015)'; + }} + onMouseLeave={(event) => { + event.currentTarget.style.transform = 'scale(1)'; + }} + /> + + ); + } + + if (mimetype.startsWith('video/')) { + return ( +