Compare commits
No commits in common. "2229a29af1cee3f3ef55771cd47764735ed9e00c" and "3f0ca83430e3903145d12cdaceeae27a0205bdca" have entirely different histories.
2229a29af1
...
3f0ca83430
32
.gitignore
vendored
32
.gitignore
vendored
@ -1,31 +1 @@
|
|||||||
# Dependencies
|
node_modules
|
||||||
node_modules/
|
|
||||||
|
|
||||||
# Build output
|
|
||||||
dist/
|
|
||||||
|
|
||||||
# Local environment files
|
|
||||||
.env
|
|
||||||
.env.local
|
|
||||||
.env.development
|
|
||||||
.env.development.local
|
|
||||||
.env.production
|
|
||||||
.env.production.local
|
|
||||||
.env.test
|
|
||||||
.env.test.local
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
|
|
||||||
# Vite cache
|
|
||||||
.vite/
|
|
||||||
|
|
||||||
# Editor and OS files
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
.idea/
|
|
||||||
.vscode/
|
|
||||||
BIN
dist/assets/favicon_blue-CzkOczz3.png
vendored
Normal file
BIN
dist/assets/favicon_blue-CzkOczz3.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.8 KiB |
68
dist/assets/index-1xjqdjIG.js
vendored
Normal file
68
dist/assets/index-1xjqdjIG.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/assets/index-BsY34Fgu.css
vendored
Normal file
1
dist/assets/index-BsY34Fgu.css
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
:root{font-family:Segoe UI,Helvetica Neue,sans-serif;color:#122230;background:radial-gradient(circle at top left,rgba(0,164,183,.12),transparent 28%),radial-gradient(circle at bottom right,rgba(229,162,42,.14),transparent 24%),#f5f8fb;color-scheme:light;--color-primary: #003150;--color-secondary: #b51f1f;--color-accent: #00a4b7;--color-highlight: #e5a22a;--color-surface: rgba(255, 255, 255, .9);--color-surface-strong: #ffffff;--color-text: #122230;--color-text-soft: #5e6d7b;--color-border: rgba(0, 49, 80, .12);--shadow-lg: 0 24px 60px rgba(0, 49, 80, .12);--shadow-md: 0 12px 28px rgba(0, 49, 80, .08)}*{box-sizing:border-box}html,body,#root{min-height:100%;margin:0}body{min-height:100vh}body,button,input{font:inherit}button{cursor:pointer}a{color:inherit;text-decoration:none}
|
||||||
BIN
dist/assets/logo_white_dark_mode-BKcVSu03.png
vendored
Normal file
BIN
dist/assets/logo_white_dark_mode-BKcVSu03.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
BIN
dist/assets/logo_white_mode-BIHgqUPv.png
vendored
Normal file
BIN
dist/assets/logo_white_mode-BIHgqUPv.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
14
dist/index.html
vendored
Normal file
14
dist/index.html
vendored
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="pt-BR">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" type="image/png" href="/assets/favicon_blue-CzkOczz3.png" />
|
||||||
|
<title>Omnichannel Sothis</title>
|
||||||
|
<script type="module" crossorigin src="/assets/index-1xjqdjIG.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-BsY34Fgu.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -1,88 +0,0 @@
|
|||||||
# 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,8 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^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": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "^4.3.3",
|
"@vitejs/plugin-react": "^4.3.3",
|
||||||
@ -1108,12 +1107,6 @@
|
|||||||
"win32"
|
"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": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
@ -1267,6 +1260,7 @@
|
|||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
@ -1287,28 +1281,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||||
@ -1441,6 +1413,7 @@
|
|||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
@ -1638,34 +1611,6 @@
|
|||||||
"semver": "bin/semver.js"
|
"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": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@ -1768,35 +1713,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"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": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|||||||
@ -11,8 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^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": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "^4.3.3",
|
"@vitejs/plugin-react": "^4.3.3",
|
||||||
|
|||||||
@ -39,7 +39,7 @@ const initialFormData = {
|
|||||||
|
|
||||||
export function LoginForm() {
|
export function LoginForm() {
|
||||||
const [formData, setFormData] = useState(initialFormData);
|
const [formData, setFormData] = useState(initialFormData);
|
||||||
const { login, startMicrosoftLogin, providers, error, isSubmitting } = useLogin();
|
const { login, isSubmitting } = useLogin();
|
||||||
|
|
||||||
async function handleSubmit(event) {
|
async function handleSubmit(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@ -48,15 +48,12 @@ export function LoginForm() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} style={{ display: 'grid', gap: '1rem' }}>
|
<form onSubmit={handleSubmit} style={{ display: 'grid', gap: '1rem' }}>
|
||||||
{providers.ldap ? (
|
|
||||||
<>
|
|
||||||
<label style={{ display: 'grid', gap: '0.5rem' }}>
|
<label style={{ display: 'grid', gap: '0.5rem' }}>
|
||||||
<span style={{ fontWeight: 600 }}>Usuario AD</span>
|
<span style={{ fontWeight: 600 }}>Usuário AD</span>
|
||||||
<input
|
<input
|
||||||
style={fieldStyle}
|
style={fieldStyle}
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="seu.usuario"
|
placeholder="seu.usuario"
|
||||||
autoComplete="username"
|
|
||||||
value={formData.username}
|
value={formData.username}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setFormData((current) => ({ ...current, username: event.target.value }))
|
setFormData((current) => ({ ...current, username: event.target.value }))
|
||||||
@ -70,7 +67,6 @@ export function LoginForm() {
|
|||||||
style={fieldStyle}
|
style={fieldStyle}
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Digite sua senha"
|
placeholder="Digite sua senha"
|
||||||
autoComplete="current-password"
|
|
||||||
value={formData.password}
|
value={formData.password}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setFormData((current) => ({ ...current, password: event.target.value }))
|
setFormData((current) => ({ ...current, password: event.target.value }))
|
||||||
@ -79,58 +75,23 @@ export function LoginForm() {
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<button style={primaryButtonStyle} type="submit" disabled={isSubmitting}>
|
<button style={primaryButtonStyle} type="submit" disabled={isSubmitting}>
|
||||||
{isSubmitting ? 'Entrando...' : 'Entrar com AD'}
|
{isSubmitting ? 'Entrando...' : 'Entrar'}
|
||||||
</button>
|
</button>
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{providers.microsoft ? (
|
<button style={secondaryButtonStyle} type="button">
|
||||||
<button style={secondaryButtonStyle} type="button" onClick={startMicrosoftLogin}>
|
|
||||||
Entrar com Microsoft
|
Entrar com Microsoft
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
|
||||||
|
|
||||||
{error ? (
|
<a
|
||||||
<div
|
href="#forgot-password"
|
||||||
role="alert"
|
|
||||||
style={{
|
|
||||||
border: '1px solid rgba(180, 35, 24, 0.24)',
|
|
||||||
borderRadius: 14,
|
|
||||||
padding: '0.85rem 1rem',
|
|
||||||
background: 'rgba(180, 35, 24, 0.08)',
|
|
||||||
color: '#b42318',
|
|
||||||
fontWeight: 700,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{!providers.ldap && !providers.microsoft ? (
|
|
||||||
<div
|
|
||||||
role="alert"
|
|
||||||
style={{
|
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
borderRadius: 14,
|
|
||||||
padding: '0.85rem 1rem',
|
|
||||||
background: '#fff',
|
|
||||||
color: 'var(--color-text-soft)',
|
|
||||||
fontWeight: 700,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Nenhum provedor de login esta habilitado.
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<span
|
|
||||||
style={{
|
style={{
|
||||||
justifySelf: 'center',
|
justifySelf: 'center',
|
||||||
color: 'var(--color-text-soft)',
|
color: 'var(--color-secondary)',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Acesso somente via AD ou Microsoft corporativo.
|
Esqueci minha senha
|
||||||
</span>
|
</a>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,51 +1,17 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import { mockLogin } from '../services/authService';
|
||||||
getAuthConfig,
|
|
||||||
loginWithAd,
|
|
||||||
startMicrosoftLogin,
|
|
||||||
storeAuthSession,
|
|
||||||
} from '../services/authService';
|
|
||||||
|
|
||||||
export function useLogin() {
|
export function useLogin() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [providers, setProviders] = useState({ ldap: true, microsoft: false });
|
|
||||||
|
|
||||||
useEffect(() => {
|
async function login() {
|
||||||
getAuthConfig()
|
|
||||||
.then((config) => setProviders(config.providers || { ldap: true, microsoft: false }))
|
|
||||||
.catch(() => setProviders({ ldap: true, microsoft: false }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
const token = params.get('token');
|
|
||||||
const rawUser = params.get('user');
|
|
||||||
|
|
||||||
if (!token || !rawUser) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const user = JSON.parse(rawUser);
|
|
||||||
storeAuthSession({ token, user });
|
|
||||||
window.history.replaceState({}, document.title, window.location.pathname);
|
|
||||||
navigate('/home', { replace: true });
|
|
||||||
} catch {
|
|
||||||
setError('Nao foi possivel concluir o login Microsoft.');
|
|
||||||
}
|
|
||||||
}, [navigate]);
|
|
||||||
|
|
||||||
async function login(credentials) {
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
setError('');
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const authResult = await loginWithAd(credentials);
|
await mockLogin();
|
||||||
storeAuthSession(authResult);
|
|
||||||
navigate('/home');
|
navigate('/home');
|
||||||
} catch (loginError) {
|
|
||||||
setError(loginError.message || 'Falha ao autenticar.');
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
@ -53,9 +19,6 @@ export function useLogin() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
error,
|
|
||||||
providers,
|
|
||||||
login,
|
login,
|
||||||
startMicrosoftLogin,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -58,7 +58,7 @@ export function LoginPage() {
|
|||||||
margin: 0,
|
margin: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Atendimento Múltiplos canais
|
MVP de atendimento
|
||||||
</p>
|
</p>
|
||||||
<h1
|
<h1
|
||||||
style={{
|
style={{
|
||||||
@ -67,7 +67,7 @@ export function LoginPage() {
|
|||||||
lineHeight: 1.05,
|
lineHeight: 1.05,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Conexão multiatendimento em um único lugar.
|
Conecte-se com seu cliente em uma única tela.
|
||||||
</h1>
|
</h1>
|
||||||
<p
|
<p
|
||||||
style={{
|
style={{
|
||||||
@ -91,7 +91,7 @@ export function LoginPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{[
|
{[
|
||||||
{ label: 'Canais', value: 'WhatsApp, SMS e E-mail' },
|
{ label: 'Canais', value: 'WhatsApp, SMS e Voz' },
|
||||||
{ label: 'Fila', value: 'Distribuição rápida' },
|
{ label: 'Fila', value: 'Distribuição rápida' },
|
||||||
{ label: 'UX', value: 'Padrão SaaS responsivo' },
|
{ label: 'UX', value: 'Padrão SaaS responsivo' },
|
||||||
].map((item) => (
|
].map((item) => (
|
||||||
@ -147,7 +147,8 @@ export function LoginPage() {
|
|||||||
lineHeight: 1.6,
|
lineHeight: 1.6,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Use seu usuario corporativo para acessar o MVP com Active Directory ou Microsoft.
|
Use seu usuário corporativo para acessar o MVP. A autenticação e mockada
|
||||||
|
nesta etapa e leva você diretamente para a dashboard principal.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,35 +1,11 @@
|
|||||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
const networkDelay = 450;
|
||||||
|
|
||||||
async function parseJsonResponse(response) {
|
export async function mockLogin() {
|
||||||
const data = await response.json().catch(() => null);
|
await new Promise((resolve) => window.setTimeout(resolve, networkDelay));
|
||||||
|
|
||||||
if (!response.ok) {
|
return {
|
||||||
throw new Error(data?.message || 'Nao foi possivel autenticar.');
|
id: 'agent-001',
|
||||||
}
|
name: 'Ana Camolesi',
|
||||||
|
email: 'ana.camolesi@sothis.local',
|
||||||
return data;
|
};
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAuthConfig() {
|
|
||||||
const response = await fetch(`${API_BASE_URL}/auth/config`);
|
|
||||||
return parseJsonResponse(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loginWithAd(credentials) {
|
|
||||||
const response = await fetch(`${API_BASE_URL}/auth/login`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(credentials),
|
|
||||||
});
|
|
||||||
|
|
||||||
return parseJsonResponse(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function startMicrosoftLogin() {
|
|
||||||
window.location.href = `${API_BASE_URL}/auth/oauth/microsoft/start`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function storeAuthSession(authResult) {
|
|
||||||
window.localStorage.setItem('authToken', authResult.token);
|
|
||||||
window.localStorage.setItem('authUser', JSON.stringify(authResult.user));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,135 +0,0 @@
|
|||||||
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,183 +1,4 @@
|
|||||||
import { useEffect, useMemo, useRef } from 'react';
|
import { useEffect, 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({
|
export function ChatWindow({
|
||||||
contact,
|
contact,
|
||||||
@ -186,10 +7,6 @@ export function ChatWindow({
|
|||||||
setSelectedArea,
|
setSelectedArea,
|
||||||
draft,
|
draft,
|
||||||
setDraft,
|
setDraft,
|
||||||
attachedFile,
|
|
||||||
onAttachFile,
|
|
||||||
onRemoveAttachedFile,
|
|
||||||
onLoadMedia,
|
|
||||||
onSend,
|
onSend,
|
||||||
onToggleTransfer,
|
onToggleTransfer,
|
||||||
isReplying,
|
isReplying,
|
||||||
@ -324,36 +141,13 @@ export function ChatWindow({
|
|||||||
background: isAgent ? 'var(--color-primary)' : '#edf1f5',
|
background: isAgent ? 'var(--color-primary)' : '#edf1f5',
|
||||||
color: isAgent ? '#fff' : 'var(--color-text)',
|
color: isAgent ? '#fff' : 'var(--color-text)',
|
||||||
boxShadow: 'var(--shadow-md)',
|
boxShadow: 'var(--shadow-md)',
|
||||||
display: 'grid',
|
|
||||||
gap: '0.65rem',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MediaRenderer
|
{message.text}
|
||||||
message={message}
|
|
||||||
contactId={contact.id}
|
|
||||||
onLoadMedia={onLoadMedia}
|
|
||||||
isAgent={isAgent}
|
|
||||||
/>
|
|
||||||
{message.text ? <span>{message.text}</span> : null}
|
|
||||||
</div>
|
</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 ? (
|
{isReplying ? (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@ -375,44 +169,10 @@ export function ChatWindow({
|
|||||||
padding: '1rem 1.25rem 1.25rem',
|
padding: '1rem 1.25rem 1.25rem',
|
||||||
borderTop: '1px solid var(--color-border)',
|
borderTop: '1px solid var(--color-border)',
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: '1fr',
|
gridTemplateColumns: isMobile ? '1fr' : '1fr auto',
|
||||||
gap: '0.75rem',
|
gap: '0.75rem',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AttachmentPreview file={attachedFile} onRemove={onRemoveAttachedFile} />
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: isMobile ? 'auto 1fr' : 'auto 1fr auto',
|
|
||||||
gap: '0.75rem',
|
|
||||||
alignItems: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={draft}
|
value={draft}
|
||||||
@ -429,7 +189,6 @@ export function ChatWindow({
|
|||||||
padding: '0.95rem 1rem',
|
padding: '0.95rem 1rem',
|
||||||
background: '#fff',
|
background: '#fff',
|
||||||
outline: 'none',
|
outline: 'none',
|
||||||
minWidth: 0,
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@ -442,12 +201,10 @@ export function ChatWindow({
|
|||||||
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
|
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
gridColumn: isMobile ? '1 / -1' : 'auto',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Enviar
|
Enviar
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</footer>
|
</footer>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,13 +1,11 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useWhatsappSocket } from '../../../shared/hooks/useWhatsappSocket';
|
|
||||||
import {
|
import {
|
||||||
attendantsByArea,
|
attendantsByArea,
|
||||||
chatContacts,
|
chatContacts,
|
||||||
|
getMockReply,
|
||||||
transferAreas,
|
transferAreas,
|
||||||
} from '../services/chatMocks';
|
} from '../services/chatMocks';
|
||||||
|
|
||||||
const API_BASE_URL = 'http://localhost:3001';
|
|
||||||
|
|
||||||
function buildInitialMessages() {
|
function buildInitialMessages() {
|
||||||
return chatContacts.reduce((acc, contact) => {
|
return chatContacts.reduce((acc, contact) => {
|
||||||
acc[contact.id] = contact.messages;
|
acc[contact.id] = contact.messages;
|
||||||
@ -15,95 +13,18 @@ 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() {
|
export function useChat() {
|
||||||
const { incomingMessage, clearIncomingMessage } = useWhatsappSocket();
|
const [contacts, setContacts] = useState(chatContacts);
|
||||||
const [contacts, setContacts] = useState(buildFallbackContacts);
|
|
||||||
const [activeContactId, setActiveContactId] = useState(chatContacts[0].id);
|
const [activeContactId, setActiveContactId] = useState(chatContacts[0].id);
|
||||||
const [messagesByContact, setMessagesByContact] = useState(buildInitialMessages);
|
const [messagesByContact, setMessagesByContact] = useState(buildInitialMessages);
|
||||||
const [draft, setDraft] = useState('');
|
const [draft, setDraft] = useState('');
|
||||||
const [attachedFile, setAttachedFile] = useState(null);
|
|
||||||
const [selectedArea, setSelectedArea] = useState(chatContacts[0].area);
|
const [selectedArea, setSelectedArea] = useState(chatContacts[0].area);
|
||||||
const [isTransferOpen, setIsTransferOpen] = useState(false);
|
const [isTransferOpen, setIsTransferOpen] = useState(false);
|
||||||
const [transferArea, setTransferArea] = useState('Suporte');
|
const [transferArea, setTransferArea] = useState('Suporte');
|
||||||
const [transferAttendant, setTransferAttendant] = useState(attendantsByArea.Suporte[0]);
|
const [transferAttendant, setTransferAttendant] = useState(attendantsByArea.Suporte[0]);
|
||||||
const [transferNote, setTransferNote] = useState('');
|
const [transferNote, setTransferNote] = useState('');
|
||||||
const [isReplying] = useState(false);
|
const [isReplying, setIsReplying] = useState(false);
|
||||||
const [isLoadingChats, setIsLoadingChats] = useState(false);
|
const replyTimeoutRef = useRef(null);
|
||||||
const [isLoadingMessages, setIsLoadingMessages] = useState(false);
|
|
||||||
const [apiError, setApiError] = useState(null);
|
|
||||||
const activeContactRef = useRef(activeContactId);
|
|
||||||
|
|
||||||
const activeContact = useMemo(
|
const activeContact = useMemo(
|
||||||
() => contacts.find((contact) => contact.id === activeContactId) || contacts[0],
|
() => contacts.find((contact) => contact.id === activeContactId) || contacts[0],
|
||||||
@ -122,223 +43,61 @@ export function useChat() {
|
|||||||
}, [transferArea]);
|
}, [transferArea]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
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 () => {
|
return () => {
|
||||||
isMounted = false;
|
if (replyTimeoutRef.current) {
|
||||||
window.clearInterval(intervalId);
|
window.clearTimeout(replyTimeoutRef.current);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
function updateContactPreview(contactId, preview) {
|
||||||
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) =>
|
setContacts((current) =>
|
||||||
current.map((contact) =>
|
current.map((contact) =>
|
||||||
contact.id === contactId
|
contact.id === contactId ? { ...contact, preview, time: 'Agora', unread: 0 } : contact,
|
||||||
? { ...contact, preview: media ? `[Midia: ${media.filename || 'Arquivo'}]` : preview, time: 'Agora', unread: 0 }
|
|
||||||
: contact,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function attachFile(file) {
|
function sendMessage() {
|
||||||
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();
|
const trimmed = draft.trim();
|
||||||
if (!trimmed && !attachedFile) {
|
if (!trimmed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const media = attachedFile
|
|
||||||
? {
|
|
||||||
data: attachedFile.data,
|
|
||||||
mimetype: attachedFile.type,
|
|
||||||
filename: attachedFile.name,
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
const newMessage = {
|
const newMessage = {
|
||||||
id: `temp-${Date.now()}`,
|
id: Date.now(),
|
||||||
chatId: activeContactId,
|
|
||||||
sender: 'agent',
|
sender: 'agent',
|
||||||
text: trimmed,
|
text: trimmed,
|
||||||
hasMedia: Boolean(media),
|
|
||||||
media,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
setMessagesByContact((current) => ({
|
setMessagesByContact((current) => ({
|
||||||
...current,
|
...current,
|
||||||
[activeContactId]: [...(current[activeContactId] || []), newMessage],
|
[activeContactId]: [...(current[activeContactId] || []), newMessage],
|
||||||
}));
|
}));
|
||||||
updateContactPreview(activeContactId, trimmed || '[Midia]', media);
|
updateContactPreview(activeContactId, trimmed);
|
||||||
setDraft('');
|
setDraft('');
|
||||||
setAttachedFile(null);
|
setIsReplying(true);
|
||||||
|
|
||||||
if (!activeContactId.includes('@')) return;
|
replyTimeoutRef.current = window.setTimeout(() => {
|
||||||
|
const reply = {
|
||||||
|
id: Date.now() + 1,
|
||||||
|
sender: 'customer',
|
||||||
|
text: getMockReply(activeContact.name),
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
setMessagesByContact((current) => ({
|
||||||
await fetch(`${API_BASE_URL}/whatsapp/send`, {
|
...current,
|
||||||
method: 'POST',
|
[activeContactId]: [...(current[activeContactId] || []), reply],
|
||||||
headers: { 'Content-Type': 'application/json' },
|
}));
|
||||||
body: JSON.stringify({
|
setContacts((current) =>
|
||||||
to: activeContactId,
|
current.map((contact) =>
|
||||||
message: trimmed,
|
contact.id === activeContactId
|
||||||
media,
|
? { ...contact, preview: reply.text, time: 'Agora', unread: contact.unread + 1 }
|
||||||
}),
|
: contact,
|
||||||
});
|
),
|
||||||
setApiError(null);
|
);
|
||||||
} catch (error) {
|
setIsReplying(false);
|
||||||
setApiError(error.message);
|
}, 1400);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function submitTransfer() {
|
function submitTransfer() {
|
||||||
@ -373,15 +132,8 @@ export function useChat() {
|
|||||||
messages,
|
messages,
|
||||||
draft,
|
draft,
|
||||||
setDraft,
|
setDraft,
|
||||||
attachedFile,
|
|
||||||
attachFile,
|
|
||||||
removeAttachedFile,
|
|
||||||
sendMessage,
|
sendMessage,
|
||||||
hydrateMessageMedia,
|
|
||||||
isReplying,
|
isReplying,
|
||||||
isLoadingChats,
|
|
||||||
isLoadingMessages,
|
|
||||||
apiError,
|
|
||||||
selectedArea,
|
selectedArea,
|
||||||
setSelectedArea,
|
setSelectedArea,
|
||||||
isTransferOpen,
|
isTransferOpen,
|
||||||
|
|||||||
@ -17,11 +17,7 @@ export function ChatPage() {
|
|||||||
messages,
|
messages,
|
||||||
draft,
|
draft,
|
||||||
setDraft,
|
setDraft,
|
||||||
attachedFile,
|
|
||||||
attachFile,
|
|
||||||
removeAttachedFile,
|
|
||||||
sendMessage,
|
sendMessage,
|
||||||
hydrateMessageMedia,
|
|
||||||
isReplying,
|
isReplying,
|
||||||
selectedArea,
|
selectedArea,
|
||||||
setSelectedArea,
|
setSelectedArea,
|
||||||
@ -121,10 +117,6 @@ export function ChatPage() {
|
|||||||
setSelectedArea={setSelectedArea}
|
setSelectedArea={setSelectedArea}
|
||||||
draft={draft}
|
draft={draft}
|
||||||
setDraft={setDraft}
|
setDraft={setDraft}
|
||||||
attachedFile={attachedFile}
|
|
||||||
onAttachFile={attachFile}
|
|
||||||
onRemoveAttachedFile={removeAttachedFile}
|
|
||||||
onLoadMedia={hydrateMessageMedia}
|
|
||||||
onSend={sendMessage}
|
onSend={sendMessage}
|
||||||
onToggleTransfer={() => setIsTransferOpen((current) => !current)}
|
onToggleTransfer={() => setIsTransferOpen((current) => !current)}
|
||||||
isReplying={isReplying}
|
isReplying={isReplying}
|
||||||
|
|||||||
@ -1,120 +0,0 @@
|
|||||||
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,5 +1,4 @@
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { clearSession } from '../../auth/services/sessionService';
|
|
||||||
|
|
||||||
export function HomeSidebar({ items, activeItem, isMobile = false }) {
|
export function HomeSidebar({ items, activeItem, isMobile = false }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@ -29,7 +28,7 @@ export function HomeSidebar({ items, activeItem, isMobile = false }) {
|
|||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Abrir atendimento
|
+ Novo Atendimento
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<nav
|
<nav
|
||||||
@ -78,27 +77,6 @@ export function HomeSidebar({ items, activeItem, isMobile = false }) {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</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>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,16 +1,3 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { getCurrentUserDisplay } from '../../auth/services/sessionService';
|
|
||||||
|
|
||||||
function formatCurrentDateTime(date) {
|
|
||||||
return new Intl.DateTimeFormat('pt-BR', {
|
|
||||||
day: '2-digit',
|
|
||||||
month: '2-digit',
|
|
||||||
year: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
}).format(date);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HomeTopbar({
|
export function HomeTopbar({
|
||||||
activeTab,
|
activeTab,
|
||||||
onTabChange,
|
onTabChange,
|
||||||
@ -21,29 +8,19 @@ export function HomeTopbar({
|
|||||||
isTablet = false,
|
isTablet = false,
|
||||||
isMobile = false,
|
isMobile = false,
|
||||||
}) {
|
}) {
|
||||||
const userDisplay = getCurrentUserDisplay();
|
|
||||||
const [currentDateTime, setCurrentDateTime] = useState(() => formatCurrentDateTime(new Date()));
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'messages', label: 'Mensagens' },
|
{ id: 'messages', label: 'Mensagens' },
|
||||||
{ id: 'calls', label: 'Ligacoes' },
|
{ id: 'calls', label: 'Ligações' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const gridTemplateColumns = isMobile
|
const gridTemplateColumns = isMobile
|
||||||
? '1fr'
|
? '1fr'
|
||||||
: isWideDesktop
|
: isWideDesktop
|
||||||
? 'max-content minmax(150px, 190px) minmax(280px, 1fr) max-content'
|
? 'max-content minmax(180px, 220px) minmax(280px, 1fr) max-content'
|
||||||
: isDesktop || isTablet
|
: isDesktop || isTablet
|
||||||
? 'repeat(2, minmax(0, 1fr))'
|
? 'repeat(2, minmax(0, 1fr))'
|
||||||
: '1fr';
|
: '1fr';
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const intervalId = window.setInterval(() => {
|
|
||||||
setCurrentDateTime(formatCurrentDateTime(new Date()));
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
return () => window.clearInterval(intervalId);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
style={{
|
style={{
|
||||||
@ -98,13 +75,9 @@ export function HomeTopbar({
|
|||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
width: isMobile ? '100%' : 'auto',
|
width: isMobile ? '100%' : 'auto',
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
textAlign: 'center',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{currentDateTime}
|
Sexta, 19 de março
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
@ -135,9 +108,9 @@ export function HomeTopbar({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ textAlign: 'right', minWidth: 0 }}>
|
<div style={{ textAlign: 'right', minWidth: 0 }}>
|
||||||
<strong style={{ display: 'block' }}>{userDisplay.name}</strong>
|
<strong style={{ display: 'block' }}>Ana Camolesi</strong>
|
||||||
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.92rem' }}>
|
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.92rem' }}>
|
||||||
{userDisplay.subtitle}
|
Atendimento omnichannel
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@ -153,7 +126,7 @@ export function HomeTopbar({
|
|||||||
fontWeight: 800,
|
fontWeight: 800,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{userDisplay.initials}
|
AM
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@ -1,8 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
const WORKSPACE_HEIGHT = 660;
|
|
||||||
|
|
||||||
function ChannelBadge({ channel }) {
|
function ChannelBadge({ channel }) {
|
||||||
const colors = {
|
const colors = {
|
||||||
WhatsApp: '#2bb741',
|
WhatsApp: '#2bb741',
|
||||||
@ -28,135 +25,20 @@ function ChannelBadge({ channel }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSuggestedReplies(conversation) {
|
|
||||||
const lastMessage = conversation?.lastMessage || conversation?.messages?.at(-1)?.text || '';
|
|
||||||
const firstName = conversation?.name?.split(' ')?.[0] || 'voce';
|
|
||||||
const lowerContext = lastMessage.toLowerCase();
|
|
||||||
|
|
||||||
if (
|
|
||||||
lowerContext.includes('fatura') ||
|
|
||||||
lowerContext.includes('cobranca') ||
|
|
||||||
lowerContext.includes('pagamento')
|
|
||||||
) {
|
|
||||||
return [
|
|
||||||
`${firstName}, vou conferir os dados financeiros e ja te retorno com a posicao correta.`,
|
|
||||||
'Recebi sua mensagem sobre cobranca. Vou validar o historico antes de seguir com a orientacao.',
|
|
||||||
'Consigo te ajudar com isso. Pode me confirmar o CPF/CNPJ ou protocolo vinculado ao atendimento?',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
lowerContext.includes('endereco') ||
|
|
||||||
lowerContext.includes('cadastro') ||
|
|
||||||
lowerContext.includes('atualizar')
|
|
||||||
) {
|
|
||||||
return [
|
|
||||||
`${firstName}, vou validar seu cadastro e confirmar se a alteracao ja foi registrada.`,
|
|
||||||
'Para seguir com a atualizacao, me confirme por favor os dados que precisam ser ajustados.',
|
|
||||||
'Entendi. Vou verificar o cadastro atual e te retorno com o proximo passo.',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
lowerContext.includes('ligar') ||
|
|
||||||
lowerContext.includes('telefone') ||
|
|
||||||
lowerContext.includes('retorno')
|
|
||||||
) {
|
|
||||||
return [
|
|
||||||
`${firstName}, consigo organizar esse retorno. Qual o melhor horario para contato?`,
|
|
||||||
'Vou registrar sua solicitacao e direcionar o retorno para o time responsavel.',
|
|
||||||
'Obrigado pelo aviso. Vou confirmar disponibilidade e te retorno por aqui.',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
`${firstName}, recebi sua mensagem e vou verificar o contexto para te orientar corretamente.`,
|
|
||||||
'Entendi. Vou analisar as informacoes do atendimento e retorno com o melhor encaminhamento.',
|
|
||||||
'Posso acionar o time responsavel e te atualizar por aqui assim que tiver uma posicao.',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MessagesWorkspace({
|
export function MessagesWorkspace({
|
||||||
conversations,
|
conversations,
|
||||||
activeConversationId,
|
activeConversationId,
|
||||||
onSelectConversation,
|
onSelectConversation,
|
||||||
|
actionItems,
|
||||||
isWideDesktop = false,
|
isWideDesktop = false,
|
||||||
isDesktop = false,
|
isDesktop = false,
|
||||||
isTablet = false,
|
isTablet = false,
|
||||||
isMobile = false,
|
isMobile = false,
|
||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const recentConversations = conversations.slice(0, 3);
|
|
||||||
const activeConversation =
|
const activeConversation =
|
||||||
recentConversations.find((conversation) => conversation.id === activeConversationId) ||
|
conversations.find((conversation) => conversation.id === activeConversationId) ||
|
||||||
recentConversations[0] ||
|
|
||||||
conversations[0];
|
conversations[0];
|
||||||
const safeActiveConversation = activeConversation || {
|
|
||||||
id: 'empty',
|
|
||||||
name: 'Nenhuma conversa',
|
|
||||||
status: 'offline',
|
|
||||||
messages: [],
|
|
||||||
};
|
|
||||||
const suggestedReplies = useMemo(
|
|
||||||
() => buildSuggestedReplies(safeActiveConversation),
|
|
||||||
[safeActiveConversation],
|
|
||||||
);
|
|
||||||
const [selectedReplyIndex, setSelectedReplyIndex] = useState(0);
|
|
||||||
const [noteDraft, setNoteDraft] = useState('');
|
|
||||||
const [notes, setNotes] = useState(() => {
|
|
||||||
try {
|
|
||||||
return JSON.parse(window.localStorage.getItem('agentNotes') || '[]');
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const selectedReply = suggestedReplies[selectedReplyIndex] || suggestedReplies[0];
|
|
||||||
const managerMessages = [
|
|
||||||
{
|
|
||||||
id: 'sla',
|
|
||||||
title: 'Comunicado do supervisor',
|
|
||||||
text: 'Priorizar atendimentos com SLA abaixo de 15 minutos antes de abrir novos casos.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'script',
|
|
||||||
title: 'Atualizacao de script',
|
|
||||||
text: 'Use o novo roteiro de confirmacao de dados em atendimentos financeiros.',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setSelectedReplyIndex(0);
|
|
||||||
}, [safeActiveConversation.id]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
window.localStorage.setItem('agentNotes', JSON.stringify(notes));
|
|
||||||
}, [notes]);
|
|
||||||
|
|
||||||
function selectPreviousReply() {
|
|
||||||
setSelectedReplyIndex((current) =>
|
|
||||||
current === 0 ? suggestedReplies.length - 1 : current - 1,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectNextReply() {
|
|
||||||
setSelectedReplyIndex((current) => (current + 1) % suggestedReplies.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveNote() {
|
|
||||||
const text = noteDraft.trim();
|
|
||||||
if (!text) return;
|
|
||||||
|
|
||||||
setNotes((current) => [
|
|
||||||
{
|
|
||||||
id: Date.now(),
|
|
||||||
text,
|
|
||||||
time: new Date().toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }),
|
|
||||||
},
|
|
||||||
...current,
|
|
||||||
]);
|
|
||||||
setNoteDraft('');
|
|
||||||
}
|
|
||||||
|
|
||||||
const gridTemplateColumns = isMobile
|
const gridTemplateColumns = isMobile
|
||||||
? '1fr'
|
? '1fr'
|
||||||
@ -166,15 +48,13 @@ export function MessagesWorkspace({
|
|||||||
? 'minmax(260px, 320px) minmax(0, 1fr)'
|
? 'minmax(260px, 320px) minmax(0, 1fr)'
|
||||||
: '1fr';
|
: '1fr';
|
||||||
|
|
||||||
const panelHeight = isMobile ? 'auto' : WORKSPACE_HEIGHT;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns,
|
gridTemplateColumns,
|
||||||
gap: '1rem',
|
gap: '1rem',
|
||||||
alignItems: 'stretch',
|
alignItems: 'start',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<section
|
<section
|
||||||
@ -185,20 +65,18 @@ export function MessagesWorkspace({
|
|||||||
padding: '1rem',
|
padding: '1rem',
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gap: '0.75rem',
|
gap: '0.75rem',
|
||||||
alignContent: 'start',
|
|
||||||
height: panelHeight,
|
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<strong style={{ fontSize: '1.05rem' }}>Conversas</strong>
|
<strong style={{ fontSize: '1.05rem' }}>Conversas</strong>
|
||||||
<p style={{ margin: '0.35rem 0 0', color: 'var(--color-text-soft)' }}>
|
<p style={{ margin: '0.35rem 0 0', color: 'var(--color-text-soft)' }}>
|
||||||
Ultimos 3 atendimentos em tempo real.
|
Atendimento em tempo real por canal.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{recentConversations.map((conversation) => {
|
{conversations.map((conversation) => {
|
||||||
const isActive = conversation.id === safeActiveConversation.id;
|
const isActive = conversation.id === activeConversation.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@ -245,23 +123,6 @@ export function MessagesWorkspace({
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{conversations.length > 3 ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => navigate('/chat')}
|
|
||||||
style={{
|
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
borderRadius: '16px',
|
|
||||||
padding: '0.85rem 1rem',
|
|
||||||
background: '#fff',
|
|
||||||
color: 'var(--color-primary)',
|
|
||||||
fontWeight: 700,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Ver todos no chat
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
@ -270,9 +131,8 @@ export function MessagesWorkspace({
|
|||||||
borderRadius: '26px',
|
borderRadius: '26px',
|
||||||
border: '1px solid var(--color-border)',
|
border: '1px solid var(--color-border)',
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateRows: 'auto minmax(0, 1fr) auto',
|
gridTemplateRows: 'auto 1fr auto',
|
||||||
height: panelHeight,
|
minHeight: 580,
|
||||||
minHeight: isMobile ? 580 : 'auto',
|
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
}}
|
}}
|
||||||
@ -288,11 +148,9 @@ export function MessagesWorkspace({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<strong style={{ display: 'block', fontSize: '1.08rem' }}>
|
<strong style={{ display: 'block', fontSize: '1.08rem' }}>{activeConversation.name}</strong>
|
||||||
{safeActiveConversation.name}
|
|
||||||
</strong>
|
|
||||||
<span style={{ color: 'var(--color-text-soft)' }}>
|
<span style={{ color: 'var(--color-text-soft)' }}>
|
||||||
{safeActiveConversation.status === 'online' ? 'Online agora' : 'Offline'}
|
{activeConversation.status === 'online' ? 'Online agora' : 'Offline'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: '0.6rem', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: '0.6rem', flexWrap: 'wrap' }}>
|
||||||
@ -332,12 +190,11 @@ export function MessagesWorkspace({
|
|||||||
display: 'grid',
|
display: 'grid',
|
||||||
gap: '0.9rem',
|
gap: '0.9rem',
|
||||||
alignContent: 'start',
|
alignContent: 'start',
|
||||||
overflowY: 'auto',
|
|
||||||
background:
|
background:
|
||||||
'linear-gradient(180deg, rgba(245, 248, 251, 0.45), rgba(255, 255, 255, 0.9))',
|
'linear-gradient(180deg, rgba(245, 248, 251, 0.45), rgba(255, 255, 255, 0.9))',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{safeActiveConversation.messages.map((message) => {
|
{activeConversation.messages.map((message) => {
|
||||||
const isAgent = message.from === 'agent';
|
const isAgent = message.from === 'agent';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -361,72 +218,37 @@ export function MessagesWorkspace({
|
|||||||
|
|
||||||
<footer
|
<footer
|
||||||
style={{
|
style={{
|
||||||
padding: '0.85rem 1.25rem 1rem',
|
padding: '1rem 1.25rem 1.25rem',
|
||||||
borderTop: '1px solid var(--color-border)',
|
borderTop: '1px solid var(--color-border)',
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gap: '0.65rem',
|
gridTemplateColumns: '1fr auto',
|
||||||
|
gap: '0.75rem',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<strong style={{ display: 'block', fontSize: '0.94rem' }}>Resposta sugerida</strong>
|
<input
|
||||||
|
type="text"
|
||||||
<div
|
value="Posso acionar o time responsavel e te retorno em seguida."
|
||||||
style={{
|
readOnly
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: '40px minmax(0, 1fr) 40px',
|
|
||||||
gap: '0.6rem',
|
|
||||||
alignItems: 'stretch',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={selectPreviousReply}
|
|
||||||
title="Resposta anterior"
|
|
||||||
style={{
|
style={{
|
||||||
border: '1px solid var(--color-border)',
|
border: '1px solid var(--color-border)',
|
||||||
borderRadius: '14px',
|
borderRadius: '18px',
|
||||||
|
padding: '0.95rem 1rem',
|
||||||
background: '#fff',
|
background: '#fff',
|
||||||
color: 'var(--color-primary)',
|
|
||||||
fontWeight: 900,
|
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
‹
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => navigate('/chat')}
|
|
||||||
style={{
|
style={{
|
||||||
border: '1px solid rgba(0, 164, 183, 0.32)',
|
border: 'none',
|
||||||
borderRadius: '16px',
|
borderRadius: '18px',
|
||||||
padding: '0.75rem 0.9rem',
|
padding: '0.95rem 1.2rem',
|
||||||
background: 'rgba(0, 164, 183, 0.07)',
|
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
|
||||||
color: 'var(--color-text)',
|
color: '#fff',
|
||||||
fontWeight: 600,
|
fontWeight: 700,
|
||||||
textAlign: 'left',
|
|
||||||
lineHeight: 1.35,
|
|
||||||
minWidth: 0,
|
|
||||||
overflow: 'hidden',
|
|
||||||
display: '-webkit-box',
|
|
||||||
WebkitLineClamp: 2,
|
|
||||||
WebkitBoxOrient: 'vertical',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{selectedReply}
|
Enviar
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={selectNextReply}
|
|
||||||
title="Proxima resposta"
|
|
||||||
style={{
|
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
borderRadius: '14px',
|
|
||||||
background: '#fff',
|
|
||||||
color: 'var(--color-primary)',
|
|
||||||
fontWeight: 900,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
›
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</footer>
|
</footer>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@ -437,102 +259,49 @@ export function MessagesWorkspace({
|
|||||||
border: '1px solid var(--color-border)',
|
border: '1px solid var(--color-border)',
|
||||||
padding: '1.2rem',
|
padding: '1.2rem',
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateRows: 'auto minmax(0, 1fr)',
|
|
||||||
gap: '1rem',
|
gap: '1rem',
|
||||||
|
alignContent: 'start',
|
||||||
gridColumn: isWideDesktop ? 'auto' : '1 / -1',
|
gridColumn: isWideDesktop ? 'auto' : '1 / -1',
|
||||||
height: panelHeight,
|
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<strong style={{ fontSize: '1.05rem' }}>Comunicados e notas</strong>
|
<strong style={{ fontSize: '1.05rem' }}>Painel de ações</strong>
|
||||||
|
<p style={{ margin: '0.35rem 0 0', color: 'var(--color-text-soft)' }}>
|
||||||
|
Contexto rápido do atendimento selecionado.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
{actionItems.map((item) => (
|
||||||
style={{
|
|
||||||
display: 'grid',
|
|
||||||
gap: '0.85rem',
|
|
||||||
alignContent: 'start',
|
|
||||||
overflowY: 'auto',
|
|
||||||
paddingRight: '0.15rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{managerMessages.map((message) => (
|
|
||||||
<article
|
<article
|
||||||
key={message.id}
|
key={item.title}
|
||||||
style={{
|
style={{
|
||||||
borderRadius: '18px',
|
borderRadius: '20px',
|
||||||
padding: '0.95rem',
|
padding: '1rem',
|
||||||
background: 'rgba(0, 49, 80, 0.04)',
|
background: 'rgba(0, 49, 80, 0.04)',
|
||||||
display: 'grid',
|
|
||||||
gap: '0.4rem',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<strong>{message.title}</strong>
|
<span style={{ color: 'var(--color-text-soft)', display: 'block', marginBottom: '0.35rem' }}>
|
||||||
<p style={{ margin: 0, color: 'var(--color-text-soft)', lineHeight: 1.5 }}>
|
{item.title}
|
||||||
{message.text}
|
</span>
|
||||||
</p>
|
<strong>{item.value}</strong>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<label style={{ display: 'grid', gap: '0.45rem' }}>
|
|
||||||
<span style={{ color: 'var(--color-text-soft)', fontWeight: 700 }}>Anotacao rapida</span>
|
|
||||||
<textarea
|
|
||||||
value={noteDraft}
|
|
||||||
onChange={(event) => setNoteDraft(event.target.value)}
|
|
||||||
placeholder="Ex: cliente pediu retorno apos as 15h"
|
|
||||||
rows={4}
|
|
||||||
style={{
|
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
borderRadius: '14px',
|
|
||||||
padding: '0.85rem 0.9rem',
|
|
||||||
background: '#fff',
|
|
||||||
color: 'var(--color-text)',
|
|
||||||
resize: 'none',
|
|
||||||
outline: 'none',
|
|
||||||
lineHeight: 1.45,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={saveNote}
|
onClick={() => navigate('/new-attendance')}
|
||||||
style={{
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '18px',
|
|
||||||
padding: '0.95rem 1rem',
|
|
||||||
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
|
|
||||||
color: '#fff',
|
|
||||||
fontWeight: 800,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Salvar anotacao
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div style={{ display: 'grid', gap: '0.55rem' }}>
|
|
||||||
{notes.length ? (
|
|
||||||
notes.map((note) => (
|
|
||||||
<article
|
|
||||||
key={note.id}
|
|
||||||
style={{
|
style={{
|
||||||
border: '1px solid var(--color-border)',
|
border: '1px solid var(--color-border)',
|
||||||
borderRadius: '16px',
|
borderRadius: '18px',
|
||||||
padding: '0.8rem',
|
padding: '0.95rem 1rem',
|
||||||
background: '#fff',
|
background: '#fff',
|
||||||
|
color: 'var(--color-primary)',
|
||||||
|
fontWeight: 700,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.82rem' }}>
|
Criar novo fluxo
|
||||||
{note.time}
|
</button>
|
||||||
</span>
|
|
||||||
<p style={{ margin: '0.35rem 0 0', lineHeight: 1.45 }}>{note.text}</p>
|
|
||||||
</article>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<span style={{ color: 'var(--color-text-soft)' }}>Nenhuma anotacao salva.</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -4,43 +4,14 @@ import { HomeSidebar } from '../components/HomeSidebar';
|
|||||||
import { HomeTopbar } from '../components/HomeTopbar';
|
import { HomeTopbar } from '../components/HomeTopbar';
|
||||||
import { MessagesWorkspace } from '../components/MessagesWorkspace';
|
import { MessagesWorkspace } from '../components/MessagesWorkspace';
|
||||||
import { CallsWorkspace } from '../components/CallsWorkspace';
|
import { CallsWorkspace } from '../components/CallsWorkspace';
|
||||||
import { AttendantOpsPanel } from '../components/AttendantOpsPanel';
|
import { actionItems, conversations, recentCalls, sidebarItems } from '../services/homeMocks';
|
||||||
import { recentCalls, sidebarItems } from '../services/homeMocks';
|
|
||||||
import { useViewport } from '../../../shared/hooks/useViewport';
|
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() {
|
export function HomePage() {
|
||||||
const { isWideDesktop, isDesktop, isTablet, isMobile } = useViewport();
|
const { isWideDesktop, isDesktop, isTablet, isMobile } = useViewport();
|
||||||
const {
|
|
||||||
contacts,
|
|
||||||
activeContactId,
|
|
||||||
setActiveContactId,
|
|
||||||
messages,
|
|
||||||
isLoadingChats,
|
|
||||||
} = useChat();
|
|
||||||
const [activeTab, setActiveTab] = useState('messages');
|
const [activeTab, setActiveTab] = useState('messages');
|
||||||
const [searchValue, setSearchValue] = useState('');
|
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 search = searchValue.trim().toLowerCase();
|
||||||
const filteredConversations = !search
|
const filteredConversations = !search
|
||||||
@ -51,9 +22,9 @@ export function HomePage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const safeConversationId =
|
const safeConversationId =
|
||||||
filteredConversations.find((conversation) => conversation.id === activeContactId)?.id ||
|
filteredConversations.find((conversation) => conversation.id === activeConversationId)?.id ||
|
||||||
filteredConversations[0]?.id ||
|
filteredConversations[0]?.id ||
|
||||||
conversations[0]?.id;
|
conversations[0].id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main
|
<main
|
||||||
@ -119,28 +90,46 @@ export function HomePage() {
|
|||||||
gap: '1rem',
|
gap: '1rem',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AttendantOpsPanel activeChatsCount={filteredConversations.length} />
|
|
||||||
|
|
||||||
{isLoadingChats ? (
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
border: '1px solid var(--color-border)',
|
display: 'grid',
|
||||||
borderRadius: 18,
|
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
|
||||||
padding: '0.85rem 1rem',
|
gap: '1rem',
|
||||||
background: '#fff',
|
|
||||||
color: 'var(--color-text-soft)',
|
|
||||||
fontWeight: 700,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Atualizando conversas do WhatsApp...
|
{[
|
||||||
|
{ 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>
|
</div>
|
||||||
) : null}
|
|
||||||
|
|
||||||
{activeTab === 'messages' ? (
|
{activeTab === 'messages' ? (
|
||||||
<MessagesWorkspace
|
<MessagesWorkspace
|
||||||
conversations={filteredConversations}
|
conversations={filteredConversations}
|
||||||
activeConversationId={safeConversationId}
|
activeConversationId={safeConversationId}
|
||||||
onSelectConversation={setActiveContactId}
|
onSelectConversation={setActiveConversationId}
|
||||||
|
actionItems={actionItems}
|
||||||
isWideDesktop={isWideDesktop}
|
isWideDesktop={isWideDesktop}
|
||||||
isDesktop={isDesktop}
|
isDesktop={isDesktop}
|
||||||
isTablet={isTablet}
|
isTablet={isTablet}
|
||||||
|
|||||||
@ -1,23 +0,0 @@
|
|||||||
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 />;
|
|
||||||
}
|
|
||||||
@ -1,91 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,8 +1,9 @@
|
|||||||
export const sidebarItems = [
|
export const sidebarItems = [
|
||||||
{ id: 'scripts', label: 'Scripts e respostas prontas' },
|
{ id: 'dashboard', label: 'Dashboard' },
|
||||||
{ id: 'personal-reports', label: 'Relatorios pessoais' },
|
{ id: 'new-attendance', label: 'Novos Atendimentos', route: '/new-attendance' },
|
||||||
{ id: 'mass-message', label: 'Disparo em massa' },
|
{ id: 'in-progress', label: 'Em andamento', count: 8 },
|
||||||
{ id: 'knowledge-base', label: 'Base de conhecimento' },
|
{ id: 'completed', label: 'Finalizados', count: 24 },
|
||||||
|
{ id: 'contacts', label: 'Contatos', count: 128 },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const conversations = [
|
export const conversations = [
|
||||||
|
|||||||
@ -1,48 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,241 +0,0 @@
|
|||||||
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('/home')}
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,59 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,272 +0,0 @@
|
|||||||
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';
|
|
||||||
import { getCurrentUserDisplay } from '../../auth/services/sessionService';
|
|
||||||
|
|
||||||
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 userDisplay = getCurrentUserDisplay();
|
|
||||||
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={userDisplay.name}
|
|
||||||
initials={userDisplay.initials}
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,322 +0,0 @@
|
|||||||
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';
|
|
||||||
import { getCurrentUserDisplay } from '../../auth/services/sessionService';
|
|
||||||
|
|
||||||
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 userDisplay = getCurrentUserDisplay();
|
|
||||||
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={userDisplay.name}
|
|
||||||
initials={userDisplay.initials}
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,65 +0,0 @@
|
|||||||
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');
|
|
||||||
fetch('http://localhost:3001/whatsapp/status')
|
|
||||||
.then((response) => response.json())
|
|
||||||
.then((data) => setStatus(data.status))
|
|
||||||
.catch(console.error);
|
|
||||||
});
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
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),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,106 +0,0 @@
|
|||||||
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',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@ -1,10 +1,9 @@
|
|||||||
import { createBrowserRouter, Navigate } from 'react-router-dom';
|
import { createBrowserRouter, Navigate } from 'react-router-dom';
|
||||||
import { LoginPage } from '../modules/auth/pages/LoginPage';
|
import { LoginPage } from '../modules/auth/pages/LoginPage';
|
||||||
import { ProfileHomePage } from '../modules/home/pages/ProfileHomePage';
|
import { HomePage } from '../modules/home/pages/HomePage';
|
||||||
import { ChatPage } from '../modules/chat/pages/ChatPage';
|
import { ChatPage } from '../modules/chat/pages/ChatPage';
|
||||||
import { CallPage } from '../modules/call/pages/CallPage';
|
import { CallPage } from '../modules/call/pages/CallPage';
|
||||||
import { NewAttendancePage } from '../modules/attendance/pages/NewAttendancePage';
|
import { NewAttendancePage } from '../modules/attendance/pages/NewAttendancePage';
|
||||||
import { WhatsappAdminPage } from '../modules/management/pages/WhatsappAdminPage';
|
|
||||||
|
|
||||||
export const router = createBrowserRouter([
|
export const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
@ -17,7 +16,7 @@ export const router = createBrowserRouter([
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/home',
|
path: '/home',
|
||||||
element: <ProfileHomePage />,
|
element: <HomePage />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/chat',
|
path: '/chat',
|
||||||
@ -31,8 +30,4 @@ export const router = createBrowserRouter([
|
|||||||
path: '/new-attendance',
|
path: '/new-attendance',
|
||||||
element: <NewAttendancePage />,
|
element: <NewAttendancePage />,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/admin/whatsapp',
|
|
||||||
element: <WhatsappAdminPage />,
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
|
|||||||
@ -1,66 +0,0 @@
|
|||||||
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