Merge branch 'dev'
This commit is contained in:
commit
dfc47ce7e8
3
.env.example
Normal file
3
.env.example
Normal file
@ -0,0 +1,3 @@
|
||||
# Frontend environment variables (Vite)
|
||||
|
||||
VITE_API_URL=http://localhost:3001
|
||||
32
.gitignore
vendored
32
.gitignore
vendored
@ -1 +1,31 @@
|
||||
node_modules
|
||||
# Dependencies
|
||||
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/
|
||||
|
||||
89
README.md
Normal file
89
README.md
Normal file
@ -0,0 +1,89 @@
|
||||
# Omnichannel Frontend
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
Frontend da plataforma Omnichannel da Sothis, responsavel pelas telas de atendimento, chat em tempo real, painel administrativo, painel operacional, gestao de contatos, configuracoes, base de conhecimento e integracoes.
|
||||
|
||||
Este repositorio contem apenas a interface web. Para subir o projeto completo com frontend, backend e configuracao de deploy, utilize o repositorio de orquestracao:
|
||||
|
||||
https://chaleiradev.sothistelecom.com/Sothis/omnichannel-deploy
|
||||
|
||||
## Stack
|
||||
|
||||
- React
|
||||
- Vite
|
||||
- JavaScript com ES Modules
|
||||
- React Router
|
||||
- Socket.IO Client
|
||||
- CSS modularizado por contexto de tela
|
||||
|
||||
## Documentacao
|
||||
|
||||
Para documentacao do projeto completo, deploy, ambientes e operacao, acesse:
|
||||
|
||||
https://chaleiradev.sothistelecom.com/Sothis/omnichannel-deploy
|
||||
|
||||
Para documentacao tecnica complementar do frontend, modulos e telas, acesse a wiki:
|
||||
|
||||
https://chaleiradev.sothistelecom.com/Sothis/omnichannel-frontend/wiki
|
||||
|
||||
## Execucao local
|
||||
|
||||
Instale as dependencias:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
Execute em modo desenvolvimento:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
O Vite normalmente sobe a aplicacao em:
|
||||
|
||||
```txt
|
||||
http://localhost:5173
|
||||
```
|
||||
|
||||
## Build e preview
|
||||
|
||||
Gere o build:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Execute o preview local do build:
|
||||
|
||||
```bash
|
||||
npm run preview
|
||||
```
|
||||
|
||||
Por padrao, o preview utiliza:
|
||||
|
||||
```txt
|
||||
http://localhost:3000
|
||||
```
|
||||
|
||||
## Estrutura principal
|
||||
|
||||
- `src/modules/auth`: login e integracoes de autenticacao.
|
||||
- `src/modules/attendance`: abertura e acompanhamento de atendimentos.
|
||||
- `src/modules/chat`: chat em tempo real e edicao rapida de contato.
|
||||
- `src/modules/call`: painel do atendente.
|
||||
- `src/modules/home`: telas iniciais e direcionamento por perfil.
|
||||
- `src/modules/management`: painel administrativo, dashboard, contatos, IA, canais, integracoes e configuracoes.
|
||||
- `src/routes`: rotas principais da aplicacao.
|
||||
- `src/shared`: componentes, hooks, estilos e servicos compartilhados.
|
||||
|
||||
## Observacoes
|
||||
|
||||
- Este frontend depende da API do `omnichannel-backend`.
|
||||
- A URL do backend deve ser configurada conforme o ambiente de execucao.
|
||||
- Para operacao completa, deploy e atualizacao em producao, consulte o repositorio `omnichannel-deploy`.
|
||||
BIN
dist/assets/favicon_blue-CzkOczz3.png
vendored
BIN
dist/assets/favicon_blue-CzkOczz3.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 4.8 KiB |
68
dist/assets/index-1xjqdjIG.js
vendored
68
dist/assets/index-1xjqdjIG.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/index-BsY34Fgu.css
vendored
1
dist/assets/index-BsY34Fgu.css
vendored
@ -1 +0,0 @@
|
||||
: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
BIN
dist/assets/logo_white_dark_mode-BKcVSu03.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
BIN
dist/assets/logo_white_mode-BIHgqUPv.png
vendored
BIN
dist/assets/logo_white_mode-BIHgqUPv.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 26 KiB |
14
dist/index.html
vendored
14
dist/index.html
vendored
@ -1,14 +0,0 @@
|
||||
<!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>
|
||||
88
docs/chat-whatsapp.md
Normal file
88
docs/chat-whatsapp.md
Normal file
@ -0,0 +1,88 @@
|
||||
# Modulo de Chat WhatsApp (Frontend)
|
||||
|
||||
## Visao geral
|
||||
|
||||
O modulo de Chat no frontend integra as conversas em tempo real do WhatsApp diretamente na tela de atendimento do operador.
|
||||
|
||||
A interface e altamente responsiva, provendo feedback instantaneo de envio (zero latencia) e sincronizando com o backend via WebSockets (Socket.io) para atualizar estados de de-duplicacao, novas mensagens, midias e controle de posse do atendimento.
|
||||
|
||||
---
|
||||
|
||||
## Componentes Principais
|
||||
|
||||
### 1. Hook de Negocio (`useChat.js`)
|
||||
Centraliza todo o estado das conversas, conexao WebSocket e operacoes de rede:
|
||||
* **`contacts`**: Lista de chats ativos sincronizados. Cada contato possui um objeto `assignment` (atribuicao) normalizado.
|
||||
* **`messagesByContact`**: Map de historico de mensagens por JID/contato.
|
||||
* **`takeChat()`**: Dispara a requisicao de rede `/whatsapp/assign` enviando o ID do atendente e o ID numerico da area do usuario logado (convertido com seguranca para inteiro).
|
||||
* **`sendMessage()`**: Trata a de-duplicacao de mensagens em milissegundos e gerencia a concorrência (race condition).
|
||||
|
||||
### 2. Painel de Atendimento (`ChatWindow.jsx`)
|
||||
O container principal da conversa selecionada. Ele renderiza:
|
||||
* **Header**: Mostra o nome resolvido do cliente, canal (WhatsApp) e o indicador de quem esta atendendo.
|
||||
* **Historico**: Area de scroll contendo as bolhas de mensagens do atendente (`agent`) e do cliente (`customer`), incluindo visualizadores para imagens, audios e links de arquivos.
|
||||
* **Footer de Input**: Caixa de texto com suporte a tecla Enter e icone de anexo de midia (com validacao automatica de tamanho).
|
||||
|
||||
---
|
||||
|
||||
## Mecanismos de UX e Estabilidade
|
||||
|
||||
### 1. Insercao Instantanea (UX Zero-Latency)
|
||||
Para evitar que o atendente perceba qualquer latencia de rede, o envio e dividido em duas etapas:
|
||||
1. **Fase Local**: A bolha de mensagem e inserida na tela imediatamente com um ID temporario (`temp-` + timestamp) e o texto digitado. O input de texto e arquivos e limpo na mesma hora.
|
||||
2. **Fase de Disparo**: A requisicao HTTP POST e disparada para o backend em segundo plano.
|
||||
|
||||
### 2. De-duplicacao de Mensagens (Prevecao de Race Condition)
|
||||
Como o backend envia a mensagem recebida via WebSocket assim que o Puppeteer a dispara, a bolha poderia aparecer duplicada na tela se a requisição de envio original ainda estivesse processando.
|
||||
* **A Solucao**: O hook de WebSocket compara as mensagens recebidas em tempo real. Se o texto bater e a diferenca temporal de timestamp for inferior a 4 segundos, ele identifica a bolha `temp-...` local, remove o prefixo temporario e atualiza-a com o ID oficial do WhatsApp gerado no servidor. **Zero duplicacoes, zero flashes na tela.**
|
||||
|
||||
### 3. Validação de Posse (Type-Safe User IDs)
|
||||
Para evitar conflitos na exibicao do banner *"⚠️ Atendido por outro colaborador"*, realizamos casting explicito dos IDs dos usuarios envolvidos:
|
||||
```javascript
|
||||
const isAssignedToMe = activeContact?.assignment?.userId && String(activeContact.assignment.userId) === String(currentUser.id);
|
||||
const isAssignedToOthers = activeContact?.assignment && String(activeContact.assignment.userId) !== String(currentUser.id);
|
||||
```
|
||||
Isso impede que comparacoes como `4 === "4"` (inteiro vindo do banco relacional vs string vindo do localStorage/JWT) avaliem incorretamente como falso, mantendo a tela bloqueada ou liberada com precisao.
|
||||
|
||||
### 4. Layout e Rolagem Estrita (680px Scroll)
|
||||
A interface de mensagens possui limitacoes verticais restritas para evitar que a tela se alongue infinitamente para baixo.
|
||||
* A bolha de historico e fixada com altura proporcional (`height: 680px` ou `calc`) e controle de transbordo `overflow-y: auto`.
|
||||
* O hook de chat escuta mudancas na lista de mensagens e realiza rolagem automatica suave (`smooth`) para o fim da tela sempre que uma nova bolha e adicionada.
|
||||
|
||||
---
|
||||
|
||||
## Novos Fluxos Homologados (WhatsApp / Meta)
|
||||
|
||||
### 1. Novo Atendimento Inteligente (`NewAttendancePage.jsx`)
|
||||
* **Remoção do Seletor de Área**: O seletor manual foi removido da tela para simplificar a operação. O sistema resolve a área dinamicamente a partir do atendente logado (`currentUser.areaPrincipal` ou `areas[0]`).
|
||||
* **Bloqueio de Campo**: Ao escolher um contato dos recentes ou da busca lateral, o input do telefone e do nome do cliente ficam bloqueados para escrita.
|
||||
* **Modo "Novo Número"**: Ao clicar no botão, o operador habilita os inputs de nome e telefone. Caso inicie o chat sem digitar um nome personalizado, o sistema aplica um fallback limpo no formato `Contato Novo (+55...)`.
|
||||
|
||||
### 2. Bloqueio e Envio de Templates Meta (`ChatWindow.jsx`)
|
||||
Como a API oficial do WhatsApp/Meta exige uma mensagem pré-aprovada para iniciar conversas ativas (sem histórico prévio), a interface aplica travas estritas:
|
||||
* **Travamento do Input**: Se a conversa selecionada possuir histórico de envio vazio (`!hasAgentMessages`), a caixa de texto principal e o botão "Enviar" ficam bloqueados.
|
||||
* **Painel de Templates**: Logo acima do rodapé de digitação, renderiza-se um seletor horizontal com os templates oficiais Meta ativos no banco (buscados de `GET /whatsapp/templates`).
|
||||
* **Substituição Dinâmica**: Ao clicar em um template, as variáveis `|NOME|`, `|DATA|` ou `|PROTOCOLO|` são interpoladas em tempo real com os dados do cliente, populando o input principal e liberando o fluxo de envio da primeira mensagem.
|
||||
|
||||
### 3. Gerenciamento de Templates para Supervisores (`SupervisorPage.jsx`)
|
||||
Supervisores possuem controle administrativo total sobre as mensagens homologadas:
|
||||
* **CRUD de Modelos**: Exibe todos os templates de WhatsApp em formato de cards visuais.
|
||||
* **Painel de Edição**: Permite criar novos templates ou editar identificadores/conteúdos de templates existentes. As alterações persistem imediatamente no banco PostgreSQL por meio dos endpoints `/whatsapp/templates`.
|
||||
|
||||
---
|
||||
|
||||
## Como Integrar e Rodar
|
||||
|
||||
### Variaveis de Ambiente
|
||||
O frontend conecta no WebSocket e na API do backend usando a porta padrao do NestJS:
|
||||
```env
|
||||
VITE_API_URL=http://localhost:3001
|
||||
VITE_WS_URL=http://localhost:3001
|
||||
```
|
||||
|
||||
### Compilando e Rodando localmente
|
||||
```bash
|
||||
cd frontend
|
||||
npm run dev
|
||||
```
|
||||
Ao selecionar uma conversa de canal "WhatsApp" que esteja livre, basta digitar uma mensagem e pressionar Enter. O chat sera automaticamente assumido por voce em tempo real, gravando no PostgreSQL e desbloqueando a janela de chat de forma instantanea.
|
||||
95
package-lock.json
generated
95
package-lock.json
generated
@ -10,7 +10,8 @@
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.30.1"
|
||||
"react-router-dom": "^6.30.1",
|
||||
"socket.io-client": "^4.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
@ -48,6 +49,7 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@ -1106,6 +1108,12 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@socket.io/component-emitter": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
@ -1212,6 +1220,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@ -1258,7 +1267,6 @@
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
@ -1279,6 +1287,28 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/engine.io-client": {
|
||||
"version": "6.6.4",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz",
|
||||
"integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io-parser": "~5.2.1",
|
||||
"ws": "~8.18.3",
|
||||
"xmlhttprequest-ssl": "~2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io-parser": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
|
||||
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||
@ -1411,7 +1441,6 @@
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
@ -1481,6 +1510,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
@ -1493,6 +1523,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
@ -1607,6 +1638,34 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-client": {
|
||||
"version": "4.8.3",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
|
||||
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io-client": "~6.6.1",
|
||||
"socket.io-parser": "~4.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-parser": {
|
||||
"version": "4.2.6",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
|
||||
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@ -1654,6 +1713,7 @@
|
||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.43",
|
||||
@ -1708,6 +1768,35 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.18.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xmlhttprequest-ssl": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
|
||||
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
|
||||
@ -11,7 +11,8 @@
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.30.1"
|
||||
"react-router-dom": "^6.30.1",
|
||||
"socket.io-client": "^4.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
|
||||
@ -2,7 +2,6 @@ export function RecentContactsList({
|
||||
contacts,
|
||||
activeContactId,
|
||||
onSelectContact,
|
||||
selectedChannel,
|
||||
}) {
|
||||
return (
|
||||
<aside
|
||||
@ -25,11 +24,6 @@ export function RecentContactsList({
|
||||
<div style={{ display: 'grid', gap: '0.75rem' }}>
|
||||
{contacts.map((contact) => {
|
||||
const isActive = contact.id === activeContactId;
|
||||
const isPreferred = selectedChannel === 'call'
|
||||
? contact.channel === 'Ligacao'
|
||||
: selectedChannel === 'sms'
|
||||
? contact.channel === 'SMS'
|
||||
: contact.channel === 'WhatsApp';
|
||||
|
||||
return (
|
||||
<button
|
||||
@ -49,24 +43,22 @@ export function RecentContactsList({
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
|
||||
<strong>{contact.name}</strong>
|
||||
{isPreferred ? (
|
||||
<span
|
||||
style={{
|
||||
padding: '0.2rem 0.5rem',
|
||||
borderRadius: 999,
|
||||
background: 'rgba(0, 164, 183, 0.12)',
|
||||
color: 'var(--color-primary)',
|
||||
background: 'rgba(43, 183, 65, 0.12)',
|
||||
color: '#25883a',
|
||||
fontSize: '0.76rem',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Sugerido
|
||||
Agenda
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<span style={{ color: 'var(--color-text-soft)' }}>{contact.phone}</span>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
|
||||
<span style={{ color: 'var(--color-primary)', fontWeight: 700 }}>{contact.channel}</span>
|
||||
<span style={{ color: 'var(--color-primary)', fontWeight: 700 }}>WhatsApp</span>
|
||||
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.85rem' }}>
|
||||
{contact.lastContact}
|
||||
</span>
|
||||
@ -74,6 +66,22 @@ export function RecentContactsList({
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{contacts.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '18px',
|
||||
padding: '1rem',
|
||||
background: 'rgba(0, 49, 80, 0.04)',
|
||||
color: 'var(--color-text-soft)',
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.45,
|
||||
}}
|
||||
>
|
||||
Nenhum contato encontrado na agenda.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
138
src/modules/attendance/pages/AgentNewAttendancePage.jsx
Normal file
138
src/modules/attendance/pages/AgentNewAttendancePage.jsx
Normal file
@ -0,0 +1,138 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { BrandMark } from '../../../shared/components/BrandMark';
|
||||
import { useViewport } from '../../../shared/hooks/useViewport';
|
||||
import { getCurrentUserDisplay } from '../../auth/services/sessionService';
|
||||
import { listContactProfiles } from '../../chat/services/contactProfileService';
|
||||
import { HomeSidebar } from '../../home/components/HomeSidebar';
|
||||
import { sidebarItems } from '../../home/services/homeMocks';
|
||||
import { NewAttendancePage } from './NewAttendancePage';
|
||||
|
||||
export function AgentNewAttendancePage() {
|
||||
const { isDesktop, isMobile } = useViewport();
|
||||
const userDisplay = getCurrentUserDisplay();
|
||||
const [contactCount, setContactCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
listContactProfiles()
|
||||
.then((items) => {
|
||||
if (isMounted) setContactCount(Array.isArray(items) ? items.length : 0);
|
||||
})
|
||||
.catch(() => {
|
||||
if (isMounted) setContactCount(0);
|
||||
});
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const sidebarWithContactCount = useMemo(
|
||||
() => sidebarItems.map((item) => (item.id === 'contacts' ? { ...item, count: contactCount } : item)),
|
||||
[contactCount],
|
||||
);
|
||||
|
||||
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(340px, 380px) 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>
|
||||
<HomeSidebar items={sidebarWithContactCount} activeItem="new-attendance" isMobile={!isDesktop} />
|
||||
</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)',
|
||||
}}
|
||||
>
|
||||
<h1 style={{ margin: 0, fontSize: '1.65rem' }}>Abrir Atendimento</h1>
|
||||
<p style={{ margin: '0.45rem 0 0', color: 'var(--color-text-soft)' }}>
|
||||
Inicie um contato ativo por WhatsApp usando mensagens pré-aprovadas.
|
||||
</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' }}>{userDisplay.name}</strong>
|
||||
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.92rem' }}>
|
||||
Atendimento omnichannel
|
||||
</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,
|
||||
}}
|
||||
>
|
||||
{userDisplay.initials}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<NewAttendancePage embedded />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@ -1,69 +1,363 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { BrandMark } from '../../../shared/components/BrandMark';
|
||||
import { useViewport } from '../../../shared/hooks/useViewport';
|
||||
import { RecentContactsList } from '../components/RecentContactsList';
|
||||
import {
|
||||
attendanceAreas,
|
||||
attendanceChannels,
|
||||
recentContacts,
|
||||
} from '../services/attendanceMocks';
|
||||
import { API_BASE_URL } from '../../../shared/services/apiConfig';
|
||||
import { getCurrentUser } from '../../auth/services/sessionService';
|
||||
import { listContactProfiles, saveContactProfile } from '../../chat/services/contactProfileService';
|
||||
import { getAccessOptions } from '../../management/services/adminAccessService';
|
||||
import { attendanceChannels } from '../services/attendanceMocks';
|
||||
|
||||
export function NewAttendancePage() {
|
||||
const countryOptions = [
|
||||
{ id: 'br', label: 'Brasil', dialCode: '55', placeholder: '(11) 99999-9999' },
|
||||
{ id: 'us', label: 'EUA', dialCode: '1', placeholder: '(212) 555-0199' },
|
||||
{ id: 'ar', label: 'Argentina', dialCode: '54', placeholder: '11 1234-5678' },
|
||||
{ id: 'cl', label: 'Chile', dialCode: '56', placeholder: '9 1234 5678' },
|
||||
{ id: 'mx', label: 'Mexico', dialCode: '52', placeholder: '55 1234 5678' },
|
||||
];
|
||||
|
||||
function getUserId(user) {
|
||||
const value = user?.databaseId || user?.id;
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? numeric : null;
|
||||
}
|
||||
|
||||
function normalizePhone(phone) {
|
||||
return String(phone || '').replace(/\D/g, '');
|
||||
}
|
||||
|
||||
function getCountryById(countryId) {
|
||||
return countryOptions.find((country) => country.id === countryId) || countryOptions[0];
|
||||
}
|
||||
|
||||
function inferCountryId(phone) {
|
||||
const digits = normalizePhone(phone);
|
||||
const matchedCountry = countryOptions.find((country) => digits.startsWith(country.dialCode));
|
||||
return matchedCountry?.id || 'br';
|
||||
}
|
||||
|
||||
function stripCountryCode(phone, countryId) {
|
||||
const digits = normalizePhone(phone);
|
||||
const country = getCountryById(countryId);
|
||||
return digits.startsWith(country.dialCode) ? digits.slice(country.dialCode.length) : digits;
|
||||
}
|
||||
|
||||
function buildInternationalPhone(phone, countryId) {
|
||||
const country = getCountryById(countryId);
|
||||
const nationalDigits = stripCountryCode(phone, country.id);
|
||||
return nationalDigits ? `${country.dialCode}${nationalDigits}` : '';
|
||||
}
|
||||
|
||||
function formatPhone(phone, countryId = inferCountryId(phone)) {
|
||||
const country = getCountryById(countryId);
|
||||
const digits = stripCountryCode(phone, country.id);
|
||||
if (!digits) return '';
|
||||
|
||||
if (country.id === 'br') {
|
||||
if (digits.length >= 11) {
|
||||
return `(${digits.slice(0, 2)}) ${digits.slice(2, 7)}-${digits.slice(7, 11)}`;
|
||||
}
|
||||
if (digits.length >= 10) {
|
||||
return `(${digits.slice(0, 2)}) ${digits.slice(2, 6)}-${digits.slice(6, 10)}`;
|
||||
}
|
||||
if (digits.length > 2) {
|
||||
return `(${digits.slice(0, 2)}) ${digits.slice(2)}`;
|
||||
}
|
||||
return digits;
|
||||
}
|
||||
|
||||
if (country.id === 'us') {
|
||||
if (digits.length >= 10) {
|
||||
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6, 10)}`;
|
||||
}
|
||||
if (digits.length > 3) {
|
||||
return `(${digits.slice(0, 3)}) ${digits.slice(3)}`;
|
||||
}
|
||||
return digits;
|
||||
}
|
||||
|
||||
if (country.id === 'ar') {
|
||||
if (digits.length >= 10) {
|
||||
return `${digits.slice(0, 2)} ${digits.slice(2, 6)}-${digits.slice(6, 10)}`;
|
||||
}
|
||||
if (digits.length > 2) {
|
||||
return `${digits.slice(0, 2)} ${digits.slice(2)}`;
|
||||
}
|
||||
return digits;
|
||||
}
|
||||
|
||||
if (country.id === 'cl') {
|
||||
if (digits.length >= 9) {
|
||||
return `${digits.slice(0, 1)} ${digits.slice(1, 5)} ${digits.slice(5, 9)}`;
|
||||
}
|
||||
if (digits.length > 1) {
|
||||
return `${digits.slice(0, 1)} ${digits.slice(1)}`;
|
||||
}
|
||||
return digits;
|
||||
}
|
||||
|
||||
if (country.id === 'mx') {
|
||||
if (digits.length >= 10) {
|
||||
return `${digits.slice(0, 2)} ${digits.slice(2, 6)} ${digits.slice(6, 10)}`;
|
||||
}
|
||||
if (digits.length > 2) {
|
||||
return `${digits.slice(0, 2)} ${digits.slice(2)}`;
|
||||
}
|
||||
return digits;
|
||||
}
|
||||
|
||||
return digits;
|
||||
}
|
||||
|
||||
function applyPhoneMask(value, countryId) {
|
||||
const digits = stripCountryCode(value, countryId);
|
||||
if (!digits) return '';
|
||||
return formatPhone(digits, countryId);
|
||||
}
|
||||
|
||||
function requiresUnsupportedTemplateFields(template) {
|
||||
const allowedFields = new Set(['nome', 'cliente', 'data', 'link', 'variavel']);
|
||||
const placeholders = String(template?.content || '').matchAll(/\{([^{}]+)\}/g);
|
||||
return Array.from(placeholders).some((match) => {
|
||||
const key = String(match[1]).trim().toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '');
|
||||
return !allowedFields.has(key);
|
||||
});
|
||||
}
|
||||
|
||||
function renderTemplatePreview(content, form, variables) {
|
||||
return String(content || '')
|
||||
.replace(/\{nome\}/gi, form.name.trim() || 'cliente')
|
||||
.replace(/\{cliente\}/gi, form.name.trim() || 'cliente')
|
||||
.replace(/\{data\}/gi, variables.date.trim() || '{data}')
|
||||
.replace(/\{link\}/gi, variables.link.trim() || '{link}')
|
||||
.replace(/\{variavel\}/gi, variables.custom.trim() || '{variavel}')
|
||||
.replace(/\{variável\}/gi, variables.custom.trim() || '{variável}');
|
||||
}
|
||||
|
||||
function formatLastContact(value) {
|
||||
if (!value) return 'Sem data';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return 'Sem data';
|
||||
return date.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' });
|
||||
}
|
||||
|
||||
function buildChatId(phone, countryId) {
|
||||
const digits = buildInternationalPhone(phone, countryId);
|
||||
if (!digits) return '';
|
||||
return `${digits}@c.us`;
|
||||
}
|
||||
|
||||
function normalizeAgendaContact(contact) {
|
||||
const phone = contact.phone || '';
|
||||
return {
|
||||
id: contact.chat_id,
|
||||
chatId: contact.chat_id,
|
||||
name: contact.name || phone || 'Contato sem nome',
|
||||
phone: formatPhone(phone),
|
||||
rawPhone: phone,
|
||||
countryId: inferCountryId(phone),
|
||||
company: contact.company || '',
|
||||
note: contact.note || '',
|
||||
lastContact: formatLastContact(contact.updated_at || contact.created_at),
|
||||
};
|
||||
}
|
||||
|
||||
function getUserAreas(user) {
|
||||
const normalizeArea = (area) => {
|
||||
if (!area) return null;
|
||||
if (typeof area === 'string') return area;
|
||||
return area.nome || area.name || null;
|
||||
};
|
||||
const areas = (Array.isArray(user?.areas) ? user.areas : []).map(normalizeArea).filter(Boolean);
|
||||
const primaryArea = normalizeArea(user?.areaPrincipal);
|
||||
if (primaryArea && !areas.includes(primaryArea)) {
|
||||
return [primaryArea, ...areas];
|
||||
}
|
||||
return areas;
|
||||
}
|
||||
|
||||
async function listWhatsappTemplates() {
|
||||
const response = await fetch(`${API_BASE_URL}/whatsapp/templates`);
|
||||
if (!response.ok) throw new Error('Falha ao carregar templates do WhatsApp.');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function startWhatsappAttendance(payload) {
|
||||
const response = await fetch(`${API_BASE_URL}/whatsapp/start-attendance`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok) throw new Error('Falha ao iniciar atendimento pelo WhatsApp.');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export function NewAttendancePage({ embedded = false }) {
|
||||
const navigate = useNavigate();
|
||||
const { isWideDesktop, isDesktop, isTablet, isMobile } = useViewport();
|
||||
const currentUser = getCurrentUser();
|
||||
const currentUserId = getUserId(currentUser);
|
||||
const currentUserAreas = getUserAreas(currentUser);
|
||||
const [contacts, setContacts] = useState([]);
|
||||
const [templates, setTemplates] = useState([]);
|
||||
const [areaOptions, setAreaOptions] = useState([]);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [selectedChannelId, setSelectedChannelId] = useState('whatsapp');
|
||||
const [selectedArea, setSelectedArea] = useState('');
|
||||
const [selectedContactId, setSelectedContactId] = useState(recentContacts[0].id);
|
||||
const [customNumber, setCustomNumber] = useState('');
|
||||
const [selectedContactId, setSelectedContactId] = useState('');
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState('');
|
||||
const [selectedCountryId, setSelectedCountryId] = useState('br');
|
||||
const [form, setForm] = useState({ phone: '', name: '', company: '', note: '' });
|
||||
const [templateVariables, setTemplateVariables] = useState({ date: '', link: '', custom: '' });
|
||||
const [isLoadingContacts, setIsLoadingContacts] = useState(false);
|
||||
const [isStarting, setIsStarting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
async function loadContacts() {
|
||||
setIsLoadingContacts(true);
|
||||
try {
|
||||
const [contactsData, templatesData, accessOptions] = await Promise.all([
|
||||
listContactProfiles(),
|
||||
listWhatsappTemplates(),
|
||||
getAccessOptions(),
|
||||
]);
|
||||
if (!isMounted) return;
|
||||
setContacts(Array.isArray(contactsData) ? contactsData.map(normalizeAgendaContact) : []);
|
||||
const supportedTemplates = Array.isArray(templatesData)
|
||||
? templatesData.filter((template) => template.status === 'approved' && !requiresUnsupportedTemplateFields(template))
|
||||
: [];
|
||||
setTemplates(supportedTemplates);
|
||||
setSelectedTemplateId((current) => current || (supportedTemplates?.[0]?.id ? String(supportedTemplates[0].id) : ''));
|
||||
setAreaOptions(accessOptions.areas || []);
|
||||
setError('');
|
||||
} catch (err) {
|
||||
if (isMounted) setError(err.message);
|
||||
} finally {
|
||||
if (isMounted) setIsLoadingContacts(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadContacts();
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const search = searchValue.trim().toLowerCase();
|
||||
const filteredContacts = useMemo(() => {
|
||||
if (!search) {
|
||||
return recentContacts;
|
||||
return contacts;
|
||||
}
|
||||
|
||||
return recentContacts.filter((contact) => {
|
||||
const haystack = `${contact.name} ${contact.phone} ${contact.channel}`.toLowerCase();
|
||||
return contacts.filter((contact) => {
|
||||
const haystack = `${contact.name} ${contact.phone} ${contact.rawPhone} ${contact.company} ${contact.note}`.toLowerCase();
|
||||
return haystack.includes(search);
|
||||
});
|
||||
}, [search]);
|
||||
|
||||
const selectedContact =
|
||||
filteredContacts.find((contact) => contact.id === selectedContactId) ||
|
||||
recentContacts.find((contact) => contact.id === selectedContactId) ||
|
||||
recentContacts[0];
|
||||
}, [contacts, search]);
|
||||
|
||||
const selectedChannel =
|
||||
attendanceChannels.find((channel) => channel.id === selectedChannelId) || attendanceChannels[0];
|
||||
const selectedCountry = getCountryById(selectedCountryId);
|
||||
const selectedTemplate = templates.find((template) => String(template.id) === String(selectedTemplateId));
|
||||
const primaryArea = areaOptions.find((area) => currentUserAreas.includes(area.nome));
|
||||
const isWhatsapp = selectedChannel.id === 'whatsapp';
|
||||
const canStartAttendance = isWhatsapp && Boolean(buildInternationalPhone(form.phone, selectedCountryId)) && Boolean(selectedTemplateId);
|
||||
|
||||
const gridTemplateColumns = isMobile
|
||||
? '1fr'
|
||||
: isWideDesktop
|
||||
? 'minmax(300px, 360px) minmax(0, 1fr)'
|
||||
? 'minmax(0, 1fr) minmax(340px, 0.8fr)'
|
||||
: isDesktop || isTablet
|
||||
? 'minmax(280px, 340px) minmax(0, 1fr)'
|
||||
? 'minmax(0, 1fr) minmax(320px, 0.85fr)'
|
||||
: '1fr';
|
||||
|
||||
function handleStartAttendance() {
|
||||
navigate(selectedChannel.route);
|
||||
function selectContact(contactId) {
|
||||
const contact = contacts.find((item) => item.id === contactId);
|
||||
if (!contact) return;
|
||||
|
||||
const contactCountryId = contact.countryId || inferCountryId(contact.rawPhone);
|
||||
setSelectedContactId(contactId);
|
||||
setSelectedCountryId(contactCountryId);
|
||||
setForm({
|
||||
phone: applyPhoneMask(contact.rawPhone || contact.phone || '', contactCountryId),
|
||||
name: contact.name || '',
|
||||
company: contact.company || '',
|
||||
note: contact.note || '',
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<main style={{ minHeight: '100vh', padding: '1.5rem' }}>
|
||||
function clearSelection() {
|
||||
setSelectedContactId('');
|
||||
setSelectedCountryId('br');
|
||||
setForm({ phone: '', name: '', company: '', note: '' });
|
||||
}
|
||||
|
||||
async function handleStartAttendance() {
|
||||
if (!canStartAttendance) {
|
||||
setError('Informe um número de WhatsApp para iniciar o atendimento.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedTemplateId) {
|
||||
setError('Selecione uma mensagem pré-aprovada para iniciar o atendimento.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentUserId) {
|
||||
setError('Não foi possível identificar o usuário logado.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsStarting(true);
|
||||
try {
|
||||
const fullPhone = buildInternationalPhone(form.phone, selectedCountryId);
|
||||
const chatId = selectedContactId || buildChatId(form.phone, selectedCountryId);
|
||||
const saved = await saveContactProfile(chatId, {
|
||||
phone: fullPhone,
|
||||
name: form.name,
|
||||
company: form.company,
|
||||
note: form.note,
|
||||
userId: currentUserId,
|
||||
});
|
||||
const startedAttendance = await startWhatsappAttendance({
|
||||
to: saved.chat_id || chatId,
|
||||
templateId: Number(selectedTemplateId),
|
||||
userId: currentUserId,
|
||||
areaId: primaryArea?.id || null,
|
||||
variables: {
|
||||
nome: form.name,
|
||||
cliente: form.name,
|
||||
data: templateVariables.date,
|
||||
link: templateVariables.link,
|
||||
variavel: templateVariables.custom,
|
||||
'variável': templateVariables.custom,
|
||||
},
|
||||
});
|
||||
setError('');
|
||||
navigate(`/chat?chatId=${encodeURIComponent(startedAttendance?.chatId || saved.chat_id || chatId)}`);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setIsStarting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const content = (
|
||||
<section
|
||||
style={{
|
||||
width: 'min(1680px, calc(100vw - 3rem))',
|
||||
margin: '0 auto',
|
||||
width: embedded ? '100%' : 'min(1680px, calc(100vw - 3rem))',
|
||||
margin: embedded ? 0 : '0 auto',
|
||||
background: 'var(--color-surface-strong)',
|
||||
borderRadius: '32px',
|
||||
boxShadow: 'var(--shadow-lg)',
|
||||
padding: '1.5rem',
|
||||
borderRadius: embedded ? 0 : '32px',
|
||||
boxShadow: embedded ? 'none' : 'var(--shadow-lg)',
|
||||
padding: embedded ? 0 : '1.5rem',
|
||||
display: 'grid',
|
||||
gap: '1.25rem',
|
||||
}}
|
||||
>
|
||||
{!embedded ? (
|
||||
<header
|
||||
style={{
|
||||
display: 'grid',
|
||||
@ -84,7 +378,7 @@ export function NewAttendancePage() {
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
Criacao rapida de atendimento
|
||||
Criação rápida de atendimento
|
||||
</div>
|
||||
<Link
|
||||
to="/home"
|
||||
@ -101,6 +395,7 @@ export function NewAttendancePage() {
|
||||
Voltar para home
|
||||
</Link>
|
||||
</header>
|
||||
) : null}
|
||||
|
||||
<section
|
||||
style={{
|
||||
@ -110,13 +405,6 @@ export function NewAttendancePage() {
|
||||
alignItems: 'start',
|
||||
}}
|
||||
>
|
||||
<RecentContactsList
|
||||
contacts={filteredContacts}
|
||||
activeContactId={selectedContact.id}
|
||||
onSelectContact={setSelectedContactId}
|
||||
selectedChannel={selectedChannelId}
|
||||
/>
|
||||
|
||||
<section
|
||||
style={{
|
||||
background: '#fff',
|
||||
@ -130,47 +418,11 @@ export function NewAttendancePage() {
|
||||
<div>
|
||||
<strong style={{ display: 'block', fontSize: '1.18rem' }}>Novo atendimento</strong>
|
||||
<p style={{ margin: '0.45rem 0 0', color: 'var(--color-text-soft)', lineHeight: 1.6 }}>
|
||||
Escolha o contato, o canal e a area opcional antes de iniciar. O fluxo e mockado
|
||||
e leva voce direto para chat ou ligacao.
|
||||
Informe um contato de WhatsApp ou selecione alguém da agenda para iniciar o atendimento.
|
||||
Para conversas novas, o primeiro envio usa uma mensagem pré-aprovada da Meta.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 1fr) auto',
|
||||
gap: '0.85rem',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="search"
|
||||
value={searchValue}
|
||||
onChange={(event) => setSearchValue(event.target.value)}
|
||||
placeholder="Buscar contato por nome ou numero"
|
||||
style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '18px',
|
||||
padding: '0.95rem 1rem',
|
||||
background: '#fff',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCustomNumber(selectedContact.phone)}
|
||||
style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '18px',
|
||||
padding: '0.95rem 1rem',
|
||||
background: '#fff',
|
||||
color: 'var(--color-primary)',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Novo numero
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
@ -180,12 +432,16 @@ export function NewAttendancePage() {
|
||||
>
|
||||
{attendanceChannels.map((channel) => {
|
||||
const isActive = channel.id === selectedChannelId;
|
||||
const isDisabled = Boolean(channel.disabled);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={channel.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedChannelId(channel.id)}
|
||||
onClick={() => {
|
||||
if (!isDisabled) setSelectedChannelId(channel.id);
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
style={{
|
||||
border: '1px solid',
|
||||
borderColor: isActive ? `${channel.accent}44` : 'var(--color-border)',
|
||||
@ -195,15 +451,15 @@ export function NewAttendancePage() {
|
||||
textAlign: 'left',
|
||||
display: 'grid',
|
||||
gap: '0.45rem',
|
||||
opacity: isDisabled ? 0.58 : 1,
|
||||
cursor: isDisabled ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
<strong style={{ color: isActive ? channel.accent : 'var(--color-text)' }}>
|
||||
{channel.label}
|
||||
</strong>
|
||||
<span style={{ color: 'var(--color-text-soft)' }}>
|
||||
{channel.id === 'call'
|
||||
? 'Inicia uma ligacao mock em tela cheia.'
|
||||
: 'Abre o fluxo de conversa em tempo real.'}
|
||||
{isDisabled ? 'Canal em construção.' : 'Inicia uma conversa pelo WhatsApp.'}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
@ -213,15 +469,22 @@ export function NewAttendancePage() {
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: isMobile ? '1fr' : 'repeat(2, minmax(0, 1fr))',
|
||||
gridTemplateColumns: isMobile ? '1fr' : 'minmax(170px, 0.45fr) minmax(0, 1fr) minmax(0, 1fr)',
|
||||
gap: '1rem',
|
||||
}}
|
||||
>
|
||||
<label style={{ display: 'grid', gap: '0.45rem' }}>
|
||||
<span style={{ fontWeight: 600 }}>Area (opcional)</span>
|
||||
<span style={{ fontWeight: 600 }}>País</span>
|
||||
<select
|
||||
value={selectedArea}
|
||||
onChange={(event) => setSelectedArea(event.target.value)}
|
||||
value={selectedCountryId}
|
||||
onChange={(event) => {
|
||||
const nextCountryId = event.target.value;
|
||||
setSelectedCountryId(nextCountryId);
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
phone: applyPhoneMask(current.phone, nextCountryId),
|
||||
}));
|
||||
}}
|
||||
style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '18px',
|
||||
@ -230,22 +493,38 @@ export function NewAttendancePage() {
|
||||
outline: 'none',
|
||||
}}
|
||||
>
|
||||
<option value="">Selecionar depois</option>
|
||||
{attendanceAreas.map((area) => (
|
||||
<option key={area} value={area}>
|
||||
{area}
|
||||
{countryOptions.map((country) => (
|
||||
<option key={country.id} value={country.id}>
|
||||
{country.label} +{country.dialCode}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'grid', gap: '0.45rem' }}>
|
||||
<span style={{ fontWeight: 600 }}>Numero selecionado</span>
|
||||
<span style={{ fontWeight: 600 }}>Número do WhatsApp</span>
|
||||
<input
|
||||
type="text"
|
||||
value={customNumber || selectedContact.phone}
|
||||
onChange={(event) => setCustomNumber(event.target.value)}
|
||||
placeholder="+55 11 99999-9999"
|
||||
value={form.phone}
|
||||
onChange={(event) => setForm((current) => ({ ...current, phone: applyPhoneMask(event.target.value, selectedCountryId) }))}
|
||||
placeholder={selectedCountry.placeholder}
|
||||
style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '18px',
|
||||
padding: '0.95rem 1rem',
|
||||
background: '#fff',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'grid', gap: '0.45rem' }}>
|
||||
<span style={{ fontWeight: 600 }}>Nome do cliente</span>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))}
|
||||
placeholder="Nome para salvar na agenda"
|
||||
style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '18px',
|
||||
@ -257,6 +536,179 @@ export function NewAttendancePage() {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: isMobile ? '1fr' : 'repeat(3, minmax(0, 1fr))',
|
||||
gap: '1rem',
|
||||
}}
|
||||
>
|
||||
<label style={{ display: 'grid', gap: '0.45rem' }}>
|
||||
<span style={{ fontWeight: 600 }}>Etiqueta de identificação</span>
|
||||
<input
|
||||
type="text"
|
||||
value={form.company}
|
||||
onChange={(event) => setForm((current) => ({ ...current, company: event.target.value }))}
|
||||
placeholder="Ex: Departamento, vaga ou conta vinculada"
|
||||
style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '18px',
|
||||
padding: '0.95rem 1rem',
|
||||
background: '#fff',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'grid', gap: '0.45rem' }}>
|
||||
<span style={{ fontWeight: 600 }}>Mensagem pré-aprovada</span>
|
||||
<select
|
||||
value={selectedTemplateId}
|
||||
onChange={(event) => setSelectedTemplateId(event.target.value)}
|
||||
style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '18px',
|
||||
padding: '0.95rem 1rem',
|
||||
background: '#fff',
|
||||
outline: 'none',
|
||||
}}
|
||||
>
|
||||
<option value="">Selecione um template</option>
|
||||
{templates.map((template) => (
|
||||
<option key={template.id} value={template.id}>
|
||||
{template.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: isMobile ? '1fr' : 'repeat(2, minmax(0, 1fr))',
|
||||
gap: '1rem',
|
||||
}}
|
||||
>
|
||||
<label style={{ display: 'grid', gap: '0.45rem' }}>
|
||||
<span style={{ fontWeight: 600 }}>Data do template</span>
|
||||
<input
|
||||
type="text"
|
||||
value={templateVariables.date}
|
||||
onChange={(event) => setTemplateVariables((current) => ({ ...current, date: event.target.value }))}
|
||||
placeholder="Ex: 26/05/2026"
|
||||
style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '18px',
|
||||
padding: '0.95rem 1rem',
|
||||
background: '#fff',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'grid', gap: '0.45rem' }}>
|
||||
<span style={{ fontWeight: 600 }}>Link do template</span>
|
||||
<input
|
||||
type="text"
|
||||
value={templateVariables.link}
|
||||
onChange={(event) => setTemplateVariables((current) => ({ ...current, link: event.target.value }))}
|
||||
placeholder="https://..."
|
||||
style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '18px',
|
||||
padding: '0.95rem 1rem',
|
||||
background: '#fff',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'grid', gap: '0.45rem' }}>
|
||||
<span style={{ fontWeight: 600 }}>Variável do template</span>
|
||||
<input
|
||||
type="text"
|
||||
value={templateVariables.custom}
|
||||
onChange={(event) => setTemplateVariables((current) => ({ ...current, custom: event.target.value }))}
|
||||
placeholder="Valor livre"
|
||||
style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '18px',
|
||||
padding: '0.95rem 1rem',
|
||||
background: '#fff',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{selectedTemplate ? (
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid rgba(0, 49, 80, 0.08)',
|
||||
borderRadius: '22px',
|
||||
padding: '1rem',
|
||||
background: 'linear-gradient(180deg, #e8f3ee, #dcefe8)',
|
||||
minHeight: 220,
|
||||
display: 'grid',
|
||||
alignContent: 'end',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'grid', gap: '0.45rem' }}>
|
||||
<strong style={{ display: 'block', color: 'var(--color-text-soft)', fontSize: '0.82rem' }}>
|
||||
Preview WhatsApp
|
||||
</strong>
|
||||
<div
|
||||
style={{
|
||||
justifySelf: 'end',
|
||||
maxWidth: '92%',
|
||||
borderRadius: '16px 16px 4px 16px',
|
||||
padding: '0.85rem 0.95rem',
|
||||
background: '#d9fdd3',
|
||||
color: '#1f2c33',
|
||||
boxShadow: '0 6px 18px rgba(0, 49, 80, 0.08)',
|
||||
whiteSpace: 'pre-wrap',
|
||||
lineHeight: 1.45,
|
||||
fontSize: '0.94rem',
|
||||
}}
|
||||
>
|
||||
{renderTemplatePreview(selectedTemplate.content, form, templateVariables)}
|
||||
<span
|
||||
style={{
|
||||
display: 'block',
|
||||
marginTop: '0.5rem',
|
||||
textAlign: 'right',
|
||||
color: 'rgba(31, 44, 51, 0.58)',
|
||||
fontSize: '0.72rem',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
10:42
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<label style={{ display: 'grid', gap: '0.45rem' }}>
|
||||
<span style={{ fontWeight: 600 }}>Observação</span>
|
||||
<textarea
|
||||
rows={5}
|
||||
value={form.note}
|
||||
onChange={(event) => setForm((current) => ({ ...current, note: event.target.value }))}
|
||||
placeholder="Contexto inicial deste atendimento."
|
||||
style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '18px',
|
||||
padding: '0.95rem 1rem',
|
||||
background: '#fff',
|
||||
outline: 'none',
|
||||
resize: 'vertical',
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error ? <span style={{ color: '#b42318', fontWeight: 700 }}>{error}</span> : null}
|
||||
{isLoadingContacts ? <span style={{ color: 'var(--color-text-soft)' }}>Carregando agenda...</span> : null}
|
||||
|
||||
<section
|
||||
style={{
|
||||
display: 'grid',
|
||||
@ -285,17 +737,20 @@ export function NewAttendancePage() {
|
||||
fontSize: '0.84rem',
|
||||
}}
|
||||
>
|
||||
Resumo do fluxo
|
||||
Resumo
|
||||
</span>
|
||||
<strong style={{ fontSize: '1.25rem' }}>{selectedContact.name}</strong>
|
||||
<strong style={{ fontSize: '1.25rem' }}>{form.name || 'Cliente sem nome'}</strong>
|
||||
<span style={{ color: 'rgba(255, 255, 255, 0.74)' }}>
|
||||
Canal escolhido: {selectedChannel.label}
|
||||
Canal: {selectedChannel.label}
|
||||
</span>
|
||||
<span style={{ color: 'rgba(255, 255, 255, 0.74)' }}>
|
||||
Numero: {customNumber || selectedContact.phone}
|
||||
Número: {buildInternationalPhone(form.phone, selectedCountryId) ? `+${buildInternationalPhone(form.phone, selectedCountryId)}` : 'Não informado'}
|
||||
</span>
|
||||
<span style={{ color: 'rgba(255, 255, 255, 0.74)' }}>
|
||||
Area: {selectedArea || 'Definir depois'}
|
||||
Etiqueta de identificação: {form.company || 'Não informada'}
|
||||
</span>
|
||||
<span style={{ color: 'rgba(255, 255, 255, 0.74)' }}>
|
||||
Origem: {selectedContactId ? 'Agenda' : 'Novo contato'}
|
||||
</span>
|
||||
</article>
|
||||
|
||||
@ -309,15 +764,31 @@ export function NewAttendancePage() {
|
||||
gap: '0.7rem',
|
||||
}}
|
||||
>
|
||||
<strong>Proxima rota</strong>
|
||||
<strong>Próxima rota</strong>
|
||||
<span style={{ color: 'var(--color-text-soft)' }}>
|
||||
{selectedChannel.route === '/call'
|
||||
? 'Ao iniciar, voce vai para a tela de ligacao.'
|
||||
: 'Ao iniciar, voce vai para a tela de chat.'}
|
||||
O contato será salvo, o template será enviado e a conversa abrirá atribuída a você no chat.
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', marginTop: '0.4rem' }}>
|
||||
{selectedContactId ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearSelection}
|
||||
style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '18px',
|
||||
padding: '1rem 1.1rem',
|
||||
background: '#fff',
|
||||
color: 'var(--color-primary)',
|
||||
fontWeight: 800,
|
||||
}}
|
||||
>
|
||||
Limpar seleção
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStartAttendance}
|
||||
disabled={!canStartAttendance || isStarting}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '18px',
|
||||
@ -325,16 +796,149 @@ export function NewAttendancePage() {
|
||||
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
|
||||
color: '#fff',
|
||||
fontWeight: 800,
|
||||
marginTop: '0.4rem',
|
||||
opacity: canStartAttendance && !isStarting ? 1 : 0.55,
|
||||
}}
|
||||
>
|
||||
Iniciar atendimento
|
||||
{isStarting ? 'Iniciando...' : 'Iniciar atendimento'}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<aside style={{ display: 'grid', gap: '0.85rem', alignContent: 'start' }}>
|
||||
<article
|
||||
style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 24,
|
||||
padding: '1rem',
|
||||
background: '#fff',
|
||||
display: 'grid',
|
||||
gap: '0.75rem',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong style={{ display: 'block' }}>Agenda de contatos</strong>
|
||||
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.9rem' }}>
|
||||
Selecione um contato salvo para preencher o atendimento.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="search"
|
||||
value={searchValue}
|
||||
onChange={(event) => setSearchValue(event.target.value)}
|
||||
placeholder="Buscar por nome, telefone ou etiqueta"
|
||||
style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '16px',
|
||||
padding: '0.85rem 0.9rem',
|
||||
background: '#fff',
|
||||
outline: 'none',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'grid', gap: '0.45rem', maxHeight: 460, overflowY: 'auto', paddingRight: '0.2rem' }}>
|
||||
{filteredContacts.map((contact) => {
|
||||
const isSelected = selectedContactId === contact.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={contact.id}
|
||||
type="button"
|
||||
onClick={() => selectContact(contact.id)}
|
||||
style={{
|
||||
border: '1px solid',
|
||||
borderColor: isSelected ? 'rgba(0, 164, 183, 0.36)' : 'var(--color-border)',
|
||||
borderRadius: 16,
|
||||
padding: '0.78rem',
|
||||
background: isSelected ? 'rgba(0, 164, 183, 0.08)' : '#fff',
|
||||
textAlign: 'left',
|
||||
display: 'grid',
|
||||
gap: '0.25rem',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<strong style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{contact.name}
|
||||
</strong>
|
||||
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.88rem' }}>
|
||||
+{contact.rawPhone || normalizePhone(contact.phone)}
|
||||
</span>
|
||||
{contact.company ? (
|
||||
<span
|
||||
style={{
|
||||
width: 'fit-content',
|
||||
borderRadius: 999,
|
||||
padding: '0.16rem 0.48rem',
|
||||
background: 'rgba(0,49,80,0.06)',
|
||||
color: 'var(--color-text-soft)',
|
||||
fontSize: '0.76rem',
|
||||
fontWeight: 800,
|
||||
}}
|
||||
>
|
||||
{contact.company}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{!filteredContacts.length ? (
|
||||
<span style={{ color: 'var(--color-text-soft)', fontWeight: 700 }}>
|
||||
Nenhum contato encontrado na agenda.
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{selectedContactId ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearSelection}
|
||||
style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 14,
|
||||
padding: '0.75rem',
|
||||
background: '#fff',
|
||||
color: 'var(--color-primary)',
|
||||
fontWeight: 800,
|
||||
}}
|
||||
>
|
||||
Limpar contato selecionado
|
||||
</button>
|
||||
) : null}
|
||||
</article>
|
||||
|
||||
<article
|
||||
style={{
|
||||
border: '1px solid rgba(0, 164, 183, 0.24)',
|
||||
borderRadius: 24,
|
||||
padding: '1rem',
|
||||
background: 'rgba(0, 164, 183, 0.06)',
|
||||
color: 'var(--color-text-soft)',
|
||||
lineHeight: 1.45,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{selectedContactId
|
||||
? 'Contato carregado da agenda. Você ainda pode ajustar nome, etiqueta e observação antes de iniciar.'
|
||||
: 'Você também pode digitar um novo número manualmente no formulário.'}
|
||||
</article>
|
||||
</aside>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
|
||||
if (embedded) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return (
|
||||
<main style={{ minHeight: '100vh', padding: '1.5rem' }}>
|
||||
{content}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -1,38 +1,5 @@
|
||||
export const attendanceChannels = [
|
||||
{ id: 'whatsapp', label: 'WhatsApp', route: '/chat', accent: '#2bb741' },
|
||||
{ id: 'sms', label: 'SMS', route: '/chat', accent: '#00a4b7' },
|
||||
{ id: 'call', label: 'Ligacao', route: '/call', accent: '#e5a22a' },
|
||||
];
|
||||
|
||||
export const attendanceAreas = ['Suporte', 'Financeiro', 'Comercial'];
|
||||
|
||||
export const recentContacts = [
|
||||
{
|
||||
id: 'maria-souza',
|
||||
name: 'Maria Souza',
|
||||
phone: '+55 11 99888-7766',
|
||||
channel: 'WhatsApp',
|
||||
lastContact: 'Hoje, 09:42',
|
||||
},
|
||||
{
|
||||
id: 'empresa-alpha',
|
||||
name: 'Empresa Alpha',
|
||||
phone: '+55 11 4002-2020',
|
||||
channel: 'Email',
|
||||
lastContact: 'Ontem, 16:18',
|
||||
},
|
||||
{
|
||||
id: 'joao-pedro',
|
||||
name: 'Joao Pedro',
|
||||
phone: '+55 31 98877-1102',
|
||||
channel: 'SMS',
|
||||
lastContact: 'Hoje, 08:15',
|
||||
},
|
||||
{
|
||||
id: 'beatriz-lima',
|
||||
name: 'Beatriz Lima',
|
||||
phone: '+55 21 99701-4455',
|
||||
channel: 'Ligacao',
|
||||
lastContact: 'Hoje, 07:51',
|
||||
},
|
||||
{ id: 'sms', label: 'SMS', route: '/chat', accent: '#00a4b7', disabled: true },
|
||||
{ id: 'email', label: 'E-mail', route: '/chat', accent: '#e5a22a', disabled: true },
|
||||
];
|
||||
|
||||
@ -39,7 +39,7 @@ const initialFormData = {
|
||||
|
||||
export function LoginForm() {
|
||||
const [formData, setFormData] = useState(initialFormData);
|
||||
const { login, isSubmitting } = useLogin();
|
||||
const { login, startMicrosoftLogin, providers, error, isSubmitting } = useLogin();
|
||||
|
||||
async function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
@ -48,12 +48,15 @@ export function LoginForm() {
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} style={{ display: 'grid', gap: '1rem' }}>
|
||||
{providers.ldap ? (
|
||||
<>
|
||||
<label style={{ display: 'grid', gap: '0.5rem' }}>
|
||||
<span style={{ fontWeight: 600 }}>Usuario AD</span>
|
||||
<input
|
||||
style={fieldStyle}
|
||||
type="text"
|
||||
placeholder="seu.usuario"
|
||||
autoComplete="username"
|
||||
value={formData.username}
|
||||
onChange={(event) =>
|
||||
setFormData((current) => ({ ...current, username: event.target.value }))
|
||||
@ -67,6 +70,7 @@ export function LoginForm() {
|
||||
style={fieldStyle}
|
||||
type="password"
|
||||
placeholder="Digite sua senha"
|
||||
autoComplete="current-password"
|
||||
value={formData.password}
|
||||
onChange={(event) =>
|
||||
setFormData((current) => ({ ...current, password: event.target.value }))
|
||||
@ -75,23 +79,58 @@ export function LoginForm() {
|
||||
</label>
|
||||
|
||||
<button style={primaryButtonStyle} type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Entrando...' : 'Entrar'}
|
||||
{isSubmitting ? 'Entrando...' : 'Entrar com AD'}
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<button style={secondaryButtonStyle} type="button">
|
||||
{providers.microsoft ? (
|
||||
<button style={secondaryButtonStyle} type="button" onClick={startMicrosoftLogin}>
|
||||
Entrar com Microsoft
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<a
|
||||
href="#forgot-password"
|
||||
{error ? (
|
||||
<div
|
||||
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={{
|
||||
justifySelf: 'center',
|
||||
color: 'var(--color-secondary)',
|
||||
color: 'var(--color-text-soft)',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Esqueci minha senha
|
||||
</a>
|
||||
Acesso somente via AD ou Microsoft corporativo.
|
||||
</span>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,17 +1,51 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { mockLogin } from '../services/authService';
|
||||
import {
|
||||
getAuthConfig,
|
||||
loginWithAd,
|
||||
startMicrosoftLogin,
|
||||
storeAuthSession,
|
||||
} from '../services/authService';
|
||||
|
||||
export function useLogin() {
|
||||
const navigate = useNavigate();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [providers, setProviders] = useState({ ldap: true, microsoft: false });
|
||||
|
||||
async function login() {
|
||||
setIsSubmitting(true);
|
||||
useEffect(() => {
|
||||
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 {
|
||||
await mockLogin();
|
||||
const user = JSON.parse(rawUser);
|
||||
storeAuthSession({ token, user });
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
navigate('/home', { replace: true });
|
||||
} catch {
|
||||
setError('Não foi possível concluir o login Microsoft.');
|
||||
}
|
||||
}, [navigate]);
|
||||
|
||||
async function login(credentials) {
|
||||
setIsSubmitting(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const authResult = await loginWithAd(credentials);
|
||||
storeAuthSession(authResult);
|
||||
navigate('/home');
|
||||
} catch (loginError) {
|
||||
setError(loginError.message || 'Falha ao autenticar.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
@ -19,6 +53,9 @@ export function useLogin() {
|
||||
|
||||
return {
|
||||
isSubmitting,
|
||||
error,
|
||||
providers,
|
||||
login,
|
||||
startMicrosoftLogin,
|
||||
};
|
||||
}
|
||||
|
||||
@ -58,7 +58,7 @@ export function LoginPage() {
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
MVP de atendimento
|
||||
Atendimento Múltiplos canais
|
||||
</p>
|
||||
<h1
|
||||
style={{
|
||||
@ -67,7 +67,7 @@ export function LoginPage() {
|
||||
lineHeight: 1.05,
|
||||
}}
|
||||
>
|
||||
Conecte-se com seu cliente em uma unica tela.
|
||||
Conexão multiatendimento em um único lugar.
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
@ -91,9 +91,9 @@ export function LoginPage() {
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{ label: 'Canais', value: 'WhatsApp, SMS e Voz' },
|
||||
{ label: 'Fila', value: 'Distribuicao rapida' },
|
||||
{ label: 'UX', value: 'Padrao SaaS responsivo' },
|
||||
{ label: 'Canais', value: 'WhatsApp, SMS e E-mail' },
|
||||
{ label: 'Fila', value: 'Distribuição rápida' },
|
||||
{ label: 'UX', value: 'Padrão SaaS responsivo' },
|
||||
].map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
@ -147,8 +147,7 @@ export function LoginPage() {
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
Use seu usuario corporativo para acessar o MVP. A autenticacao e mockada
|
||||
nesta etapa e leva voce diretamente para a dashboard principal.
|
||||
Use seu usuário corporativo para acessar o MVP com Active Directory ou Microsoft.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,11 +1,35 @@
|
||||
const networkDelay = 450;
|
||||
import { API_BASE_URL } from '../../../shared/services/apiConfig';
|
||||
|
||||
export async function mockLogin() {
|
||||
await new Promise((resolve) => window.setTimeout(resolve, networkDelay));
|
||||
async function parseJsonResponse(response) {
|
||||
const data = await response.json().catch(() => null);
|
||||
|
||||
return {
|
||||
id: 'agent-001',
|
||||
name: 'Ana Camolesi',
|
||||
email: 'ana.camolesi@sothis.local',
|
||||
};
|
||||
if (!response.ok) {
|
||||
throw new Error(data?.message || 'Não foi possível autenticar.');
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
135
src/modules/auth/services/sessionService.js
Normal file
135
src/modules/auth/services/sessionService.js
Normal file
@ -0,0 +1,135 @@
|
||||
const PROFILE_ALIASES = {
|
||||
admin: 'admin',
|
||||
administrador: 'admin',
|
||||
supervisor: 'supervisor',
|
||||
gestor: 'supervisor',
|
||||
agente: 'agent',
|
||||
atendente: 'agent',
|
||||
agent: 'agent',
|
||||
};
|
||||
|
||||
const DEMO_PROFILE_BY_USERNAME = {
|
||||
admin: 'admin',
|
||||
'lucas.admin': 'admin',
|
||||
supervisor: 'supervisor',
|
||||
'marina.alves': 'supervisor',
|
||||
'rafael.nunes': 'supervisor',
|
||||
};
|
||||
|
||||
function readStoredUser() {
|
||||
const rawUser = window.localStorage.getItem('authUser');
|
||||
|
||||
if (!rawUser) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(rawUser);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeProfile(value) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return PROFILE_ALIASES[value.trim().toLowerCase()] || null;
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
return normalizeProfile(value.nome || value.name || value.role || value.perfil);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveProfileFromList(values) {
|
||||
if (!Array.isArray(values)) {
|
||||
return normalizeProfile(values);
|
||||
}
|
||||
|
||||
const normalizedProfiles = values.map(normalizeProfile).filter(Boolean);
|
||||
|
||||
if (normalizedProfiles.includes('admin')) {
|
||||
return 'admin';
|
||||
}
|
||||
|
||||
if (normalizedProfiles.includes('supervisor')) {
|
||||
return 'supervisor';
|
||||
}
|
||||
|
||||
return normalizedProfiles[0] || null;
|
||||
}
|
||||
|
||||
export function getCurrentUser() {
|
||||
return readStoredUser();
|
||||
}
|
||||
|
||||
export function getCurrentUserDisplay() {
|
||||
const user = getCurrentUser();
|
||||
const fullName = user?.name || user?.nome || user?.username || 'Ana Camolesi';
|
||||
const nameParts = fullName.split(' ').filter(Boolean);
|
||||
const name =
|
||||
nameParts.length > 1 ? `${nameParts[0]} ${nameParts[nameParts.length - 1]}` : fullName;
|
||||
const areas = Array.isArray(user?.areas) ? user.areas : [];
|
||||
const profiles = Array.isArray(user?.perfis)
|
||||
? user.perfis
|
||||
: Array.isArray(user?.profiles)
|
||||
? user.profiles
|
||||
: [];
|
||||
const area = user?.areaPrincipal || areas[0] || null;
|
||||
const profile = profiles[0] || user?.perfil || user?.role || null;
|
||||
const subtitle = [profile, area].filter(Boolean).join(' - ') || 'Atendimento omnichannel';
|
||||
const initials = name
|
||||
.split(' ')
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0])
|
||||
.join('')
|
||||
.toUpperCase();
|
||||
|
||||
return {
|
||||
name,
|
||||
subtitle,
|
||||
initials: initials || 'AM',
|
||||
};
|
||||
}
|
||||
|
||||
export function getCurrentUserProfile() {
|
||||
const user = getCurrentUser();
|
||||
|
||||
if (!user) {
|
||||
return 'agent';
|
||||
}
|
||||
|
||||
if (user.accessStatus === 'unassigned') {
|
||||
return 'unassigned';
|
||||
}
|
||||
|
||||
const backendProfile =
|
||||
resolveProfileFromList(user.role) ||
|
||||
resolveProfileFromList(user.perfil) ||
|
||||
resolveProfileFromList(user.perfis) ||
|
||||
resolveProfileFromList(user.profiles);
|
||||
|
||||
if (backendProfile) {
|
||||
return backendProfile;
|
||||
}
|
||||
|
||||
const username = String(user.username || user.email || user.name || '').trim().toLowerCase();
|
||||
const demoProfile = DEMO_PROFILE_BY_USERNAME[username];
|
||||
|
||||
if (demoProfile) {
|
||||
return demoProfile;
|
||||
}
|
||||
|
||||
return 'agent';
|
||||
}
|
||||
|
||||
export function clearSession() {
|
||||
window.localStorage.removeItem('authToken');
|
||||
window.localStorage.removeItem('authUser');
|
||||
}
|
||||
@ -21,7 +21,7 @@ export function CallHeader({ isMobile = false }) {
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
Ligacao ativa
|
||||
Ligação ativa
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
||||
@ -98,9 +98,9 @@ export function CallPage() {
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{ label: 'Numero', value: activeCall.number },
|
||||
{ label: 'Número', value: activeCall.number },
|
||||
{ label: 'Canal original', value: 'Atendimento omnichannel' },
|
||||
{ label: 'Responsavel atual', value: 'Ana Camolesi' },
|
||||
{ label: 'Responsável atual', value: 'Ana Camolesi' },
|
||||
].map((item) => (
|
||||
<article
|
||||
key={item.label}
|
||||
@ -164,8 +164,8 @@ export function CallPage() {
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
Voce esta em uma ligacao ativa com a cliente. Os controles abaixo sao visuais
|
||||
neste MVP e ajudam a demonstrar a experiencia de voz do produto.
|
||||
Você está em uma ligação ativa com a cliente. Os controles abaixo são visuais
|
||||
neste MVP e ajudam a demonstrar a experiência de voz do produto.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -187,7 +187,7 @@ export function CallPage() {
|
||||
color: 'rgba(255, 255, 255, 0.72)',
|
||||
}}
|
||||
>
|
||||
Qualidade da chamada: Estavel
|
||||
Qualidade da chamada: Estável
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
@ -197,7 +197,7 @@ export function CallPage() {
|
||||
color: 'rgba(255, 255, 255, 0.72)',
|
||||
}}
|
||||
>
|
||||
Gravacao mock: Habilitada
|
||||
Gravação: Habilitada
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@ -23,10 +23,133 @@ function ChannelBadge({ channel }) {
|
||||
);
|
||||
}
|
||||
|
||||
function AssignmentDot({ contact, currentUserId }) {
|
||||
const assignment = contact.assignment;
|
||||
const assignedUserId = assignment?.user_id ? Number(assignment.user_id) : null;
|
||||
const isQueued = assignment?.status === 'queued' && !assignedUserId;
|
||||
const isMine = assignedUserId && currentUserId && assignedUserId === Number(currentUserId);
|
||||
const meta = isQueued
|
||||
? {
|
||||
color: '#e5a22a',
|
||||
label: 'Chamado na fila da especialidade, ainda sem atribuição',
|
||||
}
|
||||
: isMine
|
||||
? {
|
||||
color: '#00a4b7',
|
||||
label: 'Chamado atribuído a mim',
|
||||
}
|
||||
: assignedUserId
|
||||
? {
|
||||
color: '#d62828',
|
||||
label: `Chamado atribuído a ${assignment?.user_nome || 'outra pessoa'}`,
|
||||
}
|
||||
: null;
|
||||
|
||||
if (!meta) return null;
|
||||
|
||||
return (
|
||||
<span
|
||||
title={meta.label}
|
||||
aria-label={meta.label}
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 999,
|
||||
background: meta.color,
|
||||
boxShadow: `0 0 0 3px ${meta.color}22`,
|
||||
flex: '0 0 auto',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SpecialtyBadge({ contact }) {
|
||||
const specialty = contact.assignment?.area_nome || contact.area;
|
||||
if (!specialty || specialty === 'Sem fila') return null;
|
||||
|
||||
return (
|
||||
<span
|
||||
title={`Especialidade: ${specialty}`}
|
||||
style={{
|
||||
color: 'var(--color-primary)',
|
||||
flex: '0 0 auto',
|
||||
fontSize: '0.72rem',
|
||||
fontWeight: 800,
|
||||
lineHeight: 1,
|
||||
borderRadius: 999,
|
||||
padding: '0.2rem 0.5rem',
|
||||
background: 'rgba(0, 49, 80, 0.08)',
|
||||
}}
|
||||
>
|
||||
{specialty}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function UnreadBadge({ count }) {
|
||||
if (!count) return null;
|
||||
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: '50%',
|
||||
background: 'var(--color-secondary)',
|
||||
color: '#fff',
|
||||
fontSize: '0.78rem',
|
||||
fontWeight: 800,
|
||||
display: 'inline-grid',
|
||||
placeItems: 'center',
|
||||
lineHeight: 1,
|
||||
flex: '0 0 auto',
|
||||
}}
|
||||
>
|
||||
{count > 99 ? '99+' : count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SavedContactIcon({ contact }) {
|
||||
const profile = contact.contactProfile;
|
||||
const hasSavedContact = Boolean(profile?.created_at || profile?.name || profile?.company || profile?.note);
|
||||
if (!hasSavedContact) return null;
|
||||
|
||||
return (
|
||||
<span
|
||||
title="Contato salvo na agenda"
|
||||
aria-label="Contato salvo na agenda"
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: '50%',
|
||||
border: '1px solid rgba(183, 121, 31, 0.28)',
|
||||
background: 'rgba(183, 121, 31, 0.1)',
|
||||
backgroundImage:
|
||||
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23b7791f' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M16 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2'/%3E%3Ccircle cx='10' cy='7' r='4'/%3E%3Cpath d='m17 11 2 2 4-4'/%3E%3C/svg%3E\")",
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundSize: 14,
|
||||
color: '#b7791f',
|
||||
flex: '0 0 auto',
|
||||
display: 'inline-grid',
|
||||
placeItems: 'center',
|
||||
fontSize: 0,
|
||||
}}
|
||||
>
|
||||
•Salvo•
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const CHAT_LIST_HEIGHT = 'min(760px, calc(100vh - 160px))';
|
||||
|
||||
export function ChatConversationList({
|
||||
contacts,
|
||||
activeContactId,
|
||||
onSelectContact,
|
||||
onOpenContact,
|
||||
currentUserId,
|
||||
isMobile = false,
|
||||
}) {
|
||||
return (
|
||||
@ -37,13 +160,18 @@ export function ChatConversationList({
|
||||
borderRadius: '28px',
|
||||
padding: '1rem',
|
||||
display: 'grid',
|
||||
gridTemplateRows: 'auto minmax(0, 1fr)',
|
||||
gap: '0.85rem',
|
||||
height: isMobile ? 'auto' : CHAT_LIST_HEIGHT,
|
||||
maxHeight: isMobile ? 'none' : CHAT_LIST_HEIGHT,
|
||||
alignSelf: 'start',
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong style={{ display: 'block', fontSize: '1.08rem' }}>Conversas ativas</strong>
|
||||
<span style={{ color: 'var(--color-text-soft)' }}>
|
||||
WhatsApp, SMS e email em uma fila visual.
|
||||
WhatsApp, SMS e e-mail em uma fila visual.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@ -52,6 +180,11 @@ export function ChatConversationList({
|
||||
display: 'grid',
|
||||
gap: '0.75rem',
|
||||
gridTemplateColumns: isMobile ? '1fr' : '1fr',
|
||||
gridAutoRows: 'max-content',
|
||||
alignContent: 'start',
|
||||
overflowY: 'auto',
|
||||
minHeight: 0,
|
||||
paddingRight: '0.15rem',
|
||||
}}
|
||||
>
|
||||
{contacts.map((contact) => {
|
||||
@ -62,6 +195,11 @@ export function ChatConversationList({
|
||||
key={contact.id}
|
||||
type="button"
|
||||
onClick={() => onSelectContact(contact.id)}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault();
|
||||
onSelectContact(contact.id);
|
||||
onOpenContact?.(contact);
|
||||
}}
|
||||
style={{
|
||||
border: '1px solid',
|
||||
borderColor: isActive ? 'rgba(0, 164, 183, 0.26)' : 'var(--color-border)',
|
||||
@ -74,34 +212,44 @@ export function ChatConversationList({
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
|
||||
<strong>{contact.name}</strong>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.5rem', minWidth: 0 }}>
|
||||
<AssignmentDot contact={contact} currentUserId={currentUserId} />
|
||||
<strong style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{contact.name}
|
||||
</strong>
|
||||
</span>
|
||||
<span style={{ fontSize: '0.82rem', color: 'var(--color-text-soft)' }}>
|
||||
{contact.time}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.75rem' }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.45rem', minWidth: 0 }}>
|
||||
<SavedContactIcon contact={contact} />
|
||||
<ChannelBadge channel={contact.channel} />
|
||||
{contact.unread ? (
|
||||
<span
|
||||
style={{
|
||||
minWidth: 24,
|
||||
borderRadius: 999,
|
||||
padding: '0.15rem 0.45rem',
|
||||
background: 'var(--color-secondary)',
|
||||
color: '#fff',
|
||||
fontSize: '0.78rem',
|
||||
fontWeight: 700,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{contact.unread}
|
||||
<SpecialtyBadge contact={contact} />
|
||||
</span>
|
||||
) : null}
|
||||
<UnreadBadge count={contact.unread} />
|
||||
</div>
|
||||
<span style={{ color: 'var(--color-text-soft)' }}>{contact.preview}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{contacts.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '18px',
|
||||
padding: '1rem',
|
||||
background: 'rgba(0, 49, 80, 0.04)',
|
||||
color: 'var(--color-text-soft)',
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.45,
|
||||
}}
|
||||
>
|
||||
Nenhuma conversa ativa na sua fila. Conversas em triagem do Agente Virtual Sothis aparecem aqui depois de classificadas.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@ -4,6 +4,7 @@ export function ChatTransferPanel({
|
||||
setTransferArea,
|
||||
transferAreas,
|
||||
attendants,
|
||||
isSameUserArea = true,
|
||||
transferAttendant,
|
||||
setTransferAttendant,
|
||||
transferNote,
|
||||
@ -39,7 +40,7 @@ export function ChatTransferPanel({
|
||||
<div>
|
||||
<strong style={{ display: 'block', fontSize: '1.06rem' }}>Transferir atendimento</strong>
|
||||
<span style={{ color: 'var(--color-text-soft)' }}>
|
||||
Reencaminhe a conversa para a area ideal.
|
||||
Reencaminhe a conversa para a especialidade ideal.
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
@ -57,7 +58,7 @@ export function ChatTransferPanel({
|
||||
</div>
|
||||
|
||||
<label style={{ display: 'grid', gap: '0.45rem' }}>
|
||||
<span style={{ fontWeight: 600 }}>Area</span>
|
||||
<span style={{ fontWeight: 600 }}>Especialidade</span>
|
||||
<select value={transferArea} onChange={(event) => setTransferArea(event.target.value)} style={fieldStyle}>
|
||||
{transferAreas.map((area) => (
|
||||
<option key={area} value={area}>
|
||||
@ -69,26 +70,39 @@ export function ChatTransferPanel({
|
||||
|
||||
<label style={{ display: 'grid', gap: '0.45rem' }}>
|
||||
<span style={{ fontWeight: 600 }}>Atendente</span>
|
||||
{isSameUserArea ? (
|
||||
<select
|
||||
value={transferAttendant}
|
||||
onChange={(event) => setTransferAttendant(event.target.value)}
|
||||
style={fieldStyle}
|
||||
>
|
||||
{attendants.map((attendant) => (
|
||||
<option key={attendant} value={attendant}>
|
||||
{attendant}
|
||||
<option key={attendant.id} value={attendant.id}>
|
||||
{attendant.nome}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
...fieldStyle,
|
||||
color: 'var(--color-text-soft)',
|
||||
fontWeight: 700,
|
||||
background: 'rgba(0, 49, 80, 0.04)',
|
||||
}}
|
||||
>
|
||||
Ao transferir para outra especialidade, a conversa cairá na fila dessa especialidade.
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'grid', gap: '0.45rem' }}>
|
||||
<span style={{ fontWeight: 600 }}>Observacao</span>
|
||||
<span style={{ fontWeight: 600 }}>Observação</span>
|
||||
<textarea
|
||||
rows={5}
|
||||
value={transferNote}
|
||||
onChange={(event) => setTransferNote(event.target.value)}
|
||||
placeholder="Contexto opcional para ajudar o proximo atendente."
|
||||
placeholder="Contexto opcional para ajudar o próximo atendente."
|
||||
style={{ ...fieldStyle, resize: 'vertical' }}
|
||||
/>
|
||||
</label>
|
||||
@ -105,7 +119,7 @@ export function ChatTransferPanel({
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Confirmar transferencia
|
||||
Confirmar transferência
|
||||
</button>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@ -1,4 +1,302 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Fragment, useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
function getMediaUrl(media) {
|
||||
if (!media?.data || !media?.mimetype) return '';
|
||||
return `data:${media.mimetype};base64,${media.data}`;
|
||||
}
|
||||
|
||||
function parseMessageText(text) {
|
||||
const rawText = String(text || '');
|
||||
const match = rawText.match(/^\*(Atendente(?: virtual)?:\s*[^*]+)\*\s*\n+/i);
|
||||
|
||||
if (!match) {
|
||||
return {
|
||||
senderLabel: null,
|
||||
body: rawText,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
senderLabel: match[1],
|
||||
body: rawText.slice(match[0].length),
|
||||
};
|
||||
}
|
||||
|
||||
function formatMessageTime(timestamp) {
|
||||
if (!timestamp) return '';
|
||||
const numericTimestamp = Number(timestamp);
|
||||
const date = new Date(numericTimestamp > 1000000000000 ? numericTimestamp : numericTimestamp * 1000);
|
||||
return date.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function getMessageDate(timestamp) {
|
||||
if (!timestamp) return null;
|
||||
const numericTimestamp = Number(timestamp);
|
||||
const date = new Date(numericTimestamp > 1000000000000 ? numericTimestamp : numericTimestamp * 1000);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
return date;
|
||||
}
|
||||
|
||||
function getDateKey(timestamp) {
|
||||
const date = getMessageDate(timestamp);
|
||||
if (!date) return '';
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function formatDateSeparator(timestamp) {
|
||||
const date = getMessageDate(timestamp);
|
||||
if (!date) return '';
|
||||
|
||||
const today = new Date();
|
||||
const isToday =
|
||||
date.getFullYear() === today.getFullYear() &&
|
||||
date.getMonth() === today.getMonth() &&
|
||||
date.getDate() === today.getDate();
|
||||
|
||||
if (isToday) return 'Hoje';
|
||||
|
||||
return date.toLocaleDateString('pt-BR');
|
||||
}
|
||||
|
||||
function DateSeparator({ label }) {
|
||||
if (!label) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr auto 1fr',
|
||||
gap: '0.75rem',
|
||||
alignItems: 'center',
|
||||
color: 'var(--color-text-soft)',
|
||||
fontSize: '0.78rem',
|
||||
fontWeight: 800,
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
<span style={{ height: 1, background: 'var(--color-border)' }} />
|
||||
<span
|
||||
style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 999,
|
||||
padding: '0.28rem 0.7rem',
|
||||
background: 'rgba(255,255,255,0.88)',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span style={{ height: 1, background: 'var(--color-border)' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 mídia...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (message.mediaError) {
|
||||
return (
|
||||
<span style={{ color: isAgent ? '#fff' : 'var(--color-text-soft)', fontWeight: 700 }}>
|
||||
Não foi possível carregar a mídia.
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
function ContactActivity({ contact }) {
|
||||
if (!contact) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const status = contact.status || 'offline';
|
||||
const color = status === 'away' ? '#e5a22a' : '#dc2626';
|
||||
const label = contact.lastSeen || 'Sem atividade recente';
|
||||
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
color: 'var(--color-text-soft)',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 999,
|
||||
background: color,
|
||||
boxShadow: `0 0 0 3px ${color}22`,
|
||||
}}
|
||||
/>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatWindow({
|
||||
contact,
|
||||
@ -7,12 +305,31 @@ export function ChatWindow({
|
||||
setSelectedArea,
|
||||
draft,
|
||||
setDraft,
|
||||
attachedFile,
|
||||
onAttachFile,
|
||||
onRemoveAttachedFile,
|
||||
onLoadMedia,
|
||||
onSend,
|
||||
onToggleTransfer,
|
||||
onAssumeChat,
|
||||
onReleaseChat,
|
||||
onCloseChat,
|
||||
canAssumeChat = false,
|
||||
canReply = true,
|
||||
assignmentLabel,
|
||||
transferNote,
|
||||
isReplying,
|
||||
isPaused = false,
|
||||
pauseDurationLabel = '00:00',
|
||||
isMobile = false,
|
||||
}) {
|
||||
const messagesRef = useRef(null);
|
||||
const safeContact = contact || {
|
||||
id: '',
|
||||
name: isPaused ? 'Atendimento pausado' : 'Nenhuma conversa ativa',
|
||||
status: 'offline',
|
||||
lastSeen: isPaused ? `Pausa em andamento: ${pauseDurationLabel}` : 'Aguardando fila do Agente Virtual Sothis',
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const container = messagesRef.current;
|
||||
@ -22,10 +339,66 @@ export function ChatWindow({
|
||||
|
||||
container.scrollTo({
|
||||
top: container.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
behavior: 'auto',
|
||||
});
|
||||
}, [messages, isReplying]);
|
||||
|
||||
if (isPaused) {
|
||||
return (
|
||||
<section
|
||||
style={{
|
||||
background: '#fff',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '28px',
|
||||
overflow: 'hidden',
|
||||
display: 'grid',
|
||||
gridTemplateRows: 'auto minmax(0, 1fr)',
|
||||
height: isMobile ? 'auto' : 'min(760px, calc(100vh - 190px))',
|
||||
minHeight: isMobile ? 420 : 0,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<header
|
||||
style={{
|
||||
padding: '1.25rem 1.5rem',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
}}
|
||||
>
|
||||
<strong style={{ display: 'block', fontSize: '1.15rem' }}>Atendimento pausado</strong>
|
||||
<span style={{ color: 'var(--color-text-soft)' }}>Pausa em andamento: {pauseDurationLabel}</span>
|
||||
</header>
|
||||
|
||||
<div
|
||||
style={{
|
||||
padding: '1.5rem',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
minHeight: 0,
|
||||
background:
|
||||
'radial-gradient(circle at top left, rgba(0, 164, 183, 0.06), transparent 22%), linear-gradient(180deg, rgba(245, 248, 251, 0.8), rgba(255, 255, 255, 0.95))',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: 460,
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 20,
|
||||
padding: '1.2rem',
|
||||
background: '#fff',
|
||||
color: 'var(--color-text-soft)',
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.5,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
Voce esta em pausa ha {pauseDurationLabel}. Retome o atendimento pela Home para visualizar a fila,
|
||||
assumir chamados e responder clientes.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
style={{
|
||||
@ -34,8 +407,10 @@ export function ChatWindow({
|
||||
borderRadius: '28px',
|
||||
overflow: 'hidden',
|
||||
display: 'grid',
|
||||
gridTemplateRows: 'auto 1fr auto',
|
||||
minHeight: 680,
|
||||
gridTemplateRows: 'auto minmax(0, 1fr) auto',
|
||||
height: isMobile ? 'auto' : 'min(760px, calc(100vh - 190px))',
|
||||
minHeight: isMobile ? 640 : 0,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<header
|
||||
@ -49,10 +424,8 @@ export function ChatWindow({
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong style={{ display: 'block', fontSize: '1.15rem' }}>{contact.name}</strong>
|
||||
<span style={{ color: 'var(--color-text-soft)' }}>
|
||||
{contact.status === 'online' ? 'Online' : 'Offline'} • {contact.lastSeen}
|
||||
</span>
|
||||
<strong style={{ display: 'block', fontSize: '1.15rem' }}>{safeContact.name}</strong>
|
||||
<ContactActivity contact={safeContact} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
@ -66,6 +439,7 @@ export function ChatWindow({
|
||||
<select
|
||||
value={selectedArea}
|
||||
onChange={(event) => setSelectedArea(event.target.value)}
|
||||
disabled
|
||||
style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '14px',
|
||||
@ -74,13 +448,63 @@ export function ChatWindow({
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<option>{selectedArea}</option>
|
||||
<option>Suporte</option>
|
||||
<option>Financeiro</option>
|
||||
<option>Comercial</option>
|
||||
</select>
|
||||
{canAssumeChat ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAssumeChat?.()}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '14px',
|
||||
padding: '0.8rem 1rem',
|
||||
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
|
||||
color: '#fff',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Assumir atendimento
|
||||
</button>
|
||||
) : null}
|
||||
{canReply ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReleaseChat}
|
||||
style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '14px',
|
||||
padding: '0.8rem 1rem',
|
||||
background: '#fff',
|
||||
color: 'var(--color-primary)',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Sair do atendimento
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCloseChat}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '14px',
|
||||
padding: '0.8rem 1rem',
|
||||
background: 'rgba(181, 31, 31, 0.1)',
|
||||
color: 'var(--color-secondary)',
|
||||
fontWeight: 800,
|
||||
}}
|
||||
>
|
||||
Encerrar atendimento
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleTransfer}
|
||||
disabled={!canReply}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '14px',
|
||||
@ -88,11 +512,30 @@ export function ChatWindow({
|
||||
background: 'rgba(0, 49, 80, 0.08)',
|
||||
color: 'var(--color-primary)',
|
||||
fontWeight: 700,
|
||||
opacity: canReply ? 1 : 0.55,
|
||||
}}
|
||||
>
|
||||
Transferir
|
||||
</button>
|
||||
</div>
|
||||
{transferNote ? (
|
||||
<div
|
||||
style={{
|
||||
gridColumn: '1 / -1',
|
||||
border: '1px solid rgba(0, 164, 183, 0.24)',
|
||||
borderRadius: 16,
|
||||
padding: '0.85rem 1rem',
|
||||
background: 'rgba(0, 164, 183, 0.07)',
|
||||
color: 'var(--color-text)',
|
||||
lineHeight: 1.45,
|
||||
}}
|
||||
>
|
||||
<strong style={{ display: 'block', color: 'var(--color-primary)', marginBottom: '0.25rem' }}>
|
||||
Observação da transferência
|
||||
</strong>
|
||||
{transferNote}
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<div
|
||||
@ -103,18 +546,26 @@ export function ChatWindow({
|
||||
gap: '0.9rem',
|
||||
alignContent: 'start',
|
||||
overflowY: 'auto',
|
||||
minHeight: 0,
|
||||
background:
|
||||
'radial-gradient(circle at top left, rgba(0, 164, 183, 0.06), transparent 22%), linear-gradient(180deg, rgba(245, 248, 251, 0.8), rgba(255, 255, 255, 0.95))',
|
||||
}}
|
||||
>
|
||||
{messages.map((message) => {
|
||||
{messages.map((message, index) => {
|
||||
const isAgent = message.sender === 'agent';
|
||||
const isSystem = message.sender === 'system';
|
||||
const parsedText = parseMessageText(message.text);
|
||||
const messageTime = formatMessageTime(message.timestamp);
|
||||
const dateKey = getDateKey(message.timestamp);
|
||||
const previousDateKey = index > 0 ? getDateKey(messages[index - 1]?.timestamp) : '';
|
||||
const shouldShowDateSeparator = dateKey && dateKey !== previousDateKey;
|
||||
const dateSeparator = formatDateSeparator(message.timestamp);
|
||||
|
||||
if (isSystem) {
|
||||
return (
|
||||
<Fragment key={message.id}>
|
||||
{shouldShowDateSeparator ? <DateSeparator label={dateSeparator} /> : null}
|
||||
<div
|
||||
key={message.id}
|
||||
style={{
|
||||
justifySelf: 'center',
|
||||
padding: '0.7rem 1rem',
|
||||
@ -127,12 +578,14 @@ export function ChatWindow({
|
||||
>
|
||||
{message.text}
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment key={message.id}>
|
||||
{shouldShowDateSeparator ? <DateSeparator label={dateSeparator} /> : null}
|
||||
<div
|
||||
key={message.id}
|
||||
style={{
|
||||
justifySelf: isAgent ? 'end' : 'start',
|
||||
maxWidth: isMobile ? '88%' : '72%',
|
||||
@ -141,13 +594,76 @@ export function ChatWindow({
|
||||
background: isAgent ? 'var(--color-primary)' : '#edf1f5',
|
||||
color: isAgent ? '#fff' : 'var(--color-text)',
|
||||
boxShadow: 'var(--shadow-md)',
|
||||
display: 'grid',
|
||||
gap: '0.65rem',
|
||||
}}
|
||||
>
|
||||
{message.text}
|
||||
<MediaRenderer
|
||||
message={message}
|
||||
contactId={safeContact.id}
|
||||
onLoadMedia={onLoadMedia}
|
||||
isAgent={isAgent}
|
||||
/>
|
||||
{parsedText.senderLabel ? (
|
||||
<strong
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '0.78rem',
|
||||
lineHeight: 1.2,
|
||||
letterSpacing: '0.02em',
|
||||
textTransform: 'uppercase',
|
||||
color: isAgent ? 'rgba(255,255,255,0.78)' : 'var(--color-primary)',
|
||||
}}
|
||||
>
|
||||
{parsedText.senderLabel}
|
||||
</strong>
|
||||
) : null}
|
||||
{parsedText.body ? (
|
||||
<span
|
||||
style={{
|
||||
display: 'block',
|
||||
whiteSpace: 'pre-wrap',
|
||||
lineHeight: 1.45,
|
||||
overflowWrap: 'anywhere',
|
||||
}}
|
||||
>
|
||||
{parsedText.body}
|
||||
</span>
|
||||
) : null}
|
||||
{messageTime ? (
|
||||
<span
|
||||
style={{
|
||||
justifySelf: 'end',
|
||||
fontSize: '0.72rem',
|
||||
lineHeight: 1,
|
||||
color: isAgent ? 'rgba(255,255,255,0.7)' : 'var(--color-text-soft)',
|
||||
}}
|
||||
>
|
||||
{messageTime}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
{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,
|
||||
}}
|
||||
>
|
||||
{isPaused
|
||||
? `Voce esta em pausa ha ${pauseDurationLabel}. Volte da pausa para visualizar a fila e seus atendimentos.`
|
||||
: 'Nenhuma mensagem carregada.'}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isReplying ? (
|
||||
<div
|
||||
style={{
|
||||
@ -169,31 +685,107 @@ export function ChatWindow({
|
||||
padding: '1rem 1.25rem 1.25rem',
|
||||
borderTop: '1px solid var(--color-border)',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: isMobile ? '1fr' : '1fr auto',
|
||||
gridTemplateColumns: '1fr',
|
||||
gap: '0.75rem',
|
||||
}}
|
||||
>
|
||||
<AttachmentPreview file={attachedFile} onRemove={onRemoveAttachedFile} />
|
||||
{!canReply ? (
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 16,
|
||||
padding: '0.8rem 1rem',
|
||||
background: 'rgba(0, 49, 80, 0.04)',
|
||||
color: 'var(--color-text-soft)',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
<span style={{ display: 'block' }}>
|
||||
{isPaused
|
||||
? `Voce esta em pausa ha ${pauseDurationLabel}. Nenhum atendimento sera exibido ate voce voltar.`
|
||||
: canAssumeChat
|
||||
? 'Este atendimento está na fila. Assuma para responder ou transferir.'
|
||||
: assignmentLabel || 'Este atendimento está atribuído a outro usuário.'}
|
||||
</span>
|
||||
{transferNote ? (
|
||||
<span style={{ display: 'block', marginTop: '0.45rem', color: 'var(--color-text)' }}>
|
||||
Obs: {transferNote}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<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' }}
|
||||
disabled={!safeContact.id}
|
||||
/>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
onSend();
|
||||
onSend?.(draft);
|
||||
}
|
||||
}}
|
||||
placeholder="Escreva sua mensagem..."
|
||||
disabled={!safeContact.id || !canReply}
|
||||
placeholder={
|
||||
isPaused
|
||||
? 'Voce esta em pausa'
|
||||
: !safeContact.id
|
||||
? 'Aguardando conversa entrar em uma fila'
|
||||
: canReply
|
||||
? 'Escreva sua mensagem...'
|
||||
: assignmentLabel?.includes('Aguardando resposta')
|
||||
? 'Aguardando resposta do cliente'
|
||||
: canAssumeChat
|
||||
? 'Assuma o atendimento para responder'
|
||||
: 'Atendimento bloqueado para resposta'
|
||||
}
|
||||
style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '18px',
|
||||
padding: '0.95rem 1rem',
|
||||
background: '#fff',
|
||||
outline: 'none',
|
||||
minWidth: 0,
|
||||
opacity: safeContact.id && canReply ? 1 : 0.6,
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSend}
|
||||
onClick={() => onSend?.(draft)}
|
||||
disabled={!safeContact.id || !canReply}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '18px',
|
||||
@ -201,10 +793,13 @@ export function ChatWindow({
|
||||
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
|
||||
color: '#fff',
|
||||
fontWeight: 700,
|
||||
gridColumn: isMobile ? '1 / -1' : 'auto',
|
||||
opacity: safeContact.id && canReply ? 1 : 0.6,
|
||||
}}
|
||||
>
|
||||
Enviar
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
);
|
||||
|
||||
197
src/modules/chat/components/ContactProfilePanel.jsx
Normal file
197
src/modules/chat/components/ContactProfilePanel.jsx
Normal file
@ -0,0 +1,197 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getCurrentUser } from '../../auth/services/sessionService';
|
||||
import { getContactProfile, saveContactProfile } from '../services/contactProfileService';
|
||||
|
||||
function getUserId(user) {
|
||||
const value = user?.databaseId || user?.id;
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? numeric : null;
|
||||
}
|
||||
|
||||
function formatPhone(phone) {
|
||||
const digits = String(phone || '').replace(/\D/g, '');
|
||||
if (!digits) return 'Telefone não disponível';
|
||||
|
||||
if (digits.startsWith('55') && digits.length === 13) {
|
||||
return `+55 (${digits.slice(2, 4)}) ${digits.slice(4, 9)}-${digits.slice(9)}`;
|
||||
}
|
||||
|
||||
if (digits.startsWith('55') && digits.length === 12) {
|
||||
return `+55 (${digits.slice(2, 4)}) ${digits.slice(4, 8)}-${digits.slice(8)}`;
|
||||
}
|
||||
|
||||
if (digits.length === 11) {
|
||||
return `(${digits.slice(0, 2)}) ${digits.slice(2, 7)}-${digits.slice(7)}`;
|
||||
}
|
||||
|
||||
if (digits.length === 10) {
|
||||
return `(${digits.slice(0, 2)}) ${digits.slice(2, 6)}-${digits.slice(6)}`;
|
||||
}
|
||||
|
||||
return phone;
|
||||
}
|
||||
|
||||
export function ContactProfilePanel({ isOpen, contact, onClose, onSaved }) {
|
||||
const [profile, setProfile] = useState(null);
|
||||
const [form, setForm] = useState({ name: '', company: '', note: '' });
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
async function loadProfile() {
|
||||
if (!isOpen || !contact?.id) return;
|
||||
try {
|
||||
const data = await getContactProfile(contact.id);
|
||||
if (!isMounted) return;
|
||||
setProfile(data);
|
||||
setForm({
|
||||
name: data.name || contact.name || '',
|
||||
company: data.company || '',
|
||||
note: data.note || '',
|
||||
});
|
||||
setError('');
|
||||
} catch (err) {
|
||||
if (isMounted) setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
loadProfile();
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [isOpen, contact?.id]);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fieldStyle = {
|
||||
width: '100%',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '16px',
|
||||
padding: '0.9rem 1rem',
|
||||
background: '#fff',
|
||||
outline: 'none',
|
||||
};
|
||||
|
||||
async function submit() {
|
||||
if (!contact?.id) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const userId = getUserId(getCurrentUser());
|
||||
const saved = await saveContactProfile(contact.id, {
|
||||
phone: profile?.phone || contact?.contactProfile?.phone || '',
|
||||
name: form.name,
|
||||
company: form.company,
|
||||
note: form.note,
|
||||
userId,
|
||||
});
|
||||
setProfile(saved);
|
||||
onSaved?.(contact.id, saved);
|
||||
setError('');
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
style={{
|
||||
background: '#fff',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '28px',
|
||||
padding: '1.25rem',
|
||||
display: 'grid',
|
||||
gap: '1rem',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
|
||||
<div>
|
||||
<strong style={{ display: 'block', fontSize: '1.06rem' }}>Contato do cliente</strong>
|
||||
<span style={{ color: 'var(--color-text-soft)' }}>
|
||||
Atualize os dados de agenda deste atendimento.
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: 'var(--color-text-soft)',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Fechar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label style={{ display: 'grid', gap: '0.45rem' }}>
|
||||
<span style={{ fontWeight: 600 }}>Nome</span>
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))}
|
||||
style={fieldStyle}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'grid', gap: '0.45rem' }}>
|
||||
<span style={{ fontWeight: 600 }}>Etiqueta de identificação</span>
|
||||
<input
|
||||
value={form.company}
|
||||
onChange={(event) => setForm((current) => ({ ...current, company: event.target.value }))}
|
||||
placeholder="Ex: Departamento, vaga ou conta vinculada"
|
||||
style={fieldStyle}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'grid', gap: '0.45rem' }}>
|
||||
<span style={{ fontWeight: 600 }}>Telefone</span>
|
||||
<input
|
||||
value={formatPhone(profile?.phone || contact?.contactProfile?.phone)}
|
||||
disabled
|
||||
style={{
|
||||
...fieldStyle,
|
||||
background: 'rgba(0, 49, 80, 0.04)',
|
||||
color: 'var(--color-text-soft)',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'grid', gap: '0.45rem' }}>
|
||||
<span style={{ fontWeight: 600 }}>Observação</span>
|
||||
<textarea
|
||||
rows={5}
|
||||
value={form.note}
|
||||
onChange={(event) => setForm((current) => ({ ...current, note: event.target.value }))}
|
||||
placeholder="Informações relevantes do cliente."
|
||||
style={{ ...fieldStyle, resize: 'vertical' }}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error ? <span style={{ color: '#b42318', fontWeight: 700 }}>{error}</span> : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={isSaving}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '16px',
|
||||
padding: '0.95rem 1rem',
|
||||
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
|
||||
color: '#fff',
|
||||
fontWeight: 700,
|
||||
opacity: isSaving ? 0.65 : 1,
|
||||
}}
|
||||
>
|
||||
{isSaving ? 'Salvando...' : 'Salvar contato'}
|
||||
</button>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,15 +1,19 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { BrandMark } from '../../../shared/components/BrandMark';
|
||||
import { useViewport } from '../../../shared/hooks/useViewport';
|
||||
import { ChatConversationList } from '../components/ChatConversationList';
|
||||
import { ChatTransferPanel } from '../components/ChatTransferPanel';
|
||||
import { ContactProfilePanel } from '../components/ContactProfilePanel';
|
||||
import { ChatWindow } from '../components/ChatWindow';
|
||||
import { useChat } from '../hooks/useChat';
|
||||
import { quickReplies } from '../services/chatMocks';
|
||||
|
||||
export function ChatPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { isWideDesktop, isDesktop, isTablet, isMobile } = useViewport();
|
||||
const {
|
||||
currentUserId,
|
||||
contacts,
|
||||
activeContact,
|
||||
activeContactId,
|
||||
@ -17,7 +21,19 @@ export function ChatPage() {
|
||||
messages,
|
||||
draft,
|
||||
setDraft,
|
||||
attachedFile,
|
||||
attachFile,
|
||||
removeAttachedFile,
|
||||
sendMessage,
|
||||
hydrateMessageMedia,
|
||||
assumeChat,
|
||||
releaseChat,
|
||||
closeChat,
|
||||
canAssumeChat,
|
||||
canReply,
|
||||
assignmentLabel,
|
||||
transferNoteLabel,
|
||||
updateContactProfile,
|
||||
isReplying,
|
||||
selectedArea,
|
||||
setSelectedArea,
|
||||
@ -27,12 +43,34 @@ export function ChatPage() {
|
||||
setTransferArea,
|
||||
transferAreas,
|
||||
attendants,
|
||||
isSameUserArea,
|
||||
transferAttendant,
|
||||
setTransferAttendant,
|
||||
transferNote,
|
||||
setTransferNote,
|
||||
submitTransfer,
|
||||
isPaused,
|
||||
pauseDurationLabel,
|
||||
} = useChat();
|
||||
const requestedChatId = searchParams.get('chatId');
|
||||
const handledRequestedChatIdRef = useRef('');
|
||||
const [isContactPanelOpen, setIsContactPanelOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!requestedChatId) return;
|
||||
if (handledRequestedChatIdRef.current === requestedChatId) return;
|
||||
if (!contacts.some((contact) => contact.id === requestedChatId)) return;
|
||||
handledRequestedChatIdRef.current = requestedChatId;
|
||||
setActiveContactId(requestedChatId);
|
||||
setSearchParams({}, { replace: true });
|
||||
}, [requestedChatId, contacts, setActiveContactId, setSearchParams]);
|
||||
|
||||
function selectContact(contactId) {
|
||||
setActiveContactId(contactId);
|
||||
if (requestedChatId) {
|
||||
setSearchParams({}, { replace: true });
|
||||
}
|
||||
}
|
||||
|
||||
const gridTemplateColumns = isMobile
|
||||
? '1fr'
|
||||
@ -76,7 +114,7 @@ export function ChatPage() {
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
Atendimento em tempo real
|
||||
{isPaused ? `Pausado ha ${pauseDurationLabel}` : 'Atendimento em tempo real'}
|
||||
</div>
|
||||
<Link
|
||||
to="/home"
|
||||
@ -99,17 +137,22 @@ export function ChatPage() {
|
||||
display: 'grid',
|
||||
gridTemplateColumns,
|
||||
gap: '1rem',
|
||||
alignItems: 'start',
|
||||
alignItems: 'stretch',
|
||||
}}
|
||||
>
|
||||
<ChatConversationList
|
||||
contacts={contacts}
|
||||
activeContactId={activeContactId}
|
||||
onSelectContact={setActiveContactId}
|
||||
onSelectContact={selectContact}
|
||||
onOpenContact={() => {
|
||||
setIsTransferOpen(false);
|
||||
setIsContactPanelOpen(true);
|
||||
}}
|
||||
currentUserId={currentUserId}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'grid', gap: '1rem', minWidth: 0 }}>
|
||||
<div style={{ display: 'grid', gap: '1rem', minWidth: 0, alignContent: 'start' }}>
|
||||
<ChatWindow
|
||||
contact={activeContact}
|
||||
messages={messages}
|
||||
@ -117,9 +160,25 @@ export function ChatPage() {
|
||||
setSelectedArea={setSelectedArea}
|
||||
draft={draft}
|
||||
setDraft={setDraft}
|
||||
attachedFile={attachedFile}
|
||||
onAttachFile={attachFile}
|
||||
onRemoveAttachedFile={removeAttachedFile}
|
||||
onLoadMedia={hydrateMessageMedia}
|
||||
onSend={sendMessage}
|
||||
onToggleTransfer={() => setIsTransferOpen((current) => !current)}
|
||||
onToggleTransfer={() => {
|
||||
setIsContactPanelOpen(false);
|
||||
setIsTransferOpen((current) => !current);
|
||||
}}
|
||||
onAssumeChat={assumeChat}
|
||||
onReleaseChat={releaseChat}
|
||||
onCloseChat={closeChat}
|
||||
canAssumeChat={canAssumeChat}
|
||||
canReply={canReply}
|
||||
assignmentLabel={assignmentLabel}
|
||||
transferNote={transferNoteLabel}
|
||||
isReplying={isReplying}
|
||||
isPaused={isPaused}
|
||||
pauseDurationLabel={pauseDurationLabel}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
|
||||
@ -135,6 +194,7 @@ export function ChatPage() {
|
||||
key={reply}
|
||||
type="button"
|
||||
onClick={() => setDraft(reply)}
|
||||
disabled={isPaused}
|
||||
style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '18px',
|
||||
@ -143,6 +203,7 @@ export function ChatPage() {
|
||||
color: 'var(--color-primary)',
|
||||
fontWeight: 600,
|
||||
textAlign: 'left',
|
||||
opacity: isPaused ? 0.55 : 1,
|
||||
}}
|
||||
>
|
||||
{reply}
|
||||
@ -152,12 +213,14 @@ export function ChatPage() {
|
||||
</div>
|
||||
|
||||
{isWideDesktop ? (
|
||||
<>
|
||||
<ChatTransferPanel
|
||||
isOpen={isTransferOpen}
|
||||
transferArea={transferArea}
|
||||
setTransferArea={setTransferArea}
|
||||
transferAreas={transferAreas}
|
||||
attendants={attendants}
|
||||
isSameUserArea={isSameUserArea}
|
||||
transferAttendant={transferAttendant}
|
||||
setTransferAttendant={setTransferAttendant}
|
||||
transferNote={transferNote}
|
||||
@ -165,16 +228,25 @@ export function ChatPage() {
|
||||
onSubmit={submitTransfer}
|
||||
onClose={() => setIsTransferOpen(false)}
|
||||
/>
|
||||
<ContactProfilePanel
|
||||
isOpen={isContactPanelOpen}
|
||||
contact={activeContact}
|
||||
onClose={() => setIsContactPanelOpen(false)}
|
||||
onSaved={updateContactProfile}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{!isWideDesktop ? (
|
||||
<>
|
||||
<ChatTransferPanel
|
||||
isOpen={isTransferOpen}
|
||||
transferArea={transferArea}
|
||||
setTransferArea={setTransferArea}
|
||||
transferAreas={transferAreas}
|
||||
attendants={attendants}
|
||||
isSameUserArea={isSameUserArea}
|
||||
transferAttendant={transferAttendant}
|
||||
setTransferAttendant={setTransferAttendant}
|
||||
transferNote={transferNote}
|
||||
@ -182,6 +254,13 @@ export function ChatPage() {
|
||||
onSubmit={submitTransfer}
|
||||
onClose={() => setIsTransferOpen(false)}
|
||||
/>
|
||||
<ContactProfilePanel
|
||||
isOpen={isContactPanelOpen}
|
||||
contact={activeContact}
|
||||
onClose={() => setIsContactPanelOpen(false)}
|
||||
onSaved={updateContactProfile}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
46
src/modules/chat/services/agentPresenceService.js
Normal file
46
src/modules/chat/services/agentPresenceService.js
Normal file
@ -0,0 +1,46 @@
|
||||
import { API_BASE_URL } from '../../../shared/services/apiConfig';
|
||||
|
||||
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 atualizar presenca do agente');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function getAgentPresence(userId) {
|
||||
return request(`/agent/presence/me?userId=${encodeURIComponent(userId)}`);
|
||||
}
|
||||
|
||||
export async function listAgentPresence() {
|
||||
return request('/agent/presence');
|
||||
}
|
||||
|
||||
export async function pauseAgent(userId) {
|
||||
return request('/agent/presence/pause', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ userId }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function resumeAgent(userId) {
|
||||
return request('/agent/presence/resume', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ userId }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function markAgentOffline(userId) {
|
||||
return request('/agent/presence/offline', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ userId }),
|
||||
});
|
||||
}
|
||||
@ -1,74 +1,7 @@
|
||||
export const chatContacts = [
|
||||
{
|
||||
id: 'maria-souza',
|
||||
name: 'Maria Souza',
|
||||
channel: 'WhatsApp',
|
||||
status: 'online',
|
||||
area: 'Suporte',
|
||||
lastSeen: 'Online agora',
|
||||
preview: 'Preciso atualizar o cadastro do meu pedido.',
|
||||
time: '09:42',
|
||||
unread: 2,
|
||||
messages: [
|
||||
{ id: 1, sender: 'customer', text: 'Oi, bom dia! Preciso de ajuda com meu pedido.' },
|
||||
{ id: 2, sender: 'agent', text: 'Bom dia, Maria! Claro, me conta o que aconteceu.' },
|
||||
{ id: 3, sender: 'customer', text: 'Quero confirmar se o endereco foi alterado.' },
|
||||
{ id: 4, sender: 'agent', text: 'Estou verificando aqui e te atualizo em instantes.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'joao-pedro',
|
||||
name: 'Joao Pedro',
|
||||
channel: 'SMS',
|
||||
status: 'offline',
|
||||
area: 'Financeiro',
|
||||
lastSeen: 'Visto ha 12 min',
|
||||
preview: 'Pode me ligar em 10 minutos?',
|
||||
time: '08:15',
|
||||
unread: 1,
|
||||
messages: [
|
||||
{ id: 1, sender: 'customer', text: 'Recebi a cobranca em duplicidade.' },
|
||||
{ id: 2, sender: 'agent', text: 'Vou analisar isso agora para voce.' },
|
||||
{ id: 3, sender: 'customer', text: 'Pode me ligar em 10 minutos?' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'empresa-alpha',
|
||||
name: 'Empresa Alpha',
|
||||
channel: 'Email',
|
||||
status: 'offline',
|
||||
area: 'Comercial',
|
||||
lastSeen: 'Visto ontem',
|
||||
preview: 'Aguardando retorno sobre a proposta comercial.',
|
||||
time: 'Ontem',
|
||||
unread: 0,
|
||||
messages: [
|
||||
{ id: 1, sender: 'customer', text: 'Precisamos rever os valores da ultima proposta.' },
|
||||
{ id: 2, sender: 'agent', text: 'Perfeito, vou encaminhar para o time comercial.' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const transferAreas = ['Suporte', 'Financeiro', 'Comercial'];
|
||||
|
||||
export const attendantsByArea = {
|
||||
Suporte: ['Ana Camolesi', 'Rafael Lopes', 'Romero Britto'],
|
||||
Financeiro: ['Roberto Pêra', 'Monica Limoeira', 'Edson Arantes'],
|
||||
Comercial: ['Natasha Homanoff', 'Helena Pêra', 'Pedro Parque'],
|
||||
};
|
||||
|
||||
export const quickReplies = [
|
||||
'Recebi sua mensagem e ja vou verificar.',
|
||||
'Consegue me confirmar o numero do protocolo?',
|
||||
'Posso seguir com essa atualizacao por aqui.',
|
||||
];
|
||||
|
||||
export function getMockReply(contactName) {
|
||||
const replies = [
|
||||
`Perfeito, obrigado pelo retorno, ${contactName.split(' ')[0]}.`,
|
||||
'Tudo bem, fico no aguardo dessa confirmacao.',
|
||||
'Entendi. Se precisar, posso encaminhar para a area responsavel.',
|
||||
];
|
||||
|
||||
return replies[Math.floor(Math.random() * replies.length)];
|
||||
}
|
||||
|
||||
23
src/modules/chat/services/contactProfileService.js
Normal file
23
src/modules/chat/services/contactProfileService.js
Normal file
@ -0,0 +1,23 @@
|
||||
import { API_BASE_URL } from '../../../shared/services/apiConfig';
|
||||
|
||||
export async function listContactProfiles() {
|
||||
const response = await fetch(`${API_BASE_URL}/contacts`);
|
||||
if (!response.ok) throw new Error('Falha ao carregar agenda.');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function getContactProfile(chatId) {
|
||||
const response = await fetch(`${API_BASE_URL}/contacts/${encodeURIComponent(chatId)}`);
|
||||
if (!response.ok) throw new Error('Falha ao carregar contato.');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function saveContactProfile(chatId, payload) {
|
||||
const response = await fetch(`${API_BASE_URL}/contacts/${encodeURIComponent(chatId)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok) throw new Error('Falha ao salvar contato.');
|
||||
return response.json();
|
||||
}
|
||||
135
src/modules/home/components/AttendantOpsPanel.jsx
Normal file
135
src/modules/home/components/AttendantOpsPanel.jsx
Normal file
@ -0,0 +1,135 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function AttendantOpsPanel({
|
||||
activeChatsCount,
|
||||
isPaused = false,
|
||||
pauseDurationLabel = '00:00',
|
||||
isPresenceLoading = false,
|
||||
onTogglePause,
|
||||
}) {
|
||||
const [secondsOnline, setSecondsOnline] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPaused) return undefined;
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
setSecondsOnline((current) => current + 1);
|
||||
}, 1000);
|
||||
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [isPaused]);
|
||||
|
||||
const formatTime = (totalSeconds) => {
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
return [hours, minutes, seconds]
|
||||
.map((value) => value.toString().padStart(2, '0'))
|
||||
.filter((value, index) => value !== '00' || index > 0)
|
||||
.join(':');
|
||||
};
|
||||
|
||||
const presenceLabel = isPaused ? 'Tempo em pausa' : 'Tempo online';
|
||||
const presenceTime = isPaused ? pauseDurationLabel : formatTime(secondsOnline);
|
||||
const statusColor = isPaused ? '#ef4444' : '#10b981';
|
||||
|
||||
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 }}>
|
||||
{presenceLabel}
|
||||
</span>
|
||||
<strong style={{ display: 'block', fontSize: '1.6rem', marginTop: '0.2rem', color: 'var(--color-text)' }}>
|
||||
{presenceTime}
|
||||
</strong>
|
||||
</div>
|
||||
<div
|
||||
title={isPaused ? 'Agente pausado' : 'Agente disponivel'}
|
||||
style={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: '50%',
|
||||
background: statusColor,
|
||||
boxShadow: `0 0 10px ${statusColor}`,
|
||||
}}
|
||||
/>
|
||||
</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)' }}>
|
||||
{isPaused ? 0 : 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
|
||||
type="button"
|
||||
onClick={onTogglePause}
|
||||
disabled={isPresenceLoading}
|
||||
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: 800,
|
||||
cursor: isPresenceLoading ? 'wait' : 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
opacity: isPresenceLoading ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{isPaused ? 'Retomar Atendimento' : 'Pausar'}
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -24,9 +24,9 @@ export function CallsWorkspace({ calls, isWideDesktop = false, isDesktop = false
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong style={{ display: 'block', fontSize: '1.1rem' }}>Ligacoes recentes</strong>
|
||||
<strong style={{ display: 'block', fontSize: '1.1rem' }}>Ligações recentes</strong>
|
||||
<span style={{ color: 'var(--color-text-soft)' }}>
|
||||
Visualizacao rapida do fluxo de voz do time.
|
||||
Visualização rápida do fluxo de voz do time.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@ -42,7 +42,7 @@ export function CallsWorkspace({ calls, isWideDesktop = false, isDesktop = false
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Nova ligacao
|
||||
Nova ligação
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { clearSession } from '../../auth/services/sessionService';
|
||||
|
||||
export function HomeSidebar({ items, activeItem, isMobile = false }) {
|
||||
const navigate = useNavigate();
|
||||
@ -17,7 +18,7 @@ export function HomeSidebar({ items, activeItem, isMobile = false }) {
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/new-attendance')}
|
||||
onClick={() => navigate('/home')}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '20px',
|
||||
@ -28,7 +29,7 @@ export function HomeSidebar({ items, activeItem, isMobile = false }) {
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
+ Novo Atendimento
|
||||
Home
|
||||
</button>
|
||||
|
||||
<nav
|
||||
@ -77,6 +78,27 @@ export function HomeSidebar({ items, activeItem, isMobile = false }) {
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
clearSession();
|
||||
navigate('/login');
|
||||
}}
|
||||
style={{
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: '18px',
|
||||
padding: '0.9rem 1rem',
|
||||
background: 'transparent',
|
||||
color: '#ef4444',
|
||||
fontWeight: 700,
|
||||
marginTop: 'auto',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
>
|
||||
Sair
|
||||
</button>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,3 +1,16 @@
|
||||
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({
|
||||
activeTab,
|
||||
onTabChange,
|
||||
@ -8,19 +21,29 @@ export function HomeTopbar({
|
||||
isTablet = false,
|
||||
isMobile = false,
|
||||
}) {
|
||||
const userDisplay = getCurrentUserDisplay();
|
||||
const [currentDateTime, setCurrentDateTime] = useState(() => formatCurrentDateTime(new Date()));
|
||||
const tabs = [
|
||||
{ id: 'messages', label: 'Mensagens' },
|
||||
{ id: 'calls', label: 'Ligacoes' },
|
||||
{ id: 'calls', label: 'Ligações' },
|
||||
];
|
||||
|
||||
const gridTemplateColumns = isMobile
|
||||
? '1fr'
|
||||
: isWideDesktop
|
||||
? 'max-content minmax(180px, 220px) minmax(280px, 1fr) max-content'
|
||||
? 'max-content minmax(150px, 190px) minmax(280px, 1fr) max-content'
|
||||
: isDesktop || isTablet
|
||||
? 'repeat(2, minmax(0, 1fr))'
|
||||
: '1fr';
|
||||
|
||||
useEffect(() => {
|
||||
const intervalId = window.setInterval(() => {
|
||||
setCurrentDateTime(formatCurrentDateTime(new Date()));
|
||||
}, 1000);
|
||||
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<header
|
||||
style={{
|
||||
@ -75,9 +98,13 @@ export function HomeTopbar({
|
||||
fontWeight: 600,
|
||||
width: isMobile ? '100%' : 'auto',
|
||||
minWidth: 0,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
Sexta, 19 de marco
|
||||
{currentDateTime}
|
||||
</div>
|
||||
|
||||
<input
|
||||
@ -108,9 +135,9 @@ export function HomeTopbar({
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: 'right', minWidth: 0 }}>
|
||||
<strong style={{ display: 'block' }}>Ana Camolesi</strong>
|
||||
<strong style={{ display: 'block' }}>{userDisplay.name}</strong>
|
||||
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.92rem' }}>
|
||||
Atendimento omnichannel
|
||||
{userDisplay.subtitle}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
@ -126,7 +153,7 @@ export function HomeTopbar({
|
||||
fontWeight: 800,
|
||||
}}
|
||||
>
|
||||
AM
|
||||
{userDisplay.initials}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@ -1,4 +1,9 @@
|
||||
import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { createAgentNote, deleteAgentNote, listAgentNotes } from '../services/agentNotesService';
|
||||
import { getCurrentUser } from '../../auth/services/sessionService';
|
||||
|
||||
const WORKSPACE_HEIGHT = 660;
|
||||
|
||||
function ChannelBadge({ channel }) {
|
||||
const colors = {
|
||||
@ -25,20 +30,295 @@ function ChannelBadge({ channel }) {
|
||||
);
|
||||
}
|
||||
|
||||
function UnreadBadge({ count }) {
|
||||
if (!count) return null;
|
||||
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: '50%',
|
||||
background: 'var(--color-secondary)',
|
||||
color: '#fff',
|
||||
fontSize: '0.78rem',
|
||||
fontWeight: 800,
|
||||
display: 'inline-grid',
|
||||
placeItems: 'center',
|
||||
lineHeight: 1,
|
||||
flex: '0 0 auto',
|
||||
}}
|
||||
>
|
||||
{count > 99 ? '99+' : count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function buildSuggestedReplies(conversation) {
|
||||
const lastMessage = conversation?.lastMessage || conversation?.messages?.at(-1)?.text || '';
|
||||
const firstName = conversation?.name?.split(' ')?.[0] || 'você';
|
||||
const lowerContext = lastMessage.toLowerCase();
|
||||
|
||||
if (
|
||||
lowerContext.includes('fatura') ||
|
||||
lowerContext.includes('cobranca') ||
|
||||
lowerContext.includes('pagamento')
|
||||
) {
|
||||
return [
|
||||
`${firstName}, vou conferir os dados financeiros e já te retorno com a posição correta.`,
|
||||
'Recebi sua mensagem sobre cobrança. Vou validar o histórico antes de seguir com a orientação.',
|
||||
'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 alteração já foi registrada.`,
|
||||
'Para seguir com a atualização, me confirme por favor os dados que precisam ser ajustados.',
|
||||
'Entendi. Vou verificar o cadastro atual e te retorno com o próximo passo.',
|
||||
];
|
||||
}
|
||||
|
||||
if (
|
||||
lowerContext.includes('ligar') ||
|
||||
lowerContext.includes('telefone') ||
|
||||
lowerContext.includes('retorno')
|
||||
) {
|
||||
return [
|
||||
`${firstName}, consigo organizar esse retorno. Qual o melhor horário para contato?`,
|
||||
'Vou registrar sua solicitação e direcionar o retorno para o time responsável.',
|
||||
'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 informações do atendimento e retorno com o melhor encaminhamento.',
|
||||
'Posso acionar o time responsável e te atualizar por aqui assim que tiver uma posição.',
|
||||
];
|
||||
}
|
||||
|
||||
function parseMessageText(text) {
|
||||
const rawText = String(text || '');
|
||||
const match = rawText.match(/^\*(Atendente(?: virtual)?:\s*[^*]+)\*\s*\n+/i);
|
||||
|
||||
if (!match) {
|
||||
return { senderLabel: null, body: rawText };
|
||||
}
|
||||
|
||||
return {
|
||||
senderLabel: match[1],
|
||||
body: rawText.slice(match[0].length),
|
||||
};
|
||||
}
|
||||
|
||||
function formatMessageTime(timestamp) {
|
||||
if (!timestamp) return '';
|
||||
const numericTimestamp = Number(timestamp);
|
||||
const date = new Date(numericTimestamp > 1000000000000 ? numericTimestamp : numericTimestamp * 1000);
|
||||
return date.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function getMessageDate(timestamp) {
|
||||
if (!timestamp) return null;
|
||||
const numericTimestamp = Number(timestamp);
|
||||
const date = new Date(numericTimestamp > 1000000000000 ? numericTimestamp : numericTimestamp * 1000);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
return date;
|
||||
}
|
||||
|
||||
function getDateKey(timestamp) {
|
||||
const date = getMessageDate(timestamp);
|
||||
if (!date) return '';
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function formatDateSeparator(timestamp) {
|
||||
const date = getMessageDate(timestamp);
|
||||
if (!date) return '';
|
||||
|
||||
const today = new Date();
|
||||
const isToday =
|
||||
date.getFullYear() === today.getFullYear() &&
|
||||
date.getMonth() === today.getMonth() &&
|
||||
date.getDate() === today.getDate();
|
||||
|
||||
if (isToday) return 'Hoje';
|
||||
|
||||
return date.toLocaleDateString('pt-BR');
|
||||
}
|
||||
|
||||
function DateSeparator({ label }) {
|
||||
if (!label) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr auto 1fr',
|
||||
gap: '0.7rem',
|
||||
alignItems: 'center',
|
||||
color: 'var(--color-text-soft)',
|
||||
fontSize: '0.74rem',
|
||||
fontWeight: 800,
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
<span style={{ height: 1, background: 'var(--color-border)' }} />
|
||||
<span
|
||||
style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 999,
|
||||
padding: '0.24rem 0.62rem',
|
||||
background: 'rgba(255,255,255,0.9)',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span style={{ height: 1, background: 'var(--color-border)' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getUserId(user) {
|
||||
const value = user?.databaseId || user?.id;
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? numeric : null;
|
||||
}
|
||||
|
||||
export function MessagesWorkspace({
|
||||
conversations,
|
||||
activeConversationId,
|
||||
onSelectConversation,
|
||||
actionItems,
|
||||
onSendSuggestedReply,
|
||||
isWideDesktop = false,
|
||||
isDesktop = false,
|
||||
isTablet = false,
|
||||
isMobile = false,
|
||||
isPaused = false,
|
||||
pauseDurationLabel = '00:00',
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const messagesRef = useRef(null);
|
||||
const currentUser = getCurrentUser();
|
||||
const currentUserId = getUserId(currentUser);
|
||||
const recentConversations = conversations.slice(0, 3);
|
||||
const activeConversation =
|
||||
conversations.find((conversation) => conversation.id === activeConversationId) ||
|
||||
recentConversations.find((conversation) => conversation.id === activeConversationId) ||
|
||||
recentConversations[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([]);
|
||||
const [notesError, setNotesError] = useState('');
|
||||
|
||||
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: 'Atualização de script',
|
||||
text: 'Use o novo roteiro de confirmação de dados em atendimentos financeiros.',
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedReplyIndex(0);
|
||||
}, [safeActiveConversation.id]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = messagesRef.current;
|
||||
if (!container) return;
|
||||
|
||||
container.scrollTo({
|
||||
top: container.scrollHeight,
|
||||
behavior: 'auto',
|
||||
});
|
||||
}, [safeActiveConversation.id, safeActiveConversation.messages]);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
async function loadNotes() {
|
||||
try {
|
||||
const data = await listAgentNotes(currentUserId);
|
||||
if (isMounted) {
|
||||
setNotes(Array.isArray(data) ? data : []);
|
||||
setNotesError('');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isMounted) setNotesError(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
loadNotes();
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [currentUserId]);
|
||||
|
||||
function selectPreviousReply() {
|
||||
setSelectedReplyIndex((current) =>
|
||||
current === 0 ? suggestedReplies.length - 1 : current - 1,
|
||||
);
|
||||
}
|
||||
|
||||
function selectNextReply() {
|
||||
setSelectedReplyIndex((current) => (current + 1) % suggestedReplies.length);
|
||||
}
|
||||
|
||||
async function saveNote() {
|
||||
const text = noteDraft.trim();
|
||||
if (!text || !currentUserId) return;
|
||||
|
||||
try {
|
||||
const note = await createAgentNote(currentUserId, text);
|
||||
setNotes((current) => [note, ...current]);
|
||||
setNoteDraft('');
|
||||
setNotesError('');
|
||||
} catch (error) {
|
||||
setNotesError(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeNote(noteId) {
|
||||
if (!currentUserId) return;
|
||||
|
||||
try {
|
||||
await deleteAgentNote(currentUserId, noteId);
|
||||
setNotes((current) => current.filter((note) => note.id !== noteId));
|
||||
setNotesError('');
|
||||
} catch (error) {
|
||||
setNotesError(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendSuggestedReply() {
|
||||
if (isPaused) return;
|
||||
if (!safeActiveConversation.id || safeActiveConversation.id === 'empty') return;
|
||||
|
||||
await onSendSuggestedReply?.(safeActiveConversation.id, selectedReply);
|
||||
navigate(`/chat?chatId=${encodeURIComponent(safeActiveConversation.id)}`);
|
||||
}
|
||||
|
||||
const gridTemplateColumns = isMobile
|
||||
? '1fr'
|
||||
@ -48,13 +328,15 @@ export function MessagesWorkspace({
|
||||
? 'minmax(260px, 320px) minmax(0, 1fr)'
|
||||
: '1fr';
|
||||
|
||||
const panelHeight = isMobile ? 'auto' : WORKSPACE_HEIGHT;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns,
|
||||
gap: '1rem',
|
||||
alignItems: 'start',
|
||||
alignItems: 'stretch',
|
||||
}}
|
||||
>
|
||||
<section
|
||||
@ -65,18 +347,20 @@ export function MessagesWorkspace({
|
||||
padding: '1rem',
|
||||
display: 'grid',
|
||||
gap: '0.75rem',
|
||||
alignContent: 'start',
|
||||
height: panelHeight,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong style={{ fontSize: '1.05rem' }}>Conversas</strong>
|
||||
<p style={{ margin: '0.35rem 0 0', color: 'var(--color-text-soft)' }}>
|
||||
Atendimento em tempo real por canal.
|
||||
Últimos 3 atendimentos em tempo real.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{conversations.map((conversation) => {
|
||||
const isActive = conversation.id === activeConversation.id;
|
||||
{recentConversations.map((conversation) => {
|
||||
const isActive = conversation.id === safeActiveConversation.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
@ -92,37 +376,59 @@ export function MessagesWorkspace({
|
||||
textAlign: 'left',
|
||||
display: 'grid',
|
||||
gap: '0.6rem',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
|
||||
<strong>{conversation.name}</strong>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', minWidth: 0 }}>
|
||||
<strong style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{conversation.name}
|
||||
</strong>
|
||||
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.86rem' }}>
|
||||
{conversation.time}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.75rem' }}>
|
||||
<ChannelBadge channel={conversation.channel} />
|
||||
{conversation.unread ? (
|
||||
<UnreadBadge count={conversation.unread} />
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
minWidth: 24,
|
||||
borderRadius: 999,
|
||||
padding: '0.15rem 0.45rem',
|
||||
background: 'var(--color-secondary)',
|
||||
color: '#fff',
|
||||
fontSize: '0.78rem',
|
||||
fontWeight: 700,
|
||||
textAlign: 'center',
|
||||
color: 'var(--color-text-soft)',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
overflowWrap: 'anywhere',
|
||||
lineHeight: 1.35,
|
||||
}}
|
||||
>
|
||||
{conversation.unread}
|
||||
{conversation.lastMessage}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<span style={{ color: 'var(--color-text-soft)' }}>{conversation.lastMessage}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{conversations.length > 3 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!isPaused) navigate('/chat');
|
||||
}}
|
||||
disabled={isPaused}
|
||||
style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '16px',
|
||||
padding: '0.85rem 1rem',
|
||||
background: '#fff',
|
||||
color: 'var(--color-primary)',
|
||||
fontWeight: 700,
|
||||
opacity: isPaused ? 0.55 : 1,
|
||||
cursor: isPaused ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
Ver todos no chat
|
||||
</button>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section
|
||||
@ -131,8 +437,9 @@ export function MessagesWorkspace({
|
||||
borderRadius: '26px',
|
||||
border: '1px solid var(--color-border)',
|
||||
display: 'grid',
|
||||
gridTemplateRows: 'auto 1fr auto',
|
||||
minHeight: 580,
|
||||
gridTemplateRows: 'auto minmax(0, 1fr) auto',
|
||||
height: panelHeight,
|
||||
minHeight: isMobile ? 580 : 'auto',
|
||||
overflow: 'hidden',
|
||||
minWidth: 0,
|
||||
}}
|
||||
@ -148,15 +455,20 @@ export function MessagesWorkspace({
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong style={{ display: 'block', fontSize: '1.08rem' }}>{activeConversation.name}</strong>
|
||||
<strong style={{ display: 'block', fontSize: '1.08rem' }}>
|
||||
{safeActiveConversation.name}
|
||||
</strong>
|
||||
<span style={{ color: 'var(--color-text-soft)' }}>
|
||||
{activeConversation.status === 'online' ? 'Online agora' : 'Offline'}
|
||||
{safeActiveConversation.lastSeen || 'Sem atividade recente'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.6rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/chat')}
|
||||
onClick={() => {
|
||||
if (!isPaused) navigate('/chat');
|
||||
}}
|
||||
disabled={isPaused}
|
||||
style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '14px',
|
||||
@ -164,6 +476,8 @@ export function MessagesWorkspace({
|
||||
background: '#fff',
|
||||
color: 'var(--color-primary)',
|
||||
fontWeight: 700,
|
||||
opacity: isPaused ? 0.55 : 1,
|
||||
cursor: isPaused ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
Abrir chat
|
||||
@ -185,21 +499,31 @@ export function MessagesWorkspace({
|
||||
</header>
|
||||
|
||||
<div
|
||||
ref={messagesRef}
|
||||
style={{
|
||||
padding: '1.25rem',
|
||||
display: 'grid',
|
||||
gap: '0.9rem',
|
||||
alignContent: 'start',
|
||||
overflowY: 'auto',
|
||||
background:
|
||||
'linear-gradient(180deg, rgba(245, 248, 251, 0.45), rgba(255, 255, 255, 0.9))',
|
||||
}}
|
||||
>
|
||||
{activeConversation.messages.map((message) => {
|
||||
{safeActiveConversation.messages.map((message, index) => {
|
||||
const isAgent = message.from === 'agent';
|
||||
const parsedText = parseMessageText(message.text);
|
||||
const messageTime = formatMessageTime(message.timestamp);
|
||||
const dateKey = getDateKey(message.timestamp);
|
||||
const previousDateKey =
|
||||
index > 0 ? getDateKey(safeActiveConversation.messages[index - 1]?.timestamp) : '';
|
||||
const shouldShowDateSeparator = dateKey && dateKey !== previousDateKey;
|
||||
const dateSeparator = formatDateSeparator(message.timestamp);
|
||||
|
||||
return (
|
||||
<Fragment key={message.id}>
|
||||
{shouldShowDateSeparator ? <DateSeparator label={dateSeparator} /> : null}
|
||||
<div
|
||||
key={message.id}
|
||||
style={{
|
||||
justifySelf: isAgent ? 'end' : 'start',
|
||||
maxWidth: '72%',
|
||||
@ -208,47 +532,125 @@ export function MessagesWorkspace({
|
||||
background: isAgent ? 'var(--color-primary)' : '#edf1f5',
|
||||
color: isAgent ? '#fff' : 'var(--color-text)',
|
||||
boxShadow: 'var(--shadow-md)',
|
||||
display: 'grid',
|
||||
gap: '0.55rem',
|
||||
}}
|
||||
>
|
||||
{message.text}
|
||||
{parsedText.senderLabel ? (
|
||||
<strong
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '0.76rem',
|
||||
lineHeight: 1.2,
|
||||
letterSpacing: '0.02em',
|
||||
textTransform: 'uppercase',
|
||||
color: isAgent ? 'rgba(255,255,255,0.78)' : 'var(--color-primary)',
|
||||
}}
|
||||
>
|
||||
{parsedText.senderLabel}
|
||||
</strong>
|
||||
) : null}
|
||||
<span
|
||||
style={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
lineHeight: 1.45,
|
||||
overflowWrap: 'anywhere',
|
||||
}}
|
||||
>
|
||||
{parsedText.body}
|
||||
</span>
|
||||
{messageTime ? (
|
||||
<span
|
||||
style={{
|
||||
justifySelf: 'end',
|
||||
fontSize: '0.72rem',
|
||||
lineHeight: 1,
|
||||
color: isAgent ? 'rgba(255,255,255,0.7)' : 'var(--color-text-soft)',
|
||||
}}
|
||||
>
|
||||
{messageTime}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<footer
|
||||
style={{
|
||||
padding: '1rem 1.25rem 1.25rem',
|
||||
padding: '0.85rem 1.25rem 1rem',
|
||||
borderTop: '1px solid var(--color-border)',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr auto',
|
||||
gap: '0.75rem',
|
||||
gap: '0.65rem',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value="Posso acionar o time responsavel e te retorno em seguida."
|
||||
readOnly
|
||||
<strong style={{ display: 'block', fontSize: '0.94rem' }}>Resposta sugerida</strong>
|
||||
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '18px',
|
||||
padding: '0.95rem 1rem',
|
||||
background: '#fff',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '40px minmax(0, 1fr) 40px',
|
||||
gap: '0.6rem',
|
||||
alignItems: 'stretch',
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={selectPreviousReply}
|
||||
title="Resposta anterior"
|
||||
disabled={isPaused}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '18px',
|
||||
padding: '0.95rem 1.2rem',
|
||||
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
|
||||
color: '#fff',
|
||||
fontWeight: 700,
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '14px',
|
||||
background: '#fff',
|
||||
color: 'var(--color-primary)',
|
||||
fontWeight: 900,
|
||||
opacity: isPaused ? 0.55 : 1,
|
||||
cursor: isPaused ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
Enviar
|
||||
‹
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={sendSuggestedReply}
|
||||
disabled={isPaused}
|
||||
style={{
|
||||
border: '1px solid rgba(0, 164, 183, 0.32)',
|
||||
borderRadius: '16px',
|
||||
padding: '0.75rem 0.9rem',
|
||||
background: 'rgba(0, 164, 183, 0.07)',
|
||||
color: 'var(--color-text)',
|
||||
fontWeight: 600,
|
||||
textAlign: 'left',
|
||||
lineHeight: 1.35,
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
opacity: isPaused ? 0.55 : 1,
|
||||
cursor: isPaused ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
{isPaused ? `Voce esta em pausa ha ${pauseDurationLabel}. Retome para responder.` : selectedReply}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={selectNextReply}
|
||||
title="Próxima resposta"
|
||||
style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '14px',
|
||||
background: '#fff',
|
||||
color: 'var(--color-primary)',
|
||||
fontWeight: 900,
|
||||
}}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
@ -259,49 +661,128 @@ export function MessagesWorkspace({
|
||||
border: '1px solid var(--color-border)',
|
||||
padding: '1.2rem',
|
||||
display: 'grid',
|
||||
gridTemplateRows: 'auto minmax(0, 1fr)',
|
||||
gap: '1rem',
|
||||
alignContent: 'start',
|
||||
gridColumn: isWideDesktop ? 'auto' : '1 / -1',
|
||||
height: panelHeight,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong style={{ fontSize: '1.05rem' }}>Painel de acoes</strong>
|
||||
<p style={{ margin: '0.35rem 0 0', color: 'var(--color-text-soft)' }}>
|
||||
Contexto rapido do atendimento selecionado.
|
||||
</p>
|
||||
<strong style={{ fontSize: '1.05rem' }}>Comunicados e notas</strong>
|
||||
</div>
|
||||
|
||||
{actionItems.map((item) => (
|
||||
<article
|
||||
key={item.title}
|
||||
<div
|
||||
style={{
|
||||
borderRadius: '20px',
|
||||
padding: '1rem',
|
||||
background: 'rgba(0, 49, 80, 0.04)',
|
||||
display: 'grid',
|
||||
gap: '0.85rem',
|
||||
alignContent: 'start',
|
||||
overflowY: 'auto',
|
||||
paddingRight: '0.15rem',
|
||||
}}
|
||||
>
|
||||
<span style={{ color: 'var(--color-text-soft)', display: 'block', marginBottom: '0.35rem' }}>
|
||||
{item.title}
|
||||
</span>
|
||||
<strong>{item.value}</strong>
|
||||
{managerMessages.map((message) => (
|
||||
<article
|
||||
key={message.id}
|
||||
style={{
|
||||
borderRadius: '18px',
|
||||
padding: '0.95rem',
|
||||
background: 'rgba(0, 49, 80, 0.04)',
|
||||
display: 'grid',
|
||||
gap: '0.4rem',
|
||||
}}
|
||||
>
|
||||
<strong>{message.title}</strong>
|
||||
<p style={{ margin: 0, color: 'var(--color-text-soft)', lineHeight: 1.5 }}>
|
||||
{message.text}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/new-attendance')}
|
||||
<label style={{ display: 'grid', gap: '0.45rem' }}>
|
||||
<span style={{ color: 'var(--color-text-soft)', fontWeight: 700 }}>Anotação rápida</span>
|
||||
<textarea
|
||||
value={noteDraft}
|
||||
onChange={(event) => setNoteDraft(event.target.value)}
|
||||
placeholder="Ex: cliente pediu retorno após 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
|
||||
type="button"
|
||||
onClick={saveNote}
|
||||
disabled={!currentUserId}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '18px',
|
||||
padding: '0.95rem 1rem',
|
||||
background: '#fff',
|
||||
color: 'var(--color-primary)',
|
||||
fontWeight: 700,
|
||||
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
|
||||
color: '#fff',
|
||||
fontWeight: 800,
|
||||
opacity: currentUserId ? 1 : 0.55,
|
||||
}}
|
||||
>
|
||||
Criar novo fluxo
|
||||
Salvar anotação
|
||||
</button>
|
||||
|
||||
{notesError ? (
|
||||
<span style={{ color: '#b42318', fontWeight: 700 }}>{notesError}</span>
|
||||
) : null}
|
||||
|
||||
<div style={{ display: 'grid', gap: '0.55rem' }}>
|
||||
{notes.length ? (
|
||||
notes.map((note) => (
|
||||
<article
|
||||
key={note.id}
|
||||
style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '16px',
|
||||
padding: '0.8rem',
|
||||
background: '#fff',
|
||||
display: 'grid',
|
||||
gap: '0.35rem',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.75rem' }}>
|
||||
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.82rem' }}>
|
||||
{formatMessageTime(new Date(note.created_at).getTime())}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeNote(note.id)}
|
||||
title="Excluir anotação"
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: 999,
|
||||
width: 26,
|
||||
height: 26,
|
||||
background: 'rgba(214, 40, 40, 0.1)',
|
||||
color: '#b42318',
|
||||
fontWeight: 900,
|
||||
}}
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
<p style={{ margin: 0, lineHeight: 1.45 }}>{note.text}</p>
|
||||
</article>
|
||||
))
|
||||
) : (
|
||||
<span style={{ color: 'var(--color-text-soft)' }}>Nenhuma anotação salva.</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
|
||||
174
src/modules/home/pages/AgentMassMessagePage.jsx
Normal file
174
src/modules/home/pages/AgentMassMessagePage.jsx
Normal file
@ -0,0 +1,174 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { BrandMark } from '../../../shared/components/BrandMark';
|
||||
import { useViewport } from '../../../shared/hooks/useViewport';
|
||||
import { getCurrentUser, getCurrentUserDisplay } from '../../auth/services/sessionService';
|
||||
import { listContactProfiles } from '../../chat/services/contactProfileService';
|
||||
import { MassMessagePanel } from '../../management/components/MassMessagePanel';
|
||||
import { getAccessOptions } from '../../management/services/adminAccessService';
|
||||
import { HomeSidebar } from '../components/HomeSidebar';
|
||||
import { sidebarItems } from '../services/homeMocks';
|
||||
|
||||
function getUserSpecialties(user) {
|
||||
const normalize = (area) => {
|
||||
if (!area) return null;
|
||||
if (typeof area === 'string') return area;
|
||||
return area.nome || area.name || null;
|
||||
};
|
||||
|
||||
const areas = Array.isArray(user?.areas) ? user.areas.map(normalize).filter(Boolean) : [];
|
||||
const primary = normalize(user?.areaPrincipal);
|
||||
return primary && !areas.includes(primary) ? [primary, ...areas] : areas;
|
||||
}
|
||||
|
||||
export function AgentMassMessagePage() {
|
||||
const { isDesktop, isMobile } = useViewport();
|
||||
const userDisplay = getCurrentUserDisplay();
|
||||
const currentUser = getCurrentUser();
|
||||
const specialties = getUserSpecialties(currentUser);
|
||||
const [areas, setAreas] = useState([]);
|
||||
const [contactCount, setContactCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
getAccessOptions()
|
||||
.then((options) => {
|
||||
if (isMounted) setAreas(options.areas || []);
|
||||
})
|
||||
.catch(() => {
|
||||
if (isMounted) setAreas([]);
|
||||
});
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
listContactProfiles()
|
||||
.then((items) => {
|
||||
if (isMounted) setContactCount(Array.isArray(items) ? items.length : 0);
|
||||
})
|
||||
.catch(() => {
|
||||
if (isMounted) setContactCount(0);
|
||||
});
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const sidebarWithContactCount = sidebarItems.map((item) =>
|
||||
item.id === 'contacts' ? { ...item, count: contactCount } : item,
|
||||
);
|
||||
|
||||
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(340px, 380px) 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>
|
||||
<HomeSidebar items={sidebarWithContactCount} activeItem="mass-message" isMobile={!isDesktop} />
|
||||
</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)',
|
||||
}}
|
||||
>
|
||||
<h1 style={{ margin: 0, fontSize: '1.65rem' }}>Disparo em massa</h1>
|
||||
<p style={{ margin: '0.45rem 0 0', color: 'var(--color-text-soft)' }}>
|
||||
Envie templates aprovados para contatos da agenda ou numeros informados manualmente.
|
||||
</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' }}>{userDisplay.name}</strong>
|
||||
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.92rem' }}>
|
||||
Atendimento omnichannel
|
||||
</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,
|
||||
}}
|
||||
>
|
||||
{userDisplay.initials}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<MassMessagePanel
|
||||
areas={areas}
|
||||
mode="agent"
|
||||
managedAreaNames={specialties}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
451
src/modules/home/pages/ContactsPage.jsx
Normal file
451
src/modules/home/pages/ContactsPage.jsx
Normal file
@ -0,0 +1,451 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { BrandMark } from '../../../shared/components/BrandMark';
|
||||
import { useViewport } from '../../../shared/hooks/useViewport';
|
||||
import { getCurrentUser, getCurrentUserDisplay } from '../../auth/services/sessionService';
|
||||
import { listContactProfiles, saveContactProfile } from '../../chat/services/contactProfileService';
|
||||
import { HomeSidebar } from '../components/HomeSidebar';
|
||||
import { sidebarItems } from '../services/homeMocks';
|
||||
|
||||
const inputStyle = {
|
||||
width: '100%',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 14,
|
||||
padding: '0.85rem 0.9rem',
|
||||
background: '#fff',
|
||||
color: 'var(--color-text)',
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
function getUserId(user) {
|
||||
const value = user?.databaseId || user?.id;
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? numeric : null;
|
||||
}
|
||||
|
||||
function onlyDigits(value) {
|
||||
return String(value || '').replace(/\D/g, '');
|
||||
}
|
||||
|
||||
function buildChatId(phone) {
|
||||
const digits = onlyDigits(phone);
|
||||
return digits ? `${digits}@c.us` : '';
|
||||
}
|
||||
|
||||
function normalizeContact(contact) {
|
||||
return {
|
||||
chatId: contact.chat_id || buildChatId(contact.phone),
|
||||
name: contact.name || contact.phone || 'Contato sem nome',
|
||||
whatsappPhone: contact.phone || '',
|
||||
callSmsPhone: contact.call_sms_phone || contact.callSmsPhone || '',
|
||||
email: contact.email || '',
|
||||
tag: contact.company || '',
|
||||
note: contact.note || '',
|
||||
updatedAt: contact.updated_at || contact.created_at || null,
|
||||
};
|
||||
}
|
||||
|
||||
function emptyDraft() {
|
||||
return {
|
||||
chatId: '',
|
||||
name: '',
|
||||
whatsappPhone: '',
|
||||
callSmsPhone: '',
|
||||
email: '',
|
||||
tag: '',
|
||||
note: '',
|
||||
};
|
||||
}
|
||||
|
||||
export function ContactsPanel({ embedded = false }) {
|
||||
const { isDesktop, isMobile } = useViewport();
|
||||
const currentUser = getCurrentUser();
|
||||
const currentUserId = getUserId(currentUser);
|
||||
const userDisplay = getCurrentUserDisplay();
|
||||
const [contacts, setContacts] = useState([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [draft, setDraft] = useState(emptyDraft());
|
||||
const [selectedChatId, setSelectedChatId] = useState('');
|
||||
const [status, setStatus] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
async function loadContacts() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await listContactProfiles();
|
||||
setContacts(Array.isArray(data) ? data.map(normalizeContact) : []);
|
||||
setStatus('');
|
||||
} catch (error) {
|
||||
setStatus(error.message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadContacts();
|
||||
}, []);
|
||||
|
||||
const sidebarWithCount = useMemo(
|
||||
() => sidebarItems.map((item) => (item.id === 'contacts' ? { ...item, count: contacts.length } : item)),
|
||||
[contacts.length],
|
||||
);
|
||||
|
||||
const filteredContacts = useMemo(() => {
|
||||
const value = search.trim().toLowerCase();
|
||||
if (!value) return contacts;
|
||||
return contacts.filter((contact) =>
|
||||
`${contact.name} ${contact.whatsappPhone} ${contact.callSmsPhone} ${contact.email} ${contact.tag} ${contact.note}`
|
||||
.toLowerCase()
|
||||
.includes(value),
|
||||
);
|
||||
}, [contacts, search]);
|
||||
|
||||
function selectContact(contact) {
|
||||
setSelectedChatId(contact.chatId);
|
||||
setDraft({ ...contact });
|
||||
setStatus('');
|
||||
}
|
||||
|
||||
function startNewContact() {
|
||||
setSelectedChatId('');
|
||||
setDraft(emptyDraft());
|
||||
setStatus('');
|
||||
}
|
||||
|
||||
async function handleSave(event) {
|
||||
event.preventDefault();
|
||||
const whatsappPhone = onlyDigits(draft.whatsappPhone);
|
||||
const chatId = selectedChatId || draft.chatId || buildChatId(whatsappPhone);
|
||||
|
||||
if (!chatId || !whatsappPhone) {
|
||||
setStatus('Informe o número de WhatsApp para salvar o contato.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await saveContactProfile(chatId, {
|
||||
phone: whatsappPhone,
|
||||
whatsappPhone,
|
||||
callSmsPhone: onlyDigits(draft.callSmsPhone),
|
||||
email: draft.email,
|
||||
name: draft.name,
|
||||
company: draft.tag,
|
||||
note: draft.note,
|
||||
userId: currentUserId,
|
||||
});
|
||||
setStatus('Contato salvo com sucesso.');
|
||||
await loadContacts();
|
||||
setSelectedChatId(chatId);
|
||||
setDraft((current) => ({ ...current, chatId, whatsappPhone }));
|
||||
} catch (error) {
|
||||
setStatus(error.message);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const content = (
|
||||
<section
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: isMobile ? '1fr' : 'minmax(320px, 0.85fr) minmax(0, 1fr)',
|
||||
gap: '1rem',
|
||||
alignItems: 'start',
|
||||
}}
|
||||
>
|
||||
<aside
|
||||
style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 24,
|
||||
padding: '1rem',
|
||||
background: '#fff',
|
||||
display: 'grid',
|
||||
gap: '0.8rem',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.75rem', alignItems: 'center' }}>
|
||||
<div>
|
||||
<strong style={{ display: 'block' }}>Agenda</strong>
|
||||
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.9rem' }}>
|
||||
{contacts.length} contato(s)
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={startNewContact}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: 14,
|
||||
padding: '0.7rem 0.85rem',
|
||||
background: 'var(--color-highlight)',
|
||||
color: '#132534',
|
||||
fontWeight: 900,
|
||||
}}
|
||||
>
|
||||
Novo
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Buscar contato"
|
||||
style={inputStyle}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'grid', gap: '0.45rem', maxHeight: 560, overflowY: 'auto', paddingRight: '0.2rem' }}>
|
||||
{filteredContacts.map((contact) => {
|
||||
const isSelected = selectedChatId === contact.chatId;
|
||||
return (
|
||||
<button
|
||||
key={contact.chatId}
|
||||
type="button"
|
||||
onClick={() => selectContact(contact)}
|
||||
style={{
|
||||
border: '1px solid',
|
||||
borderColor: isSelected ? 'rgba(0, 164, 183, 0.36)' : 'var(--color-border)',
|
||||
borderRadius: 16,
|
||||
padding: '0.8rem',
|
||||
background: isSelected ? 'rgba(0, 164, 183, 0.08)' : '#fff',
|
||||
textAlign: 'left',
|
||||
display: 'grid',
|
||||
gap: '0.25rem',
|
||||
}}
|
||||
>
|
||||
<strong>{contact.name}</strong>
|
||||
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.88rem' }}>
|
||||
WhatsApp: +{contact.whatsappPhone}
|
||||
</span>
|
||||
{contact.email ? (
|
||||
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.84rem' }}>
|
||||
{contact.email}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{!filteredContacts.length ? (
|
||||
<span style={{ color: 'var(--color-text-soft)', fontWeight: 700 }}>
|
||||
Nenhum contato encontrado.
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<form
|
||||
onSubmit={handleSave}
|
||||
style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 24,
|
||||
padding: '1.2rem',
|
||||
background: '#fff',
|
||||
display: 'grid',
|
||||
gap: '0.9rem',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong style={{ display: 'block', fontSize: '1.08rem' }}>
|
||||
{selectedChatId ? 'Editar contato' : 'Novo contato'}
|
||||
</strong>
|
||||
<span style={{ color: 'var(--color-text-soft)' }}>
|
||||
O WhatsApp é usado para vincular o contato à conversa.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : 'repeat(2, minmax(0, 1fr))', gap: '0.85rem' }}>
|
||||
<label style={{ display: 'grid', gap: '0.4rem' }}>
|
||||
<span style={{ fontWeight: 700 }}>Nome</span>
|
||||
<input
|
||||
value={draft.name}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'grid', gap: '0.4rem' }}>
|
||||
<span style={{ fontWeight: 700 }}>Etiqueta de identificação</span>
|
||||
<input
|
||||
value={draft.tag}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, tag: event.target.value }))}
|
||||
placeholder="Ex: Departamento, vaga ou conta vinculada"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'grid', gap: '0.4rem' }}>
|
||||
<span style={{ fontWeight: 700 }}>Número WhatsApp</span>
|
||||
<input
|
||||
value={draft.whatsappPhone}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, whatsappPhone: event.target.value }))}
|
||||
placeholder="5511988267544"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'grid', gap: '0.4rem' }}>
|
||||
<span style={{ fontWeight: 700 }}>Ligação/SMS</span>
|
||||
<input
|
||||
value={draft.callSmsPhone}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, callSmsPhone: event.target.value }))}
|
||||
placeholder="5511988267544"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'grid', gap: '0.4rem' }}>
|
||||
<span style={{ fontWeight: 700 }}>E-mail</span>
|
||||
<input
|
||||
type="email"
|
||||
value={draft.email}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, email: event.target.value }))}
|
||||
placeholder="nome@empresa.com"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label style={{ display: 'grid', gap: '0.4rem' }}>
|
||||
<span style={{ fontWeight: 700 }}>Observação</span>
|
||||
<textarea
|
||||
rows={5}
|
||||
value={draft.note}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, note: event.target.value }))}
|
||||
style={{ ...inputStyle, resize: 'vertical', lineHeight: 1.5 }}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{status ? <span style={{ color: status.includes('sucesso') ? 'var(--color-primary)' : '#b42318', fontWeight: 800 }}>{status}</span> : null}
|
||||
{isLoading ? <span style={{ color: 'var(--color-text-soft)', fontWeight: 700 }}>Carregando agenda...</span> : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: 16,
|
||||
padding: '0.95rem 1rem',
|
||||
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
|
||||
color: '#fff',
|
||||
fontWeight: 900,
|
||||
opacity: isSaving ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{isSaving ? 'Salvando...' : 'Salvar contato'}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
|
||||
if (embedded) {
|
||||
return content;
|
||||
}
|
||||
|
||||
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(340px, 380px) 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>
|
||||
<HomeSidebar items={sidebarWithCount} activeItem="contacts" isMobile={!isDesktop} />
|
||||
</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)',
|
||||
}}
|
||||
>
|
||||
<h1 style={{ margin: 0, fontSize: '1.65rem' }}>Contatos</h1>
|
||||
<p style={{ margin: '0.45rem 0 0', color: 'var(--color-text-soft)' }}>
|
||||
Agenda geral com WhatsApp, telefone para ligação/SMS, e-mail, etiqueta e observação.
|
||||
</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' }}>{userDisplay.name}</strong>
|
||||
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.92rem' }}>
|
||||
Atendimento omnichannel
|
||||
</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,
|
||||
}}
|
||||
>
|
||||
{userDisplay.initials}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContactsPage() {
|
||||
return <ContactsPanel />;
|
||||
}
|
||||
@ -1,17 +1,85 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { BrandMark } from '../../../shared/components/BrandMark';
|
||||
import { HomeSidebar } from '../components/HomeSidebar';
|
||||
import { HomeTopbar } from '../components/HomeTopbar';
|
||||
import { MessagesWorkspace } from '../components/MessagesWorkspace';
|
||||
import { CallsWorkspace } from '../components/CallsWorkspace';
|
||||
import { actionItems, conversations, recentCalls, sidebarItems } from '../services/homeMocks';
|
||||
import { AttendantOpsPanel } from '../components/AttendantOpsPanel';
|
||||
import { recentCalls, sidebarItems } from '../services/homeMocks';
|
||||
import { useViewport } from '../../../shared/hooks/useViewport';
|
||||
import { useChat } from '../../chat/hooks/useChat';
|
||||
import { listContactProfiles } from '../../chat/services/contactProfileService';
|
||||
|
||||
function truncatePreview(value, limit = 96) {
|
||||
const text = String(value || '').replace(/\s+/g, ' ').trim();
|
||||
if (text.length <= limit) return text;
|
||||
return `${text.slice(0, limit).trim()}...`;
|
||||
}
|
||||
|
||||
function toHomeConversation(contact, messages = []) {
|
||||
const lastMessage = contact.preview || messages[messages.length - 1]?.text || '';
|
||||
|
||||
return {
|
||||
id: contact.id,
|
||||
name: contact.name,
|
||||
channel: contact.channel || 'WhatsApp',
|
||||
status: contact.status || 'online',
|
||||
lastMessage: truncatePreview(lastMessage),
|
||||
unread: contact.unread || 0,
|
||||
time: contact.time || 'Agora',
|
||||
lastSeen: contact.lastSeen,
|
||||
messages: messages.map((message) => ({
|
||||
id: message.id,
|
||||
from: message.sender === 'agent' ? 'agent' : 'customer',
|
||||
text: message.text || (message.hasMedia ? '[Mídia]' : ''),
|
||||
timestamp: message.timestamp,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function HomePage() {
|
||||
const { isWideDesktop, isDesktop, isTablet, isMobile } = useViewport();
|
||||
const {
|
||||
contacts,
|
||||
activeContactId,
|
||||
setActiveContactId,
|
||||
messages,
|
||||
sendMessage,
|
||||
isLoadingChats,
|
||||
isPaused,
|
||||
pauseDurationLabel,
|
||||
isPresenceLoading,
|
||||
pauseAttendance,
|
||||
resumeAttendance,
|
||||
} = useChat();
|
||||
const [activeTab, setActiveTab] = useState('messages');
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [activeConversationId, setActiveConversationId] = useState(conversations[0].id);
|
||||
const [contactCount, setContactCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
listContactProfiles()
|
||||
.then((items) => {
|
||||
if (isMounted) setContactCount(Array.isArray(items) ? items.length : 0);
|
||||
})
|
||||
.catch(() => {
|
||||
if (isMounted) setContactCount(0);
|
||||
});
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const sidebarWithContactCount = useMemo(
|
||||
() => sidebarItems.map((item) => (item.id === 'contacts' ? { ...item, count: contactCount } : item)),
|
||||
[contactCount],
|
||||
);
|
||||
|
||||
const conversations = contacts.map((contact) =>
|
||||
toHomeConversation(contact, contact.id === activeContactId ? messages : []),
|
||||
);
|
||||
|
||||
const search = searchValue.trim().toLowerCase();
|
||||
const filteredConversations = !search
|
||||
@ -22,9 +90,9 @@ export function HomePage() {
|
||||
});
|
||||
|
||||
const safeConversationId =
|
||||
filteredConversations.find((conversation) => conversation.id === activeConversationId)?.id ||
|
||||
filteredConversations.find((conversation) => conversation.id === activeContactId)?.id ||
|
||||
filteredConversations[0]?.id ||
|
||||
conversations[0].id;
|
||||
conversations[0]?.id;
|
||||
|
||||
return (
|
||||
<main
|
||||
@ -69,7 +137,7 @@ export function HomePage() {
|
||||
>
|
||||
<BrandMark size="lg" />
|
||||
</div>
|
||||
<HomeSidebar items={sidebarItems} activeItem="dashboard" isMobile={!isDesktop} />
|
||||
<HomeSidebar items={sidebarWithContactCount} activeItem="dashboard" isMobile={!isDesktop} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gap: '1.25rem', minWidth: 0 }}>
|
||||
@ -90,50 +158,44 @@ export function HomePage() {
|
||||
gap: '1rem',
|
||||
}}
|
||||
>
|
||||
<AttendantOpsPanel
|
||||
activeChatsCount={filteredConversations.length}
|
||||
isPaused={isPaused}
|
||||
pauseDurationLabel={pauseDurationLabel}
|
||||
isPresenceLoading={isPresenceLoading}
|
||||
onTogglePause={isPaused ? resumeAttendance : pauseAttendance}
|
||||
/>
|
||||
|
||||
{isLoadingChats ? (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
|
||||
gap: '1rem',
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{ label: 'Atendimentos ativos', value: '18', detail: '7 aguardando retorno' },
|
||||
{ label: 'Primeira resposta', value: '2m 14s', detail: 'Dentro do SLA' },
|
||||
{ label: 'Fila de voz', value: '4 chamadas', detail: '1 prioridade alta' },
|
||||
].map((item) => (
|
||||
<article
|
||||
key={item.label}
|
||||
style={{
|
||||
padding: '1.15rem',
|
||||
borderRadius: '22px',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 18,
|
||||
padding: '0.85rem 1rem',
|
||||
background: '#fff',
|
||||
color: 'var(--color-text-soft)',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
))}
|
||||
Atualizando conversas do WhatsApp...
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activeTab === 'messages' ? (
|
||||
<MessagesWorkspace
|
||||
conversations={filteredConversations}
|
||||
activeConversationId={safeConversationId}
|
||||
onSelectConversation={setActiveConversationId}
|
||||
actionItems={actionItems}
|
||||
onSelectConversation={setActiveContactId}
|
||||
onSendSuggestedReply={async (conversationId, reply) => {
|
||||
setActiveContactId(conversationId);
|
||||
await sendMessage(reply, conversationId);
|
||||
}}
|
||||
isWideDesktop={isWideDesktop}
|
||||
isDesktop={isDesktop}
|
||||
isTablet={isTablet}
|
||||
isMobile={isMobile}
|
||||
isPaused={isPaused}
|
||||
pauseDurationLabel={pauseDurationLabel}
|
||||
/>
|
||||
) : (
|
||||
<CallsWorkspace
|
||||
|
||||
23
src/modules/home/pages/ProfileHomePage.jsx
Normal file
23
src/modules/home/pages/ProfileHomePage.jsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { AdminPage } from '../../management/pages/AdminPage';
|
||||
import { SupervisorPage } from '../../management/pages/SupervisorPage';
|
||||
import { getCurrentUserProfile } from '../../auth/services/sessionService';
|
||||
import { HomePage } from './HomePage';
|
||||
import { UnassignedHomePage } from './UnassignedHomePage';
|
||||
|
||||
export function ProfileHomePage() {
|
||||
const profile = getCurrentUserProfile();
|
||||
|
||||
if (profile === 'admin') {
|
||||
return <AdminPage />;
|
||||
}
|
||||
|
||||
if (profile === 'supervisor') {
|
||||
return <SupervisorPage />;
|
||||
}
|
||||
|
||||
if (profile === 'unassigned') {
|
||||
return <UnassignedHomePage />;
|
||||
}
|
||||
|
||||
return <HomePage />;
|
||||
}
|
||||
91
src/modules/home/pages/UnassignedHomePage.jsx
Normal file
91
src/modules/home/pages/UnassignedHomePage.jsx
Normal file
@ -0,0 +1,91 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { BrandMark } from '../../../shared/components/BrandMark';
|
||||
import { clearSession, getCurrentUser } from '../../auth/services/sessionService';
|
||||
|
||||
export function UnassignedHomePage() {
|
||||
const navigate = useNavigate();
|
||||
const user = getCurrentUser();
|
||||
|
||||
function handleLogout() {
|
||||
clearSession();
|
||||
navigate('/login', { replace: true });
|
||||
}
|
||||
|
||||
return (
|
||||
<main
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
padding: '2rem',
|
||||
}}
|
||||
>
|
||||
<section
|
||||
style={{
|
||||
width: 'min(760px, 100%)',
|
||||
background: '#fff',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '32px',
|
||||
boxShadow: 'var(--shadow-lg)',
|
||||
padding: '2rem',
|
||||
display: 'grid',
|
||||
gap: '1.5rem',
|
||||
}}
|
||||
>
|
||||
<BrandMark size="lg" />
|
||||
|
||||
<div style={{ display: 'grid', gap: '0.75rem' }}>
|
||||
<span
|
||||
style={{
|
||||
width: 'fit-content',
|
||||
padding: '0.4rem 0.75rem',
|
||||
borderRadius: 999,
|
||||
background: 'rgba(229, 162, 42, 0.14)',
|
||||
color: '#8a5a00',
|
||||
fontWeight: 800,
|
||||
}}
|
||||
>
|
||||
Acesso aguardando configuração
|
||||
</span>
|
||||
<h1 style={{ margin: 0, fontSize: '2rem' }}>Seu usuário ainda não tem atribuições</h1>
|
||||
<p style={{ margin: 0, color: 'var(--color-text-soft)', lineHeight: 1.7 }}>
|
||||
O login foi realizado, mas um administrador ainda precisa vincular seu usuário a um
|
||||
perfil de acesso e uma especialidade 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)' }}>Usuário autenticado</span>
|
||||
<strong>{user?.name || user?.username || 'Usuário'}</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>
|
||||
);
|
||||
}
|
||||
27
src/modules/home/services/agentNotesService.js
Normal file
27
src/modules/home/services/agentNotesService.js
Normal file
@ -0,0 +1,27 @@
|
||||
import { API_BASE_URL } from '../../../shared/services/apiConfig';
|
||||
|
||||
export async function listAgentNotes(userId) {
|
||||
if (!userId) return [];
|
||||
const response = await fetch(`${API_BASE_URL}/agent/notes?userId=${encodeURIComponent(userId)}`);
|
||||
if (!response.ok) throw new Error('Falha ao carregar anotações.');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function createAgentNote(userId, text) {
|
||||
const response = await fetch(`${API_BASE_URL}/agent/notes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userId, text }),
|
||||
});
|
||||
if (!response.ok) throw new Error('Falha ao salvar anotação.');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function deleteAgentNote(userId, noteId) {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/agent/notes/${encodeURIComponent(noteId)}?userId=${encodeURIComponent(userId)}`,
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
if (!response.ok) throw new Error('Falha ao excluir anotação.');
|
||||
return response.json();
|
||||
}
|
||||
@ -1,9 +1,9 @@
|
||||
export const sidebarItems = [
|
||||
{ id: 'dashboard', label: 'Dashboard' },
|
||||
{ id: 'new-attendance', label: 'Novos Atendimentos', route: '/new-attendance' },
|
||||
{ id: 'in-progress', label: 'Em andamento', count: 8 },
|
||||
{ id: 'completed', label: 'Finalizados', count: 24 },
|
||||
{ id: 'contacts', label: 'Contatos', count: 128 },
|
||||
export const sidebarItems = [
|
||||
{ id: 'new-attendance', label: 'Abrir atendimento', route: '/new-attendance' },
|
||||
{ id: 'mass-message', label: 'Disparo em massa', route: '/mass-message' },
|
||||
{ id: 'knowledge-base', label: 'Base de conhecimento' },
|
||||
{ id: 'scripts', label: 'Scripts e respostas prontas' },
|
||||
{ id: 'contacts', label: 'Contatos', route: '/contacts' },
|
||||
];
|
||||
|
||||
export const conversations = [
|
||||
@ -30,28 +30,28 @@ export const conversations = [
|
||||
unread: 0,
|
||||
time: 'Ontem',
|
||||
messages: [
|
||||
{ id: 1, from: 'customer', text: 'Precisamos rever os valores da ultima proposta.' },
|
||||
{ id: 1, from: 'customer', text: 'Precisamos rever os valores da última proposta.' },
|
||||
{ id: 2, from: 'agent', text: 'Perfeito, vou encaminhar para o time comercial.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'joao-pedro',
|
||||
name: 'Joao Pedro',
|
||||
name: 'João Pedro',
|
||||
channel: 'SMS',
|
||||
status: 'online',
|
||||
lastMessage: 'Pode me ligar em 10 minutos?',
|
||||
unread: 1,
|
||||
time: '08:15',
|
||||
messages: [
|
||||
{ id: 1, from: 'customer', text: 'Recebi a cobranca em duplicidade.' },
|
||||
{ id: 2, from: 'agent', text: 'Vou analisar isso agora para voce.' },
|
||||
{ id: 1, from: 'customer', text: 'Recebi a cobrança em duplicidade.' },
|
||||
{ id: 2, from: 'agent', text: 'Vou analisar isso agora para você.' },
|
||||
{ id: 3, from: 'customer', text: 'Pode me ligar em 10 minutos?' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const actionItems = [
|
||||
{ title: 'Area atual', value: 'Suporte' },
|
||||
{ title: 'Especialidade atual', value: 'Suporte' },
|
||||
{ title: 'SLA restante', value: '18 min' },
|
||||
{ title: 'Prioridade', value: 'Alta' },
|
||||
];
|
||||
|
||||
49
src/modules/management/components/DataPanel.jsx
Normal file
49
src/modules/management/components/DataPanel.jsx
Normal file
@ -0,0 +1,49 @@
|
||||
export function DataPanel({ title, description, actionLabel, onAction, 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"
|
||||
onClick={onAction}
|
||||
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>
|
||||
);
|
||||
}
|
||||
861
src/modules/management/components/KnowledgeBasePanel.jsx
Normal file
861
src/modules/management/components/KnowledgeBasePanel.jsx
Normal file
@ -0,0 +1,861 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { DataPanel } from './DataPanel';
|
||||
import {
|
||||
createBotFlowNode,
|
||||
deleteBotFlowNode,
|
||||
getBotFlow,
|
||||
listBotFlowVersions,
|
||||
publishBotFlow,
|
||||
updateBotFlowNode,
|
||||
} from '../services/knowledgeService';
|
||||
|
||||
const fieldStyle = {
|
||||
width: '100%',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 14,
|
||||
padding: '0.78rem 0.9rem',
|
||||
background: '#fff',
|
||||
color: 'var(--color-text)',
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
const primaryButton = {
|
||||
border: 'none',
|
||||
borderRadius: 14,
|
||||
padding: '0.78rem 1rem',
|
||||
background: 'var(--color-primary)',
|
||||
color: '#fff',
|
||||
fontWeight: 800,
|
||||
};
|
||||
|
||||
const ghostButton = {
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 14,
|
||||
padding: '0.72rem 0.9rem',
|
||||
background: '#fff',
|
||||
color: 'var(--color-text)',
|
||||
fontWeight: 800,
|
||||
};
|
||||
|
||||
const emptyDraft = {
|
||||
nodeType: 'question',
|
||||
title: '',
|
||||
messageText: '',
|
||||
keywords: '',
|
||||
fallbackMessage: '',
|
||||
fallbackAttempts: 2,
|
||||
fallbackAreaId: '',
|
||||
areaId: '',
|
||||
};
|
||||
|
||||
const closeDefaultMessage = 'Perfeito, vou encerrar por aqui. Se precisar de algo mais, é só chamar novamente.';
|
||||
|
||||
function nodeTypeLabel(type) {
|
||||
if (type === 'greeting') return 'Saudação';
|
||||
if (type === 'agent') return 'Enviar para agente';
|
||||
if (type === 'close') return 'Encerrar pelo bot';
|
||||
return 'Pergunta';
|
||||
}
|
||||
|
||||
function splitKeywords(value) {
|
||||
return String(value || '')
|
||||
.split(',')
|
||||
.map((keyword) => keyword.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function collectPublishWarnings(node, warnings = []) {
|
||||
if (!node) return warnings;
|
||||
const children = node.children || [];
|
||||
const isTerminal = node.node_type === 'agent' || node.node_type === 'close';
|
||||
if (!isTerminal && children.length === 0) {
|
||||
warnings.push(`"${node.title}" precisa ter pelo menos um filho.`);
|
||||
}
|
||||
if (node.node_type === 'agent' && !node.area_id) {
|
||||
warnings.push(`"${node.title}" precisa de uma especialidade.`);
|
||||
}
|
||||
children.forEach((child) => collectPublishWarnings(child, warnings));
|
||||
return warnings;
|
||||
}
|
||||
|
||||
function getFlowNodeWidth(level) {
|
||||
return level >= 2 ? 260 : 300;
|
||||
}
|
||||
|
||||
function getFlowChildGap(level) {
|
||||
return level >= 2 ? 56 : 40;
|
||||
}
|
||||
|
||||
function getFlowSubtreeWidth(node, level = 0) {
|
||||
if (!node) return getFlowNodeWidth(level);
|
||||
const children = node.children || [];
|
||||
const nodeWidth = getFlowNodeWidth(level);
|
||||
const horizontalPadding = level >= 2 ? 56 : 72;
|
||||
|
||||
if (!children.length) {
|
||||
return nodeWidth + horizontalPadding;
|
||||
}
|
||||
|
||||
const gap = getFlowChildGap(level);
|
||||
const childrenWidth =
|
||||
children.reduce((total, child) => total + getFlowSubtreeWidth(child, level + 1), 0) +
|
||||
Math.max(0, children.length - 1) * gap;
|
||||
|
||||
return Math.max(nodeWidth + horizontalPadding, childrenWidth);
|
||||
}
|
||||
|
||||
function WhatsAppPreview({ message }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: '#e7f5ef',
|
||||
borderRadius: 18,
|
||||
padding: '0.85rem',
|
||||
display: 'grid',
|
||||
gap: '0.45rem',
|
||||
}}
|
||||
>
|
||||
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.78rem', fontWeight: 800 }}>Preview WhatsApp</span>
|
||||
<div
|
||||
style={{
|
||||
justifySelf: 'start',
|
||||
maxWidth: 420,
|
||||
borderRadius: '0 14px 14px 14px',
|
||||
background: '#fff',
|
||||
padding: '0.75rem 0.85rem',
|
||||
boxShadow: '0 8px 22px rgba(0, 49, 80, 0.08)',
|
||||
whiteSpace: 'pre-wrap',
|
||||
lineHeight: 1.45,
|
||||
}}
|
||||
>
|
||||
{message || 'Digite a mensagem para visualizar aqui.'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FlowNode({ node, areasById, onAdd, onEdit, onDelete, level = 0, parentTitle = '' }) {
|
||||
const keywords = splitKeywords(node.keywords);
|
||||
const isRoot = node.node_type === 'greeting';
|
||||
const isAgent = node.node_type === 'agent';
|
||||
const isClose = node.node_type === 'close';
|
||||
const isDeep = level >= 2;
|
||||
const nodeWidth = getFlowNodeWidth(level);
|
||||
const visibleKeywordLimit = isDeep ? 4 : 8;
|
||||
const childGap = getFlowChildGap(level);
|
||||
const subtreeWidth = getFlowSubtreeWidth(node, level);
|
||||
const firstChildWidth = node.children?.length ? getFlowSubtreeWidth(node.children[0], level + 1) : 0;
|
||||
const lastChildWidth = node.children?.length
|
||||
? getFlowSubtreeWidth(node.children[node.children.length - 1], level + 1)
|
||||
: 0;
|
||||
const accentColor = isRoot
|
||||
? 'var(--color-primary)'
|
||||
: isAgent
|
||||
? '#3260b3'
|
||||
: isClose
|
||||
? '#0f8f77'
|
||||
: 'var(--color-highlight)';
|
||||
const nodeMessage = node.message_text || (isAgent ? '' : 'Sem mensagem configurada.');
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
justifyItems: 'center',
|
||||
gap: '0.95rem',
|
||||
minWidth: subtreeWidth,
|
||||
width: subtreeWidth,
|
||||
}}
|
||||
>
|
||||
<article
|
||||
style={{
|
||||
width: nodeWidth,
|
||||
border: '1px solid var(--color-border)',
|
||||
borderTop: `5px solid ${accentColor}`,
|
||||
borderRadius: 18,
|
||||
background: isRoot
|
||||
? 'linear-gradient(180deg, #fff, rgba(0,164,183,0.09))'
|
||||
: isAgent
|
||||
? 'linear-gradient(180deg, #fff, rgba(50,96,179,0.09))'
|
||||
: isClose
|
||||
? 'linear-gradient(180deg, #fff, rgba(0,164,183,0.1))'
|
||||
: '#fff',
|
||||
boxShadow: '0 12px 28px rgba(0, 49, 80, 0.08)',
|
||||
padding: isDeep ? '0.8rem' : '0.95rem',
|
||||
display: 'grid',
|
||||
gap: '0.7rem',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.7rem', alignItems: 'start' }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', gap: '0.4rem', alignItems: 'center', flexWrap: 'wrap', marginBottom: '0.15rem' }}>
|
||||
<span style={{ color: 'var(--color-primary)', fontSize: '0.74rem', fontWeight: 900, textTransform: 'uppercase' }}>
|
||||
{nodeTypeLabel(node.node_type)}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
borderRadius: 999,
|
||||
padding: '0.12rem 0.42rem',
|
||||
background: 'rgba(0,49,80,0.06)',
|
||||
color: 'var(--color-text-soft)',
|
||||
fontSize: '0.68rem',
|
||||
fontWeight: 900,
|
||||
}}
|
||||
>
|
||||
Nível {level + 1}
|
||||
</span>
|
||||
</div>
|
||||
<strong style={{ display: 'block', lineHeight: 1.25 }}>{node.title}</strong>
|
||||
{!isRoot && parentTitle ? (
|
||||
<span style={{ display: 'block', marginTop: '0.22rem', color: 'var(--color-text-soft)', fontSize: '0.78rem', fontWeight: 700 }}>
|
||||
abaixo de: {parentTitle}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{!isAgent && !isClose && onAdd ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAdd(node)}
|
||||
title="Adicionar filho"
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 12,
|
||||
border: 'none',
|
||||
background: 'var(--color-highlight)',
|
||||
color: '#132534',
|
||||
fontWeight: 900,
|
||||
}}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isAgent ? (
|
||||
<span style={{ color: 'var(--color-text-soft)', lineHeight: 1.35 }}>
|
||||
Fila: {node.area_nome || areasById.get(Number(node.area_id))?.nome || 'não definida'}
|
||||
</span>
|
||||
) : isClose ? (
|
||||
<span style={{ color: 'var(--color-text-soft)', lineHeight: 1.35, whiteSpace: 'pre-wrap' }}>
|
||||
{isDeep && nodeMessage.length > 96 ? `${nodeMessage.slice(0, 96)}...` : nodeMessage}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--color-text-soft)', whiteSpace: isDeep ? 'normal' : 'pre-wrap', lineHeight: 1.35 }}>
|
||||
{isDeep && nodeMessage.length > 96 ? `${nodeMessage.slice(0, 96)}...` : nodeMessage}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!isRoot && keywords.length ? (
|
||||
<div style={{ display: 'grid', gap: '0.35rem' }}>
|
||||
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.72rem', fontWeight: 900 }}>
|
||||
Respostas que chegam aqui
|
||||
</span>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem' }}>
|
||||
{keywords.slice(0, visibleKeywordLimit).map((keyword) => (
|
||||
<span
|
||||
key={keyword}
|
||||
style={{
|
||||
borderRadius: 999,
|
||||
background: 'rgba(0,49,80,0.07)',
|
||||
padding: '0.22rem 0.5rem',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 800,
|
||||
}}
|
||||
>
|
||||
{keyword}
|
||||
</span>
|
||||
))}
|
||||
{keywords.length > visibleKeywordLimit ? (
|
||||
<span
|
||||
style={{
|
||||
borderRadius: 999,
|
||||
background: 'rgba(0,49,80,0.04)',
|
||||
padding: '0.22rem 0.5rem',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 800,
|
||||
color: 'var(--color-text-soft)',
|
||||
}}
|
||||
>
|
||||
+{keywords.length - visibleKeywordLimit}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.45rem', flexWrap: 'wrap' }}>
|
||||
{onEdit ? (
|
||||
<button type="button" onClick={() => onEdit(node)} style={{ ...ghostButton, padding: '0.55rem 0.7rem' }}>
|
||||
Editar
|
||||
</button>
|
||||
) : null}
|
||||
{!isRoot && onDelete ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(node)}
|
||||
style={{
|
||||
...ghostButton,
|
||||
padding: '0.55rem 0.7rem',
|
||||
color: 'var(--color-secondary)',
|
||||
}}
|
||||
>
|
||||
Remover
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{node.children?.length ? (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
width: 2,
|
||||
height: 38,
|
||||
background: 'linear-gradient(180deg, rgba(0,49,80,0.28), rgba(0,49,80,0.1))',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: childGap,
|
||||
alignItems: 'start',
|
||||
justifyContent: 'center',
|
||||
flexWrap: 'nowrap',
|
||||
paddingTop: 34,
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{node.children.length > 1 ? (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: firstChildWidth / 2,
|
||||
right: lastChildWidth / 2,
|
||||
height: 2,
|
||||
background: 'rgba(0,49,80,0.16)',
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{node.children.map((child) => {
|
||||
const childWidth = getFlowSubtreeWidth(child, level + 1);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={child.id}
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'grid',
|
||||
justifyItems: 'center',
|
||||
minWidth: childWidth,
|
||||
width: childWidth,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -34,
|
||||
left: '50%',
|
||||
width: 2,
|
||||
height: 34,
|
||||
background: 'rgba(0,49,80,0.2)',
|
||||
transform: 'translateX(-50%)',
|
||||
}}
|
||||
/>
|
||||
<FlowNode
|
||||
node={child}
|
||||
areasById={areasById}
|
||||
onAdd={onAdd}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
level={level + 1}
|
||||
parentTitle={node.title}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NodeModal({ mode, node, parent, areas, draft, onDraftChange, onClose, onSave }) {
|
||||
if (!mode) return null;
|
||||
const isEdit = mode === 'edit';
|
||||
const isRoot = node?.node_type === 'greeting';
|
||||
const isAgent = draft.nodeType === 'agent' || node?.node_type === 'agent';
|
||||
const isClose = draft.nodeType === 'close' || node?.node_type === 'close';
|
||||
const canChooseType = !isEdit;
|
||||
|
||||
function change(key, value) {
|
||||
onDraftChange((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0, 20, 32, 0.42)',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
zIndex: 50,
|
||||
padding: '1rem',
|
||||
}}
|
||||
>
|
||||
<section
|
||||
style={{
|
||||
width: 'min(760px, calc(100vw - 2rem))',
|
||||
maxHeight: 'calc(100vh - 2rem)',
|
||||
overflowY: 'auto',
|
||||
borderRadius: 24,
|
||||
background: '#fff',
|
||||
boxShadow: 'var(--shadow-lg)',
|
||||
padding: '1.25rem',
|
||||
display: 'grid',
|
||||
gap: '1rem',
|
||||
}}
|
||||
>
|
||||
<header style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', alignItems: 'start' }}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: '1.25rem' }}>
|
||||
{isEdit ? 'Editar nó' : `Adicionar filho em ${parent?.title}`}
|
||||
</h2>
|
||||
<p style={{ margin: '0.35rem 0 0', color: 'var(--color-text-soft)' }}>
|
||||
Configure a mensagem, as palavras que ativam o caminho e o destino quando for terminal.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} style={ghostButton}>Fechar</button>
|
||||
</header>
|
||||
|
||||
{canChooseType ? (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
|
||||
{[
|
||||
['question', 'Adicionar pergunta'],
|
||||
['agent', 'Enviar para agente'],
|
||||
['close', 'Encerrar pelo bot'],
|
||||
].map(([type, label]) => (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => change('nodeType', type)}
|
||||
style={{
|
||||
border: `1px solid ${draft.nodeType === type ? 'var(--color-primary)' : 'var(--color-border)'}`,
|
||||
borderRadius: 16,
|
||||
padding: '0.9rem',
|
||||
background: draft.nodeType === type ? 'rgba(0,164,183,0.08)' : '#fff',
|
||||
color: 'var(--color-text)',
|
||||
fontWeight: 900,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div style={{ display: 'grid', gap: '0.75rem' }}>
|
||||
<label style={{ display: 'grid', gap: '0.35rem' }}>
|
||||
<span style={{ fontWeight: 800 }}>Título interno</span>
|
||||
<input value={draft.title || ''} onChange={(event) => change('title', event.target.value)} style={fieldStyle} />
|
||||
</label>
|
||||
|
||||
{!isRoot ? (
|
||||
<label style={{ display: 'grid', gap: '0.35rem' }}>
|
||||
<span style={{ fontWeight: 800 }}>Keywords que ativam este nó</span>
|
||||
<input
|
||||
value={draft.keywords || ''}
|
||||
onChange={(event) => change('keywords', event.target.value)}
|
||||
placeholder="Ex: 1, colaborador, ativo, funcionário"
|
||||
style={fieldStyle}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
{isAgent ? (
|
||||
<label style={{ display: 'grid', gap: '0.35rem' }}>
|
||||
<span style={{ fontWeight: 800 }}>Especialidade de destino</span>
|
||||
<select value={draft.areaId || ''} onChange={(event) => change('areaId', event.target.value)} style={fieldStyle}>
|
||||
<option value="">Selecione</option>
|
||||
{areas.map((area) => (
|
||||
<option key={area.id} value={area.id}>{area.nome}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : isClose ? (
|
||||
<>
|
||||
<label style={{ display: 'grid', gap: '0.35rem' }}>
|
||||
<span style={{ fontWeight: 800 }}>Mensagem de encerramento</span>
|
||||
<textarea
|
||||
rows={4}
|
||||
value={draft.messageText || ''}
|
||||
onChange={(event) => change('messageText', event.target.value)}
|
||||
placeholder="Ex: Perfeito, vou encerrar por aqui. Se precisar de algo mais, é só chamar."
|
||||
style={{ ...fieldStyle, resize: 'vertical' }}
|
||||
/>
|
||||
</label>
|
||||
<WhatsAppPreview message={draft.messageText} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label style={{ display: 'grid', gap: '0.35rem' }}>
|
||||
<span style={{ fontWeight: 800 }}>Mensagem enviada pelo bot</span>
|
||||
<textarea
|
||||
rows={5}
|
||||
value={draft.messageText || ''}
|
||||
onChange={(event) => change('messageText', event.target.value)}
|
||||
style={{ ...fieldStyle, resize: 'vertical' }}
|
||||
/>
|
||||
</label>
|
||||
<WhatsAppPreview message={draft.messageText} />
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 140px minmax(180px, 0.5fr)', gap: '0.75rem' }}>
|
||||
<label style={{ display: 'grid', gap: '0.35rem' }}>
|
||||
<span style={{ fontWeight: 800 }}>Mensagem de fallback</span>
|
||||
<input
|
||||
value={draft.fallbackMessage || ''}
|
||||
onChange={(event) => change('fallbackMessage', event.target.value)}
|
||||
style={fieldStyle}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'grid', gap: '0.35rem' }}>
|
||||
<span style={{ fontWeight: 800 }}>Tentativas</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="5"
|
||||
value={draft.fallbackAttempts || 2}
|
||||
onChange={(event) => change('fallbackAttempts', event.target.value)}
|
||||
style={fieldStyle}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'grid', gap: '0.35rem' }}>
|
||||
<span style={{ fontWeight: 800 }}>Fila de fallback</span>
|
||||
<select
|
||||
value={draft.fallbackAreaId || ''}
|
||||
onChange={(event) => change('fallbackAreaId', event.target.value)}
|
||||
style={fieldStyle}
|
||||
>
|
||||
<option value="">Herdar/Suporte</option>
|
||||
{areas.map((area) => (
|
||||
<option key={area.id} value={area.id}>{area.nome}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.75rem' }}>
|
||||
<button type="button" onClick={onClose} style={ghostButton}>Cancelar</button>
|
||||
<button type="button" onClick={onSave} style={primaryButton}>Salvar</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function KnowledgeBasePanel({ areas, mode = 'admin', isMobile = false }) {
|
||||
const canEdit = mode === 'admin';
|
||||
const [flow, setFlow] = useState(null);
|
||||
const [versions, setVersions] = useState([]);
|
||||
const [status, setStatus] = useState('');
|
||||
const [statusTone, setStatusTone] = useState('info');
|
||||
const [isPublishing, setIsPublishing] = useState(false);
|
||||
const [zoom, setZoom] = useState(0.92);
|
||||
const [modalMode, setModalMode] = useState(null);
|
||||
const [selectedNode, setSelectedNode] = useState(null);
|
||||
const [parentNode, setParentNode] = useState(null);
|
||||
const [draft, setDraft] = useState(emptyDraft);
|
||||
|
||||
const areasById = useMemo(() => new Map(areas.map((area) => [Number(area.id), area])), [areas]);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [flowData, versionData] = await Promise.all([getBotFlow(), listBotFlowVersions()]);
|
||||
setFlow(flowData);
|
||||
setVersions(Array.isArray(versionData) ? versionData : []);
|
||||
} catch (error) {
|
||||
setStatus(error.message);
|
||||
setStatusTone('error');
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
function openAdd(node) {
|
||||
setParentNode(node);
|
||||
setSelectedNode(null);
|
||||
setDraft({ ...emptyDraft });
|
||||
setModalMode('add');
|
||||
}
|
||||
|
||||
function openEdit(node) {
|
||||
setSelectedNode(node);
|
||||
setParentNode(null);
|
||||
setDraft({
|
||||
nodeType: node.node_type,
|
||||
title: node.title || '',
|
||||
messageText: node.message_text || '',
|
||||
keywords: node.keywords || '',
|
||||
fallbackMessage: node.fallback_message || '',
|
||||
fallbackAttempts: node.fallback_attempts || 2,
|
||||
fallbackAreaId: node.fallback_area_id || '',
|
||||
areaId: node.area_id || '',
|
||||
});
|
||||
setModalMode('edit');
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
setModalMode(null);
|
||||
setSelectedNode(null);
|
||||
setParentNode(null);
|
||||
setDraft(emptyDraft);
|
||||
}
|
||||
|
||||
async function saveNode() {
|
||||
try {
|
||||
const messageText =
|
||||
draft.nodeType === 'close' && !draft.messageText.trim()
|
||||
? closeDefaultMessage
|
||||
: draft.messageText;
|
||||
const payload = {
|
||||
nodeType: draft.nodeType,
|
||||
title: draft.title,
|
||||
messageText,
|
||||
keywords: draft.keywords,
|
||||
fallbackMessage: draft.fallbackMessage,
|
||||
fallbackAttempts: Number(draft.fallbackAttempts || 2),
|
||||
fallbackAreaId: draft.fallbackAreaId ? Number(draft.fallbackAreaId) : null,
|
||||
areaId: draft.areaId ? Number(draft.areaId) : null,
|
||||
};
|
||||
|
||||
if (modalMode === 'add') {
|
||||
await createBotFlowNode({ ...payload, parentId: parentNode.id });
|
||||
setStatus('Nó adicionado ao fluxo.');
|
||||
setStatusTone('success');
|
||||
} else {
|
||||
await updateBotFlowNode(selectedNode.id, payload);
|
||||
setStatus('Nó atualizado.');
|
||||
setStatusTone('success');
|
||||
}
|
||||
|
||||
closeModal();
|
||||
await load();
|
||||
} catch (error) {
|
||||
setStatus(error.message);
|
||||
setStatusTone('error');
|
||||
}
|
||||
}
|
||||
|
||||
async function removeNode(node) {
|
||||
if (!window.confirm(`Remover "${node.title}" e todos os filhos?`)) return;
|
||||
try {
|
||||
await deleteBotFlowNode(node.id);
|
||||
await load();
|
||||
setStatus('Nó removido.');
|
||||
setStatusTone('success');
|
||||
} catch (error) {
|
||||
setStatus(error.message);
|
||||
setStatusTone('error');
|
||||
}
|
||||
}
|
||||
|
||||
async function publish() {
|
||||
setIsPublishing(true);
|
||||
setStatus('Publicando fluxo...');
|
||||
setStatusTone('info');
|
||||
try {
|
||||
await publishBotFlow();
|
||||
await load();
|
||||
setStatus('Fluxo publicado. As novas conversas passam a usar esta árvore.');
|
||||
setStatusTone('success');
|
||||
} catch (error) {
|
||||
setStatus(error.message);
|
||||
setStatusTone('error');
|
||||
} finally {
|
||||
setIsPublishing(false);
|
||||
}
|
||||
}
|
||||
|
||||
const root = flow?.tree;
|
||||
const hasPublished = Boolean(flow?.latestPublished);
|
||||
const publishWarnings = useMemo(() => collectPublishWarnings(root).slice(0, 5), [root]);
|
||||
const treeMinWidth = useMemo(() => Math.max(1100, getFlowSubtreeWidth(root)), [root]);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: '1rem' }}>
|
||||
<DataPanel
|
||||
title="Fluxo do Bot"
|
||||
description="Monte a árvore de decisão do Agente Virtual Sothis. O fluxo só entra em produção depois de publicado."
|
||||
>
|
||||
<div style={{ display: 'grid', gap: '1rem' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 1fr) auto auto',
|
||||
gap: '0.75rem',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'grid', gap: '0.25rem' }}>
|
||||
<strong>{hasPublished ? `Publicado: versão ${flow.latestPublished.version_number}` : 'Nenhum fluxo publicado ainda'}</strong>
|
||||
<span style={{ color: 'var(--color-text-soft)' }}>
|
||||
Draft atual: edite livremente e publique apenas quando estiver consistente.
|
||||
</span>
|
||||
</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.6rem', fontWeight: 800 }}>
|
||||
Zoom
|
||||
<input
|
||||
type="range"
|
||||
min="0.65"
|
||||
max="1.15"
|
||||
step="0.05"
|
||||
value={zoom}
|
||||
onChange={(event) => setZoom(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
{canEdit ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={publish}
|
||||
disabled={isPublishing}
|
||||
style={{
|
||||
...primaryButton,
|
||||
opacity: isPublishing ? 0.72 : 1,
|
||||
cursor: isPublishing ? 'wait' : 'pointer',
|
||||
}}
|
||||
>
|
||||
{isPublishing ? 'Publicando...' : 'Publicar fluxo'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{publishWarnings.length ? (
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid rgba(241,184,42,0.45)',
|
||||
borderRadius: 16,
|
||||
background: 'rgba(241,184,42,0.12)',
|
||||
padding: '0.85rem 1rem',
|
||||
color: 'var(--color-text)',
|
||||
display: 'grid',
|
||||
gap: '0.35rem',
|
||||
}}
|
||||
>
|
||||
<strong>Antes de publicar</strong>
|
||||
{publishWarnings.map((warning) => (
|
||||
<span key={warning} style={{ color: 'var(--color-text-soft)', fontWeight: 700 }}>
|
||||
{warning}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 22,
|
||||
background:
|
||||
'linear-gradient(180deg, #fff, rgba(0,49,80,0.03)), radial-gradient(circle at 1px 1px, rgba(0,49,80,0.08) 1px, transparent 0)',
|
||||
backgroundSize: 'auto, 22px 22px',
|
||||
overflow: 'auto',
|
||||
minHeight: 520,
|
||||
padding: '1.25rem',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
transform: `scale(${zoom})`,
|
||||
transformOrigin: 'top center',
|
||||
minWidth: treeMinWidth,
|
||||
minHeight: 480,
|
||||
display: 'grid',
|
||||
justifyContent: 'center',
|
||||
alignContent: 'start',
|
||||
}}
|
||||
>
|
||||
{root ? (
|
||||
<FlowNode
|
||||
node={root}
|
||||
areasById={areasById}
|
||||
onAdd={canEdit ? openAdd : null}
|
||||
onEdit={canEdit ? openEdit : null}
|
||||
onDelete={canEdit ? removeNode : null}
|
||||
/>
|
||||
) : (
|
||||
<span style={{ color: 'var(--color-text-soft)', fontWeight: 800 }}>Carregando árvore...</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{status ? (
|
||||
<span
|
||||
style={{
|
||||
borderRadius: 14,
|
||||
padding: '0.75rem 0.85rem',
|
||||
background:
|
||||
statusTone === 'error'
|
||||
? 'rgba(181,31,31,0.08)'
|
||||
: statusTone === 'success'
|
||||
? 'rgba(0,164,183,0.09)'
|
||||
: 'rgba(0,49,80,0.06)',
|
||||
color: statusTone === 'error' ? 'var(--color-secondary)' : 'var(--color-primary)',
|
||||
fontWeight: 800,
|
||||
}}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</DataPanel>
|
||||
|
||||
<DataPanel
|
||||
title="Histórico de versões"
|
||||
description="Cada publicação gera uma versão. Nesta primeira etapa o histórico é consultivo; restauração pode ser ligada na próxima rodada."
|
||||
>
|
||||
<div style={{ display: 'grid', gap: '0.55rem', maxHeight: 220, overflowY: 'auto' }}>
|
||||
{versions.length ? versions.map((version) => (
|
||||
<div
|
||||
key={version.id}
|
||||
style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 14,
|
||||
padding: '0.75rem 0.85rem',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
gap: '1rem',
|
||||
}}
|
||||
>
|
||||
<strong>Versão {version.version_number}</strong>
|
||||
<span style={{ color: 'var(--color-text-soft)' }}>
|
||||
{version.published_at ? new Date(version.published_at).toLocaleString('pt-BR') : 'Sem data'}
|
||||
</span>
|
||||
</div>
|
||||
)) : (
|
||||
<span style={{ color: 'var(--color-text-soft)' }}>Nenhuma versão publicada.</span>
|
||||
)}
|
||||
</div>
|
||||
</DataPanel>
|
||||
|
||||
<NodeModal
|
||||
mode={modalMode}
|
||||
node={selectedNode}
|
||||
parent={parentNode}
|
||||
areas={areas}
|
||||
draft={draft}
|
||||
onDraftChange={setDraft}
|
||||
onClose={closeModal}
|
||||
onSave={saveNode}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
284
src/modules/management/components/ManagementLayout.jsx
Normal file
284
src/modules/management/components/ManagementLayout.jsx
Normal file
@ -0,0 +1,284 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { BrandMark } from '../../../shared/components/BrandMark';
|
||||
import { clearSession } from '../../auth/services/sessionService';
|
||||
|
||||
const navigationBySection = {
|
||||
supervisor: [
|
||||
{ id: 'dashboard', label: 'Home' },
|
||||
{ id: 'templates', label: 'Templates' },
|
||||
{ id: 'knowledge', label: 'Fluxo do Bot' },
|
||||
{ id: 'ai-contents', label: 'Conteúdos da IA' },
|
||||
{ id: 'audit', label: 'Auditoria' },
|
||||
{ type: 'separator' },
|
||||
{ id: 'attendance', label: 'Atendimento' },
|
||||
{ id: 'new-attendance', label: 'Abrir Atendimento' },
|
||||
{ id: 'mass-message', label: 'Disparo em Massa' },
|
||||
{ id: 'contacts', label: 'Contatos' },
|
||||
],
|
||||
admin: [
|
||||
{ id: 'today', label: 'Operação' },
|
||||
{ type: 'separator' },
|
||||
{ id: 'users-access', label: 'Usuários & Acessos' },
|
||||
{ id: 'templates', label: 'Templates' },
|
||||
{ id: 'knowledge', label: 'Fluxo do Bot' },
|
||||
{ id: 'ai-contents', label: 'Conteúdos da IA' },
|
||||
{ id: 'audit', label: 'Auditoria' },
|
||||
{ id: 'channels', label: 'Canais e Integração' },
|
||||
{ type: 'separator' },
|
||||
{ id: 'attendance', label: 'Atendimento' },
|
||||
{ id: 'new-attendance', label: 'Abrir Atendimento' },
|
||||
{ id: 'mass-message', label: 'Disparo em Massa' },
|
||||
{ id: 'contacts', label: 'Contatos' },
|
||||
{ type: 'separator' },
|
||||
{ id: 'settings', label: 'Configurações' },
|
||||
],
|
||||
};
|
||||
|
||||
const actionLabelBySection = {
|
||||
supervisor: '+ Redistribuir atendimento',
|
||||
admin: 'Home',
|
||||
};
|
||||
|
||||
export function ManagementLayout({
|
||||
title,
|
||||
subtitle,
|
||||
activeSection,
|
||||
profileLabel,
|
||||
initials,
|
||||
children,
|
||||
isDesktop,
|
||||
isMobile,
|
||||
activeNavItem,
|
||||
onNavItemChange,
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const navItems = navigationBySection[activeSection] || navigationBySection.supervisor;
|
||||
const actionLabel = actionLabelBySection[activeSection] || 'Home';
|
||||
|
||||
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={() => {
|
||||
if (activeSection === 'admin') {
|
||||
onNavItemChange?.('home');
|
||||
return;
|
||||
}
|
||||
|
||||
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) => {
|
||||
if (item.type === 'separator') {
|
||||
return (
|
||||
<div
|
||||
key={`separator-${index}`}
|
||||
style={{
|
||||
height: 1,
|
||||
background: 'rgba(255, 255, 255, 0.16)',
|
||||
margin: '0.35rem 0',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const isActive = activeNavItem ? item.id === activeNavItem : index === 0;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (item.path) {
|
||||
navigate(item.path);
|
||||
return;
|
||||
}
|
||||
onNavItemChange?.(item.id);
|
||||
}}
|
||||
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: '#ef4444',
|
||||
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 gestão
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: '16px',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
background: 'linear-gradient(135deg, var(--color-accent), var(--color-primary))',
|
||||
color: '#fff',
|
||||
fontWeight: 800,
|
||||
}}
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
59
src/modules/management/components/ManagementTable.jsx
Normal file
59
src/modules/management/components/ManagementTable.jsx
Normal file
@ -0,0 +1,59 @@
|
||||
export function ManagementTable({ columns, rows, getRowId, isMobile = false }) {
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: '0.75rem' }}>
|
||||
{!isMobile ? (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${columns.length}, minmax(0, 1fr))`,
|
||||
gap: '1rem',
|
||||
padding: '0 1rem',
|
||||
color: 'var(--color-text-soft)',
|
||||
fontWeight: 700,
|
||||
fontSize: '0.88rem',
|
||||
}}
|
||||
>
|
||||
{columns.map((column) => (
|
||||
<span key={column.key}>{column.label}</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{rows.map((row) => (
|
||||
<article
|
||||
key={getRowId(row)}
|
||||
style={{
|
||||
borderRadius: '20px',
|
||||
border: '1px solid var(--color-border)',
|
||||
padding: '1rem',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: isMobile ? '1fr' : `repeat(${columns.length}, minmax(0, 1fr))`,
|
||||
gap: isMobile ? '0.65rem' : '1rem',
|
||||
alignItems: 'center',
|
||||
background: '#fff',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{columns.map((column) => (
|
||||
<div key={column.key} style={{ minWidth: 0 }}>
|
||||
{isMobile ? (
|
||||
<span
|
||||
style={{
|
||||
display: 'block',
|
||||
color: 'var(--color-text-soft)',
|
||||
fontSize: '0.82rem',
|
||||
fontWeight: 700,
|
||||
marginBottom: '0.2rem',
|
||||
}}
|
||||
>
|
||||
{column.label}
|
||||
</span>
|
||||
) : null}
|
||||
{column.render ? column.render(row) : <span>{row[column.key]}</span>}
|
||||
</div>
|
||||
))}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
493
src/modules/management/components/MassMessagePanel.jsx
Normal file
493
src/modules/management/components/MassMessagePanel.jsx
Normal file
@ -0,0 +1,493 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { API_BASE_URL } from '../../../shared/services/apiConfig';
|
||||
import { getCurrentUser } from '../../auth/services/sessionService';
|
||||
import { listContactProfiles } from '../../chat/services/contactProfileService';
|
||||
import { DataPanel } from './DataPanel';
|
||||
import { listTemplates } from '../services/templateService';
|
||||
|
||||
const inputStyle = {
|
||||
width: '100%',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 14,
|
||||
padding: '0.85rem 0.9rem',
|
||||
background: '#fff',
|
||||
color: 'var(--color-text)',
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
function getUserId(user) {
|
||||
const value = user?.databaseId || user?.id;
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? numeric : null;
|
||||
}
|
||||
|
||||
function normalizePhoneToChatId(value) {
|
||||
const digits = String(value || '').replace(/\D/g, '');
|
||||
if (!digits) return '';
|
||||
return `${digits}@c.us`;
|
||||
}
|
||||
|
||||
function getPhoneFromChatId(chatId) {
|
||||
return String(chatId || '').split('@')[0].replace(/\D/g, '');
|
||||
}
|
||||
|
||||
function normalizeContact(contact) {
|
||||
const phone = String(contact.phone || getPhoneFromChatId(contact.chat_id)).replace(/\D/g, '');
|
||||
return {
|
||||
id: contact.chat_id || normalizePhoneToChatId(phone),
|
||||
name: contact.name || phone || 'Contato sem nome',
|
||||
company: contact.company || '',
|
||||
phone,
|
||||
chatId: contact.chat_id || normalizePhoneToChatId(phone),
|
||||
};
|
||||
}
|
||||
|
||||
function renderPreview(content, variables) {
|
||||
const name = variables?.nome || 'colaborador';
|
||||
return String(content || '')
|
||||
.replace(/\{nome\}/gi, name || 'colaborador')
|
||||
.replace(/\{cliente\}/gi, name || 'colaborador')
|
||||
.replace(/\{data\}/gi, variables?.data || '{data}')
|
||||
.replace(/\{link\}/gi, variables?.link || '{link}')
|
||||
.replace(/\{variavel\}/gi, variables?.variavel || '{variavel}')
|
||||
.replace(/\{variável\}/gi, variables?.variavel || '{variável}');
|
||||
}
|
||||
|
||||
async function startAttendance(payload) {
|
||||
const response = await fetch(`${API_BASE_URL}/whatsapp/start-attendance`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Falha ao enviar disparo.');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export function MassMessagePanel({
|
||||
areas,
|
||||
managedAreaNames = [],
|
||||
mode = 'admin',
|
||||
isMobile = false,
|
||||
}) {
|
||||
const currentUserId = getUserId(getCurrentUser());
|
||||
const isAdmin = mode === 'admin';
|
||||
const visibleAreaNames = isAdmin ? [] : managedAreaNames;
|
||||
const [templates, setTemplates] = useState([]);
|
||||
const [contacts, setContacts] = useState([]);
|
||||
const [selectedAreaId, setSelectedAreaId] = useState('');
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState('');
|
||||
const [defaultName, setDefaultName] = useState('colaborador');
|
||||
const [templateDate, setTemplateDate] = useState('');
|
||||
const [templateLink, setTemplateLink] = useState('');
|
||||
const [templateCustomVariable, setTemplateCustomVariable] = useState('');
|
||||
const [numbersText, setNumbersText] = useState('');
|
||||
const [contactSearch, setContactSearch] = useState('');
|
||||
const [selectedContactIds, setSelectedContactIds] = useState([]);
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [results, setResults] = useState([]);
|
||||
const [status, setStatus] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
listTemplates()
|
||||
.then((data) => {
|
||||
if (!isMounted) return;
|
||||
const approved = Array.isArray(data)
|
||||
? data.filter((template) => {
|
||||
const isApproved = template.status === 'approved';
|
||||
const isManaged = !visibleAreaNames.length || visibleAreaNames.includes(template.area_nome);
|
||||
return isApproved && isManaged;
|
||||
})
|
||||
: [];
|
||||
setTemplates(approved);
|
||||
setSelectedTemplateId((current) => current || (approved[0]?.id ? String(approved[0].id) : ''));
|
||||
})
|
||||
.catch((error) => {
|
||||
if (isMounted) setStatus(error.message);
|
||||
});
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [visibleAreaNames.join('|')]);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
listContactProfiles()
|
||||
.then((data) => {
|
||||
if (!isMounted) return;
|
||||
setContacts(Array.isArray(data) ? data.map(normalizeContact).filter((contact) => contact.phone) : []);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (isMounted) setStatus(error.message);
|
||||
});
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const selectedTemplate = templates.find((template) => String(template.id) === String(selectedTemplateId));
|
||||
const filteredTemplates = useMemo(() => {
|
||||
if (!selectedAreaId) return templates;
|
||||
return templates.filter((template) => String(template.area_id || '') === String(selectedAreaId));
|
||||
}, [templates, selectedAreaId]);
|
||||
|
||||
const numbers = useMemo(
|
||||
() =>
|
||||
numbersText
|
||||
.split(/\r?\n|,|;/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
[numbersText],
|
||||
);
|
||||
|
||||
const selectedContacts = useMemo(
|
||||
() => contacts.filter((contact) => selectedContactIds.includes(contact.id)),
|
||||
[contacts, selectedContactIds],
|
||||
);
|
||||
|
||||
const filteredContacts = useMemo(() => {
|
||||
const search = contactSearch.trim().toLowerCase();
|
||||
if (!search) return contacts;
|
||||
return contacts.filter((contact) =>
|
||||
`${contact.name} ${contact.company} ${contact.phone}`.toLowerCase().includes(search),
|
||||
);
|
||||
}, [contacts, contactSearch]);
|
||||
|
||||
const recipients = useMemo(() => {
|
||||
const items = [];
|
||||
const seen = new Set();
|
||||
|
||||
numbers.forEach((number) => {
|
||||
const chatId = normalizePhoneToChatId(number);
|
||||
if (!chatId || seen.has(chatId)) return;
|
||||
seen.add(chatId);
|
||||
items.push({
|
||||
id: chatId,
|
||||
number,
|
||||
chatId,
|
||||
name: defaultName,
|
||||
});
|
||||
});
|
||||
|
||||
return items;
|
||||
}, [defaultName, numbers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTemplate && filteredTemplates.some((template) => String(template.id) === String(selectedTemplateId))) {
|
||||
return;
|
||||
}
|
||||
setSelectedTemplateId(filteredTemplates[0]?.id ? String(filteredTemplates[0].id) : '');
|
||||
}, [filteredTemplates, selectedTemplate, selectedTemplateId]);
|
||||
|
||||
function toggleContact(contact) {
|
||||
const phone = String(contact.phone || '').replace(/\D/g, '');
|
||||
if (!phone) return;
|
||||
|
||||
setSelectedContactIds((current) => {
|
||||
const isSelected = current.includes(contact.id);
|
||||
return isSelected ? current.filter((id) => id !== contact.id) : [...current, contact.id];
|
||||
});
|
||||
|
||||
setNumbersText((current) => {
|
||||
const currentNumbers = current
|
||||
.split(/\r?\n|,|;/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
const exists = currentNumbers.some((item) => String(item).replace(/\D/g, '') === phone);
|
||||
const nextNumbers = exists
|
||||
? currentNumbers.filter((item) => String(item).replace(/\D/g, '') !== phone)
|
||||
: [...currentNumbers, phone];
|
||||
return nextNumbers.join('\n');
|
||||
});
|
||||
}
|
||||
|
||||
function clearSelectedContacts() {
|
||||
const selectedPhones = new Set(selectedContacts.map((contact) => contact.phone));
|
||||
setSelectedContactIds([]);
|
||||
setNumbersText((current) =>
|
||||
current
|
||||
.split(/\r?\n|,|;/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.filter((item) => !selectedPhones.has(String(item).replace(/\D/g, '')))
|
||||
.join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
async function sendSelectedRecipients() {
|
||||
if (!currentUserId) {
|
||||
setStatus('Não foi possível identificar o usuário logado.');
|
||||
return;
|
||||
}
|
||||
if (!selectedTemplateId) {
|
||||
setStatus('Selecione um template aprovado.');
|
||||
return;
|
||||
}
|
||||
if (!recipients.length) {
|
||||
setStatus('Informe ao menos um número ou selecione contatos da agenda.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSending(true);
|
||||
setResults([]);
|
||||
setStatus('');
|
||||
|
||||
const nextResults = [];
|
||||
for (const recipient of recipients) {
|
||||
if (!recipient.chatId) {
|
||||
nextResults.push({ number: recipient.number, status: 'erro', detail: 'Número inválido' });
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await startAttendance({
|
||||
to: recipient.chatId,
|
||||
templateId: Number(selectedTemplateId),
|
||||
userId: currentUserId,
|
||||
areaId: selectedTemplate?.area_id || null,
|
||||
variables: {
|
||||
nome: defaultName,
|
||||
cliente: defaultName,
|
||||
data: templateDate,
|
||||
link: templateLink,
|
||||
variavel: templateCustomVariable,
|
||||
'variável': templateCustomVariable,
|
||||
},
|
||||
});
|
||||
nextResults.push({
|
||||
number: recipient.number,
|
||||
status: 'enviado',
|
||||
detail: `${recipient.name || 'Contato'} - template enviado e atendimento iniciado`,
|
||||
});
|
||||
} catch (error) {
|
||||
nextResults.push({ number: recipient.number, status: 'erro', detail: error.message });
|
||||
}
|
||||
setResults([...nextResults]);
|
||||
}
|
||||
|
||||
setStatus(`Disparo finalizado: ${nextResults.filter((item) => item.status === 'enviado').length} enviados.`);
|
||||
setIsSending(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<DataPanel
|
||||
title="Disparo em massa"
|
||||
description="Envie templates aprovados para uma lista de colaboradores. Após o envio, a conversa aguarda resposta do cliente."
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 1fr) minmax(320px, 0.85fr)',
|
||||
gap: '1rem',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'grid', gap: '0.85rem' }}>
|
||||
<label style={{ display: 'grid', gap: '0.4rem' }}>
|
||||
<span style={{ fontWeight: 700 }}>Especialidade</span>
|
||||
<select value={selectedAreaId} onChange={(event) => setSelectedAreaId(event.target.value)} style={inputStyle}>
|
||||
<option value="">Todas as especialidades</option>
|
||||
{areas
|
||||
.filter((area) => isAdmin || !managedAreaNames.length || managedAreaNames.includes(area.nome))
|
||||
.map((area) => (
|
||||
<option key={area.id} value={area.id}>
|
||||
{area.nome}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'grid', gap: '0.4rem' }}>
|
||||
<span style={{ fontWeight: 700 }}>Template aprovado</span>
|
||||
<select value={selectedTemplateId} onChange={(event) => setSelectedTemplateId(event.target.value)} style={inputStyle}>
|
||||
<option value="">Selecione</option>
|
||||
{filteredTemplates.map((template) => (
|
||||
<option key={template.id} value={template.id}>
|
||||
{template.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'grid', gap: '0.4rem' }}>
|
||||
<span style={{ fontWeight: 700 }}>Nome usado no preview</span>
|
||||
<input value={defaultName} onChange={(event) => setDefaultName(event.target.value)} style={inputStyle} />
|
||||
</label>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : 'repeat(3, minmax(0, 1fr))', gap: '0.85rem' }}>
|
||||
<label style={{ display: 'grid', gap: '0.4rem' }}>
|
||||
<span style={{ fontWeight: 700 }}>Data</span>
|
||||
<input value={templateDate} onChange={(event) => setTemplateDate(event.target.value)} placeholder="Ex: 26/05/2026" style={inputStyle} />
|
||||
</label>
|
||||
<label style={{ display: 'grid', gap: '0.4rem' }}>
|
||||
<span style={{ fontWeight: 700 }}>Link</span>
|
||||
<input value={templateLink} onChange={(event) => setTemplateLink(event.target.value)} placeholder="https://..." style={inputStyle} />
|
||||
</label>
|
||||
<label style={{ display: 'grid', gap: '0.4rem' }}>
|
||||
<span style={{ fontWeight: 700 }}>Variável</span>
|
||||
<input value={templateCustomVariable} onChange={(event) => setTemplateCustomVariable(event.target.value)} placeholder="Valor livre" style={inputStyle} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label style={{ display: 'grid', gap: '0.4rem' }}>
|
||||
<span style={{ fontWeight: 700 }}>Números manuais</span>
|
||||
<textarea
|
||||
rows={8}
|
||||
value={numbersText}
|
||||
onChange={(event) => setNumbersText(event.target.value)}
|
||||
placeholder="5511999999999 5511888888888"
|
||||
style={{ ...inputStyle, resize: 'vertical', lineHeight: 1.5 }}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={sendSelectedRecipients}
|
||||
disabled={isSending}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: 16,
|
||||
padding: '0.95rem 1rem',
|
||||
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
|
||||
color: '#fff',
|
||||
fontWeight: 800,
|
||||
opacity: isSending ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{isSending ? 'Enviando...' : `Enviar para ${recipients.length || 0} contato(s)`}
|
||||
</button>
|
||||
|
||||
{status ? <span style={{ color: 'var(--color-primary)', fontWeight: 800 }}>{status}</span> : null}
|
||||
</div>
|
||||
|
||||
<aside style={{ display: 'grid', gap: '0.85rem', alignContent: 'start' }}>
|
||||
<article
|
||||
style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 18,
|
||||
padding: '1rem',
|
||||
background: '#fff',
|
||||
display: 'grid',
|
||||
gap: '0.75rem',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong style={{ display: 'block' }}>Agenda de contatos</strong>
|
||||
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.9rem' }}>
|
||||
Selecione contatos salvos para incluir no disparo.
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
value={contactSearch}
|
||||
onChange={(event) => setContactSearch(event.target.value)}
|
||||
placeholder="Buscar por nome, empresa ou telefone"
|
||||
style={inputStyle}
|
||||
/>
|
||||
<div style={{ display: 'grid', gap: '0.45rem', maxHeight: 260, overflowY: 'auto', paddingRight: '0.2rem' }}>
|
||||
{filteredContacts.map((contact) => {
|
||||
const isSelected = selectedContactIds.includes(contact.id);
|
||||
return (
|
||||
<button
|
||||
key={contact.id}
|
||||
type="button"
|
||||
onClick={() => toggleContact(contact)}
|
||||
style={{
|
||||
border: '1px solid',
|
||||
borderColor: isSelected ? 'rgba(0, 164, 183, 0.36)' : 'var(--color-border)',
|
||||
borderRadius: 14,
|
||||
padding: '0.7rem',
|
||||
background: isSelected ? 'rgba(0, 164, 183, 0.08)' : '#fff',
|
||||
textAlign: 'left',
|
||||
display: 'grid',
|
||||
gap: '0.2rem',
|
||||
}}
|
||||
>
|
||||
<strong>{contact.name}</strong>
|
||||
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.88rem' }}>
|
||||
+{contact.phone}{contact.company ? ` · ${contact.company}` : ''}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{!filteredContacts.length ? (
|
||||
<span style={{ color: 'var(--color-text-soft)', fontWeight: 700 }}>
|
||||
Nenhum contato encontrado na agenda.
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{selectedContacts.length ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearSelectedContacts}
|
||||
style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 14,
|
||||
padding: '0.75rem',
|
||||
background: '#fff',
|
||||
color: 'var(--color-primary)',
|
||||
fontWeight: 800,
|
||||
}}
|
||||
>
|
||||
Limpar seleção ({selectedContacts.length})
|
||||
</button>
|
||||
) : null}
|
||||
</article>
|
||||
|
||||
<article
|
||||
style={{
|
||||
border: '1px solid rgba(0, 164, 183, 0.24)',
|
||||
borderRadius: 18,
|
||||
padding: '1rem',
|
||||
background: 'rgba(0, 164, 183, 0.06)',
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
<strong style={{ display: 'block', color: 'var(--color-primary)', marginBottom: '0.45rem' }}>
|
||||
Preview
|
||||
</strong>
|
||||
{selectedTemplate
|
||||
? renderPreview(selectedTemplate.content, { nome: defaultName, data: templateDate, link: templateLink, variavel: templateCustomVariable })
|
||||
: 'Selecione um template aprovado.'}
|
||||
</article>
|
||||
|
||||
<article
|
||||
style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 18,
|
||||
padding: '1rem',
|
||||
background: '#fff',
|
||||
color: 'var(--color-text-soft)',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Destinatários no campo: {numbers.length}.
|
||||
</article>
|
||||
|
||||
<div style={{ display: 'grid', gap: '0.55rem', maxHeight: 320, overflowY: 'auto' }}>
|
||||
{results.map((result) => (
|
||||
<div
|
||||
key={`${result.number}-${result.status}`}
|
||||
style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 14,
|
||||
padding: '0.75rem',
|
||||
background: result.status === 'enviado' ? 'rgba(16,185,129,0.08)' : 'rgba(181,31,31,0.08)',
|
||||
}}
|
||||
>
|
||||
<strong style={{ display: 'block' }}>{result.number}</strong>
|
||||
<span style={{ color: 'var(--color-text-soft)' }}>{result.detail}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</DataPanel>
|
||||
);
|
||||
}
|
||||
31
src/modules/management/components/MetricGrid.jsx
Normal file
31
src/modules/management/components/MetricGrid.jsx
Normal file
@ -0,0 +1,31 @@
|
||||
export function MetricGrid({ metrics, minCardWidth = '180px' }) {
|
||||
return (
|
||||
<section
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(auto-fit, minmax(${minCardWidth}, 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>
|
||||
);
|
||||
}
|
||||
342
src/modules/management/components/OperationalDashboard.jsx
Normal file
342
src/modules/management/components/OperationalDashboard.jsx
Normal file
@ -0,0 +1,342 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { DataPanel } from './DataPanel';
|
||||
import { MetricGrid } from './MetricGrid';
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
const areaOptions = [
|
||||
{ value: 'all', label: 'Todas as especialidades' },
|
||||
{ value: 'Suporte', label: 'Suporte' },
|
||||
{ value: 'Comercial', label: 'Comercial' },
|
||||
{ value: 'Financeiro', label: 'Financeiro' },
|
||||
];
|
||||
|
||||
const operationMockByArea = {
|
||||
all: {
|
||||
kpis: [
|
||||
{ label: 'Finalizados hoje', value: '126', detail: 'todos os canais' },
|
||||
{ label: 'Em aberto agora', value: '42', detail: '18 em atendimento' },
|
||||
{ label: 'Na fila', value: '12', detail: 'aguardando assumir' },
|
||||
{ label: 'Tempo médio do dia', value: '8m 12s', detail: 'TMA operacional' },
|
||||
{ label: 'Atendentes online', value: '8 de 11', detail: '3 em pausa/offline' },
|
||||
],
|
||||
team: [
|
||||
{ id: 'ana', name: 'Ana Camolesi', status: 'online', open: 4, lastClosed: '6 min' },
|
||||
{ id: 'rafael', name: 'Rafael Lopes', status: 'online', open: 3, lastClosed: '12 min' },
|
||||
{ id: 'marina', name: 'Marina Alves', status: 'paused', open: 1, lastClosed: '18 min' },
|
||||
{ id: 'camila', name: 'Camila Rocha', status: 'online', open: 5, lastClosed: '22 min' },
|
||||
{ id: 'joao', name: 'Joao Pedro', status: 'offline', open: 0, lastClosed: '1h 05min' },
|
||||
],
|
||||
queue: [
|
||||
{ id: 'q1', channel: 'WhatsApp', contact: 'Maria Souza', waitingMinutes: 24 },
|
||||
{ id: 'q2', channel: 'Email', contact: 'Empresa Alpha', waitingMinutes: 18 },
|
||||
{ id: 'q3', channel: 'SMS', contact: 'Carlos Nunes', waitingMinutes: 11 },
|
||||
{ id: 'q4', channel: 'WhatsApp', contact: 'Grupo Solaris', waitingMinutes: 7 },
|
||||
],
|
||||
hourly: [8, 11, 15, 13, 19, 22, 18, 26, 24, 31, 28],
|
||||
},
|
||||
Suporte: {
|
||||
kpis: [
|
||||
{ label: 'Finalizados hoje', value: '58', detail: 'suporte técnico' },
|
||||
{ label: 'Em aberto agora', value: '21', detail: '9 em atendimento' },
|
||||
{ label: 'Na fila', value: '7', detail: 'aguardando assumir' },
|
||||
{ label: 'Tempo médio do dia', value: '9m 04s', detail: 'TMA suporte' },
|
||||
{ label: 'Atendentes online', value: '4 de 6', detail: '1 pausa, 1 offline' },
|
||||
],
|
||||
team: [
|
||||
{ id: 'ana', name: 'Ana Camolesi', status: 'online', open: 4, lastClosed: '6 min' },
|
||||
{ id: 'rafael', name: 'Rafael Lopes', status: 'online', open: 3, lastClosed: '12 min' },
|
||||
{ id: 'beatriz', name: 'Beatriz Lima', status: 'paused', open: 2, lastClosed: '21 min' },
|
||||
{ id: 'pedro', name: 'Pedro Santos', status: 'offline', open: 0, lastClosed: '48 min' },
|
||||
],
|
||||
queue: [
|
||||
{ id: 's1', channel: 'WhatsApp', contact: 'Maria Souza', waitingMinutes: 24 },
|
||||
{ id: 's2', channel: 'WhatsApp', contact: 'Bruno Matos', waitingMinutes: 15 },
|
||||
{ id: 's3', channel: 'Email', contact: 'TI Alpha', waitingMinutes: 9 },
|
||||
],
|
||||
hourly: [4, 5, 7, 6, 10, 12, 9, 15, 13, 18, 16],
|
||||
},
|
||||
Comercial: {
|
||||
kpis: [
|
||||
{ label: 'Finalizados hoje', value: '39', detail: 'leads e propostas' },
|
||||
{ label: 'Em aberto agora', value: '13', detail: '6 em atendimento' },
|
||||
{ label: 'Na fila', value: '3', detail: 'aguardando assumir' },
|
||||
{ label: 'Tempo médio do dia', value: '7m 38s', detail: 'TMA comercial' },
|
||||
{ label: 'Atendentes online', value: '3 de 3', detail: 'time completo' },
|
||||
],
|
||||
team: [
|
||||
{ id: 'camila', name: 'Camila Rocha', status: 'online', open: 5, lastClosed: '22 min' },
|
||||
{ id: 'lucas', name: 'Lucas Nunes', status: 'online', open: 4, lastClosed: '14 min' },
|
||||
{ id: 'helena', name: 'Helena Costa', status: 'online', open: 4, lastClosed: '31 min' },
|
||||
],
|
||||
queue: [
|
||||
{ id: 'c1', channel: 'WhatsApp', contact: 'Grupo Solaris', waitingMinutes: 17 },
|
||||
{ id: 'c2', channel: 'Email', contact: 'Empresa Beta', waitingMinutes: 10 },
|
||||
{ id: 'c3', channel: 'SMS', contact: 'Renata Prado', waitingMinutes: 4 },
|
||||
],
|
||||
hourly: [2, 4, 6, 5, 8, 7, 8, 10, 11, 13, 12],
|
||||
},
|
||||
Financeiro: {
|
||||
kpis: [
|
||||
{ label: 'Finalizados hoje', value: '29', detail: 'faturas e boletos' },
|
||||
{ label: 'Em aberto agora', value: '8', detail: '3 em atendimento' },
|
||||
{ label: 'Na fila', value: '2', detail: 'aguardando assumir' },
|
||||
{ label: 'Tempo médio do dia', value: '6m 51s', detail: 'TMA financeiro' },
|
||||
{ label: 'Atendentes online', value: '1 de 2', detail: '1 offline' },
|
||||
],
|
||||
team: [
|
||||
{ id: 'marina', name: 'Marina Alves', status: 'paused', open: 1, lastClosed: '18 min' },
|
||||
{ id: 'joao', name: 'Joao Pedro', status: 'offline', open: 0, lastClosed: '1h 05min' },
|
||||
{ id: 'roberto', name: 'Roberto Pera', status: 'online', open: 3, lastClosed: '9 min' },
|
||||
],
|
||||
queue: [
|
||||
{ id: 'f1', channel: 'WhatsApp', contact: 'Joao Pedro', waitingMinutes: 22 },
|
||||
{ id: 'f2', channel: 'Email', contact: 'Financeiro Omega', waitingMinutes: 8 },
|
||||
],
|
||||
hourly: [2, 2, 2, 2, 4, 5, 4, 6, 5, 7, 6],
|
||||
},
|
||||
};
|
||||
|
||||
const statusMeta = {
|
||||
online: { label: 'Online', background: 'rgba(34, 197, 94, 0.12)', color: '#15803d' },
|
||||
paused: { label: 'Pausado', background: 'rgba(229, 162, 42, 0.16)', color: '#8a5a00' },
|
||||
offline: { label: 'Offline', background: 'rgba(100, 116, 139, 0.14)', color: '#475569' },
|
||||
};
|
||||
|
||||
const channelColors = {
|
||||
WhatsApp: { background: 'rgba(43, 183, 65, 0.12)', color: '#15803d' },
|
||||
Email: { background: 'rgba(229, 162, 42, 0.16)', color: '#8a5a00' },
|
||||
SMS: { background: 'rgba(0, 164, 183, 0.12)', color: 'var(--color-primary)' },
|
||||
};
|
||||
|
||||
function Badge({ children, tone }) {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
width: 'fit-content',
|
||||
borderRadius: 999,
|
||||
padding: '0.25rem 0.6rem',
|
||||
background: tone.background,
|
||||
color: tone.color,
|
||||
fontWeight: 800,
|
||||
fontSize: '0.82rem',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function HourlyBarChart({ values }) {
|
||||
const labels = ['08h', '09h', '10h', '11h', '12h', '13h', '14h', '15h', '16h', '17h', '18h'];
|
||||
const maxValue = Math.max(...values, 1);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: '0.75rem' }}>
|
||||
<div
|
||||
style={{
|
||||
height: 260,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${values.length}, minmax(10px, 1fr))`,
|
||||
gap: '0.65rem',
|
||||
alignItems: 'end',
|
||||
padding: '0.5rem 0 0',
|
||||
}}
|
||||
>
|
||||
{values.map((value, index) => (
|
||||
<div
|
||||
key={`${value}-${index}`}
|
||||
title={`${labels[index]}: ${value} atendimentos`}
|
||||
style={{
|
||||
height: `${Math.max(8, (value / maxValue) * 100)}%`,
|
||||
borderRadius: '10px 10px 4px 4px',
|
||||
background: 'linear-gradient(180deg, var(--color-secondary), rgba(181, 31, 31, 0.62))',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: `repeat(${labels.length}, 1fr)`, gap: '0.25rem', color: 'var(--color-text-soft)', fontSize: '0.78rem', fontWeight: 700 }}>
|
||||
{labels.map((label) => (
|
||||
<span key={label} style={{ textAlign: 'center' }}>{label}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OperationalDashboard({ isDesktop, isMobile }) {
|
||||
const [selectedArea, setSelectedArea] = useState('all');
|
||||
const [assignmentTarget, setAssignmentTarget] = useState(null);
|
||||
const data = operationMockByArea[selectedArea] || operationMockByArea.all;
|
||||
|
||||
const onlineAgents = useMemo(
|
||||
() => data.team.filter((agent) => agent.status === 'online'),
|
||||
[data.team],
|
||||
);
|
||||
|
||||
const sortedQueue = useMemo(
|
||||
() => [...data.queue].sort((a, b) => b.waitingMinutes - a.waitingMinutes),
|
||||
[data.queue],
|
||||
);
|
||||
|
||||
return (
|
||||
<section style={{ display: 'grid', gap: '1rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<label style={{ display: 'grid', gap: '0.35rem', width: isMobile ? '100%' : 260 }}>
|
||||
<span style={{ fontWeight: 700 }}>Filtro por especialidade</span>
|
||||
<select value={selectedArea} onChange={(event) => setSelectedArea(event.target.value)} style={selectStyle}>
|
||||
{areaOptions.map((area) => (
|
||||
<option key={area.value} value={area.value}>{area.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<MetricGrid metrics={data.kpis} minCardWidth="160px" />
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: isDesktop ? 'minmax(0, 1.15fr) minmax(360px, 0.85fr)' : '1fr', gap: '1rem', alignItems: 'start' }}>
|
||||
<DataPanel title="Painel do time" description="Status operacional em tempo real simulado.">
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: 620 }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: 'left', color: 'var(--color-text-soft)', fontSize: '0.82rem' }}>
|
||||
<th style={{ padding: '0.65rem 0.75rem' }}>Nome</th>
|
||||
<th style={{ padding: '0.65rem 0.75rem' }}>Status</th>
|
||||
<th style={{ padding: '0.65rem 0.75rem' }}>Atendimentos abertos</th>
|
||||
<th style={{ padding: '0.65rem 0.75rem' }}>Último finalizado há</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.team.map((agent) => (
|
||||
<tr key={agent.id} style={{ borderTop: '1px solid var(--color-border)' }}>
|
||||
<td style={{ padding: '0.8rem 0.75rem', fontWeight: 800 }}>{agent.name}</td>
|
||||
<td style={{ padding: '0.8rem 0.75rem' }}>
|
||||
<Badge tone={statusMeta[agent.status]}>{statusMeta[agent.status].label}</Badge>
|
||||
</td>
|
||||
<td style={{ padding: '0.8rem 0.75rem' }}>{agent.open}</td>
|
||||
<td style={{ padding: '0.8rem 0.75rem', color: 'var(--color-text-soft)', fontWeight: 700 }}>{agent.lastClosed}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</DataPanel>
|
||||
|
||||
<DataPanel title="Fila de espera" description="Conversas não assumidas, ordenadas pelo maior tempo de espera.">
|
||||
<div style={{ display: 'grid', gap: '0.65rem' }}>
|
||||
{sortedQueue.map((item) => (
|
||||
<article
|
||||
key={item.id}
|
||||
style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 16,
|
||||
padding: '0.8rem',
|
||||
background: '#fff',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'auto minmax(0, 1fr) auto',
|
||||
gap: '0.75rem',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Badge tone={channelColors[item.channel]}>{item.channel}</Badge>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<strong style={{ display: 'block' }}>{item.contact}</strong>
|
||||
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.9rem', fontWeight: 700 }}>
|
||||
Aguardando há {item.waitingMinutes} min
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAssignmentTarget(item)}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: 12,
|
||||
padding: '0.65rem 0.8rem',
|
||||
background: 'var(--color-primary)',
|
||||
color: '#fff',
|
||||
fontWeight: 800,
|
||||
}}
|
||||
>
|
||||
Atribuir
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</DataPanel>
|
||||
</div>
|
||||
|
||||
<DataPanel title="Atendimentos finalizados por hora" description="Volume do dia entre 08h e 18h.">
|
||||
<HourlyBarChart values={data.hourly} />
|
||||
</DataPanel>
|
||||
|
||||
{assignmentTarget ? (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0, 49, 80, 0.28)',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
padding: '1rem',
|
||||
zIndex: 20,
|
||||
}}
|
||||
>
|
||||
<div style={{ width: 'min(460px, 100%)', background: '#fff', borderRadius: 22, padding: '1.25rem', boxShadow: 'var(--shadow-lg)', display: 'grid', gap: '1rem' }}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: '1.2rem' }}>Atribuir atendimento</h2>
|
||||
<p style={{ margin: '0.35rem 0 0', color: 'var(--color-text-soft)' }}>
|
||||
Selecione um atendente online para {assignmentTarget.contact}.
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gap: '0.55rem' }}>
|
||||
{onlineAgents.length ? onlineAgents.map((agent) => (
|
||||
<button
|
||||
key={agent.id}
|
||||
type="button"
|
||||
onClick={() => setAssignmentTarget(null)}
|
||||
style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 14,
|
||||
padding: '0.8rem 0.9rem',
|
||||
background: '#fff',
|
||||
color: 'var(--color-text)',
|
||||
fontWeight: 800,
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
{agent.name}
|
||||
</button>
|
||||
)) : (
|
||||
<span style={{ color: 'var(--color-text-soft)', fontWeight: 700 }}>Nenhum atendente online nesta especialidade.</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAssignmentTarget(null)}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: 14,
|
||||
padding: '0.8rem 1rem',
|
||||
background: 'rgba(0, 49, 80, 0.08)',
|
||||
color: 'var(--color-primary)',
|
||||
fontWeight: 800,
|
||||
}}
|
||||
>
|
||||
Fechar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
445
src/modules/management/components/TemplateManagementPanel.jsx
Normal file
445
src/modules/management/components/TemplateManagementPanel.jsx
Normal file
@ -0,0 +1,445 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { DataPanel } from './DataPanel';
|
||||
import {
|
||||
approveTemplateByAdmin,
|
||||
deleteTemplate,
|
||||
listTemplates,
|
||||
rejectTemplateByAdmin,
|
||||
saveTemplate,
|
||||
} from '../services/templateService';
|
||||
|
||||
const fieldStyle = {
|
||||
width: '100%',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '14px',
|
||||
padding: '0.75rem 0.85rem',
|
||||
background: '#fff',
|
||||
color: 'var(--color-text)',
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
const statusMeta = {
|
||||
approved: {
|
||||
label: 'Aprovado pela Meta',
|
||||
background: 'rgba(34, 197, 94, 0.12)',
|
||||
color: '#15803d',
|
||||
},
|
||||
meta_review: {
|
||||
label: 'Em análise pela Meta',
|
||||
background: 'rgba(229, 162, 42, 0.16)',
|
||||
color: '#8a5a00',
|
||||
},
|
||||
admin_review: {
|
||||
label: 'Aguardando aprovação do admin',
|
||||
background: 'rgba(0, 49, 80, 0.1)',
|
||||
color: 'var(--color-primary)',
|
||||
},
|
||||
rejected: {
|
||||
label: 'Reprovado pelo admin',
|
||||
background: 'rgba(181, 31, 31, 0.1)',
|
||||
color: 'var(--color-secondary)',
|
||||
},
|
||||
};
|
||||
|
||||
const templateCategories = [
|
||||
{ id: 'UTILITY', label: 'UTILITY', detail: 'Confirmações, lembretes e atualizações', cost: 'Menor custo' },
|
||||
{ id: 'MARKETING', label: 'MARKETING', detail: 'Promoções, ofertas e engajamento', cost: 'Maior custo' },
|
||||
{ id: 'AUTHENTICATION', label: 'AUTHENTICATION', detail: 'OTP e códigos de verificação', cost: 'Menor custo' },
|
||||
];
|
||||
|
||||
function getTemplateStatus(template) {
|
||||
return statusMeta[template.status] || statusMeta.approved;
|
||||
}
|
||||
|
||||
function getRemainingMetaText(template) {
|
||||
if (template.status !== 'meta_review' || !template.meta_submitted_at) return '';
|
||||
const submittedAt = new Date(template.meta_submitted_at).getTime();
|
||||
const approvedAt = submittedAt + 15 * 60 * 1000;
|
||||
const remainingMs = approvedAt - Date.now();
|
||||
if (remainingMs <= 0) return 'Aprovação disponível ao atualizar.';
|
||||
const minutes = Math.ceil(remainingMs / 60000);
|
||||
return `Aprovação em aproximadamente ${minutes} min.`;
|
||||
}
|
||||
|
||||
function renderTemplatePreview(content) {
|
||||
return String(content || 'Digite a mensagem do template...')
|
||||
.replace(/\{nome\}/gi, 'Ana Paula')
|
||||
.replace(/\{cliente\}/gi, 'Ana Paula')
|
||||
.replace(/\{data\}/gi, '26/05/2026')
|
||||
.replace(/\{link\}/gi, 'https://sothis.com.br/rh')
|
||||
.replace(/\{variavel\}/gi, 'informação personalizada')
|
||||
.replace(/\{variável\}/gi, 'informação personalizada');
|
||||
}
|
||||
|
||||
export function TemplateManagementPanel({
|
||||
areas = [],
|
||||
mode = 'admin',
|
||||
managedAreaNames = [],
|
||||
isMobile = false,
|
||||
}) {
|
||||
const [templates, setTemplates] = useState([]);
|
||||
const [selectedArea, setSelectedArea] = useState('all');
|
||||
const [form, setForm] = useState({ name: '', content: '', areaId: '', category: 'UTILITY' });
|
||||
const [statusMessage, setStatusMessage] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const isAdmin = mode === 'admin';
|
||||
const visibleAreas = isAdmin
|
||||
? areas
|
||||
: areas.filter((area) => managedAreaNames.includes(area.nome));
|
||||
|
||||
const filteredTemplates = useMemo(() => {
|
||||
return templates.filter((template) => {
|
||||
const areaMatches = selectedArea === 'all' || String(template.area_id || '') === selectedArea;
|
||||
const supervisorAreaMatches =
|
||||
isAdmin ||
|
||||
!template.area_nome ||
|
||||
managedAreaNames.includes(template.area_nome);
|
||||
return areaMatches && supervisorAreaMatches;
|
||||
});
|
||||
}, [templates, selectedArea, isAdmin, managedAreaNames]);
|
||||
|
||||
async function loadTemplates() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await listTemplates();
|
||||
setTemplates(Array.isArray(data) ? data : []);
|
||||
setStatusMessage('');
|
||||
} catch (error) {
|
||||
setStatusMessage(error.message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadTemplates();
|
||||
}, []);
|
||||
|
||||
async function submitTemplate(event) {
|
||||
event.preventDefault();
|
||||
const name = form.name.trim();
|
||||
const content = form.content.trim();
|
||||
if (!name || !content) return;
|
||||
|
||||
try {
|
||||
await saveTemplate({
|
||||
name,
|
||||
content,
|
||||
category: form.category,
|
||||
areaId: Number(form.areaId) || null,
|
||||
requestedByRole: isAdmin ? 'admin' : 'supervisor',
|
||||
});
|
||||
setForm({ name: '', content: '', areaId: '', category: 'UTILITY' });
|
||||
setStatusMessage(
|
||||
isAdmin
|
||||
? 'Template enviado para aprovação.'
|
||||
: 'Template enviado para aprovação do admin.',
|
||||
);
|
||||
await loadTemplates();
|
||||
} catch (error) {
|
||||
setStatusMessage(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function approveTemplate(templateId) {
|
||||
try {
|
||||
await approveTemplateByAdmin(templateId);
|
||||
setStatusMessage('Template aprovado pelo admin e enviado para análise da Meta.');
|
||||
await loadTemplates();
|
||||
} catch (error) {
|
||||
setStatusMessage(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function rejectTemplate(templateId) {
|
||||
try {
|
||||
await rejectTemplateByAdmin(templateId);
|
||||
setStatusMessage('Template reprovado pelo admin.');
|
||||
await loadTemplates();
|
||||
} catch (error) {
|
||||
setStatusMessage(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeTemplate(templateId) {
|
||||
try {
|
||||
await deleteTemplate(templateId);
|
||||
setStatusMessage('Template excluído.');
|
||||
await loadTemplates();
|
||||
} catch (error) {
|
||||
setStatusMessage(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section style={{ display: 'grid', gap: '1rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<label style={{ display: 'grid', gap: '0.35rem', width: isMobile ? '100%' : 280 }}>
|
||||
<span style={{ fontWeight: 800 }}>Filtrar por especialidade</span>
|
||||
<select value={selectedArea} onChange={(event) => setSelectedArea(event.target.value)} style={fieldStyle}>
|
||||
<option value="all">Todas as especialidades</option>
|
||||
{visibleAreas.map((area) => (
|
||||
<option key={area.id} value={area.id}>
|
||||
{area.nome}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<DataPanel
|
||||
title={isAdmin ? 'Templates WhatsApp' : 'Solicitar template'}
|
||||
description={
|
||||
isAdmin
|
||||
? 'Crie templates e aprove solicitações de supervisores antes do envio para a Meta.'
|
||||
: 'Templates enviados por supervisor passam primeiro pela aprovação do admin.'
|
||||
}
|
||||
>
|
||||
<form onSubmit={submitTemplate} style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 1fr) minmax(300px, 0.55fr)', gap: '1rem', alignItems: 'start' }}>
|
||||
<div style={{ display: 'grid', gap: '0.85rem' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 0.8fr) minmax(0, 0.7fr) minmax(220px, 0.55fr)', gap: '0.85rem' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))}
|
||||
placeholder="Identificador do template"
|
||||
style={fieldStyle}
|
||||
/>
|
||||
<select
|
||||
value={form.areaId}
|
||||
onChange={(event) => setForm((current) => ({ ...current, areaId: event.target.value }))}
|
||||
style={fieldStyle}
|
||||
>
|
||||
<option value="">Sem especialidade</option>
|
||||
{visibleAreas.map((area) => (
|
||||
<option key={area.id} value={area.id}>
|
||||
{area.nome}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={form.category}
|
||||
onChange={(event) => setForm((current) => ({ ...current, category: event.target.value }))}
|
||||
style={fieldStyle}
|
||||
>
|
||||
{templateCategories.map((category) => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.label} - {category.cost}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.45rem', flexWrap: 'wrap' }}>
|
||||
{['{nome}', '{data}', '{link}', '{variavel}'].map((variable) => (
|
||||
<button
|
||||
key={variable}
|
||||
type="button"
|
||||
onClick={() => setForm((current) => ({ ...current, content: `${current.content}${current.content ? ' ' : ''}${variable}` }))}
|
||||
style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 999,
|
||||
padding: '0.45rem 0.7rem',
|
||||
background: '#fff',
|
||||
color: 'var(--color-primary)',
|
||||
fontWeight: 800,
|
||||
}}
|
||||
>
|
||||
Adicionar {variable}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={form.content}
|
||||
onChange={(event) => setForm((current) => ({ ...current, content: event.target.value }))}
|
||||
placeholder="Mensagem do template. Ex: Olá, {nome}. Podemos seguir com seu atendimento por aqui?"
|
||||
rows={6}
|
||||
style={{ ...fieldStyle, resize: 'vertical', lineHeight: 1.5 }}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: 16,
|
||||
padding: '0.9rem 1rem',
|
||||
background: 'var(--color-primary)',
|
||||
color: '#fff',
|
||||
fontWeight: 800,
|
||||
width: 'fit-content',
|
||||
}}
|
||||
>
|
||||
{isAdmin ? 'Enviar para aprovação' : 'Enviar para admin'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<aside
|
||||
aria-label="Preview do template no WhatsApp"
|
||||
style={{
|
||||
borderRadius: 22,
|
||||
padding: '1rem',
|
||||
background: 'linear-gradient(180deg, #e8f3ee, #dcefe8)',
|
||||
border: '1px solid rgba(0, 49, 80, 0.08)',
|
||||
minHeight: 260,
|
||||
display: 'grid',
|
||||
alignContent: 'end',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'grid', gap: '0.45rem' }}>
|
||||
<span style={{ color: 'var(--color-text-soft)', fontWeight: 800, fontSize: '0.82rem' }}>
|
||||
Preview WhatsApp
|
||||
</span>
|
||||
<div
|
||||
style={{
|
||||
justifySelf: 'end',
|
||||
maxWidth: '92%',
|
||||
borderRadius: '16px 16px 4px 16px',
|
||||
padding: '0.85rem 0.95rem',
|
||||
background: '#d9fdd3',
|
||||
color: '#1f2c33',
|
||||
boxShadow: '0 6px 18px rgba(0, 49, 80, 0.08)',
|
||||
whiteSpace: 'pre-wrap',
|
||||
lineHeight: 1.45,
|
||||
fontSize: '0.94rem',
|
||||
}}
|
||||
>
|
||||
{renderTemplatePreview(form.content)}
|
||||
<span
|
||||
style={{
|
||||
display: 'block',
|
||||
marginTop: '0.5rem',
|
||||
textAlign: 'right',
|
||||
color: 'rgba(31, 44, 51, 0.58)',
|
||||
fontSize: '0.72rem',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
10:42
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</form>
|
||||
|
||||
{statusMessage ? (
|
||||
<div style={{ marginTop: '0.85rem', color: 'var(--color-primary)', fontWeight: 800 }}>
|
||||
{statusMessage}
|
||||
</div>
|
||||
) : null}
|
||||
</DataPanel>
|
||||
|
||||
<DataPanel title="Lista de templates" description={isLoading ? 'Carregando templates...' : 'Status do fluxo de aprovação.'}>
|
||||
<div style={{ display: 'grid', gap: '0.75rem', maxHeight: 520, overflowY: 'auto', paddingRight: '0.2rem' }}>
|
||||
{filteredTemplates.map((template) => {
|
||||
const status = getTemplateStatus(template);
|
||||
const remainingMetaText = getRemainingMetaText(template);
|
||||
|
||||
return (
|
||||
<article
|
||||
key={template.id}
|
||||
style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 18,
|
||||
padding: '1rem',
|
||||
background: '#fff',
|
||||
display: 'grid',
|
||||
gap: '0.65rem',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<strong style={{ display: 'block' }}>{template.name}</strong>
|
||||
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.9rem' }}>
|
||||
{template.area_nome || 'Sem especialidade'} · {template.category || 'UTILITY'}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
width: 'fit-content',
|
||||
borderRadius: 999,
|
||||
padding: '0.3rem 0.65rem',
|
||||
background: status.background,
|
||||
color: status.color,
|
||||
fontWeight: 800,
|
||||
fontSize: '0.82rem',
|
||||
}}
|
||||
>
|
||||
{status.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p style={{ margin: 0, color: 'var(--color-text-soft)', lineHeight: 1.5 }}>
|
||||
{template.content}
|
||||
</p>
|
||||
|
||||
{remainingMetaText ? (
|
||||
<span style={{ color: '#8a5a00', fontWeight: 700 }}>{remainingMetaText}</span>
|
||||
) : null}
|
||||
|
||||
{isAdmin ? (
|
||||
<div style={{ display: 'flex', gap: '0.55rem', flexWrap: 'wrap' }}>
|
||||
{template.status === 'admin_review' ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => approveTemplate(template.id)}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: 14,
|
||||
padding: '0.75rem 0.9rem',
|
||||
background: 'var(--color-primary)',
|
||||
color: '#fff',
|
||||
fontWeight: 800,
|
||||
}}
|
||||
>
|
||||
Aprovar e enviar para Meta
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => rejectTemplate(template.id)}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: 14,
|
||||
padding: '0.75rem 0.9rem',
|
||||
background: 'rgba(181, 31, 31, 0.1)',
|
||||
color: 'var(--color-secondary)',
|
||||
fontWeight: 800,
|
||||
}}
|
||||
>
|
||||
Reprovar
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeTemplate(template.id)}
|
||||
style={{
|
||||
border: '1px solid rgba(181, 31, 31, 0.22)',
|
||||
borderRadius: 14,
|
||||
padding: '0.75rem 0.9rem',
|
||||
background: '#fff',
|
||||
color: 'var(--color-secondary)',
|
||||
fontWeight: 800,
|
||||
}}
|
||||
>
|
||||
Excluir
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
|
||||
{!filteredTemplates.length ? (
|
||||
<span style={{ color: 'var(--color-text-soft)', fontWeight: 700 }}>
|
||||
Nenhum template encontrado para o filtro atual.
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</DataPanel>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
2146
src/modules/management/pages/AdminPage.jsx
Normal file
2146
src/modules/management/pages/AdminPage.jsx
Normal file
File diff suppressed because it is too large
Load Diff
119
src/modules/management/pages/SupervisorPage.jsx
Normal file
119
src/modules/management/pages/SupervisorPage.jsx
Normal file
@ -0,0 +1,119 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ManagementLayout } from '../components/ManagementLayout';
|
||||
import { OperationalDashboard } from '../components/OperationalDashboard';
|
||||
import { TemplateManagementPanel } from '../components/TemplateManagementPanel';
|
||||
import { KnowledgeBasePanel } from '../components/KnowledgeBasePanel';
|
||||
import { MassMessagePanel } from '../components/MassMessagePanel';
|
||||
import { DataPanel } from '../components/DataPanel';
|
||||
import { NewAttendancePage } from '../../attendance/pages/NewAttendancePage';
|
||||
import { ContactsPanel } from '../../home/pages/ContactsPage';
|
||||
import { getAccessOptions } from '../services/adminAccessService';
|
||||
import { useViewport } from '../../../shared/hooks/useViewport';
|
||||
import { getCurrentUser, getCurrentUserDisplay } from '../../auth/services/sessionService';
|
||||
import { AdminAttendanceWorkspace } from './AdminPage';
|
||||
|
||||
function getUserSpecialties(user) {
|
||||
const normalize = (area) => {
|
||||
if (!area) return null;
|
||||
if (typeof area === 'string') return area;
|
||||
return area.nome || area.name || null;
|
||||
};
|
||||
|
||||
const areas = Array.isArray(user?.areas) ? user.areas.map(normalize).filter(Boolean) : [];
|
||||
const primary = normalize(user?.areaPrincipal);
|
||||
return primary && !areas.includes(primary) ? [primary, ...areas] : areas;
|
||||
}
|
||||
|
||||
export function SupervisorPage() {
|
||||
const { isWideDesktop, isDesktop, isTablet, isMobile } = useViewport();
|
||||
const userDisplay = getCurrentUserDisplay();
|
||||
const currentUser = getCurrentUser();
|
||||
const managedSpecialties = getUserSpecialties(currentUser);
|
||||
const [activeSection, setActiveSection] = useState('dashboard');
|
||||
const [areas, setAreas] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
getAccessOptions()
|
||||
.then((options) => {
|
||||
if (isMounted) setAreas(options.areas || []);
|
||||
})
|
||||
.catch(() => {
|
||||
if (isMounted) setAreas([]);
|
||||
});
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
function renderPlaceholder(title, description) {
|
||||
return (
|
||||
<DataPanel title={title} description={description}>
|
||||
<div style={{ border: '1px solid var(--color-border)', borderRadius: 18, padding: '1rem', background: '#fff', color: 'var(--color-text-soft)', fontWeight: 700 }}>
|
||||
Secao em preparacao.
|
||||
</div>
|
||||
</DataPanel>
|
||||
);
|
||||
}
|
||||
|
||||
const sectionContent = {
|
||||
dashboard: <OperationalDashboard isDesktop={isDesktop} isMobile={isMobile} />,
|
||||
templates: (
|
||||
<TemplateManagementPanel
|
||||
areas={areas}
|
||||
mode="supervisor"
|
||||
managedAreaNames={managedSpecialties}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
),
|
||||
knowledge: (
|
||||
<KnowledgeBasePanel
|
||||
areas={areas}
|
||||
mode="supervisor"
|
||||
managedAreaNames={managedSpecialties}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
),
|
||||
'ai-contents': renderPlaceholder(
|
||||
'Conteúdos da IA',
|
||||
'A IA está em fase de testes. O cadastro e a curadoria da base ficam centralizados no admin.',
|
||||
),
|
||||
audit: renderPlaceholder('Auditoria', 'Eventos do time supervisionado serao consolidados aqui.'),
|
||||
attendance: (
|
||||
<AdminAttendanceWorkspace
|
||||
isWideDesktop={isWideDesktop}
|
||||
isDesktop={isDesktop}
|
||||
isTablet={isTablet}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
),
|
||||
'new-attendance': <NewAttendancePage embedded />,
|
||||
'mass-message': (
|
||||
<MassMessagePanel
|
||||
areas={areas}
|
||||
mode="supervisor"
|
||||
managedAreaNames={managedSpecialties}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
),
|
||||
contacts: <ContactsPanel embedded />,
|
||||
};
|
||||
|
||||
return (
|
||||
<ManagementLayout
|
||||
title="Painel do supervisor"
|
||||
subtitle="Indicadores do dia, fila de espera e acompanhamento operacional do time."
|
||||
activeSection="supervisor"
|
||||
profileLabel={userDisplay.name}
|
||||
initials={userDisplay.initials}
|
||||
isDesktop={isDesktop}
|
||||
isMobile={isMobile}
|
||||
activeNavItem={activeSection}
|
||||
onNavItemChange={setActiveSection}
|
||||
>
|
||||
{sectionContent[activeSection] || sectionContent.dashboard}
|
||||
</ManagementLayout>
|
||||
);
|
||||
}
|
||||
66
src/modules/management/pages/WhatsappAdminPage.jsx
Normal file
66
src/modules/management/pages/WhatsappAdminPage.jsx
Normal file
@ -0,0 +1,66 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { io } from 'socket.io-client';
|
||||
import { API_BASE_URL, WHATSAPP_SOCKET_URL } from '../../../shared/services/apiConfig';
|
||||
|
||||
export const WhatsappAdminPage = () => {
|
||||
const [qrCode, setQrCode] = useState(null);
|
||||
const [status, setStatus] = useState('DISCONNECTED');
|
||||
|
||||
useEffect(() => {
|
||||
// Conecta ao namespace /whatsapp
|
||||
const socket = io(WHATSAPP_SOCKET_URL);
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('Connected to WhatsApp WebSocket');
|
||||
fetch(`${API_BASE_URL}/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>
|
||||
);
|
||||
};
|
||||
97
src/modules/management/services/adminAccessService.js
Normal file
97
src/modules/management/services/adminAccessService.js
Normal file
@ -0,0 +1,97 @@
|
||||
import { API_BASE_URL } from '../../../shared/services/apiConfig';
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let message = 'Falha ao consultar acessos';
|
||||
try {
|
||||
const payload = await response.json();
|
||||
message = payload?.message || payload?.error || message;
|
||||
} catch {
|
||||
// Mantem mensagem padrao.
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function getAccessOptions() {
|
||||
return request('/admin/access/options');
|
||||
}
|
||||
|
||||
export async function getAdminOverview() {
|
||||
return request('/admin/access/overview');
|
||||
}
|
||||
|
||||
export async function getAttendantRanking(areaId) {
|
||||
const query = areaId ? `?areaId=${encodeURIComponent(areaId)}` : '';
|
||||
return request(`/admin/access/ranking${query}`);
|
||||
}
|
||||
|
||||
export async function getAuditLogs(page = 1, limit = 100) {
|
||||
return request(`/admin/access/audit?page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`);
|
||||
}
|
||||
|
||||
export async function getAiContents() {
|
||||
return request('/admin/access/ai-contents');
|
||||
}
|
||||
|
||||
export async function getAiContentFile(id) {
|
||||
return request(`/admin/access/ai-contents/${id}/file`);
|
||||
}
|
||||
|
||||
export async function createAiContent(payload) {
|
||||
return request('/admin/access/ai-contents', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteAiContent(id) {
|
||||
return request(`/admin/access/ai-contents/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAccessUsers() {
|
||||
return request('/admin/access/users');
|
||||
}
|
||||
|
||||
export async function getAccessAreas() {
|
||||
return request('/admin/access/areas');
|
||||
}
|
||||
|
||||
export async function updateUserAccess(userId, access) {
|
||||
return request(`/admin/access/users/${userId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(access),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createAccessArea(payload) {
|
||||
return request('/admin/access/areas', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateAccessArea(areaId, payload) {
|
||||
return request(`/admin/access/areas/${areaId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteAccessArea(areaId) {
|
||||
return request(`/admin/access/areas/${areaId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
124
src/modules/management/services/knowledgeService.js
Normal file
124
src/modules/management/services/knowledgeService.js
Normal file
@ -0,0 +1,124 @@
|
||||
import { API_BASE_URL } from '../../../shared/services/apiConfig';
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let message = 'Falha ao consultar base de conhecimento.';
|
||||
try {
|
||||
const payload = await response.json();
|
||||
message = Array.isArray(payload?.message)
|
||||
? payload.message.join(' ')
|
||||
: payload?.message || payload?.error || message;
|
||||
} catch {
|
||||
// Mantem a mensagem padrao quando a API nao devolve JSON.
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export function listRoutingKeywords(areaId) {
|
||||
const query = areaId ? `?areaId=${encodeURIComponent(areaId)}` : '';
|
||||
return request(`/admin/knowledge/routing-keywords${query}`);
|
||||
}
|
||||
|
||||
export function getBotFlow() {
|
||||
return request('/admin/knowledge/bot-flow');
|
||||
}
|
||||
|
||||
export function listBotFlowVersions() {
|
||||
return request('/admin/knowledge/bot-flow/versions');
|
||||
}
|
||||
|
||||
export function createBotFlowNode(payload) {
|
||||
return request('/admin/knowledge/bot-flow/nodes', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateBotFlowNode(id, payload) {
|
||||
return request(`/admin/knowledge/bot-flow/nodes/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteBotFlowNode(id) {
|
||||
return request(`/admin/knowledge/bot-flow/nodes/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
export function publishBotFlow() {
|
||||
return request('/admin/knowledge/bot-flow/publish', {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export function getTriageFlow() {
|
||||
return request('/admin/knowledge/triage-flow');
|
||||
}
|
||||
|
||||
export function updateTriageFlow(payload) {
|
||||
return request('/admin/knowledge/triage-flow', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function createTriageAudience(payload) {
|
||||
return request('/admin/knowledge/triage-flow/audiences', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateTriageAudience(id, payload) {
|
||||
return request(`/admin/knowledge/triage-flow/audiences/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function createTriageIntent(payload) {
|
||||
return request('/admin/knowledge/triage-flow/intents', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateTriageIntent(id, payload) {
|
||||
return request(`/admin/knowledge/triage-flow/intents/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function createRoutingKeyword(payload) {
|
||||
return request('/admin/knowledge/routing-keywords', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateRoutingKeyword(id, payload) {
|
||||
return request(`/admin/knowledge/routing-keywords/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteRoutingKeyword(id) {
|
||||
return request(`/admin/knowledge/routing-keywords/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
106
src/modules/management/services/managementMocks.js
Normal file
106
src/modules/management/services/managementMocks.js
Normal file
@ -0,0 +1,106 @@
|
||||
export const supervisorMetrics = [
|
||||
{ label: 'Atendimentos abertos', value: '42', detail: '12 aguardando agente' },
|
||||
{ label: 'SLA em risco', value: '7', detail: 'Financeiro concentra 4 casos' },
|
||||
{ label: 'Agentes online', value: '18', detail: '3 em pausa operacional' },
|
||||
{ label: 'Transferências hoje', value: '23', detail: 'Tempo médio 4m 20s' },
|
||||
];
|
||||
|
||||
export const adminMetrics = [
|
||||
{ label: 'Usuários ativos', value: '64', detail: '8 supervisores configurados' },
|
||||
{ label: 'Especialidades cadastradas', value: '3', detail: 'Suporte, Financeiro e Comercial' },
|
||||
{ label: 'Conteúdos IA', value: '28', detail: '6 aguardando revisão' },
|
||||
{ label: 'Canais conectados', value: '1', detail: 'WhatsApp em homologação' },
|
||||
];
|
||||
|
||||
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: 'Revisão',
|
||||
updatedAt: 'Segunda',
|
||||
},
|
||||
];
|
||||
53
src/modules/management/services/templateService.js
Normal file
53
src/modules/management/services/templateService.js
Normal file
@ -0,0 +1,53 @@
|
||||
import { API_BASE_URL } from '../../../shared/services/apiConfig';
|
||||
|
||||
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 templates.');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export function listTemplates() {
|
||||
return request('/whatsapp/templates');
|
||||
}
|
||||
|
||||
export function saveTemplate(payload) {
|
||||
return request('/whatsapp/templates', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateTemplate(id, payload) {
|
||||
return request(`/whatsapp/templates/update/${id}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function approveTemplateByAdmin(id) {
|
||||
return request(`/whatsapp/templates/approve-admin/${id}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export function rejectTemplateByAdmin(id) {
|
||||
return request(`/whatsapp/templates/reject-admin/${id}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteTemplate(id) {
|
||||
return request(`/whatsapp/templates/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
@ -1,9 +1,12 @@
|
||||
import { createBrowserRouter, Navigate } from 'react-router-dom';
|
||||
import { LoginPage } from '../modules/auth/pages/LoginPage';
|
||||
import { HomePage } from '../modules/home/pages/HomePage';
|
||||
import { ProfileHomePage } from '../modules/home/pages/ProfileHomePage';
|
||||
import { AgentMassMessagePage } from '../modules/home/pages/AgentMassMessagePage';
|
||||
import { ContactsPage } from '../modules/home/pages/ContactsPage';
|
||||
import { ChatPage } from '../modules/chat/pages/ChatPage';
|
||||
import { CallPage } from '../modules/call/pages/CallPage';
|
||||
import { NewAttendancePage } from '../modules/attendance/pages/NewAttendancePage';
|
||||
import { AgentNewAttendancePage } from '../modules/attendance/pages/AgentNewAttendancePage';
|
||||
import { WhatsappAdminPage } from '../modules/management/pages/WhatsappAdminPage';
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
@ -16,7 +19,7 @@ export const router = createBrowserRouter([
|
||||
},
|
||||
{
|
||||
path: '/home',
|
||||
element: <HomePage />,
|
||||
element: <ProfileHomePage />,
|
||||
},
|
||||
{
|
||||
path: '/chat',
|
||||
@ -28,6 +31,18 @@ export const router = createBrowserRouter([
|
||||
},
|
||||
{
|
||||
path: '/new-attendance',
|
||||
element: <NewAttendancePage />,
|
||||
element: <AgentNewAttendancePage />,
|
||||
},
|
||||
{
|
||||
path: '/mass-message',
|
||||
element: <AgentMassMessagePage />,
|
||||
},
|
||||
{
|
||||
path: '/contacts',
|
||||
element: <ContactsPage />,
|
||||
},
|
||||
{
|
||||
path: '/admin/whatsapp',
|
||||
element: <WhatsappAdminPage />,
|
||||
},
|
||||
]);
|
||||
|
||||
59
src/shared/hooks/useWhatsappSocket.js
Normal file
59
src/shared/hooks/useWhatsappSocket.js
Normal file
@ -0,0 +1,59 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import io from 'socket.io-client';
|
||||
import { API_BASE_URL, WHATSAPP_SOCKET_URL } from '../services/apiConfig';
|
||||
|
||||
export function useWhatsappSocket() {
|
||||
const [socket, setSocket] = useState(null);
|
||||
const [qrCode, setQrCode] = useState(null);
|
||||
const [status, setStatus] = useState('DISCONNECTED');
|
||||
const [incomingMessage, setIncomingMessage] = useState(null);
|
||||
const socketRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (socketRef.current) return;
|
||||
|
||||
// Conectar ao namespace /whatsapp
|
||||
const newSocket = io(WHATSAPP_SOCKET_URL, {
|
||||
reconnectionAttempts: 5,
|
||||
});
|
||||
|
||||
socketRef.current = newSocket;
|
||||
setSocket(newSocket);
|
||||
|
||||
newSocket.on('connect', () => {
|
||||
console.log('Conectado ao WebSocket do WhatsApp');
|
||||
// Fetch status atual
|
||||
fetch(`${API_BASE_URL}/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);
|
||||
});
|
||||
|
||||
return () => {
|
||||
newSocket.disconnect();
|
||||
socketRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
socket,
|
||||
qrCode,
|
||||
status,
|
||||
incomingMessage,
|
||||
clearIncomingMessage: () => setIncomingMessage(null),
|
||||
};
|
||||
}
|
||||
2
src/shared/services/apiConfig.js
Normal file
2
src/shared/services/apiConfig.js
Normal file
@ -0,0 +1,2 @@
|
||||
export const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||
export const WHATSAPP_SOCKET_URL = `${API_BASE_URL}/whatsapp`;
|
||||
Loading…
Reference in New Issue
Block a user