WIP: Reconstrução e refatoração do frontend
This commit is contained in:
parent
3f0ca83430
commit
de1e4f518b
5
.gitignore
vendored
5
.gitignore
vendored
@ -1 +1,4 @@
|
||||
node_modules
|
||||
node_modules
|
||||
dist
|
||||
.env.development
|
||||
.env.production
|
||||
|
||||
88
docs/chat-whatsapp.md
Normal file
88
docs/chat-whatsapp.md
Normal file
@ -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.
|
||||
90
package-lock.json
generated
90
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
135
src/modules/auth/services/sessionService.js
Normal file
135
src/modules/auth/services/sessionService.js
Normal file
@ -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');
|
||||
}
|
||||
@ -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 (
|
||||
<div
|
||||
style={{
|
||||
width: 260,
|
||||
maxWidth: '100%',
|
||||
height: 150,
|
||||
borderRadius: 14,
|
||||
background: isAgent ? 'rgba(255,255,255,0.18)' : 'rgba(0,49,80,0.08)',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
color: isAgent ? '#fff' : 'var(--color-text-soft)',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Carregando midia...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (message.mediaError) {
|
||||
return (
|
||||
<span style={{ color: isAgent ? '#fff' : 'var(--color-text-soft)', fontWeight: 700 }}>
|
||||
Nao foi possivel carregar a midia.
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (mimetype.startsWith('image/')) {
|
||||
return (
|
||||
<a href={mediaUrl} target="_blank" rel="noreferrer" style={{ display: 'block' }}>
|
||||
<img
|
||||
src={mediaUrl}
|
||||
alt={filename}
|
||||
style={{
|
||||
display: 'block',
|
||||
width: 280,
|
||||
maxWidth: '100%',
|
||||
maxHeight: 340,
|
||||
objectFit: 'cover',
|
||||
borderRadius: 14,
|
||||
boxShadow: '0 14px 30px rgba(0,0,0,0.18)',
|
||||
transition: 'transform 160ms ease',
|
||||
}}
|
||||
onMouseEnter={(event) => {
|
||||
event.currentTarget.style.transform = 'scale(1.015)';
|
||||
}}
|
||||
onMouseLeave={(event) => {
|
||||
event.currentTarget.style.transform = 'scale(1)';
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
if (mimetype.startsWith('video/')) {
|
||||
return (
|
||||
<video
|
||||
src={mediaUrl}
|
||||
controls
|
||||
style={{
|
||||
width: 320,
|
||||
maxWidth: '100%',
|
||||
borderRadius: 14,
|
||||
background: '#111',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (mimetype.startsWith('audio/') || mimetype.includes('ogg')) {
|
||||
return <audio src={mediaUrl} controls style={{ width: 280, maxWidth: '100%' }} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={mediaUrl}
|
||||
download={filename}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'auto 1fr',
|
||||
gap: '0.75rem',
|
||||
alignItems: 'center',
|
||||
padding: '0.85rem',
|
||||
borderRadius: 14,
|
||||
background: isAgent ? 'rgba(255,255,255,0.16)' : '#fff',
|
||||
color: isAgent ? '#fff' : 'var(--color-primary)',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
<span aria-hidden="true">📄</span>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{filename}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function AttachmentPreview({ file, onRemove }) {
|
||||
if (!file) return null;
|
||||
const mediaUrl = getMediaUrl({ data: file.data, mimetype: file.type });
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 16,
|
||||
padding: '0.75rem',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'auto 1fr auto',
|
||||
gap: '0.75rem',
|
||||
alignItems: 'center',
|
||||
background: '#fff',
|
||||
}}
|
||||
>
|
||||
{file.type?.startsWith('image/') ? (
|
||||
<img
|
||||
src={mediaUrl}
|
||||
alt={file.name}
|
||||
style={{ width: 54, height: 54, objectFit: 'cover', borderRadius: 12 }}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
style={{
|
||||
width: 54,
|
||||
height: 54,
|
||||
borderRadius: 12,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
background: 'rgba(0,49,80,0.08)',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
📎
|
||||
</span>
|
||||
)}
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<strong style={{ display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{file.name}
|
||||
</strong>
|
||||
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.86rem' }}>{file.type || 'arquivo'}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
title="Remover anexo"
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: 12,
|
||||
width: 36,
|
||||
height: 36,
|
||||
background: 'rgba(214, 40, 40, 0.1)',
|
||||
color: '#b42318',
|
||||
fontWeight: 900,
|
||||
}}
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatWindow({
|
||||
contact,
|
||||
@ -7,6 +186,10 @@ export function ChatWindow({
|
||||
setSelectedArea,
|
||||
draft,
|
||||
setDraft,
|
||||
attachedFile,
|
||||
onAttachFile,
|
||||
onRemoveAttachedFile,
|
||||
onLoadMedia,
|
||||
onSend,
|
||||
onToggleTransfer,
|
||||
isReplying,
|
||||
@ -141,13 +324,36 @@ export function ChatWindow({
|
||||
background: isAgent ? 'var(--color-primary)' : '#edf1f5',
|
||||
color: isAgent ? '#fff' : 'var(--color-text)',
|
||||
boxShadow: 'var(--shadow-md)',
|
||||
display: 'grid',
|
||||
gap: '0.65rem',
|
||||
}}
|
||||
>
|
||||
{message.text}
|
||||
<MediaRenderer
|
||||
message={message}
|
||||
contactId={contact.id}
|
||||
onLoadMedia={onLoadMedia}
|
||||
isAgent={isAgent}
|
||||
/>
|
||||
{message.text ? <span>{message.text}</span> : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{messages.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
justifySelf: 'center',
|
||||
padding: '0.8rem 1rem',
|
||||
borderRadius: 16,
|
||||
background: 'rgba(0,49,80,0.06)',
|
||||
color: 'var(--color-text-soft)',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Nenhuma mensagem carregada.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isReplying ? (
|
||||
<div
|
||||
style={{
|
||||
@ -169,42 +375,79 @@ export function ChatWindow({
|
||||
padding: '1rem 1.25rem 1.25rem',
|
||||
borderTop: '1px solid var(--color-border)',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: isMobile ? '1fr' : '1fr auto',
|
||||
gridTemplateColumns: '1fr',
|
||||
gap: '0.75rem',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
onSend();
|
||||
}
|
||||
}}
|
||||
placeholder="Escreva sua mensagem..."
|
||||
<AttachmentPreview file={attachedFile} onRemove={onRemoveAttachedFile} />
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '18px',
|
||||
padding: '0.95rem 1rem',
|
||||
background: '#fff',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSend}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '18px',
|
||||
padding: '0.95rem 1.2rem',
|
||||
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
|
||||
color: '#fff',
|
||||
fontWeight: 700,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: isMobile ? 'auto 1fr' : 'auto 1fr auto',
|
||||
gap: '0.75rem',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
Enviar
|
||||
</button>
|
||||
<label
|
||||
title="Anexar arquivo"
|
||||
style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 16,
|
||||
width: 48,
|
||||
height: 48,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
background: '#fff',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 900,
|
||||
}}
|
||||
>
|
||||
📎
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/jpg,image/webp,video/mp4,video/webm,audio/mp3,audio/mpeg,audio/ogg,audio/wav,application/pdf"
|
||||
onChange={(event) => {
|
||||
onAttachFile?.(event.target.files?.[0]);
|
||||
event.target.value = '';
|
||||
}}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
onSend();
|
||||
}
|
||||
}}
|
||||
placeholder="Escreva sua mensagem..."
|
||||
style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '18px',
|
||||
padding: '0.95rem 1rem',
|
||||
background: '#fff',
|
||||
outline: 'none',
|
||||
minWidth: 0,
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSend}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '18px',
|
||||
padding: '0.95rem 1.2rem',
|
||||
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
|
||||
color: '#fff',
|
||||
fontWeight: 700,
|
||||
gridColumn: isMobile ? '1 / -1' : 'auto',
|
||||
}}
|
||||
>
|
||||
Enviar
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
);
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useWhatsappSocket } from '../../../shared/hooks/useWhatsappSocket';
|
||||
import {
|
||||
attendantsByArea,
|
||||
chatContacts,
|
||||
getMockReply,
|
||||
transferAreas,
|
||||
} from '../services/chatMocks';
|
||||
|
||||
const API_BASE_URL = 'http://localhost:3001';
|
||||
|
||||
function buildInitialMessages() {
|
||||
return chatContacts.reduce((acc, contact) => {
|
||||
acc[contact.id] = contact.messages;
|
||||
@ -13,18 +15,95 @@ function buildInitialMessages() {
|
||||
}, {});
|
||||
}
|
||||
|
||||
function getSerializedId(value) {
|
||||
if (!value) return '';
|
||||
if (typeof value === 'string') return value;
|
||||
return value._serialized || `${value.user || ''}@${value.server || 'c.us'}`;
|
||||
}
|
||||
|
||||
function formatTime(timestamp) {
|
||||
if (!timestamp) return '';
|
||||
const date = new Date(timestamp * 1000);
|
||||
return date.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function getContactName(chat) {
|
||||
const serializedId = getSerializedId(chat.id);
|
||||
return chat.name || chat.pushname || serializedId.split('@')[0] || 'Contato';
|
||||
}
|
||||
|
||||
function getPreviewFromMessage(message) {
|
||||
if (message?.body) return message.body;
|
||||
if (message?.text) return message.text;
|
||||
if (message?.hasMedia || message?.media) return '[Midia]';
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizeChat(chat) {
|
||||
const id = getSerializedId(chat.id);
|
||||
return {
|
||||
id,
|
||||
name: getContactName(chat),
|
||||
channel: 'WhatsApp',
|
||||
status: 'online',
|
||||
area: chat.assignment?.area_id ? String(chat.assignment.area_id) : 'Suporte',
|
||||
lastSeen: chat.timestamp ? `Visto as ${formatTime(chat.timestamp)}` : 'Online agora',
|
||||
preview: chat.preview || chat.lastMessage?.body || '',
|
||||
time: formatTime(chat.timestamp) || 'Agora',
|
||||
unread: chat.unreadCount || 0,
|
||||
assignment: chat.assignment || null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMessage(message) {
|
||||
const id = getSerializedId(message.id) || message.id || `msg-${Date.now()}`;
|
||||
const sender = message.sender || (message.fromMe ? 'agent' : 'customer');
|
||||
return {
|
||||
id,
|
||||
chatId: message.from || message.to || message.chatId,
|
||||
sender,
|
||||
text: message.body ?? message.text ?? '',
|
||||
timestamp: message.timestamp,
|
||||
hasMedia: Boolean(message.hasMedia || message.media),
|
||||
media: message.media || null,
|
||||
mediaLoading: false,
|
||||
mediaError: null,
|
||||
};
|
||||
}
|
||||
|
||||
function fileToBase64(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = String(reader.result || '');
|
||||
resolve(result.includes(',') ? result.split(',')[1] : result);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function buildFallbackContacts() {
|
||||
return chatContacts.map((contact) => ({ ...contact }));
|
||||
}
|
||||
|
||||
export function useChat() {
|
||||
const [contacts, setContacts] = useState(chatContacts);
|
||||
const { incomingMessage, clearIncomingMessage } = useWhatsappSocket();
|
||||
const [contacts, setContacts] = useState(buildFallbackContacts);
|
||||
const [activeContactId, setActiveContactId] = useState(chatContacts[0].id);
|
||||
const [messagesByContact, setMessagesByContact] = useState(buildInitialMessages);
|
||||
const [draft, setDraft] = useState('');
|
||||
const [attachedFile, setAttachedFile] = useState(null);
|
||||
const [selectedArea, setSelectedArea] = useState(chatContacts[0].area);
|
||||
const [isTransferOpen, setIsTransferOpen] = useState(false);
|
||||
const [transferArea, setTransferArea] = useState('Suporte');
|
||||
const [transferAttendant, setTransferAttendant] = useState(attendantsByArea.Suporte[0]);
|
||||
const [transferNote, setTransferNote] = useState('');
|
||||
const [isReplying, setIsReplying] = useState(false);
|
||||
const replyTimeoutRef = useRef(null);
|
||||
const [isReplying] = useState(false);
|
||||
const [isLoadingChats, setIsLoadingChats] = useState(false);
|
||||
const [isLoadingMessages, setIsLoadingMessages] = useState(false);
|
||||
const [apiError, setApiError] = useState(null);
|
||||
const activeContactRef = useRef(activeContactId);
|
||||
|
||||
const activeContact = useMemo(
|
||||
() => contacts.find((contact) => contact.id === activeContactId) || contacts[0],
|
||||
@ -43,61 +122,223 @@ export function useChat() {
|
||||
}, [transferArea]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (replyTimeoutRef.current) {
|
||||
window.clearTimeout(replyTimeoutRef.current);
|
||||
activeContactRef.current = activeContactId;
|
||||
}, [activeContactId]);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
async function loadChats() {
|
||||
setIsLoadingChats(true);
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/whatsapp/chats`);
|
||||
if (!response.ok) throw new Error('Falha ao carregar chats do WhatsApp.');
|
||||
const data = await response.json();
|
||||
if (!isMounted || !Array.isArray(data) || data.length === 0) return;
|
||||
|
||||
const nextContacts = data.map(normalizeChat);
|
||||
setContacts(nextContacts);
|
||||
setActiveContactId((current) =>
|
||||
nextContacts.some((contact) => contact.id === current) ? current : nextContacts[0].id,
|
||||
);
|
||||
setApiError(null);
|
||||
} catch (error) {
|
||||
if (isMounted) setApiError(error.message);
|
||||
} finally {
|
||||
if (isMounted) setIsLoadingChats(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadChats();
|
||||
const intervalId = window.setInterval(loadChats, 30000);
|
||||
return () => {
|
||||
isMounted = false;
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, []);
|
||||
|
||||
function updateContactPreview(contactId, preview) {
|
||||
useEffect(() => {
|
||||
if (!activeContactId) return;
|
||||
let isMounted = true;
|
||||
|
||||
async function loadMessages() {
|
||||
if (!activeContactId.includes('@')) return;
|
||||
setIsLoadingMessages(true);
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/whatsapp/messages/${encodeURIComponent(activeContactId)}`);
|
||||
if (!response.ok) throw new Error('Falha ao carregar mensagens do WhatsApp.');
|
||||
const data = await response.json();
|
||||
if (!isMounted || !Array.isArray(data)) return;
|
||||
setMessagesByContact((current) => ({
|
||||
...current,
|
||||
[activeContactId]: data.map((message) => ({
|
||||
...normalizeMessage(message),
|
||||
chatId: activeContactId,
|
||||
})),
|
||||
}));
|
||||
setApiError(null);
|
||||
} catch (error) {
|
||||
if (isMounted) setApiError(error.message);
|
||||
} finally {
|
||||
if (isMounted) setIsLoadingMessages(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadMessages();
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [activeContactId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!incomingMessage) return;
|
||||
const contactId = incomingMessage.from || incomingMessage.to || incomingMessage.chatId;
|
||||
if (!contactId) return;
|
||||
|
||||
const message = {
|
||||
...normalizeMessage(incomingMessage),
|
||||
chatId: contactId,
|
||||
};
|
||||
const preview = getPreviewFromMessage(message);
|
||||
|
||||
setMessagesByContact((current) => {
|
||||
const currentMessages = current[contactId] || [];
|
||||
if (currentMessages.some((item) => item.id === message.id)) return current;
|
||||
return {
|
||||
...current,
|
||||
[contactId]: [...currentMessages, message],
|
||||
};
|
||||
});
|
||||
|
||||
setContacts((current) => {
|
||||
const existing = current.find((contact) => contact.id === contactId);
|
||||
const nextContact = {
|
||||
...(existing || {
|
||||
id: contactId,
|
||||
name: incomingMessage.notifyName || contactId.split('@')[0],
|
||||
channel: 'WhatsApp',
|
||||
status: 'online',
|
||||
area: 'Suporte',
|
||||
lastSeen: 'Online agora',
|
||||
unread: 0,
|
||||
}),
|
||||
preview,
|
||||
time: 'Agora',
|
||||
unread:
|
||||
incomingMessage.fromMe || contactId === activeContactRef.current
|
||||
? 0
|
||||
: (existing?.unread || 0) + 1,
|
||||
};
|
||||
return [nextContact, ...current.filter((contact) => contact.id !== contactId)];
|
||||
});
|
||||
|
||||
clearIncomingMessage();
|
||||
}, [incomingMessage, clearIncomingMessage]);
|
||||
|
||||
function updateContactPreview(contactId, preview, media) {
|
||||
setContacts((current) =>
|
||||
current.map((contact) =>
|
||||
contact.id === contactId ? { ...contact, preview, time: 'Agora', unread: 0 } : contact,
|
||||
contact.id === contactId
|
||||
? { ...contact, preview: media ? `[Midia: ${media.filename || 'Arquivo'}]` : preview, time: 'Agora', unread: 0 }
|
||||
: contact,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function sendMessage() {
|
||||
async function attachFile(file) {
|
||||
if (!file) return;
|
||||
const data = await fileToBase64(file);
|
||||
setAttachedFile({
|
||||
name: file.name,
|
||||
type: file.type || 'application/octet-stream',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
function removeAttachedFile() {
|
||||
setAttachedFile(null);
|
||||
}
|
||||
|
||||
async function hydrateMessageMedia(contactId, messageId) {
|
||||
if (!contactId || !messageId) return;
|
||||
|
||||
setMessagesByContact((current) => ({
|
||||
...current,
|
||||
[contactId]: (current[contactId] || []).map((message) =>
|
||||
message.id === messageId ? { ...message, mediaLoading: true, mediaError: null } : message,
|
||||
),
|
||||
}));
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/whatsapp/media/${encodeURIComponent(contactId)}/${encodeURIComponent(messageId)}`,
|
||||
);
|
||||
if (!response.ok) throw new Error('Falha ao carregar midia.');
|
||||
const media = await response.json();
|
||||
setMessagesByContact((current) => ({
|
||||
...current,
|
||||
[contactId]: (current[contactId] || []).map((message) =>
|
||||
message.id === messageId ? { ...message, media, mediaLoading: false } : message,
|
||||
),
|
||||
}));
|
||||
} catch (error) {
|
||||
setMessagesByContact((current) => ({
|
||||
...current,
|
||||
[contactId]: (current[contactId] || []).map((message) =>
|
||||
message.id === messageId
|
||||
? { ...message, mediaLoading: false, mediaError: error.message || 'Erro ao carregar midia.' }
|
||||
: message,
|
||||
),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
const trimmed = draft.trim();
|
||||
if (!trimmed) {
|
||||
if (!trimmed && !attachedFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
const media = attachedFile
|
||||
? {
|
||||
data: attachedFile.data,
|
||||
mimetype: attachedFile.type,
|
||||
filename: attachedFile.name,
|
||||
}
|
||||
: null;
|
||||
const newMessage = {
|
||||
id: Date.now(),
|
||||
id: `temp-${Date.now()}`,
|
||||
chatId: activeContactId,
|
||||
sender: 'agent',
|
||||
text: trimmed,
|
||||
hasMedia: Boolean(media),
|
||||
media,
|
||||
};
|
||||
|
||||
setMessagesByContact((current) => ({
|
||||
...current,
|
||||
[activeContactId]: [...(current[activeContactId] || []), newMessage],
|
||||
}));
|
||||
updateContactPreview(activeContactId, trimmed);
|
||||
updateContactPreview(activeContactId, trimmed || '[Midia]', media);
|
||||
setDraft('');
|
||||
setIsReplying(true);
|
||||
setAttachedFile(null);
|
||||
|
||||
replyTimeoutRef.current = window.setTimeout(() => {
|
||||
const reply = {
|
||||
id: Date.now() + 1,
|
||||
sender: 'customer',
|
||||
text: getMockReply(activeContact.name),
|
||||
};
|
||||
if (!activeContactId.includes('@')) return;
|
||||
|
||||
setMessagesByContact((current) => ({
|
||||
...current,
|
||||
[activeContactId]: [...(current[activeContactId] || []), reply],
|
||||
}));
|
||||
setContacts((current) =>
|
||||
current.map((contact) =>
|
||||
contact.id === activeContactId
|
||||
? { ...contact, preview: reply.text, time: 'Agora', unread: contact.unread + 1 }
|
||||
: contact,
|
||||
),
|
||||
);
|
||||
setIsReplying(false);
|
||||
}, 1400);
|
||||
try {
|
||||
await fetch(`${API_BASE_URL}/whatsapp/send`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
to: activeContactId,
|
||||
message: trimmed,
|
||||
media,
|
||||
}),
|
||||
});
|
||||
setApiError(null);
|
||||
} catch (error) {
|
||||
setApiError(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function submitTransfer() {
|
||||
@ -132,8 +373,15 @@ export function useChat() {
|
||||
messages,
|
||||
draft,
|
||||
setDraft,
|
||||
attachedFile,
|
||||
attachFile,
|
||||
removeAttachedFile,
|
||||
sendMessage,
|
||||
hydrateMessageMedia,
|
||||
isReplying,
|
||||
isLoadingChats,
|
||||
isLoadingMessages,
|
||||
apiError,
|
||||
selectedArea,
|
||||
setSelectedArea,
|
||||
isTransferOpen,
|
||||
|
||||
@ -17,7 +17,11 @@ export function ChatPage() {
|
||||
messages,
|
||||
draft,
|
||||
setDraft,
|
||||
attachedFile,
|
||||
attachFile,
|
||||
removeAttachedFile,
|
||||
sendMessage,
|
||||
hydrateMessageMedia,
|
||||
isReplying,
|
||||
selectedArea,
|
||||
setSelectedArea,
|
||||
@ -117,6 +121,10 @@ export function ChatPage() {
|
||||
setSelectedArea={setSelectedArea}
|
||||
draft={draft}
|
||||
setDraft={setDraft}
|
||||
attachedFile={attachedFile}
|
||||
onAttachFile={attachFile}
|
||||
onRemoveAttachedFile={removeAttachedFile}
|
||||
onLoadMedia={hydrateMessageMedia}
|
||||
onSend={sendMessage}
|
||||
onToggleTransfer={() => setIsTransferOpen((current) => !current)}
|
||||
isReplying={isReplying}
|
||||
|
||||
120
src/modules/home/components/AttendantOpsPanel.jsx
Normal file
120
src/modules/home/components/AttendantOpsPanel.jsx
Normal file
@ -0,0 +1,120 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function AttendantOpsPanel({ activeChatsCount }) {
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [secondsOnline, setSecondsOnline] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let interval;
|
||||
if (!isPaused) {
|
||||
interval = setInterval(() => {
|
||||
setSecondsOnline((s) => s + 1);
|
||||
}, 1000);
|
||||
}
|
||||
return () => clearInterval(interval);
|
||||
}, [isPaused]);
|
||||
|
||||
const formatTime = (totalSeconds) => {
|
||||
const h = Math.floor(totalSeconds / 3600);
|
||||
const m = Math.floor((totalSeconds % 3600) / 60);
|
||||
const s = totalSeconds % 60;
|
||||
return [h, m, s]
|
||||
.map(v => v.toString().padStart(2, '0'))
|
||||
.filter((v, i) => v !== '00' || i > 0)
|
||||
.join(':');
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
||||
gap: '1rem',
|
||||
}}
|
||||
>
|
||||
<article
|
||||
style={{
|
||||
padding: '1.25rem',
|
||||
borderRadius: '24px',
|
||||
border: '1px solid var(--color-border)',
|
||||
background: 'linear-gradient(145deg, #ffffff, #f8fafc)',
|
||||
boxShadow: 'var(--shadow-sm)',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.9rem', fontWeight: 600 }}>
|
||||
Tempo Online
|
||||
</span>
|
||||
<strong style={{ display: 'block', fontSize: '1.6rem', marginTop: '0.2rem', color: 'var(--color-text)' }}>
|
||||
{formatTime(secondsOnline)}
|
||||
</strong>
|
||||
</div>
|
||||
<div style={{
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
borderRadius: '50%',
|
||||
background: isPaused ? '#ef4444' : '#10b981',
|
||||
boxShadow: `0 0 10px ${isPaused ? '#ef4444' : '#10b981'}`,
|
||||
animation: !isPaused ? 'pulse 2s infinite' : 'none'
|
||||
}} />
|
||||
</article>
|
||||
|
||||
<article
|
||||
style={{
|
||||
padding: '1.25rem',
|
||||
borderRadius: '24px',
|
||||
border: '1px solid var(--color-border)',
|
||||
background: 'linear-gradient(145deg, #ffffff, #f8fafc)',
|
||||
boxShadow: 'var(--shadow-sm)',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.9rem', fontWeight: 600 }}>
|
||||
Atendimentos Abertos
|
||||
</span>
|
||||
<strong style={{ display: 'block', fontSize: '1.6rem', marginTop: '0.2rem', color: 'var(--color-text)' }}>
|
||||
{activeChatsCount}
|
||||
</strong>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article
|
||||
style={{
|
||||
padding: '1.25rem',
|
||||
borderRadius: '24px',
|
||||
border: '1px solid var(--color-border)',
|
||||
background: 'linear-gradient(145deg, #ffffff, #f8fafc)',
|
||||
boxShadow: 'var(--shadow-sm)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => setIsPaused(!isPaused)}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
padding: '1rem',
|
||||
borderRadius: '16px',
|
||||
border: 'none',
|
||||
background: isPaused ? 'rgba(16, 185, 129, 0.1)' : 'rgba(239, 68, 68, 0.1)',
|
||||
color: isPaused ? '#10b981' : '#ef4444',
|
||||
fontSize: '1rem',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
{isPaused ? '▶ Retomar Atendimento' : '⏸ Pausar'}
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { clearSession, getCurrentUserProfile } from '../../auth/services/sessionService';
|
||||
|
||||
export function HomeSidebar({ items, activeItem, isMobile = false }) {
|
||||
const navigate = useNavigate();
|
||||
@ -77,6 +78,27 @@ export function HomeSidebar({ items, activeItem, isMobile = false }) {
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
clearSession();
|
||||
navigate('/login');
|
||||
}}
|
||||
style={{
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: '18px',
|
||||
padding: '0.9rem 1rem',
|
||||
background: 'transparent',
|
||||
color: '#ef4444',
|
||||
fontWeight: 700,
|
||||
marginTop: 'auto',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
>
|
||||
Sair
|
||||
</button>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@ -4,14 +4,43 @@ import { HomeSidebar } from '../components/HomeSidebar';
|
||||
import { HomeTopbar } from '../components/HomeTopbar';
|
||||
import { MessagesWorkspace } from '../components/MessagesWorkspace';
|
||||
import { CallsWorkspace } from '../components/CallsWorkspace';
|
||||
import { actionItems, conversations, recentCalls, sidebarItems } from '../services/homeMocks';
|
||||
import { AttendantOpsPanel } from '../components/AttendantOpsPanel';
|
||||
import { actionItems, recentCalls, sidebarItems } from '../services/homeMocks';
|
||||
import { useViewport } from '../../../shared/hooks/useViewport';
|
||||
import { useChat } from '../../chat/hooks/useChat';
|
||||
|
||||
function toHomeConversation(contact, messages = []) {
|
||||
return {
|
||||
id: contact.id,
|
||||
name: contact.name,
|
||||
channel: contact.channel || 'WhatsApp',
|
||||
status: contact.status || 'online',
|
||||
lastMessage: contact.preview || messages[messages.length - 1]?.text || '',
|
||||
unread: contact.unread || 0,
|
||||
time: contact.time || 'Agora',
|
||||
messages: messages.map((message) => ({
|
||||
id: message.id,
|
||||
from: message.sender === 'agent' ? 'agent' : 'customer',
|
||||
text: message.text || (message.hasMedia ? '[Midia]' : ''),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function HomePage() {
|
||||
const { isWideDesktop, isDesktop, isTablet, isMobile } = useViewport();
|
||||
const {
|
||||
contacts,
|
||||
activeContactId,
|
||||
setActiveContactId,
|
||||
messages,
|
||||
isLoadingChats,
|
||||
} = useChat();
|
||||
const [activeTab, setActiveTab] = useState('messages');
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [activeConversationId, setActiveConversationId] = useState(conversations[0].id);
|
||||
|
||||
const conversations = contacts.map((contact) =>
|
||||
toHomeConversation(contact, contact.id === activeContactId ? messages : []),
|
||||
);
|
||||
|
||||
const search = searchValue.trim().toLowerCase();
|
||||
const filteredConversations = !search
|
||||
@ -22,9 +51,9 @@ export function HomePage() {
|
||||
});
|
||||
|
||||
const safeConversationId =
|
||||
filteredConversations.find((conversation) => conversation.id === activeConversationId)?.id ||
|
||||
filteredConversations.find((conversation) => conversation.id === activeContactId)?.id ||
|
||||
filteredConversations[0]?.id ||
|
||||
conversations[0].id;
|
||||
conversations[0]?.id;
|
||||
|
||||
return (
|
||||
<main
|
||||
@ -90,45 +119,28 @@ export function HomePage() {
|
||||
gap: '1rem',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
|
||||
gap: '1rem',
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{ label: 'Atendimentos ativos', value: '18', detail: '7 aguardando retorno' },
|
||||
{ label: 'Primeira resposta', value: '2m 14s', detail: 'Dentro do SLA' },
|
||||
{ label: 'Fila de voz', value: '4 chamadas', detail: '1 prioridade alta' },
|
||||
].map((item) => (
|
||||
<article
|
||||
key={item.label}
|
||||
style={{
|
||||
padding: '1.15rem',
|
||||
borderRadius: '22px',
|
||||
border: '1px solid var(--color-border)',
|
||||
background: '#fff',
|
||||
}}
|
||||
>
|
||||
<span style={{ color: 'var(--color-text-soft)', display: 'block' }}>
|
||||
{item.label}
|
||||
</span>
|
||||
<strong style={{ display: 'block', fontSize: '1.4rem', marginTop: '0.45rem' }}>
|
||||
{item.value}
|
||||
</strong>
|
||||
<span style={{ color: 'var(--color-text-soft)', display: 'block', marginTop: '0.45rem' }}>
|
||||
{item.detail}
|
||||
</span>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<AttendantOpsPanel activeChatsCount={filteredConversations.length} />
|
||||
|
||||
{isLoadingChats ? (
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 18,
|
||||
padding: '0.85rem 1rem',
|
||||
background: '#fff',
|
||||
color: 'var(--color-text-soft)',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Atualizando conversas do WhatsApp...
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activeTab === 'messages' ? (
|
||||
<MessagesWorkspace
|
||||
conversations={filteredConversations}
|
||||
activeConversationId={safeConversationId}
|
||||
onSelectConversation={setActiveConversationId}
|
||||
onSelectConversation={setActiveContactId}
|
||||
actionItems={actionItems}
|
||||
isWideDesktop={isWideDesktop}
|
||||
isDesktop={isDesktop}
|
||||
|
||||
23
src/modules/home/pages/ProfileHomePage.jsx
Normal file
23
src/modules/home/pages/ProfileHomePage.jsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { AdminPage } from '../../management/pages/AdminPage';
|
||||
import { SupervisorPage } from '../../management/pages/SupervisorPage';
|
||||
import { getCurrentUserProfile } from '../../auth/services/sessionService';
|
||||
import { HomePage } from './HomePage';
|
||||
import { UnassignedHomePage } from './UnassignedHomePage';
|
||||
|
||||
export function ProfileHomePage() {
|
||||
const profile = getCurrentUserProfile();
|
||||
|
||||
if (profile === 'admin') {
|
||||
return <AdminPage />;
|
||||
}
|
||||
|
||||
if (profile === 'supervisor') {
|
||||
return <SupervisorPage />;
|
||||
}
|
||||
|
||||
if (profile === 'unassigned') {
|
||||
return <UnassignedHomePage />;
|
||||
}
|
||||
|
||||
return <HomePage />;
|
||||
}
|
||||
91
src/modules/home/pages/UnassignedHomePage.jsx
Normal file
91
src/modules/home/pages/UnassignedHomePage.jsx
Normal file
@ -0,0 +1,91 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { BrandMark } from '../../../shared/components/BrandMark';
|
||||
import { clearSession, getCurrentUser } from '../../auth/services/sessionService';
|
||||
|
||||
export function UnassignedHomePage() {
|
||||
const navigate = useNavigate();
|
||||
const user = getCurrentUser();
|
||||
|
||||
function handleLogout() {
|
||||
clearSession();
|
||||
navigate('/login', { replace: true });
|
||||
}
|
||||
|
||||
return (
|
||||
<main
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
padding: '2rem',
|
||||
}}
|
||||
>
|
||||
<section
|
||||
style={{
|
||||
width: 'min(760px, 100%)',
|
||||
background: '#fff',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '32px',
|
||||
boxShadow: 'var(--shadow-lg)',
|
||||
padding: '2rem',
|
||||
display: 'grid',
|
||||
gap: '1.5rem',
|
||||
}}
|
||||
>
|
||||
<BrandMark size="lg" />
|
||||
|
||||
<div style={{ display: 'grid', gap: '0.75rem' }}>
|
||||
<span
|
||||
style={{
|
||||
width: 'fit-content',
|
||||
padding: '0.4rem 0.75rem',
|
||||
borderRadius: 999,
|
||||
background: 'rgba(229, 162, 42, 0.14)',
|
||||
color: '#8a5a00',
|
||||
fontWeight: 800,
|
||||
}}
|
||||
>
|
||||
Acesso aguardando configuracao
|
||||
</span>
|
||||
<h1 style={{ margin: 0, fontSize: '2rem' }}>Seu usuario ainda nao tem atribuicoes</h1>
|
||||
<p style={{ margin: 0, color: 'var(--color-text-soft)', lineHeight: 1.7 }}>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
borderRadius: '24px',
|
||||
background: 'rgba(0, 49, 80, 0.04)',
|
||||
padding: '1.25rem',
|
||||
display: 'grid',
|
||||
gap: '0.65rem',
|
||||
}}
|
||||
>
|
||||
<span style={{ color: 'var(--color-text-soft)' }}>Usuario autenticado</span>
|
||||
<strong>{user?.name || user?.username || 'Usuario'}</strong>
|
||||
<span style={{ color: 'var(--color-text-soft)' }}>
|
||||
{user?.email || user?.username || 'Sem email informado'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '18px',
|
||||
padding: '0.95rem 1.15rem',
|
||||
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
|
||||
color: '#fff',
|
||||
fontWeight: 800,
|
||||
width: 'fit-content',
|
||||
}}
|
||||
>
|
||||
Sair
|
||||
</button>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
48
src/modules/management/components/DataPanel.jsx
Normal file
48
src/modules/management/components/DataPanel.jsx
Normal file
@ -0,0 +1,48 @@
|
||||
export function DataPanel({ title, description, actionLabel, children }) {
|
||||
return (
|
||||
<section
|
||||
style={{
|
||||
background: '#fff',
|
||||
borderRadius: '26px',
|
||||
border: '1px solid var(--color-border)',
|
||||
padding: '1.25rem',
|
||||
display: 'grid',
|
||||
gap: '1rem',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: '1rem',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong style={{ display: 'block', fontSize: '1.08rem' }}>{title}</strong>
|
||||
<span style={{ color: 'var(--color-text-soft)' }}>{description}</span>
|
||||
</div>
|
||||
|
||||
{actionLabel ? (
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '18px',
|
||||
padding: '0.9rem 1rem',
|
||||
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
|
||||
color: '#fff',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{actionLabel}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
241
src/modules/management/components/ManagementLayout.jsx
Normal file
241
src/modules/management/components/ManagementLayout.jsx
Normal file
@ -0,0 +1,241 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { BrandMark } from '../../../shared/components/BrandMark';
|
||||
import { clearSession } from '../../auth/services/sessionService';
|
||||
|
||||
const navigationBySection = {
|
||||
supervisor: [
|
||||
{ id: 'dashboard', label: 'Dashboard', count: null },
|
||||
{ id: 'queues', label: 'Filas em tempo real', count: 42 },
|
||||
{ id: 'areas', label: 'Areas supervisionadas', count: 3 },
|
||||
{ id: 'agents', label: 'Agentes online', count: 18 },
|
||||
{ id: 'reports', label: 'Relatorios', count: null },
|
||||
],
|
||||
admin: [
|
||||
{ id: 'dashboard', label: 'Dashboard', count: null },
|
||||
{ id: 'users', label: 'Usuarios e acessos', count: 64 },
|
||||
{ id: 'areas', label: 'Areas', count: 3 },
|
||||
{ id: 'knowledge', label: 'Conteudo para IA', count: 28 },
|
||||
{ id: 'channels', label: 'Canais', count: 1 },
|
||||
{ id: 'audit', label: 'Auditoria', count: null },
|
||||
],
|
||||
};
|
||||
|
||||
const actionLabelBySection = {
|
||||
supervisor: '+ Redistribuir atendimento',
|
||||
admin: '+ Nova configuracao',
|
||||
};
|
||||
|
||||
export function ManagementLayout({
|
||||
title,
|
||||
subtitle,
|
||||
activeSection,
|
||||
profileLabel,
|
||||
initials,
|
||||
children,
|
||||
isDesktop,
|
||||
isMobile,
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const navItems = navigationBySection[activeSection] || navigationBySection.supervisor;
|
||||
const actionLabel = actionLabelBySection[activeSection] || '+ Nova acao';
|
||||
|
||||
function handleLogout() {
|
||||
clearSession();
|
||||
navigate('/login', { replace: true });
|
||||
}
|
||||
|
||||
return (
|
||||
<main style={{ minHeight: '100vh', padding: '1.5rem' }}>
|
||||
<section
|
||||
style={{
|
||||
width: 'min(1680px, calc(100vw - 3rem))',
|
||||
margin: '0 auto',
|
||||
background: 'var(--color-surface-strong)',
|
||||
borderRadius: '32px',
|
||||
boxShadow: 'var(--shadow-lg)',
|
||||
padding: '1.5rem',
|
||||
display: 'grid',
|
||||
gap: '1.5rem',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: isDesktop ? 'minmax(300px, 360px) minmax(0, 1fr)' : '1fr',
|
||||
gap: '1.5rem',
|
||||
alignItems: 'start',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'grid', gap: '1.25rem' }}>
|
||||
<div
|
||||
style={{
|
||||
background: '#fff',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '28px',
|
||||
padding: '1.5rem',
|
||||
}}
|
||||
>
|
||||
<BrandMark size="lg" />
|
||||
</div>
|
||||
|
||||
<aside
|
||||
style={{
|
||||
background: 'linear-gradient(180deg, rgba(0, 49, 80, 0.98), rgba(7, 64, 98, 0.96))',
|
||||
color: '#fff',
|
||||
borderRadius: '28px',
|
||||
padding: '1.5rem',
|
||||
display: 'grid',
|
||||
gap: '1.25rem',
|
||||
alignContent: 'start',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(activeSection === 'admin' ? '/admin' : '/supervisor')}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '20px',
|
||||
padding: '1rem 1.15rem',
|
||||
background: 'linear-gradient(135deg, var(--color-highlight), #f3b94d)',
|
||||
color: '#132534',
|
||||
fontWeight: 800,
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
{actionLabel}
|
||||
</button>
|
||||
|
||||
<nav
|
||||
style={{
|
||||
display: 'grid',
|
||||
gap: '0.5rem',
|
||||
gridTemplateColumns: isMobile ? 'repeat(auto-fit, minmax(180px, 1fr))' : '1fr',
|
||||
}}
|
||||
>
|
||||
{navItems.map((item, index) => {
|
||||
const isActive = index === 0;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '18px',
|
||||
padding: '0.9rem 1rem',
|
||||
background: isActive ? 'rgba(255, 255, 255, 0.14)' : 'transparent',
|
||||
color: '#fff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
fontWeight: isActive ? 700 : 500,
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
{item.count ? (
|
||||
<span
|
||||
style={{
|
||||
minWidth: 30,
|
||||
borderRadius: 999,
|
||||
padding: '0.2rem 0.5rem',
|
||||
background: 'rgba(255, 255, 255, 0.12)',
|
||||
fontSize: '0.82rem',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{item.count}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
style={{
|
||||
border: '1px solid rgba(255, 255, 255, 0.18)',
|
||||
borderRadius: '18px',
|
||||
padding: '0.9rem 1rem',
|
||||
background: 'transparent',
|
||||
color: '#fff',
|
||||
fontWeight: 700,
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
Sair
|
||||
</button>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gap: '1.25rem', minWidth: 0 }}>
|
||||
<header
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 1fr) auto',
|
||||
gap: '1rem',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: '1.1rem 1.25rem',
|
||||
borderRadius: '22px',
|
||||
background: '#fff',
|
||||
border: '1px solid var(--color-border)',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<h1 style={{ margin: 0, fontSize: '1.65rem' }}>{title}</h1>
|
||||
<p style={{ margin: '0.45rem 0 0', color: 'var(--color-text-soft)' }}>
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.9rem',
|
||||
justifySelf: isMobile ? 'stretch' : 'end',
|
||||
justifyContent: isMobile ? 'space-between' : 'flex-end',
|
||||
padding: '0.85rem 1rem',
|
||||
borderRadius: '22px',
|
||||
background: '#fff',
|
||||
border: '1px solid var(--color-border)',
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<strong style={{ display: 'block' }}>{profileLabel}</strong>
|
||||
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.92rem' }}>
|
||||
Ambiente de gestao
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: '16px',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
background: 'linear-gradient(135deg, var(--color-accent), var(--color-primary))',
|
||||
color: '#fff',
|
||||
fontWeight: 800,
|
||||
}}
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
59
src/modules/management/components/ManagementTable.jsx
Normal file
59
src/modules/management/components/ManagementTable.jsx
Normal file
@ -0,0 +1,59 @@
|
||||
export function ManagementTable({ columns, rows, getRowId, isMobile = false }) {
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: '0.75rem' }}>
|
||||
{!isMobile ? (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${columns.length}, minmax(0, 1fr))`,
|
||||
gap: '1rem',
|
||||
padding: '0 1rem',
|
||||
color: 'var(--color-text-soft)',
|
||||
fontWeight: 700,
|
||||
fontSize: '0.88rem',
|
||||
}}
|
||||
>
|
||||
{columns.map((column) => (
|
||||
<span key={column.key}>{column.label}</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{rows.map((row) => (
|
||||
<article
|
||||
key={getRowId(row)}
|
||||
style={{
|
||||
borderRadius: '20px',
|
||||
border: '1px solid var(--color-border)',
|
||||
padding: '1rem',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: isMobile ? '1fr' : `repeat(${columns.length}, minmax(0, 1fr))`,
|
||||
gap: isMobile ? '0.65rem' : '1rem',
|
||||
alignItems: 'center',
|
||||
background: '#fff',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{columns.map((column) => (
|
||||
<div key={column.key} style={{ minWidth: 0 }}>
|
||||
{isMobile ? (
|
||||
<span
|
||||
style={{
|
||||
display: 'block',
|
||||
color: 'var(--color-text-soft)',
|
||||
fontSize: '0.82rem',
|
||||
fontWeight: 700,
|
||||
marginBottom: '0.2rem',
|
||||
}}
|
||||
>
|
||||
{column.label}
|
||||
</span>
|
||||
) : null}
|
||||
{column.render ? column.render(row) : <span>{row[column.key]}</span>}
|
||||
</div>
|
||||
))}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
src/modules/management/components/MetricGrid.jsx
Normal file
31
src/modules/management/components/MetricGrid.jsx
Normal file
@ -0,0 +1,31 @@
|
||||
export function MetricGrid({ metrics }) {
|
||||
return (
|
||||
<section
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
|
||||
gap: '1rem',
|
||||
}}
|
||||
>
|
||||
{metrics.map((item) => (
|
||||
<article
|
||||
key={item.label}
|
||||
style={{
|
||||
padding: '1.15rem',
|
||||
borderRadius: '22px',
|
||||
border: '1px solid var(--color-border)',
|
||||
background: '#fff',
|
||||
}}
|
||||
>
|
||||
<span style={{ color: 'var(--color-text-soft)', display: 'block' }}>{item.label}</span>
|
||||
<strong style={{ display: 'block', fontSize: '1.4rem', marginTop: '0.45rem' }}>
|
||||
{item.value}
|
||||
</strong>
|
||||
<span style={{ color: 'var(--color-text-soft)', display: 'block', marginTop: '0.45rem' }}>
|
||||
{item.detail}
|
||||
</span>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
270
src/modules/management/pages/AdminPage.jsx
Normal file
270
src/modules/management/pages/AdminPage.jsx
Normal file
@ -0,0 +1,270 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { DataPanel } from '../components/DataPanel';
|
||||
import { ManagementLayout } from '../components/ManagementLayout';
|
||||
import { ManagementTable } from '../components/ManagementTable';
|
||||
import { MetricGrid } from '../components/MetricGrid';
|
||||
import { adminMetrics, aiContentRows, areaRows, userRows } from '../services/managementMocks';
|
||||
import { getAccessOptions, getAccessUsers, updateUserAccess } from '../services/adminAccessService';
|
||||
import { useViewport } from '../../../shared/hooks/useViewport';
|
||||
|
||||
const areaColumns = [
|
||||
{ key: 'name', label: 'Area' },
|
||||
{ key: 'owner', label: 'Responsavel' },
|
||||
{ key: 'members', label: 'Usuarios' },
|
||||
{ key: 'status', label: 'Status' },
|
||||
];
|
||||
|
||||
const contentColumns = [
|
||||
{ key: 'title', label: 'Conteudo' },
|
||||
{ key: 'area', label: 'Area' },
|
||||
{ key: 'status', label: 'Status' },
|
||||
{ key: 'updatedAt', label: 'Atualizado' },
|
||||
];
|
||||
|
||||
const selectStyle = {
|
||||
width: '100%',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '14px',
|
||||
padding: '0.75rem 0.85rem',
|
||||
background: '#fff',
|
||||
color: 'var(--color-text)',
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
function mapMockUsers() {
|
||||
return userRows.map((user) => ({
|
||||
id: user.id,
|
||||
nome: user.name,
|
||||
email: user.email,
|
||||
perfilPrincipal: { id: user.role, nome: user.role },
|
||||
areaPrincipal: { id: user.area, nome: user.area },
|
||||
accessStatus: 'assigned',
|
||||
}));
|
||||
}
|
||||
|
||||
export function AdminPage() {
|
||||
const { isDesktop, isMobile } = useViewport();
|
||||
const [users, setUsers] = useState(mapMockUsers);
|
||||
const [profiles, setProfiles] = useState([]);
|
||||
const [areas, setAreas] = useState([]);
|
||||
const [isLoadingAccess, setIsLoadingAccess] = useState(true);
|
||||
const [accessError, setAccessError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
async function loadAccessData() {
|
||||
try {
|
||||
const [options, accessUsers] = await Promise.all([getAccessOptions(), getAccessUsers()]);
|
||||
|
||||
if (!isMounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setProfiles(options.profiles || []);
|
||||
setAreas(options.areas || []);
|
||||
setUsers(accessUsers || []);
|
||||
setAccessError('');
|
||||
} catch {
|
||||
if (isMounted) {
|
||||
setAccessError('Backend indisponivel. Exibindo dados demonstrativos.');
|
||||
}
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setIsLoadingAccess(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadAccessData();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function handleAccessChange(user, field, value) {
|
||||
const currentPerfilId = user.perfilPrincipal?.id || null;
|
||||
const currentAreaId = user.areaPrincipal?.id || null;
|
||||
const nextAccess = {
|
||||
perfilId: field === 'perfil' ? Number(value) || null : currentPerfilId,
|
||||
areaId: field === 'area' ? Number(value) || null : currentAreaId,
|
||||
};
|
||||
|
||||
setUsers((current) =>
|
||||
current.map((item) =>
|
||||
item.id === user.id
|
||||
? {
|
||||
...item,
|
||||
perfilPrincipal:
|
||||
profiles.find((profile) => profile.id === nextAccess.perfilId) || null,
|
||||
areaPrincipal: areas.find((area) => area.id === nextAccess.areaId) || null,
|
||||
accessStatus: nextAccess.perfilId && nextAccess.areaId ? 'assigned' : 'unassigned',
|
||||
}
|
||||
: item,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
const updatedUser = await updateUserAccess(user.id, nextAccess);
|
||||
|
||||
if (updatedUser) {
|
||||
setUsers((current) =>
|
||||
current.map((item) => (item.id === updatedUser.id ? updatedUser : item)),
|
||||
);
|
||||
}
|
||||
|
||||
setAccessError('');
|
||||
} catch {
|
||||
setAccessError('Nao foi possivel salvar a atribuicao. Confira o backend.');
|
||||
}
|
||||
}
|
||||
|
||||
const userColumns = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'nome',
|
||||
label: 'Usuario',
|
||||
render: (row) => (
|
||||
<div>
|
||||
<strong style={{ display: 'block' }}>{row.nome}</strong>
|
||||
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.9rem' }}>
|
||||
{row.email || 'Sem email'}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'perfil',
|
||||
label: 'Perfil',
|
||||
render: (row) =>
|
||||
profiles.length ? (
|
||||
<select
|
||||
value={row.perfilPrincipal?.id || ''}
|
||||
onChange={(event) => handleAccessChange(row, 'perfil', event.target.value)}
|
||||
style={selectStyle}
|
||||
>
|
||||
<option value="">Sem perfil</option>
|
||||
{profiles.map((profile) => (
|
||||
<option key={profile.id} value={profile.id}>
|
||||
{profile.nome}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<span>{row.perfilPrincipal?.nome || 'Sem perfil'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'area',
|
||||
label: 'Area',
|
||||
render: (row) =>
|
||||
areas.length ? (
|
||||
<select
|
||||
value={row.areaPrincipal?.id || ''}
|
||||
onChange={(event) => handleAccessChange(row, 'area', event.target.value)}
|
||||
style={selectStyle}
|
||||
>
|
||||
<option value="">Sem area</option>
|
||||
{areas.map((area) => (
|
||||
<option key={area.id} value={area.id}>
|
||||
{area.nome}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<span>{row.areaPrincipal?.nome || 'Sem area'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
render: (row) => {
|
||||
const isAssigned = row.accessStatus === 'assigned';
|
||||
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
width: 'fit-content',
|
||||
borderRadius: 999,
|
||||
padding: '0.25rem 0.6rem',
|
||||
background: isAssigned ? 'rgba(0, 164, 183, 0.1)' : 'rgba(229, 162, 42, 0.16)',
|
||||
color: isAssigned ? 'var(--color-primary)' : '#8a5a00',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{isAssigned ? 'Atribuido' : 'Pendente'}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[areas, profiles],
|
||||
);
|
||||
|
||||
return (
|
||||
<ManagementLayout
|
||||
title="Painel administrativo"
|
||||
subtitle="Controle de usuarios, perfis, areas e base de conteudo para IA."
|
||||
activeSection="admin"
|
||||
profileLabel="Lucas Admin"
|
||||
initials="LA"
|
||||
isDesktop={isDesktop}
|
||||
isMobile={isMobile}
|
||||
>
|
||||
<MetricGrid metrics={adminMetrics} />
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: isDesktop ? 'minmax(0, 1.2fr) minmax(320px, 0.8fr)' : '1fr',
|
||||
gap: '1rem',
|
||||
alignItems: 'start',
|
||||
}}
|
||||
>
|
||||
<DataPanel
|
||||
title="Usuarios e niveis de acesso"
|
||||
description={
|
||||
isLoadingAccess
|
||||
? 'Carregando usuarios do banco...'
|
||||
: accessError || 'Gerencie perfil e area principal dos usuarios autenticados.'
|
||||
}
|
||||
actionLabel="Adicionar usuario"
|
||||
>
|
||||
<ManagementTable
|
||||
columns={userColumns}
|
||||
rows={users}
|
||||
getRowId={(row) => row.id}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
</DataPanel>
|
||||
|
||||
<DataPanel
|
||||
title="Areas"
|
||||
description="Areas operacionais e seus responsaveis."
|
||||
actionLabel="Nova area"
|
||||
>
|
||||
<ManagementTable
|
||||
columns={areaColumns}
|
||||
rows={areaRows}
|
||||
getRowId={(row) => row.id}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
</DataPanel>
|
||||
</div>
|
||||
|
||||
<DataPanel
|
||||
title="Conteudo para IA"
|
||||
description="Entradas mockadas para alimentar a base de conhecimento."
|
||||
actionLabel="Adicionar conteudo"
|
||||
>
|
||||
<ManagementTable
|
||||
columns={contentColumns}
|
||||
rows={aiContentRows}
|
||||
getRowId={(row) => row.id}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
</DataPanel>
|
||||
</ManagementLayout>
|
||||
);
|
||||
}
|
||||
320
src/modules/management/pages/SupervisorPage.jsx
Normal file
320
src/modules/management/pages/SupervisorPage.jsx
Normal file
@ -0,0 +1,320 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { DataPanel } from '../components/DataPanel';
|
||||
import { ManagementLayout } from '../components/ManagementLayout';
|
||||
import { ManagementTable } from '../components/ManagementTable';
|
||||
import { MetricGrid } from '../components/MetricGrid';
|
||||
import { areaRows, queueRows, supervisorMetrics } from '../services/managementMocks';
|
||||
import { useViewport } from '../../../shared/hooks/useViewport';
|
||||
|
||||
const queueColumns = [
|
||||
{ key: 'customer', label: 'Cliente' },
|
||||
{ key: 'channel', label: 'Canal' },
|
||||
{ key: 'area', label: 'Area' },
|
||||
{ key: 'wait', label: 'Espera' },
|
||||
{
|
||||
key: 'priority',
|
||||
label: 'Prioridade',
|
||||
render: (row) => (
|
||||
<span
|
||||
style={{
|
||||
width: 'fit-content',
|
||||
borderRadius: 999,
|
||||
padding: '0.25rem 0.6rem',
|
||||
background: row.priority === 'Alta' ? 'rgba(181, 31, 31, 0.1)' : 'rgba(0, 49, 80, 0.08)',
|
||||
color: row.priority === 'Alta' ? 'var(--color-secondary)' : 'var(--color-primary)',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{row.priority}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const areaColumns = [
|
||||
{ key: 'name', label: 'Area' },
|
||||
{ key: 'owner', label: 'Responsavel' },
|
||||
{ key: 'members', label: 'Usuarios' },
|
||||
{ key: 'openTickets', label: 'Abertos' },
|
||||
{ key: 'status', label: 'Status' },
|
||||
];
|
||||
|
||||
export function SupervisorPage() {
|
||||
const { isDesktop, isMobile } = useViewport();
|
||||
const [templates, setTemplates] = useState([]);
|
||||
const [editingTemplate, setEditingTemplate] = useState(null);
|
||||
const [editName, setEditName] = useState('');
|
||||
const [editContent, setEditContent] = useState('');
|
||||
const [saveStatus, setSaveStatus] = useState('');
|
||||
|
||||
const fetchTemplates = async () => {
|
||||
try {
|
||||
const res = await fetch('http://localhost:3001/whatsapp/templates');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setTemplates(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTemplates();
|
||||
}, []);
|
||||
|
||||
const handleEdit = (tpl) => {
|
||||
setEditingTemplate(tpl);
|
||||
setEditName(tpl.name);
|
||||
setEditContent(tpl.content);
|
||||
setSaveStatus('');
|
||||
};
|
||||
|
||||
const handleSave = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!editName || !editContent) return;
|
||||
try {
|
||||
const url = editingTemplate
|
||||
? `http://localhost:3001/whatsapp/templates/update/${editingTemplate.id}`
|
||||
: 'http://localhost:3001/whatsapp/templates';
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: editName, content: editContent }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setSaveStatus('Salvo com sucesso!');
|
||||
setEditingTemplate(null);
|
||||
setEditName('');
|
||||
setEditContent('');
|
||||
fetchTemplates();
|
||||
setTimeout(() => setSaveStatus(''), 3000);
|
||||
} else {
|
||||
setSaveStatus('Erro ao salvar template.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setSaveStatus('Erro ao salvar template.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ManagementLayout
|
||||
title="Painel do supervisor"
|
||||
subtitle="Acompanhamento operacional das filas, areas e distribuicao de atendimento."
|
||||
activeSection="supervisor"
|
||||
profileLabel="Marina Alves"
|
||||
initials="MA"
|
||||
isDesktop={isDesktop}
|
||||
isMobile={isMobile}
|
||||
>
|
||||
<MetricGrid metrics={supervisorMetrics} />
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: isDesktop ? 'minmax(0, 1.35fr) minmax(320px, 0.85fr)' : '1fr',
|
||||
gap: '1rem',
|
||||
alignItems: 'start',
|
||||
}}
|
||||
>
|
||||
<DataPanel
|
||||
title="Fila em tempo real"
|
||||
description="Mock da visao que depois sera alimentada pelos atendimentos reais."
|
||||
actionLabel="Redistribuir fila"
|
||||
>
|
||||
<ManagementTable
|
||||
columns={queueColumns}
|
||||
rows={queueRows}
|
||||
getRowId={(row) => row.id}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
</DataPanel>
|
||||
|
||||
<DataPanel
|
||||
title="Areas supervisionadas"
|
||||
description="Resumo operacional por area."
|
||||
actionLabel="Ver detalhes"
|
||||
>
|
||||
<ManagementTable
|
||||
columns={areaColumns}
|
||||
rows={areaRows}
|
||||
getRowId={(row) => row.id}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
</DataPanel>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '1.5rem' }}>
|
||||
<DataPanel
|
||||
title="Homologador de Templates WhatsApp (Meta)"
|
||||
description="Gerencie os modelos de primeiro contato pré-aprovados pela Meta para uso dos atendentes."
|
||||
>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: isDesktop ? '1.2fr 0.8fr' : '1fr', gap: '1.5rem', padding: '0.5rem 0' }}>
|
||||
<div style={{ display: 'grid', gap: '0.75rem' }}>
|
||||
{templates.map((tpl) => (
|
||||
<div
|
||||
key={tpl.id}
|
||||
style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '16px',
|
||||
padding: '1rem',
|
||||
background: '#fff',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
gap: '0.6rem',
|
||||
boxShadow: 'var(--shadow-sm)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<strong style={{ fontSize: '0.96rem', color: 'var(--color-primary)', textTransform: 'uppercase', letterSpacing: '0.02em' }}>
|
||||
{tpl.name}
|
||||
</strong>
|
||||
<span style={{ fontSize: '0.75rem', fontWeight: 700, padding: '0.2rem 0.5rem', borderRadius: '999px', background: 'rgba(34, 197, 94, 0.1)', color: '#16a34a' }}>
|
||||
✓ Homologado Meta
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ margin: 0, color: 'var(--color-text-soft)', fontSize: '0.88rem', lineHeight: 1.5 }}>
|
||||
{tpl.content}
|
||||
</p>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '0.25rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleEdit(tpl)}
|
||||
style={{
|
||||
border: 'none',
|
||||
background: 'rgba(0, 49, 80, 0.08)',
|
||||
color: 'var(--color-primary)',
|
||||
padding: '0.45rem 0.85rem',
|
||||
borderRadius: '10px',
|
||||
fontSize: '0.82rem',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
>
|
||||
Editar Modelo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSave}
|
||||
style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '20px',
|
||||
padding: '1.25rem',
|
||||
background: '#f8fafc',
|
||||
display: 'grid',
|
||||
gap: '1rem',
|
||||
height: 'fit-content',
|
||||
}}
|
||||
>
|
||||
<strong style={{ fontSize: '1.05rem', display: 'block', borderBottom: '1px solid var(--color-border)', paddingBottom: '0.5rem', color: 'var(--color-text)' }}>
|
||||
{editingTemplate ? `Editar Template #${editingTemplate.id}` : 'Criar Novo Template'}
|
||||
</strong>
|
||||
|
||||
{saveStatus && (
|
||||
<div style={{
|
||||
padding: '0.65rem 0.85rem',
|
||||
borderRadius: '12px',
|
||||
background: saveStatus.includes('sucesso') ? 'rgba(34, 197, 94, 0.12)' : 'rgba(239, 68, 68, 0.12)',
|
||||
color: saveStatus.includes('sucesso') ? '#16a34a' : '#ef4444',
|
||||
fontWeight: 700,
|
||||
fontSize: '0.85rem',
|
||||
}}>
|
||||
{saveStatus}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label style={{ display: 'grid', gap: '0.35rem', color: 'var(--color-text)' }}>
|
||||
<span style={{ fontSize: '0.84rem', fontWeight: 600 }}>Identificador Único (Nome)</span>
|
||||
<input
|
||||
type="text"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
placeholder="ex: aviso_fatura"
|
||||
style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '12px',
|
||||
padding: '0.75rem 0.85rem',
|
||||
background: '#fff',
|
||||
outline: 'none',
|
||||
fontSize: '0.88rem',
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'grid', gap: '0.35rem', color: 'var(--color-text)' }}>
|
||||
<span style={{ fontSize: '0.84rem', fontWeight: 600 }}>Mensagem do Template</span>
|
||||
<textarea
|
||||
value={editContent}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
placeholder="Use placeholders como {nome}, {data} ou {protocolo}..."
|
||||
rows={4}
|
||||
style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '12px',
|
||||
padding: '0.75rem 0.85rem',
|
||||
background: '#fff',
|
||||
outline: 'none',
|
||||
fontSize: '0.88rem',
|
||||
resize: 'none',
|
||||
lineHeight: 1.5,
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end', marginTop: '0.5rem' }}>
|
||||
{editingTemplate && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditingTemplate(null);
|
||||
setEditName('');
|
||||
setEditContent('');
|
||||
}}
|
||||
style={{
|
||||
border: 'none',
|
||||
background: 'rgba(239, 68, 68, 0.08)',
|
||||
color: '#ef4444',
|
||||
padding: '0.65rem 1rem',
|
||||
borderRadius: '12px',
|
||||
fontSize: '0.85rem',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
border: 'none',
|
||||
background: 'var(--color-primary)',
|
||||
color: '#fff',
|
||||
padding: '0.65rem 1rem',
|
||||
borderRadius: '12px',
|
||||
fontSize: '0.85rem',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{editingTemplate ? 'Atualizar Modelo' : 'Criar Modelo'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</DataPanel>
|
||||
</div>
|
||||
</ManagementLayout>
|
||||
);
|
||||
}
|
||||
61
src/modules/management/pages/WhatsappAdminPage.jsx
Normal file
61
src/modules/management/pages/WhatsappAdminPage.jsx
Normal file
@ -0,0 +1,61 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { io } from 'socket.io-client';
|
||||
|
||||
export const WhatsappAdminPage = () => {
|
||||
const [qrCode, setQrCode] = useState(null);
|
||||
const [status, setStatus] = useState('DISCONNECTED');
|
||||
|
||||
useEffect(() => {
|
||||
// Conecta ao namespace /whatsapp
|
||||
const socket = io('http://localhost:3001/whatsapp');
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('Connected to WhatsApp WebSocket');
|
||||
});
|
||||
|
||||
socket.on('qr', (qrDataUrl) => {
|
||||
setQrCode(qrDataUrl);
|
||||
setStatus('AWAITING_QR');
|
||||
});
|
||||
|
||||
socket.on('status', (newStatus) => {
|
||||
setStatus(newStatus);
|
||||
if (newStatus === 'CONNECTED') {
|
||||
setQrCode(null);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h1 className="text-2xl font-bold mb-4">Configuração do WhatsApp</h1>
|
||||
|
||||
<div className="bg-white p-6 rounded-lg shadow-md max-w-md">
|
||||
<h2 className="text-lg font-semibold mb-2">Status da Conexão: <span className={status === 'CONNECTED' ? 'text-green-600' : 'text-red-600'}>{status}</span></h2>
|
||||
|
||||
{status === 'AWAITING_QR' && qrCode && (
|
||||
<div className="mt-4 flex flex-col items-center">
|
||||
<p className="mb-2 text-gray-600">Escaneie o QR Code abaixo com seu WhatsApp:</p>
|
||||
<img src={qrCode} alt="WhatsApp QR Code" className="border p-2 rounded" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'CONNECTED' && (
|
||||
<div className="mt-4 p-4 bg-green-50 text-green-700 rounded border border-green-200">
|
||||
O WhatsApp está conectado e pronto para uso!
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'DISCONNECTED' && (
|
||||
<div className="mt-4 p-4 bg-yellow-50 text-yellow-700 rounded border border-yellow-200">
|
||||
Aguardando inicialização do cliente WhatsApp no servidor...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
33
src/modules/management/services/adminAccessService.js
Normal file
33
src/modules/management/services/adminAccessService.js
Normal file
@ -0,0 +1,33 @@
|
||||
const API_BASE_URL =
|
||||
import.meta.env.VITE_API_BASE_URL || import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Falha ao consultar acessos');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function getAccessOptions() {
|
||||
return request('/admin/access/options');
|
||||
}
|
||||
|
||||
export async function getAccessUsers() {
|
||||
return request('/admin/access/users');
|
||||
}
|
||||
|
||||
export async function updateUserAccess(userId, access) {
|
||||
return request(`/admin/access/users/${userId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(access),
|
||||
});
|
||||
}
|
||||
106
src/modules/management/services/managementMocks.js
Normal file
106
src/modules/management/services/managementMocks.js
Normal file
@ -0,0 +1,106 @@
|
||||
export const supervisorMetrics = [
|
||||
{ label: 'Atendimentos abertos', value: '42', detail: '12 aguardando agente' },
|
||||
{ label: 'SLA em risco', value: '7', detail: 'Financeiro concentra 4 casos' },
|
||||
{ label: 'Agentes online', value: '18', detail: '3 em pausa operacional' },
|
||||
{ label: 'Transferencias hoje', value: '23', detail: 'Tempo medio 4m 20s' },
|
||||
];
|
||||
|
||||
export const adminMetrics = [
|
||||
{ label: 'Usuarios ativos', value: '64', detail: '8 supervisores configurados' },
|
||||
{ label: 'Areas cadastradas', value: '3', detail: 'Suporte, Financeiro e Comercial' },
|
||||
{ label: 'Conteudos IA', value: '28', detail: '6 aguardando revisao' },
|
||||
{ label: 'Canais conectados', value: '1', detail: 'WhatsApp em homologacao' },
|
||||
];
|
||||
|
||||
export const areaRows = [
|
||||
{
|
||||
id: 'suporte',
|
||||
name: 'Suporte',
|
||||
owner: 'Marina Alves',
|
||||
members: 22,
|
||||
openTickets: 18,
|
||||
status: 'Ativa',
|
||||
},
|
||||
{
|
||||
id: 'financeiro',
|
||||
name: 'Financeiro',
|
||||
owner: 'Rafael Nunes',
|
||||
members: 11,
|
||||
openTickets: 9,
|
||||
status: 'Ativa',
|
||||
},
|
||||
{
|
||||
id: 'comercial',
|
||||
name: 'Comercial',
|
||||
owner: 'Camila Rocha',
|
||||
members: 14,
|
||||
openTickets: 15,
|
||||
status: 'Ativa',
|
||||
},
|
||||
];
|
||||
|
||||
export const userRows = [
|
||||
{
|
||||
id: 'ana-camolesi',
|
||||
name: 'Ana Camolesi',
|
||||
email: 'ana.camolesi@sothis.com.br',
|
||||
role: 'Agente',
|
||||
area: 'Suporte',
|
||||
status: 'Ativo',
|
||||
},
|
||||
{
|
||||
id: 'marina-alves',
|
||||
name: 'Marina Alves',
|
||||
email: 'marina.alves@sothis.com.br',
|
||||
role: 'Supervisor',
|
||||
area: 'Suporte',
|
||||
status: 'Ativo',
|
||||
},
|
||||
{
|
||||
id: 'rafael-nunes',
|
||||
name: 'Rafael Nunes',
|
||||
email: 'rafael.nunes@sothis.com.br',
|
||||
role: 'Supervisor',
|
||||
area: 'Financeiro',
|
||||
status: 'Ativo',
|
||||
},
|
||||
{
|
||||
id: 'lucas-admin',
|
||||
name: 'Lucas Admin',
|
||||
email: 'lucas.admin@sothis.com.br',
|
||||
role: 'Admin',
|
||||
area: 'Todas',
|
||||
status: 'Ativo',
|
||||
},
|
||||
];
|
||||
|
||||
export const queueRows = [
|
||||
{ id: 'q1', customer: 'Maria Souza', channel: 'WhatsApp', area: 'Suporte', wait: '8 min', priority: 'Alta' },
|
||||
{ id: 'q2', customer: 'Empresa Alpha', channel: 'Email', area: 'Comercial', wait: '14 min', priority: 'Media' },
|
||||
{ id: 'q3', customer: 'Joao Pedro', channel: 'WhatsApp', area: 'Financeiro', wait: '5 min', priority: 'Alta' },
|
||||
{ id: 'q4', customer: 'Grupo Solaris', channel: 'Voz', area: 'Comercial', wait: '2 min', priority: 'Normal' },
|
||||
];
|
||||
|
||||
export const aiContentRows = [
|
||||
{
|
||||
id: 'c1',
|
||||
title: 'Politica de segunda via de boleto',
|
||||
area: 'Financeiro',
|
||||
status: 'Publicado',
|
||||
updatedAt: 'Hoje',
|
||||
},
|
||||
{
|
||||
id: 'c2',
|
||||
title: 'Passo a passo para troca de senha',
|
||||
area: 'Suporte',
|
||||
status: 'Rascunho',
|
||||
updatedAt: 'Ontem',
|
||||
},
|
||||
{
|
||||
id: 'c3',
|
||||
title: 'Argumentario de proposta comercial',
|
||||
area: 'Comercial',
|
||||
status: 'Revisao',
|
||||
updatedAt: 'Segunda',
|
||||
},
|
||||
];
|
||||
@ -4,6 +4,20 @@ import { HomePage } from '../modules/home/pages/HomePage';
|
||||
import { ChatPage } from '../modules/chat/pages/ChatPage';
|
||||
import { CallPage } from '../modules/call/pages/CallPage';
|
||||
import { NewAttendancePage } from '../modules/attendance/pages/NewAttendancePage';
|
||||
import { AdminPage } from '../modules/management/pages/AdminPage';
|
||||
import { SupervisorPage } from '../modules/management/pages/SupervisorPage';
|
||||
import { getCurrentUserProfile } from '../modules/auth/services/sessionService';
|
||||
|
||||
function HomeRouter() {
|
||||
const profile = getCurrentUserProfile();
|
||||
if (profile === 'admin') {
|
||||
return <AdminPage />;
|
||||
}
|
||||
if (profile === 'supervisor') {
|
||||
return <SupervisorPage />;
|
||||
}
|
||||
return <HomePage />;
|
||||
}
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
@ -16,7 +30,7 @@ export const router = createBrowserRouter([
|
||||
},
|
||||
{
|
||||
path: '/home',
|
||||
element: <HomePage />,
|
||||
element: <HomeRouter />,
|
||||
},
|
||||
{
|
||||
path: '/chat',
|
||||
|
||||
66
src/shared/hooks/useWhatsappSocket.js
Normal file
66
src/shared/hooks/useWhatsappSocket.js
Normal file
@ -0,0 +1,66 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import io from 'socket.io-client';
|
||||
|
||||
export function useWhatsappSocket() {
|
||||
const [socket, setSocket] = useState(null);
|
||||
const [qrCode, setQrCode] = useState(null);
|
||||
const [status, setStatus] = useState('DISCONNECTED');
|
||||
const [incomingMessage, setIncomingMessage] = useState(null);
|
||||
const [presenceUpdate, setPresenceUpdate] = useState(null);
|
||||
const socketRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (socketRef.current) return;
|
||||
|
||||
// Conectar ao namespace /whatsapp
|
||||
const newSocket = io('http://localhost:3001/whatsapp', {
|
||||
reconnectionAttempts: 5,
|
||||
});
|
||||
|
||||
socketRef.current = newSocket;
|
||||
setSocket(newSocket);
|
||||
|
||||
newSocket.on('connect', () => {
|
||||
console.log('Conectado ao WebSocket do WhatsApp');
|
||||
// Fetch status atual
|
||||
fetch('http://localhost:3001/whatsapp/status')
|
||||
.then(res => res.json())
|
||||
.then(data => setStatus(data.status))
|
||||
.catch(console.error);
|
||||
});
|
||||
|
||||
newSocket.on('qr', (qr) => {
|
||||
setQrCode(qr);
|
||||
setStatus('AWAITING_QR');
|
||||
});
|
||||
|
||||
newSocket.on('status', (newStatus) => {
|
||||
setStatus(newStatus);
|
||||
});
|
||||
|
||||
newSocket.on('message', (message) => {
|
||||
console.log('Nova mensagem recebida:', message);
|
||||
setIncomingMessage(message);
|
||||
});
|
||||
|
||||
newSocket.on('presence', (presence) => {
|
||||
console.log('Atualização de presença:', presence);
|
||||
setPresenceUpdate(presence);
|
||||
});
|
||||
|
||||
return () => {
|
||||
newSocket.disconnect();
|
||||
socketRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
socket,
|
||||
qrCode,
|
||||
status,
|
||||
incomingMessage,
|
||||
presenceUpdate,
|
||||
clearIncomingMessage: () => setIncomingMessage(null),
|
||||
clearPresenceUpdate: () => setPresenceUpdate(null)
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user