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 (
+
+ O login foi realizado, mas um administrador ainda precisa vincular seu usuario a um
+ perfil de acesso e a uma area operacional antes de liberar a plataforma.
+