Compare commits
No commits in common. "dfc47ce7e8e2a19858587e368ee9846f28d20ed7" and "f08a65de287105604c4ec6de93861147779bd2a9" have entirely different histories.
dfc47ce7e8
...
f08a65de28
@ -1,3 +0,0 @@
|
|||||||
# Frontend environment variables (Vite)
|
|
||||||
|
|
||||||
VITE_API_URL=http://localhost:3001
|
|
||||||
32
.gitignore
vendored
32
.gitignore
vendored
@ -1,31 +1 @@
|
|||||||
# Dependencies
|
node_modules
|
||||||
node_modules/
|
|
||||||
|
|
||||||
# Build output
|
|
||||||
dist/
|
|
||||||
|
|
||||||
# Local environment files
|
|
||||||
.env
|
|
||||||
.env.local
|
|
||||||
.env.development
|
|
||||||
.env.development.local
|
|
||||||
.env.production
|
|
||||||
.env.production.local
|
|
||||||
.env.test
|
|
||||||
.env.test.local
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
|
|
||||||
# Vite cache
|
|
||||||
.vite/
|
|
||||||
|
|
||||||
# Editor and OS files
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
.idea/
|
|
||||||
.vscode/
|
|
||||||
89
README.md
89
README.md
@ -1,89 +0,0 @@
|
|||||||
# 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
Normal file
BIN
dist/assets/favicon_blue-CzkOczz3.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.8 KiB |
68
dist/assets/index-1xjqdjIG.js
vendored
Normal file
68
dist/assets/index-1xjqdjIG.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/assets/index-BsY34Fgu.css
vendored
Normal file
1
dist/assets/index-BsY34Fgu.css
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
:root{font-family:Segoe UI,Helvetica Neue,sans-serif;color:#122230;background:radial-gradient(circle at top left,rgba(0,164,183,.12),transparent 28%),radial-gradient(circle at bottom right,rgba(229,162,42,.14),transparent 24%),#f5f8fb;color-scheme:light;--color-primary: #003150;--color-secondary: #b51f1f;--color-accent: #00a4b7;--color-highlight: #e5a22a;--color-surface: rgba(255, 255, 255, .9);--color-surface-strong: #ffffff;--color-text: #122230;--color-text-soft: #5e6d7b;--color-border: rgba(0, 49, 80, .12);--shadow-lg: 0 24px 60px rgba(0, 49, 80, .12);--shadow-md: 0 12px 28px rgba(0, 49, 80, .08)}*{box-sizing:border-box}html,body,#root{min-height:100%;margin:0}body{min-height:100vh}body,button,input{font:inherit}button{cursor:pointer}a{color:inherit;text-decoration:none}
|
||||||
BIN
dist/assets/logo_white_dark_mode-BKcVSu03.png
vendored
Normal file
BIN
dist/assets/logo_white_dark_mode-BKcVSu03.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
BIN
dist/assets/logo_white_mode-BIHgqUPv.png
vendored
Normal file
BIN
dist/assets/logo_white_mode-BIHgqUPv.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
14
dist/index.html
vendored
Normal file
14
dist/index.html
vendored
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="pt-BR">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" type="image/png" href="/assets/favicon_blue-CzkOczz3.png" />
|
||||||
|
<title>Omnichannel Sothis</title>
|
||||||
|
<script type="module" crossorigin src="/assets/index-1xjqdjIG.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-BsY34Fgu.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -1,88 +0,0 @@
|
|||||||
# Modulo de Chat WhatsApp (Frontend)
|
|
||||||
|
|
||||||
## Visao geral
|
|
||||||
|
|
||||||
O modulo de Chat no frontend integra as conversas em tempo real do WhatsApp diretamente na tela de atendimento do operador.
|
|
||||||
|
|
||||||
A interface e altamente responsiva, provendo feedback instantaneo de envio (zero latencia) e sincronizando com o backend via WebSockets (Socket.io) para atualizar estados de de-duplicacao, novas mensagens, midias e controle de posse do atendimento.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Componentes Principais
|
|
||||||
|
|
||||||
### 1. Hook de Negocio (`useChat.js`)
|
|
||||||
Centraliza todo o estado das conversas, conexao WebSocket e operacoes de rede:
|
|
||||||
* **`contacts`**: Lista de chats ativos sincronizados. Cada contato possui um objeto `assignment` (atribuicao) normalizado.
|
|
||||||
* **`messagesByContact`**: Map de historico de mensagens por JID/contato.
|
|
||||||
* **`takeChat()`**: Dispara a requisicao de rede `/whatsapp/assign` enviando o ID do atendente e o ID numerico da area do usuario logado (convertido com seguranca para inteiro).
|
|
||||||
* **`sendMessage()`**: Trata a de-duplicacao de mensagens em milissegundos e gerencia a concorrência (race condition).
|
|
||||||
|
|
||||||
### 2. Painel de Atendimento (`ChatWindow.jsx`)
|
|
||||||
O container principal da conversa selecionada. Ele renderiza:
|
|
||||||
* **Header**: Mostra o nome resolvido do cliente, canal (WhatsApp) e o indicador de quem esta atendendo.
|
|
||||||
* **Historico**: Area de scroll contendo as bolhas de mensagens do atendente (`agent`) e do cliente (`customer`), incluindo visualizadores para imagens, audios e links de arquivos.
|
|
||||||
* **Footer de Input**: Caixa de texto com suporte a tecla Enter e icone de anexo de midia (com validacao automatica de tamanho).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Mecanismos de UX e Estabilidade
|
|
||||||
|
|
||||||
### 1. Insercao Instantanea (UX Zero-Latency)
|
|
||||||
Para evitar que o atendente perceba qualquer latencia de rede, o envio e dividido em duas etapas:
|
|
||||||
1. **Fase Local**: A bolha de mensagem e inserida na tela imediatamente com um ID temporario (`temp-` + timestamp) e o texto digitado. O input de texto e arquivos e limpo na mesma hora.
|
|
||||||
2. **Fase de Disparo**: A requisicao HTTP POST e disparada para o backend em segundo plano.
|
|
||||||
|
|
||||||
### 2. De-duplicacao de Mensagens (Prevecao de Race Condition)
|
|
||||||
Como o backend envia a mensagem recebida via WebSocket assim que o Puppeteer a dispara, a bolha poderia aparecer duplicada na tela se a requisição de envio original ainda estivesse processando.
|
|
||||||
* **A Solucao**: O hook de WebSocket compara as mensagens recebidas em tempo real. Se o texto bater e a diferenca temporal de timestamp for inferior a 4 segundos, ele identifica a bolha `temp-...` local, remove o prefixo temporario e atualiza-a com o ID oficial do WhatsApp gerado no servidor. **Zero duplicacoes, zero flashes na tela.**
|
|
||||||
|
|
||||||
### 3. Validação de Posse (Type-Safe User IDs)
|
|
||||||
Para evitar conflitos na exibicao do banner *"⚠️ Atendido por outro colaborador"*, realizamos casting explicito dos IDs dos usuarios envolvidos:
|
|
||||||
```javascript
|
|
||||||
const isAssignedToMe = activeContact?.assignment?.userId && String(activeContact.assignment.userId) === String(currentUser.id);
|
|
||||||
const isAssignedToOthers = activeContact?.assignment && String(activeContact.assignment.userId) !== String(currentUser.id);
|
|
||||||
```
|
|
||||||
Isso impede que comparacoes como `4 === "4"` (inteiro vindo do banco relacional vs string vindo do localStorage/JWT) avaliem incorretamente como falso, mantendo a tela bloqueada ou liberada com precisao.
|
|
||||||
|
|
||||||
### 4. Layout e Rolagem Estrita (680px Scroll)
|
|
||||||
A interface de mensagens possui limitacoes verticais restritas para evitar que a tela se alongue infinitamente para baixo.
|
|
||||||
* A bolha de historico e fixada com altura proporcional (`height: 680px` ou `calc`) e controle de transbordo `overflow-y: auto`.
|
|
||||||
* O hook de chat escuta mudancas na lista de mensagens e realiza rolagem automatica suave (`smooth`) para o fim da tela sempre que uma nova bolha e adicionada.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Novos Fluxos Homologados (WhatsApp / Meta)
|
|
||||||
|
|
||||||
### 1. Novo Atendimento Inteligente (`NewAttendancePage.jsx`)
|
|
||||||
* **Remoção do Seletor de Área**: O seletor manual foi removido da tela para simplificar a operação. O sistema resolve a área dinamicamente a partir do atendente logado (`currentUser.areaPrincipal` ou `areas[0]`).
|
|
||||||
* **Bloqueio de Campo**: Ao escolher um contato dos recentes ou da busca lateral, o input do telefone e do nome do cliente ficam bloqueados para escrita.
|
|
||||||
* **Modo "Novo Número"**: Ao clicar no botão, o operador habilita os inputs de nome e telefone. Caso inicie o chat sem digitar um nome personalizado, o sistema aplica um fallback limpo no formato `Contato Novo (+55...)`.
|
|
||||||
|
|
||||||
### 2. Bloqueio e Envio de Templates Meta (`ChatWindow.jsx`)
|
|
||||||
Como a API oficial do WhatsApp/Meta exige uma mensagem pré-aprovada para iniciar conversas ativas (sem histórico prévio), a interface aplica travas estritas:
|
|
||||||
* **Travamento do Input**: Se a conversa selecionada possuir histórico de envio vazio (`!hasAgentMessages`), a caixa de texto principal e o botão "Enviar" ficam bloqueados.
|
|
||||||
* **Painel de Templates**: Logo acima do rodapé de digitação, renderiza-se um seletor horizontal com os templates oficiais Meta ativos no banco (buscados de `GET /whatsapp/templates`).
|
|
||||||
* **Substituição Dinâmica**: Ao clicar em um template, as variáveis `|NOME|`, `|DATA|` ou `|PROTOCOLO|` são interpoladas em tempo real com os dados do cliente, populando o input principal e liberando o fluxo de envio da primeira mensagem.
|
|
||||||
|
|
||||||
### 3. Gerenciamento de Templates para Supervisores (`SupervisorPage.jsx`)
|
|
||||||
Supervisores possuem controle administrativo total sobre as mensagens homologadas:
|
|
||||||
* **CRUD de Modelos**: Exibe todos os templates de WhatsApp em formato de cards visuais.
|
|
||||||
* **Painel de Edição**: Permite criar novos templates ou editar identificadores/conteúdos de templates existentes. As alterações persistem imediatamente no banco PostgreSQL por meio dos endpoints `/whatsapp/templates`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Como Integrar e Rodar
|
|
||||||
|
|
||||||
### Variaveis de Ambiente
|
|
||||||
O frontend conecta no WebSocket e na API do backend usando a porta padrao do NestJS:
|
|
||||||
```env
|
|
||||||
VITE_API_URL=http://localhost:3001
|
|
||||||
VITE_WS_URL=http://localhost:3001
|
|
||||||
```
|
|
||||||
|
|
||||||
### Compilando e Rodando localmente
|
|
||||||
```bash
|
|
||||||
cd frontend
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
Ao selecionar uma conversa de canal "WhatsApp" que esteja livre, basta digitar uma mensagem e pressionar Enter. O chat sera automaticamente assumido por voce em tempo real, gravando no PostgreSQL e desbloqueando a janela de chat de forma instantanea.
|
|
||||||
95
package-lock.json
generated
95
package-lock.json
generated
@ -10,8 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.30.1",
|
"react-router-dom": "^6.30.1"
|
||||||
"socket.io-client": "^4.8.3"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "^4.3.3",
|
"@vitejs/plugin-react": "^4.3.3",
|
||||||
@ -49,7 +48,6 @@
|
|||||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.0",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.0",
|
||||||
@ -1108,12 +1106,6 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@socket.io/component-emitter": {
|
|
||||||
"version": "3.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
|
||||||
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/babel__core": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
@ -1220,7 +1212,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@ -1267,6 +1258,7 @@
|
|||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
@ -1287,28 +1279,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/engine.io-client": {
|
|
||||||
"version": "6.6.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz",
|
|
||||||
"integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@socket.io/component-emitter": "~3.1.0",
|
|
||||||
"debug": "~4.4.1",
|
|
||||||
"engine.io-parser": "~5.2.1",
|
|
||||||
"ws": "~8.18.3",
|
|
||||||
"xmlhttprequest-ssl": "~2.1.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/engine.io-parser": {
|
|
||||||
"version": "5.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
|
|
||||||
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||||
@ -1441,6 +1411,7 @@
|
|||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
@ -1510,7 +1481,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0"
|
"loose-envify": "^1.1.0"
|
||||||
},
|
},
|
||||||
@ -1523,7 +1493,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0",
|
"loose-envify": "^1.1.0",
|
||||||
"scheduler": "^0.23.2"
|
"scheduler": "^0.23.2"
|
||||||
@ -1638,34 +1607,6 @@
|
|||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/socket.io-client": {
|
|
||||||
"version": "4.8.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
|
|
||||||
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@socket.io/component-emitter": "~3.1.0",
|
|
||||||
"debug": "~4.4.1",
|
|
||||||
"engine.io-client": "~6.6.1",
|
|
||||||
"socket.io-parser": "~4.2.4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/socket.io-parser": {
|
|
||||||
"version": "4.2.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
|
|
||||||
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@socket.io/component-emitter": "~3.1.0",
|
|
||||||
"debug": "~4.4.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@ -1713,7 +1654,6 @@
|
|||||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.21.3",
|
"esbuild": "^0.21.3",
|
||||||
"postcss": "^8.4.43",
|
"postcss": "^8.4.43",
|
||||||
@ -1768,35 +1708,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ws": {
|
|
||||||
"version": "8.18.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
|
||||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"bufferutil": "^4.0.1",
|
|
||||||
"utf-8-validate": ">=5.0.2"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"bufferutil": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"utf-8-validate": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/xmlhttprequest-ssl": {
|
|
||||||
"version": "2.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
|
|
||||||
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|||||||
@ -11,8 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.30.1",
|
"react-router-dom": "^6.30.1"
|
||||||
"socket.io-client": "^4.8.3"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "^4.3.3",
|
"@vitejs/plugin-react": "^4.3.3",
|
||||||
|
|||||||
@ -2,6 +2,7 @@ export function RecentContactsList({
|
|||||||
contacts,
|
contacts,
|
||||||
activeContactId,
|
activeContactId,
|
||||||
onSelectContact,
|
onSelectContact,
|
||||||
|
selectedChannel,
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
@ -24,6 +25,11 @@ export function RecentContactsList({
|
|||||||
<div style={{ display: 'grid', gap: '0.75rem' }}>
|
<div style={{ display: 'grid', gap: '0.75rem' }}>
|
||||||
{contacts.map((contact) => {
|
{contacts.map((contact) => {
|
||||||
const isActive = contact.id === activeContactId;
|
const isActive = contact.id === activeContactId;
|
||||||
|
const isPreferred = selectedChannel === 'call'
|
||||||
|
? contact.channel === 'Ligacao'
|
||||||
|
: selectedChannel === 'sms'
|
||||||
|
? contact.channel === 'SMS'
|
||||||
|
: contact.channel === 'WhatsApp';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@ -43,22 +49,24 @@ export function RecentContactsList({
|
|||||||
>
|
>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
|
||||||
<strong>{contact.name}</strong>
|
<strong>{contact.name}</strong>
|
||||||
|
{isPreferred ? (
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
padding: '0.2rem 0.5rem',
|
padding: '0.2rem 0.5rem',
|
||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
background: 'rgba(43, 183, 65, 0.12)',
|
background: 'rgba(0, 164, 183, 0.12)',
|
||||||
color: '#25883a',
|
color: 'var(--color-primary)',
|
||||||
fontSize: '0.76rem',
|
fontSize: '0.76rem',
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Agenda
|
Sugerido
|
||||||
</span>
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<span style={{ color: 'var(--color-text-soft)' }}>{contact.phone}</span>
|
<span style={{ color: 'var(--color-text-soft)' }}>{contact.phone}</span>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
|
||||||
<span style={{ color: 'var(--color-primary)', fontWeight: 700 }}>WhatsApp</span>
|
<span style={{ color: 'var(--color-primary)', fontWeight: 700 }}>{contact.channel}</span>
|
||||||
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.85rem' }}>
|
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.85rem' }}>
|
||||||
{contact.lastContact}
|
{contact.lastContact}
|
||||||
</span>
|
</span>
|
||||||
@ -66,22 +74,6 @@ export function RecentContactsList({
|
|||||||
</button>
|
</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>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,138 +0,0 @@
|
|||||||
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,363 +1,69 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { BrandMark } from '../../../shared/components/BrandMark';
|
import { BrandMark } from '../../../shared/components/BrandMark';
|
||||||
import { useViewport } from '../../../shared/hooks/useViewport';
|
import { useViewport } from '../../../shared/hooks/useViewport';
|
||||||
import { API_BASE_URL } from '../../../shared/services/apiConfig';
|
import { RecentContactsList } from '../components/RecentContactsList';
|
||||||
import { getCurrentUser } from '../../auth/services/sessionService';
|
import {
|
||||||
import { listContactProfiles, saveContactProfile } from '../../chat/services/contactProfileService';
|
attendanceAreas,
|
||||||
import { getAccessOptions } from '../../management/services/adminAccessService';
|
attendanceChannels,
|
||||||
import { attendanceChannels } from '../services/attendanceMocks';
|
recentContacts,
|
||||||
|
} from '../services/attendanceMocks';
|
||||||
|
|
||||||
const countryOptions = [
|
export function NewAttendancePage() {
|
||||||
{ 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 navigate = useNavigate();
|
||||||
const { isWideDesktop, isDesktop, isTablet, isMobile } = useViewport();
|
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 [searchValue, setSearchValue] = useState('');
|
||||||
const [selectedChannelId, setSelectedChannelId] = useState('whatsapp');
|
const [selectedChannelId, setSelectedChannelId] = useState('whatsapp');
|
||||||
const [selectedContactId, setSelectedContactId] = useState('');
|
const [selectedArea, setSelectedArea] = useState('');
|
||||||
const [selectedTemplateId, setSelectedTemplateId] = useState('');
|
const [selectedContactId, setSelectedContactId] = useState(recentContacts[0].id);
|
||||||
const [selectedCountryId, setSelectedCountryId] = useState('br');
|
const [customNumber, setCustomNumber] = useState('');
|
||||||
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 search = searchValue.trim().toLowerCase();
|
||||||
const filteredContacts = useMemo(() => {
|
const filteredContacts = useMemo(() => {
|
||||||
if (!search) {
|
if (!search) {
|
||||||
return contacts;
|
return recentContacts;
|
||||||
}
|
}
|
||||||
|
|
||||||
return contacts.filter((contact) => {
|
return recentContacts.filter((contact) => {
|
||||||
const haystack = `${contact.name} ${contact.phone} ${contact.rawPhone} ${contact.company} ${contact.note}`.toLowerCase();
|
const haystack = `${contact.name} ${contact.phone} ${contact.channel}`.toLowerCase();
|
||||||
return haystack.includes(search);
|
return haystack.includes(search);
|
||||||
});
|
});
|
||||||
}, [contacts, search]);
|
}, [search]);
|
||||||
|
|
||||||
|
const selectedContact =
|
||||||
|
filteredContacts.find((contact) => contact.id === selectedContactId) ||
|
||||||
|
recentContacts.find((contact) => contact.id === selectedContactId) ||
|
||||||
|
recentContacts[0];
|
||||||
|
|
||||||
const selectedChannel =
|
const selectedChannel =
|
||||||
attendanceChannels.find((channel) => channel.id === selectedChannelId) || attendanceChannels[0];
|
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
|
const gridTemplateColumns = isMobile
|
||||||
? '1fr'
|
? '1fr'
|
||||||
: isWideDesktop
|
: isWideDesktop
|
||||||
? 'minmax(0, 1fr) minmax(340px, 0.8fr)'
|
? 'minmax(300px, 360px) minmax(0, 1fr)'
|
||||||
: isDesktop || isTablet
|
: isDesktop || isTablet
|
||||||
? 'minmax(0, 1fr) minmax(320px, 0.85fr)'
|
? 'minmax(280px, 340px) minmax(0, 1fr)'
|
||||||
: '1fr';
|
: '1fr';
|
||||||
|
|
||||||
function selectContact(contactId) {
|
function handleStartAttendance() {
|
||||||
const contact = contacts.find((item) => item.id === contactId);
|
navigate(selectedChannel.route);
|
||||||
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 || '',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearSelection() {
|
return (
|
||||||
setSelectedContactId('');
|
<main style={{ minHeight: '100vh', padding: '1.5rem' }}>
|
||||||
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
|
<section
|
||||||
style={{
|
style={{
|
||||||
width: embedded ? '100%' : 'min(1680px, calc(100vw - 3rem))',
|
width: 'min(1680px, calc(100vw - 3rem))',
|
||||||
margin: embedded ? 0 : '0 auto',
|
margin: '0 auto',
|
||||||
background: 'var(--color-surface-strong)',
|
background: 'var(--color-surface-strong)',
|
||||||
borderRadius: embedded ? 0 : '32px',
|
borderRadius: '32px',
|
||||||
boxShadow: embedded ? 'none' : 'var(--shadow-lg)',
|
boxShadow: 'var(--shadow-lg)',
|
||||||
padding: embedded ? 0 : '1.5rem',
|
padding: '1.5rem',
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gap: '1.25rem',
|
gap: '1.25rem',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{!embedded ? (
|
|
||||||
<header
|
<header
|
||||||
style={{
|
style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
@ -378,7 +84,7 @@ export function NewAttendancePage({ embedded = false }) {
|
|||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Criação rápida de atendimento
|
Criacao rapida de atendimento
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
to="/home"
|
to="/home"
|
||||||
@ -395,7 +101,6 @@ export function NewAttendancePage({ embedded = false }) {
|
|||||||
Voltar para home
|
Voltar para home
|
||||||
</Link>
|
</Link>
|
||||||
</header>
|
</header>
|
||||||
) : null}
|
|
||||||
|
|
||||||
<section
|
<section
|
||||||
style={{
|
style={{
|
||||||
@ -405,6 +110,13 @@ export function NewAttendancePage({ embedded = false }) {
|
|||||||
alignItems: 'start',
|
alignItems: 'start',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<RecentContactsList
|
||||||
|
contacts={filteredContacts}
|
||||||
|
activeContactId={selectedContact.id}
|
||||||
|
onSelectContact={setSelectedContactId}
|
||||||
|
selectedChannel={selectedChannelId}
|
||||||
|
/>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
style={{
|
style={{
|
||||||
background: '#fff',
|
background: '#fff',
|
||||||
@ -418,11 +130,47 @@ export function NewAttendancePage({ embedded = false }) {
|
|||||||
<div>
|
<div>
|
||||||
<strong style={{ display: 'block', fontSize: '1.18rem' }}>Novo atendimento</strong>
|
<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 }}>
|
<p style={{ margin: '0.45rem 0 0', color: 'var(--color-text-soft)', lineHeight: 1.6 }}>
|
||||||
Informe um contato de WhatsApp ou selecione alguém da agenda para iniciar o atendimento.
|
Escolha o contato, o canal e a area opcional antes de iniciar. O fluxo e mockado
|
||||||
Para conversas novas, o primeiro envio usa uma mensagem pré-aprovada da Meta.
|
e leva voce direto para chat ou ligacao.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
@ -432,16 +180,12 @@ export function NewAttendancePage({ embedded = false }) {
|
|||||||
>
|
>
|
||||||
{attendanceChannels.map((channel) => {
|
{attendanceChannels.map((channel) => {
|
||||||
const isActive = channel.id === selectedChannelId;
|
const isActive = channel.id === selectedChannelId;
|
||||||
const isDisabled = Boolean(channel.disabled);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={channel.id}
|
key={channel.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => setSelectedChannelId(channel.id)}
|
||||||
if (!isDisabled) setSelectedChannelId(channel.id);
|
|
||||||
}}
|
|
||||||
disabled={isDisabled}
|
|
||||||
style={{
|
style={{
|
||||||
border: '1px solid',
|
border: '1px solid',
|
||||||
borderColor: isActive ? `${channel.accent}44` : 'var(--color-border)',
|
borderColor: isActive ? `${channel.accent}44` : 'var(--color-border)',
|
||||||
@ -451,138 +195,21 @@ export function NewAttendancePage({ embedded = false }) {
|
|||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gap: '0.45rem',
|
gap: '0.45rem',
|
||||||
opacity: isDisabled ? 0.58 : 1,
|
|
||||||
cursor: isDisabled ? 'not-allowed' : 'pointer',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<strong style={{ color: isActive ? channel.accent : 'var(--color-text)' }}>
|
<strong style={{ color: isActive ? channel.accent : 'var(--color-text)' }}>
|
||||||
{channel.label}
|
{channel.label}
|
||||||
</strong>
|
</strong>
|
||||||
<span style={{ color: 'var(--color-text-soft)' }}>
|
<span style={{ color: 'var(--color-text-soft)' }}>
|
||||||
{isDisabled ? 'Canal em construção.' : 'Inicia uma conversa pelo WhatsApp.'}
|
{channel.id === 'call'
|
||||||
|
? 'Inicia uma ligacao mock em tela cheia.'
|
||||||
|
: 'Abre o fluxo de conversa em tempo real.'}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'grid',
|
|
||||||
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 }}>País</span>
|
|
||||||
<select
|
|
||||||
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',
|
|
||||||
padding: '0.95rem 1rem',
|
|
||||||
background: '#fff',
|
|
||||||
outline: 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{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 }}>Número do WhatsApp</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
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',
|
|
||||||
padding: '0.95rem 1rem',
|
|
||||||
background: '#fff',
|
|
||||||
outline: 'none',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</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
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
@ -591,12 +218,10 @@ export function NewAttendancePage({ embedded = false }) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<label style={{ display: 'grid', gap: '0.45rem' }}>
|
<label style={{ display: 'grid', gap: '0.45rem' }}>
|
||||||
<span style={{ fontWeight: 600 }}>Data do template</span>
|
<span style={{ fontWeight: 600 }}>Area (opcional)</span>
|
||||||
<input
|
<select
|
||||||
type="text"
|
value={selectedArea}
|
||||||
value={templateVariables.date}
|
onChange={(event) => setSelectedArea(event.target.value)}
|
||||||
onChange={(event) => setTemplateVariables((current) => ({ ...current, date: event.target.value }))}
|
|
||||||
placeholder="Ex: 26/05/2026"
|
|
||||||
style={{
|
style={{
|
||||||
border: '1px solid var(--color-border)',
|
border: '1px solid var(--color-border)',
|
||||||
borderRadius: '18px',
|
borderRadius: '18px',
|
||||||
@ -604,110 +229,33 @@ export function NewAttendancePage({ embedded = false }) {
|
|||||||
background: '#fff',
|
background: '#fff',
|
||||||
outline: 'none',
|
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' }}>
|
<option value="">Selecionar depois</option>
|
||||||
<strong style={{ display: 'block', color: 'var(--color-text-soft)', fontSize: '0.82rem' }}>
|
{attendanceAreas.map((area) => (
|
||||||
Preview WhatsApp
|
<option key={area} value={area}>
|
||||||
</strong>
|
{area}
|
||||||
<div
|
</option>
|
||||||
style={{
|
))}
|
||||||
justifySelf: 'end',
|
</select>
|
||||||
maxWidth: '92%',
|
</label>
|
||||||
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' }}>
|
<label style={{ display: 'grid', gap: '0.45rem' }}>
|
||||||
<span style={{ fontWeight: 600 }}>Observação</span>
|
<span style={{ fontWeight: 600 }}>Numero selecionado</span>
|
||||||
<textarea
|
<input
|
||||||
rows={5}
|
type="text"
|
||||||
value={form.note}
|
value={customNumber || selectedContact.phone}
|
||||||
onChange={(event) => setForm((current) => ({ ...current, note: event.target.value }))}
|
onChange={(event) => setCustomNumber(event.target.value)}
|
||||||
placeholder="Contexto inicial deste atendimento."
|
placeholder="+55 11 99999-9999"
|
||||||
style={{
|
style={{
|
||||||
border: '1px solid var(--color-border)',
|
border: '1px solid var(--color-border)',
|
||||||
borderRadius: '18px',
|
borderRadius: '18px',
|
||||||
padding: '0.95rem 1rem',
|
padding: '0.95rem 1rem',
|
||||||
background: '#fff',
|
background: '#fff',
|
||||||
outline: 'none',
|
outline: 'none',
|
||||||
resize: 'vertical',
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
</div>
|
||||||
{error ? <span style={{ color: '#b42318', fontWeight: 700 }}>{error}</span> : null}
|
|
||||||
{isLoadingContacts ? <span style={{ color: 'var(--color-text-soft)' }}>Carregando agenda...</span> : null}
|
|
||||||
|
|
||||||
<section
|
<section
|
||||||
style={{
|
style={{
|
||||||
@ -737,20 +285,17 @@ export function NewAttendancePage({ embedded = false }) {
|
|||||||
fontSize: '0.84rem',
|
fontSize: '0.84rem',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Resumo
|
Resumo do fluxo
|
||||||
</span>
|
</span>
|
||||||
<strong style={{ fontSize: '1.25rem' }}>{form.name || 'Cliente sem nome'}</strong>
|
<strong style={{ fontSize: '1.25rem' }}>{selectedContact.name}</strong>
|
||||||
<span style={{ color: 'rgba(255, 255, 255, 0.74)' }}>
|
<span style={{ color: 'rgba(255, 255, 255, 0.74)' }}>
|
||||||
Canal: {selectedChannel.label}
|
Canal escolhido: {selectedChannel.label}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ color: 'rgba(255, 255, 255, 0.74)' }}>
|
<span style={{ color: 'rgba(255, 255, 255, 0.74)' }}>
|
||||||
Número: {buildInternationalPhone(form.phone, selectedCountryId) ? `+${buildInternationalPhone(form.phone, selectedCountryId)}` : 'Não informado'}
|
Numero: {customNumber || selectedContact.phone}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ color: 'rgba(255, 255, 255, 0.74)' }}>
|
<span style={{ color: 'rgba(255, 255, 255, 0.74)' }}>
|
||||||
Etiqueta de identificação: {form.company || 'Não informada'}
|
Area: {selectedArea || 'Definir depois'}
|
||||||
</span>
|
|
||||||
<span style={{ color: 'rgba(255, 255, 255, 0.74)' }}>
|
|
||||||
Origem: {selectedContactId ? 'Agenda' : 'Novo contato'}
|
|
||||||
</span>
|
</span>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
@ -764,31 +309,15 @@ export function NewAttendancePage({ embedded = false }) {
|
|||||||
gap: '0.7rem',
|
gap: '0.7rem',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<strong>Próxima rota</strong>
|
<strong>Proxima rota</strong>
|
||||||
<span style={{ color: 'var(--color-text-soft)' }}>
|
<span style={{ color: 'var(--color-text-soft)' }}>
|
||||||
O contato será salvo, o template será enviado e a conversa abrirá atribuída a você no chat.
|
{selectedChannel.route === '/call'
|
||||||
|
? 'Ao iniciar, voce vai para a tela de ligacao.'
|
||||||
|
: 'Ao iniciar, voce vai para a tela de chat.'}
|
||||||
</span>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleStartAttendance}
|
onClick={handleStartAttendance}
|
||||||
disabled={!canStartAttendance || isStarting}
|
|
||||||
style={{
|
style={{
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '18px',
|
borderRadius: '18px',
|
||||||
@ -796,149 +325,16 @@ export function NewAttendancePage({ embedded = false }) {
|
|||||||
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
|
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
fontWeight: 800,
|
fontWeight: 800,
|
||||||
opacity: canStartAttendance && !isStarting ? 1 : 0.55,
|
marginTop: '0.4rem',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isStarting ? 'Iniciando...' : 'Iniciar atendimento'}
|
Iniciar atendimento
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
</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>
|
||||||
</section>
|
</section>
|
||||||
);
|
|
||||||
|
|
||||||
if (embedded) {
|
|
||||||
return content;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main style={{ minHeight: '100vh', padding: '1.5rem' }}>
|
|
||||||
{content}
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,38 @@
|
|||||||
export const attendanceChannels = [
|
export const attendanceChannels = [
|
||||||
{ id: 'whatsapp', label: 'WhatsApp', route: '/chat', accent: '#2bb741' },
|
{ id: 'whatsapp', label: 'WhatsApp', route: '/chat', accent: '#2bb741' },
|
||||||
{ id: 'sms', label: 'SMS', route: '/chat', accent: '#00a4b7', disabled: true },
|
{ id: 'sms', label: 'SMS', route: '/chat', accent: '#00a4b7' },
|
||||||
{ id: 'email', label: 'E-mail', route: '/chat', accent: '#e5a22a', disabled: true },
|
{ 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',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@ -39,7 +39,7 @@ const initialFormData = {
|
|||||||
|
|
||||||
export function LoginForm() {
|
export function LoginForm() {
|
||||||
const [formData, setFormData] = useState(initialFormData);
|
const [formData, setFormData] = useState(initialFormData);
|
||||||
const { login, startMicrosoftLogin, providers, error, isSubmitting } = useLogin();
|
const { login, isSubmitting } = useLogin();
|
||||||
|
|
||||||
async function handleSubmit(event) {
|
async function handleSubmit(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@ -48,15 +48,12 @@ export function LoginForm() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} style={{ display: 'grid', gap: '1rem' }}>
|
<form onSubmit={handleSubmit} style={{ display: 'grid', gap: '1rem' }}>
|
||||||
{providers.ldap ? (
|
|
||||||
<>
|
|
||||||
<label style={{ display: 'grid', gap: '0.5rem' }}>
|
<label style={{ display: 'grid', gap: '0.5rem' }}>
|
||||||
<span style={{ fontWeight: 600 }}>Usuario AD</span>
|
<span style={{ fontWeight: 600 }}>Usuario AD</span>
|
||||||
<input
|
<input
|
||||||
style={fieldStyle}
|
style={fieldStyle}
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="seu.usuario"
|
placeholder="seu.usuario"
|
||||||
autoComplete="username"
|
|
||||||
value={formData.username}
|
value={formData.username}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setFormData((current) => ({ ...current, username: event.target.value }))
|
setFormData((current) => ({ ...current, username: event.target.value }))
|
||||||
@ -70,7 +67,6 @@ export function LoginForm() {
|
|||||||
style={fieldStyle}
|
style={fieldStyle}
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Digite sua senha"
|
placeholder="Digite sua senha"
|
||||||
autoComplete="current-password"
|
|
||||||
value={formData.password}
|
value={formData.password}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setFormData((current) => ({ ...current, password: event.target.value }))
|
setFormData((current) => ({ ...current, password: event.target.value }))
|
||||||
@ -79,58 +75,23 @@ export function LoginForm() {
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<button style={primaryButtonStyle} type="submit" disabled={isSubmitting}>
|
<button style={primaryButtonStyle} type="submit" disabled={isSubmitting}>
|
||||||
{isSubmitting ? 'Entrando...' : 'Entrar com AD'}
|
{isSubmitting ? 'Entrando...' : 'Entrar'}
|
||||||
</button>
|
</button>
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{providers.microsoft ? (
|
<button style={secondaryButtonStyle} type="button">
|
||||||
<button style={secondaryButtonStyle} type="button" onClick={startMicrosoftLogin}>
|
|
||||||
Entrar com Microsoft
|
Entrar com Microsoft
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
|
||||||
|
|
||||||
{error ? (
|
<a
|
||||||
<div
|
href="#forgot-password"
|
||||||
role="alert"
|
|
||||||
style={{
|
|
||||||
border: '1px solid rgba(180, 35, 24, 0.24)',
|
|
||||||
borderRadius: 14,
|
|
||||||
padding: '0.85rem 1rem',
|
|
||||||
background: 'rgba(180, 35, 24, 0.08)',
|
|
||||||
color: '#b42318',
|
|
||||||
fontWeight: 700,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{!providers.ldap && !providers.microsoft ? (
|
|
||||||
<div
|
|
||||||
role="alert"
|
|
||||||
style={{
|
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
borderRadius: 14,
|
|
||||||
padding: '0.85rem 1rem',
|
|
||||||
background: '#fff',
|
|
||||||
color: 'var(--color-text-soft)',
|
|
||||||
fontWeight: 700,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Nenhum provedor de login esta habilitado.
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<span
|
|
||||||
style={{
|
style={{
|
||||||
justifySelf: 'center',
|
justifySelf: 'center',
|
||||||
color: 'var(--color-text-soft)',
|
color: 'var(--color-secondary)',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Acesso somente via AD ou Microsoft corporativo.
|
Esqueci minha senha
|
||||||
</span>
|
</a>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,51 +1,17 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import { mockLogin } from '../services/authService';
|
||||||
getAuthConfig,
|
|
||||||
loginWithAd,
|
|
||||||
startMicrosoftLogin,
|
|
||||||
storeAuthSession,
|
|
||||||
} from '../services/authService';
|
|
||||||
|
|
||||||
export function useLogin() {
|
export function useLogin() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [providers, setProviders] = useState({ ldap: true, microsoft: false });
|
|
||||||
|
|
||||||
useEffect(() => {
|
async function login() {
|
||||||
getAuthConfig()
|
|
||||||
.then((config) => setProviders(config.providers || { ldap: true, microsoft: false }))
|
|
||||||
.catch(() => setProviders({ ldap: true, microsoft: false }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
const token = params.get('token');
|
|
||||||
const rawUser = params.get('user');
|
|
||||||
|
|
||||||
if (!token || !rawUser) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const user = JSON.parse(rawUser);
|
|
||||||
storeAuthSession({ token, user });
|
|
||||||
window.history.replaceState({}, document.title, window.location.pathname);
|
|
||||||
navigate('/home', { replace: true });
|
|
||||||
} catch {
|
|
||||||
setError('Não foi possível concluir o login Microsoft.');
|
|
||||||
}
|
|
||||||
}, [navigate]);
|
|
||||||
|
|
||||||
async function login(credentials) {
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
setError('');
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const authResult = await loginWithAd(credentials);
|
await mockLogin();
|
||||||
storeAuthSession(authResult);
|
|
||||||
navigate('/home');
|
navigate('/home');
|
||||||
} catch (loginError) {
|
|
||||||
setError(loginError.message || 'Falha ao autenticar.');
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
@ -53,9 +19,6 @@ export function useLogin() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
error,
|
|
||||||
providers,
|
|
||||||
login,
|
login,
|
||||||
startMicrosoftLogin,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -58,7 +58,7 @@ export function LoginPage() {
|
|||||||
margin: 0,
|
margin: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Atendimento Múltiplos canais
|
MVP de atendimento
|
||||||
</p>
|
</p>
|
||||||
<h1
|
<h1
|
||||||
style={{
|
style={{
|
||||||
@ -67,7 +67,7 @@ export function LoginPage() {
|
|||||||
lineHeight: 1.05,
|
lineHeight: 1.05,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Conexão multiatendimento em um único lugar.
|
Conecte-se com seu cliente em uma unica tela.
|
||||||
</h1>
|
</h1>
|
||||||
<p
|
<p
|
||||||
style={{
|
style={{
|
||||||
@ -91,9 +91,9 @@ export function LoginPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{[
|
{[
|
||||||
{ label: 'Canais', value: 'WhatsApp, SMS e E-mail' },
|
{ label: 'Canais', value: 'WhatsApp, SMS e Voz' },
|
||||||
{ label: 'Fila', value: 'Distribuição rápida' },
|
{ label: 'Fila', value: 'Distribuicao rapida' },
|
||||||
{ label: 'UX', value: 'Padrão SaaS responsivo' },
|
{ label: 'UX', value: 'Padrao SaaS responsivo' },
|
||||||
].map((item) => (
|
].map((item) => (
|
||||||
<div
|
<div
|
||||||
key={item.label}
|
key={item.label}
|
||||||
@ -147,7 +147,8 @@ export function LoginPage() {
|
|||||||
lineHeight: 1.6,
|
lineHeight: 1.6,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Use seu usuário corporativo para acessar o MVP com Active Directory ou Microsoft.
|
Use seu usuario corporativo para acessar o MVP. A autenticacao e mockada
|
||||||
|
nesta etapa e leva voce diretamente para a dashboard principal.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,35 +1,11 @@
|
|||||||
import { API_BASE_URL } from '../../../shared/services/apiConfig';
|
const networkDelay = 450;
|
||||||
|
|
||||||
async function parseJsonResponse(response) {
|
export async function mockLogin() {
|
||||||
const data = await response.json().catch(() => null);
|
await new Promise((resolve) => window.setTimeout(resolve, networkDelay));
|
||||||
|
|
||||||
if (!response.ok) {
|
return {
|
||||||
throw new Error(data?.message || 'Não foi possível autenticar.');
|
id: 'agent-001',
|
||||||
}
|
name: 'Ana Camolesi',
|
||||||
|
email: 'ana.camolesi@sothis.local',
|
||||||
return data;
|
};
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAuthConfig() {
|
|
||||||
const response = await fetch(`${API_BASE_URL}/auth/config`);
|
|
||||||
return parseJsonResponse(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loginWithAd(credentials) {
|
|
||||||
const response = await fetch(`${API_BASE_URL}/auth/login`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(credentials),
|
|
||||||
});
|
|
||||||
|
|
||||||
return parseJsonResponse(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function startMicrosoftLogin() {
|
|
||||||
window.location.href = `${API_BASE_URL}/auth/oauth/microsoft/start`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function storeAuthSession(authResult) {
|
|
||||||
window.localStorage.setItem('authToken', authResult.token);
|
|
||||||
window.localStorage.setItem('authUser', JSON.stringify(authResult.user));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,135 +0,0 @@
|
|||||||
const PROFILE_ALIASES = {
|
|
||||||
admin: 'admin',
|
|
||||||
administrador: 'admin',
|
|
||||||
supervisor: 'supervisor',
|
|
||||||
gestor: 'supervisor',
|
|
||||||
agente: 'agent',
|
|
||||||
atendente: 'agent',
|
|
||||||
agent: 'agent',
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEMO_PROFILE_BY_USERNAME = {
|
|
||||||
admin: 'admin',
|
|
||||||
'lucas.admin': 'admin',
|
|
||||||
supervisor: 'supervisor',
|
|
||||||
'marina.alves': 'supervisor',
|
|
||||||
'rafael.nunes': 'supervisor',
|
|
||||||
};
|
|
||||||
|
|
||||||
function readStoredUser() {
|
|
||||||
const rawUser = window.localStorage.getItem('authUser');
|
|
||||||
|
|
||||||
if (!rawUser) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return JSON.parse(rawUser);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeProfile(value) {
|
|
||||||
if (!value) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
return PROFILE_ALIASES[value.trim().toLowerCase()] || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value === 'object') {
|
|
||||||
return normalizeProfile(value.nome || value.name || value.role || value.perfil);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveProfileFromList(values) {
|
|
||||||
if (!Array.isArray(values)) {
|
|
||||||
return normalizeProfile(values);
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedProfiles = values.map(normalizeProfile).filter(Boolean);
|
|
||||||
|
|
||||||
if (normalizedProfiles.includes('admin')) {
|
|
||||||
return 'admin';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (normalizedProfiles.includes('supervisor')) {
|
|
||||||
return 'supervisor';
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalizedProfiles[0] || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getCurrentUser() {
|
|
||||||
return readStoredUser();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getCurrentUserDisplay() {
|
|
||||||
const user = getCurrentUser();
|
|
||||||
const fullName = user?.name || user?.nome || user?.username || 'Ana Camolesi';
|
|
||||||
const nameParts = fullName.split(' ').filter(Boolean);
|
|
||||||
const name =
|
|
||||||
nameParts.length > 1 ? `${nameParts[0]} ${nameParts[nameParts.length - 1]}` : fullName;
|
|
||||||
const areas = Array.isArray(user?.areas) ? user.areas : [];
|
|
||||||
const profiles = Array.isArray(user?.perfis)
|
|
||||||
? user.perfis
|
|
||||||
: Array.isArray(user?.profiles)
|
|
||||||
? user.profiles
|
|
||||||
: [];
|
|
||||||
const area = user?.areaPrincipal || areas[0] || null;
|
|
||||||
const profile = profiles[0] || user?.perfil || user?.role || null;
|
|
||||||
const subtitle = [profile, area].filter(Boolean).join(' - ') || 'Atendimento omnichannel';
|
|
||||||
const initials = name
|
|
||||||
.split(' ')
|
|
||||||
.filter(Boolean)
|
|
||||||
.slice(0, 2)
|
|
||||||
.map((part) => part[0])
|
|
||||||
.join('')
|
|
||||||
.toUpperCase();
|
|
||||||
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
subtitle,
|
|
||||||
initials: initials || 'AM',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getCurrentUserProfile() {
|
|
||||||
const user = getCurrentUser();
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return 'agent';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.accessStatus === 'unassigned') {
|
|
||||||
return 'unassigned';
|
|
||||||
}
|
|
||||||
|
|
||||||
const backendProfile =
|
|
||||||
resolveProfileFromList(user.role) ||
|
|
||||||
resolveProfileFromList(user.perfil) ||
|
|
||||||
resolveProfileFromList(user.perfis) ||
|
|
||||||
resolveProfileFromList(user.profiles);
|
|
||||||
|
|
||||||
if (backendProfile) {
|
|
||||||
return backendProfile;
|
|
||||||
}
|
|
||||||
|
|
||||||
const username = String(user.username || user.email || user.name || '').trim().toLowerCase();
|
|
||||||
const demoProfile = DEMO_PROFILE_BY_USERNAME[username];
|
|
||||||
|
|
||||||
if (demoProfile) {
|
|
||||||
return demoProfile;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'agent';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearSession() {
|
|
||||||
window.localStorage.removeItem('authToken');
|
|
||||||
window.localStorage.removeItem('authUser');
|
|
||||||
}
|
|
||||||
@ -21,7 +21,7 @@ export function CallHeader({ isMobile = false }) {
|
|||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Ligação ativa
|
Ligacao ativa
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -98,9 +98,9 @@ export function CallPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{[
|
{[
|
||||||
{ label: 'Número', value: activeCall.number },
|
{ label: 'Numero', value: activeCall.number },
|
||||||
{ label: 'Canal original', value: 'Atendimento omnichannel' },
|
{ label: 'Canal original', value: 'Atendimento omnichannel' },
|
||||||
{ label: 'Responsável atual', value: 'Ana Camolesi' },
|
{ label: 'Responsavel atual', value: 'Ana Camolesi' },
|
||||||
].map((item) => (
|
].map((item) => (
|
||||||
<article
|
<article
|
||||||
key={item.label}
|
key={item.label}
|
||||||
@ -164,8 +164,8 @@ export function CallPage() {
|
|||||||
lineHeight: 1.6,
|
lineHeight: 1.6,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Você está em uma ligação ativa com a cliente. Os controles abaixo são visuais
|
Voce esta em uma ligacao ativa com a cliente. Os controles abaixo sao visuais
|
||||||
neste MVP e ajudam a demonstrar a experiência de voz do produto.
|
neste MVP e ajudam a demonstrar a experiencia de voz do produto.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -187,7 +187,7 @@ export function CallPage() {
|
|||||||
color: 'rgba(255, 255, 255, 0.72)',
|
color: 'rgba(255, 255, 255, 0.72)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Qualidade da chamada: Estável
|
Qualidade da chamada: Estavel
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@ -197,7 +197,7 @@ export function CallPage() {
|
|||||||
color: 'rgba(255, 255, 255, 0.72)',
|
color: 'rgba(255, 255, 255, 0.72)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Gravação: Habilitada
|
Gravacao mock: Habilitada
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@ -23,133 +23,10 @@ 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({
|
export function ChatConversationList({
|
||||||
contacts,
|
contacts,
|
||||||
activeContactId,
|
activeContactId,
|
||||||
onSelectContact,
|
onSelectContact,
|
||||||
onOpenContact,
|
|
||||||
currentUserId,
|
|
||||||
isMobile = false,
|
isMobile = false,
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@ -160,18 +37,13 @@ export function ChatConversationList({
|
|||||||
borderRadius: '28px',
|
borderRadius: '28px',
|
||||||
padding: '1rem',
|
padding: '1rem',
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateRows: 'auto minmax(0, 1fr)',
|
|
||||||
gap: '0.85rem',
|
gap: '0.85rem',
|
||||||
height: isMobile ? 'auto' : CHAT_LIST_HEIGHT,
|
|
||||||
maxHeight: isMobile ? 'none' : CHAT_LIST_HEIGHT,
|
|
||||||
alignSelf: 'start',
|
|
||||||
minHeight: 0,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<strong style={{ display: 'block', fontSize: '1.08rem' }}>Conversas ativas</strong>
|
<strong style={{ display: 'block', fontSize: '1.08rem' }}>Conversas ativas</strong>
|
||||||
<span style={{ color: 'var(--color-text-soft)' }}>
|
<span style={{ color: 'var(--color-text-soft)' }}>
|
||||||
WhatsApp, SMS e e-mail em uma fila visual.
|
WhatsApp, SMS e email em uma fila visual.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -180,11 +52,6 @@ export function ChatConversationList({
|
|||||||
display: 'grid',
|
display: 'grid',
|
||||||
gap: '0.75rem',
|
gap: '0.75rem',
|
||||||
gridTemplateColumns: isMobile ? '1fr' : '1fr',
|
gridTemplateColumns: isMobile ? '1fr' : '1fr',
|
||||||
gridAutoRows: 'max-content',
|
|
||||||
alignContent: 'start',
|
|
||||||
overflowY: 'auto',
|
|
||||||
minHeight: 0,
|
|
||||||
paddingRight: '0.15rem',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{contacts.map((contact) => {
|
{contacts.map((contact) => {
|
||||||
@ -195,11 +62,6 @@ export function ChatConversationList({
|
|||||||
key={contact.id}
|
key={contact.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSelectContact(contact.id)}
|
onClick={() => onSelectContact(contact.id)}
|
||||||
onContextMenu={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
onSelectContact(contact.id);
|
|
||||||
onOpenContact?.(contact);
|
|
||||||
}}
|
|
||||||
style={{
|
style={{
|
||||||
border: '1px solid',
|
border: '1px solid',
|
||||||
borderColor: isActive ? 'rgba(0, 164, 183, 0.26)' : 'var(--color-border)',
|
borderColor: isActive ? 'rgba(0, 164, 183, 0.26)' : 'var(--color-border)',
|
||||||
@ -212,44 +74,34 @@ export function ChatConversationList({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
|
||||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.5rem', minWidth: 0 }}>
|
<strong>{contact.name}</strong>
|
||||||
<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)' }}>
|
<span style={{ fontSize: '0.82rem', color: 'var(--color-text-soft)' }}>
|
||||||
{contact.time}
|
{contact.time}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.75rem' }}>
|
<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} />
|
<ChannelBadge channel={contact.channel} />
|
||||||
<SpecialtyBadge contact={contact} />
|
{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}
|
||||||
</span>
|
</span>
|
||||||
<UnreadBadge count={contact.unread} />
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<span style={{ color: 'var(--color-text-soft)' }}>{contact.preview}</span>
|
<span style={{ color: 'var(--color-text-soft)' }}>{contact.preview}</span>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -4,7 +4,6 @@ export function ChatTransferPanel({
|
|||||||
setTransferArea,
|
setTransferArea,
|
||||||
transferAreas,
|
transferAreas,
|
||||||
attendants,
|
attendants,
|
||||||
isSameUserArea = true,
|
|
||||||
transferAttendant,
|
transferAttendant,
|
||||||
setTransferAttendant,
|
setTransferAttendant,
|
||||||
transferNote,
|
transferNote,
|
||||||
@ -40,7 +39,7 @@ export function ChatTransferPanel({
|
|||||||
<div>
|
<div>
|
||||||
<strong style={{ display: 'block', fontSize: '1.06rem' }}>Transferir atendimento</strong>
|
<strong style={{ display: 'block', fontSize: '1.06rem' }}>Transferir atendimento</strong>
|
||||||
<span style={{ color: 'var(--color-text-soft)' }}>
|
<span style={{ color: 'var(--color-text-soft)' }}>
|
||||||
Reencaminhe a conversa para a especialidade ideal.
|
Reencaminhe a conversa para a area ideal.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@ -58,7 +57,7 @@ export function ChatTransferPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label style={{ display: 'grid', gap: '0.45rem' }}>
|
<label style={{ display: 'grid', gap: '0.45rem' }}>
|
||||||
<span style={{ fontWeight: 600 }}>Especialidade</span>
|
<span style={{ fontWeight: 600 }}>Area</span>
|
||||||
<select value={transferArea} onChange={(event) => setTransferArea(event.target.value)} style={fieldStyle}>
|
<select value={transferArea} onChange={(event) => setTransferArea(event.target.value)} style={fieldStyle}>
|
||||||
{transferAreas.map((area) => (
|
{transferAreas.map((area) => (
|
||||||
<option key={area} value={area}>
|
<option key={area} value={area}>
|
||||||
@ -70,39 +69,26 @@ export function ChatTransferPanel({
|
|||||||
|
|
||||||
<label style={{ display: 'grid', gap: '0.45rem' }}>
|
<label style={{ display: 'grid', gap: '0.45rem' }}>
|
||||||
<span style={{ fontWeight: 600 }}>Atendente</span>
|
<span style={{ fontWeight: 600 }}>Atendente</span>
|
||||||
{isSameUserArea ? (
|
|
||||||
<select
|
<select
|
||||||
value={transferAttendant}
|
value={transferAttendant}
|
||||||
onChange={(event) => setTransferAttendant(event.target.value)}
|
onChange={(event) => setTransferAttendant(event.target.value)}
|
||||||
style={fieldStyle}
|
style={fieldStyle}
|
||||||
>
|
>
|
||||||
{attendants.map((attendant) => (
|
{attendants.map((attendant) => (
|
||||||
<option key={attendant.id} value={attendant.id}>
|
<option key={attendant} value={attendant}>
|
||||||
{attendant.nome}
|
{attendant}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</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>
|
||||||
|
|
||||||
<label style={{ display: 'grid', gap: '0.45rem' }}>
|
<label style={{ display: 'grid', gap: '0.45rem' }}>
|
||||||
<span style={{ fontWeight: 600 }}>Observação</span>
|
<span style={{ fontWeight: 600 }}>Observacao</span>
|
||||||
<textarea
|
<textarea
|
||||||
rows={5}
|
rows={5}
|
||||||
value={transferNote}
|
value={transferNote}
|
||||||
onChange={(event) => setTransferNote(event.target.value)}
|
onChange={(event) => setTransferNote(event.target.value)}
|
||||||
placeholder="Contexto opcional para ajudar o próximo atendente."
|
placeholder="Contexto opcional para ajudar o proximo atendente."
|
||||||
style={{ ...fieldStyle, resize: 'vertical' }}
|
style={{ ...fieldStyle, resize: 'vertical' }}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@ -119,7 +105,7 @@ export function ChatTransferPanel({
|
|||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Confirmar transferência
|
Confirmar transferencia
|
||||||
</button>
|
</button>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,302 +1,4 @@
|
|||||||
import { Fragment, useEffect, useMemo, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
function getMediaUrl(media) {
|
|
||||||
if (!media?.data || !media?.mimetype) return '';
|
|
||||||
return `data:${media.mimetype};base64,${media.data}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function 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({
|
export function ChatWindow({
|
||||||
contact,
|
contact,
|
||||||
@ -305,31 +7,12 @@ export function ChatWindow({
|
|||||||
setSelectedArea,
|
setSelectedArea,
|
||||||
draft,
|
draft,
|
||||||
setDraft,
|
setDraft,
|
||||||
attachedFile,
|
|
||||||
onAttachFile,
|
|
||||||
onRemoveAttachedFile,
|
|
||||||
onLoadMedia,
|
|
||||||
onSend,
|
onSend,
|
||||||
onToggleTransfer,
|
onToggleTransfer,
|
||||||
onAssumeChat,
|
|
||||||
onReleaseChat,
|
|
||||||
onCloseChat,
|
|
||||||
canAssumeChat = false,
|
|
||||||
canReply = true,
|
|
||||||
assignmentLabel,
|
|
||||||
transferNote,
|
|
||||||
isReplying,
|
isReplying,
|
||||||
isPaused = false,
|
|
||||||
pauseDurationLabel = '00:00',
|
|
||||||
isMobile = false,
|
isMobile = false,
|
||||||
}) {
|
}) {
|
||||||
const messagesRef = useRef(null);
|
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(() => {
|
useEffect(() => {
|
||||||
const container = messagesRef.current;
|
const container = messagesRef.current;
|
||||||
@ -339,11 +22,10 @@ export function ChatWindow({
|
|||||||
|
|
||||||
container.scrollTo({
|
container.scrollTo({
|
||||||
top: container.scrollHeight,
|
top: container.scrollHeight,
|
||||||
behavior: 'auto',
|
behavior: 'smooth',
|
||||||
});
|
});
|
||||||
}, [messages, isReplying]);
|
}, [messages, isReplying]);
|
||||||
|
|
||||||
if (isPaused) {
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
style={{
|
style={{
|
||||||
@ -352,65 +34,8 @@ export function ChatWindow({
|
|||||||
borderRadius: '28px',
|
borderRadius: '28px',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateRows: 'auto minmax(0, 1fr)',
|
gridTemplateRows: 'auto 1fr auto',
|
||||||
height: isMobile ? 'auto' : 'min(760px, calc(100vh - 190px))',
|
minHeight: 680,
|
||||||
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={{
|
|
||||||
background: '#fff',
|
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
borderRadius: '28px',
|
|
||||||
overflow: 'hidden',
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateRows: 'auto minmax(0, 1fr) auto',
|
|
||||||
height: isMobile ? 'auto' : 'min(760px, calc(100vh - 190px))',
|
|
||||||
minHeight: isMobile ? 640 : 0,
|
|
||||||
minWidth: 0,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<header
|
<header
|
||||||
@ -424,8 +49,10 @@ export function ChatWindow({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<strong style={{ display: 'block', fontSize: '1.15rem' }}>{safeContact.name}</strong>
|
<strong style={{ display: 'block', fontSize: '1.15rem' }}>{contact.name}</strong>
|
||||||
<ContactActivity contact={safeContact} />
|
<span style={{ color: 'var(--color-text-soft)' }}>
|
||||||
|
{contact.status === 'online' ? 'Online' : 'Offline'} • {contact.lastSeen}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@ -439,7 +66,6 @@ export function ChatWindow({
|
|||||||
<select
|
<select
|
||||||
value={selectedArea}
|
value={selectedArea}
|
||||||
onChange={(event) => setSelectedArea(event.target.value)}
|
onChange={(event) => setSelectedArea(event.target.value)}
|
||||||
disabled
|
|
||||||
style={{
|
style={{
|
||||||
border: '1px solid var(--color-border)',
|
border: '1px solid var(--color-border)',
|
||||||
borderRadius: '14px',
|
borderRadius: '14px',
|
||||||
@ -448,63 +74,13 @@ export function ChatWindow({
|
|||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option>{selectedArea}</option>
|
|
||||||
<option>Suporte</option>
|
<option>Suporte</option>
|
||||||
<option>Financeiro</option>
|
<option>Financeiro</option>
|
||||||
<option>Comercial</option>
|
<option>Comercial</option>
|
||||||
</select>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onToggleTransfer}
|
onClick={onToggleTransfer}
|
||||||
disabled={!canReply}
|
|
||||||
style={{
|
style={{
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '14px',
|
borderRadius: '14px',
|
||||||
@ -512,30 +88,11 @@ export function ChatWindow({
|
|||||||
background: 'rgba(0, 49, 80, 0.08)',
|
background: 'rgba(0, 49, 80, 0.08)',
|
||||||
color: 'var(--color-primary)',
|
color: 'var(--color-primary)',
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
opacity: canReply ? 1 : 0.55,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Transferir
|
Transferir
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</header>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@ -546,26 +103,18 @@ export function ChatWindow({
|
|||||||
gap: '0.9rem',
|
gap: '0.9rem',
|
||||||
alignContent: 'start',
|
alignContent: 'start',
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
minHeight: 0,
|
|
||||||
background:
|
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))',
|
'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, index) => {
|
{messages.map((message) => {
|
||||||
const isAgent = message.sender === 'agent';
|
const isAgent = message.sender === 'agent';
|
||||||
const isSystem = message.sender === 'system';
|
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) {
|
if (isSystem) {
|
||||||
return (
|
return (
|
||||||
<Fragment key={message.id}>
|
|
||||||
{shouldShowDateSeparator ? <DateSeparator label={dateSeparator} /> : null}
|
|
||||||
<div
|
<div
|
||||||
|
key={message.id}
|
||||||
style={{
|
style={{
|
||||||
justifySelf: 'center',
|
justifySelf: 'center',
|
||||||
padding: '0.7rem 1rem',
|
padding: '0.7rem 1rem',
|
||||||
@ -578,14 +127,12 @@ export function ChatWindow({
|
|||||||
>
|
>
|
||||||
{message.text}
|
{message.text}
|
||||||
</div>
|
</div>
|
||||||
</Fragment>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment key={message.id}>
|
|
||||||
{shouldShowDateSeparator ? <DateSeparator label={dateSeparator} /> : null}
|
|
||||||
<div
|
<div
|
||||||
|
key={message.id}
|
||||||
style={{
|
style={{
|
||||||
justifySelf: isAgent ? 'end' : 'start',
|
justifySelf: isAgent ? 'end' : 'start',
|
||||||
maxWidth: isMobile ? '88%' : '72%',
|
maxWidth: isMobile ? '88%' : '72%',
|
||||||
@ -594,76 +141,13 @@ export function ChatWindow({
|
|||||||
background: isAgent ? 'var(--color-primary)' : '#edf1f5',
|
background: isAgent ? 'var(--color-primary)' : '#edf1f5',
|
||||||
color: isAgent ? '#fff' : 'var(--color-text)',
|
color: isAgent ? '#fff' : 'var(--color-text)',
|
||||||
boxShadow: 'var(--shadow-md)',
|
boxShadow: 'var(--shadow-md)',
|
||||||
display: 'grid',
|
|
||||||
gap: '0.65rem',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MediaRenderer
|
{message.text}
|
||||||
message={message}
|
|
||||||
contactId={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>
|
</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 ? (
|
{isReplying ? (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@ -685,107 +169,31 @@ export function ChatWindow({
|
|||||||
padding: '1rem 1.25rem 1.25rem',
|
padding: '1rem 1.25rem 1.25rem',
|
||||||
borderTop: '1px solid var(--color-border)',
|
borderTop: '1px solid var(--color-border)',
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: '1fr',
|
gridTemplateColumns: isMobile ? '1fr' : '1fr auto',
|
||||||
gap: '0.75rem',
|
gap: '0.75rem',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AttachmentPreview file={attachedFile} onRemove={onRemoveAttachedFile} />
|
|
||||||
{!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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={(event) => setDraft(event.target.value)}
|
onChange={(event) => setDraft(event.target.value)}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === 'Enter') {
|
if (event.key === 'Enter') {
|
||||||
onSend?.(draft);
|
onSend();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={!safeContact.id || !canReply}
|
placeholder="Escreva sua mensagem..."
|
||||||
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={{
|
style={{
|
||||||
border: '1px solid var(--color-border)',
|
border: '1px solid var(--color-border)',
|
||||||
borderRadius: '18px',
|
borderRadius: '18px',
|
||||||
padding: '0.95rem 1rem',
|
padding: '0.95rem 1rem',
|
||||||
background: '#fff',
|
background: '#fff',
|
||||||
outline: 'none',
|
outline: 'none',
|
||||||
minWidth: 0,
|
|
||||||
opacity: safeContact.id && canReply ? 1 : 0.6,
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSend?.(draft)}
|
onClick={onSend}
|
||||||
disabled={!safeContact.id || !canReply}
|
|
||||||
style={{
|
style={{
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '18px',
|
borderRadius: '18px',
|
||||||
@ -793,13 +201,10 @@ export function ChatWindow({
|
|||||||
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
|
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
gridColumn: isMobile ? '1 / -1' : 'auto',
|
|
||||||
opacity: safeContact.id && canReply ? 1 : 0.6,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Enviar
|
Enviar
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</footer>
|
</footer>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,197 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,869 +1,110 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useWhatsappSocket } from '../../../shared/hooks/useWhatsappSocket';
|
|
||||||
import { API_BASE_URL } from '../../../shared/services/apiConfig';
|
|
||||||
import { getAccessOptions, getAccessUsers } from '../../management/services/adminAccessService';
|
|
||||||
import { getCurrentUser, getCurrentUserProfile } from '../../auth/services/sessionService';
|
|
||||||
import { transferAreas as fallbackTransferAreas } from '../services/chatMocks';
|
|
||||||
import {
|
import {
|
||||||
getAgentPresence,
|
attendantsByArea,
|
||||||
listAgentPresence,
|
chatContacts,
|
||||||
pauseAgent,
|
getMockReply,
|
||||||
resumeAgent,
|
transferAreas,
|
||||||
} from '../services/agentPresenceService';
|
} from '../services/chatMocks';
|
||||||
|
|
||||||
const MAX_ATTACHMENT_SIZE_BYTES = 15 * 1024 * 1024;
|
function buildInitialMessages() {
|
||||||
|
return chatContacts.reduce((acc, contact) => {
|
||||||
function getPresenceByUserId(presenceList, userId) {
|
acc[contact.id] = contact.messages;
|
||||||
return presenceList.find((presence) => Number(presence.user_id) === Number(userId)) || null;
|
return acc;
|
||||||
}
|
}, {});
|
||||||
|
|
||||||
function formatPauseDuration(totalSeconds) {
|
|
||||||
const seconds = Math.max(0, Number(totalSeconds || 0));
|
|
||||||
const minutes = Math.floor(seconds / 60);
|
|
||||||
const remainingSeconds = seconds % 60;
|
|
||||||
const hours = Math.floor(minutes / 60);
|
|
||||||
const remainingMinutes = minutes % 60;
|
|
||||||
|
|
||||||
if (hours > 0) {
|
|
||||||
return `${hours}h ${String(remainingMinutes).padStart(2, '0')}m`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${String(remainingMinutes).padStart(2, '0')}:${String(remainingSeconds).padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLastMessageFromMe(messages = []) {
|
|
||||||
const lastMessage = [...messages].reverse().find(isDisplayableMessage);
|
|
||||||
if (!lastMessage) return false;
|
|
||||||
return lastMessage.sender === 'agent' || lastMessage.fromMe === true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSerializedId(value) {
|
|
||||||
if (!value) return '';
|
|
||||||
if (typeof value === 'string') return value;
|
|
||||||
return value._serialized || `${value.user || ''}@${value.server || 'c.us'}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTime(timestamp) {
|
|
||||||
if (!timestamp) return '';
|
|
||||||
const date = new Date(timestamp * 1000);
|
|
||||||
return date.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
|
|
||||||
}
|
|
||||||
|
|
||||||
function getContactName(chat) {
|
|
||||||
const serializedId = getSerializedId(chat.id);
|
|
||||||
return chat.name || chat.pushname || serializedId.split('@')[0] || 'Contato';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPreviewFromMessage(message) {
|
|
||||||
if (message?.body) return message.body;
|
|
||||||
if (message?.text) return message.text;
|
|
||||||
if (message?.hasMedia || message?.media) return '[Mídia]';
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeChat(chat) {
|
|
||||||
const id = getSerializedId(chat.id);
|
|
||||||
const assignment = chat.assignment || null;
|
|
||||||
const lastSeenTimestamp = chat.timestamp || null;
|
|
||||||
const hasLastMessageFromMe = typeof chat.lastMessageFromMe === 'boolean';
|
|
||||||
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
name: getContactName(chat),
|
|
||||||
channel: 'WhatsApp',
|
|
||||||
status: lastSeenTimestamp ? 'away' : 'offline',
|
|
||||||
area: assignment?.area_nome || (assignment?.area_id ? String(assignment.area_id) : 'Sem fila'),
|
|
||||||
areaId: assignment?.area_id || null,
|
|
||||||
lastSeen: lastSeenTimestamp ? `Última atividade as ${formatTime(lastSeenTimestamp)}` : 'Sem atividade recente',
|
|
||||||
preview: chat.preview || chat.lastMessage?.body || '',
|
|
||||||
time: formatTime(chat.timestamp) || 'Agora',
|
|
||||||
unread: chat.unreadCount || 0,
|
|
||||||
lastMessageFromMe: hasLastMessageFromMe ? chat.lastMessageFromMe : Boolean(chat.lastMessage?.fromMe),
|
|
||||||
contactProfile: chat.contactProfile || null,
|
|
||||||
assignment,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeMessage(message) {
|
|
||||||
const id = getSerializedId(message.id) || message.id || `msg-${Date.now()}`;
|
|
||||||
const sender = message.sender || (message.fromMe ? 'agent' : 'customer');
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
chatId: message.from || message.to || message.chatId,
|
|
||||||
sender,
|
|
||||||
text: message.body ?? message.text ?? '',
|
|
||||||
timestamp: message.timestamp,
|
|
||||||
hasMedia: Boolean(message.hasMedia || message.media),
|
|
||||||
media: message.media || null,
|
|
||||||
mediaLoading: false,
|
|
||||||
mediaError: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function isDisplayableMessage(message) {
|
|
||||||
const text = String(message?.text ?? message?.body ?? '').trim();
|
|
||||||
return Boolean(text || message?.hasMedia || message?.media);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getComparableMessageTime(message) {
|
|
||||||
if (message.timestamp) return Number(message.timestamp);
|
|
||||||
if (typeof message.id === 'string' && message.id.startsWith('temp-')) {
|
|
||||||
return Math.floor(Number(message.id.replace('temp-', '')) / 1000);
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripSenderHeader(text) {
|
|
||||||
return String(text || '')
|
|
||||||
.replace(/^\*(Atendente(?: virtual)?:\s*[^*]+)\*\s*\n+/i, '')
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function areLikelySameMessage(currentMessage, nextMessage) {
|
|
||||||
if (!currentMessage || !nextMessage) return false;
|
|
||||||
if (currentMessage.id && nextMessage.id && currentMessage.id === nextMessage.id) return true;
|
|
||||||
if (currentMessage.chatId && nextMessage.chatId && currentMessage.chatId !== nextMessage.chatId) return false;
|
|
||||||
if (currentMessage.sender !== nextMessage.sender) return false;
|
|
||||||
if (stripSenderHeader(currentMessage.text) !== stripSenderHeader(nextMessage.text)) return false;
|
|
||||||
if (Boolean(currentMessage.hasMedia) !== Boolean(nextMessage.hasMedia)) return false;
|
|
||||||
|
|
||||||
const currentTime = getComparableMessageTime(currentMessage);
|
|
||||||
const nextTime = getComparableMessageTime(nextMessage);
|
|
||||||
if (!currentTime || !nextTime) return false;
|
|
||||||
|
|
||||||
return Math.abs(currentTime - nextTime) <= 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeMessageList(currentMessages, nextMessage) {
|
|
||||||
const exactIndex = currentMessages.findIndex((message) => message.id === nextMessage.id);
|
|
||||||
if (exactIndex >= 0) {
|
|
||||||
return currentMessages.map((message, index) => (index === exactIndex ? { ...message, ...nextMessage } : message));
|
|
||||||
}
|
|
||||||
|
|
||||||
const likelyIndex = currentMessages.findIndex((message) => areLikelySameMessage(message, nextMessage));
|
|
||||||
if (likelyIndex >= 0) {
|
|
||||||
return currentMessages.map((message, index) => (index === likelyIndex ? { ...message, ...nextMessage } : message));
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...currentMessages, nextMessage];
|
|
||||||
}
|
|
||||||
|
|
||||||
function dedupeMessages(messages) {
|
|
||||||
return messages.reduce((acc, message) => mergeMessageList(acc, message), []);
|
|
||||||
}
|
|
||||||
|
|
||||||
function fileToBase64(file) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = () => {
|
|
||||||
const result = String(reader.result || '');
|
|
||||||
resolve(result.includes(',') ? result.split(',')[1] : result);
|
|
||||||
};
|
|
||||||
reader.onerror = reject;
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeComparableContact(contact) {
|
|
||||||
return {
|
|
||||||
id: contact.id,
|
|
||||||
name: contact.name,
|
|
||||||
preview: contact.preview,
|
|
||||||
time: contact.time,
|
|
||||||
unread: contact.unread,
|
|
||||||
area: contact.area,
|
|
||||||
areaId: contact.areaId,
|
|
||||||
lastSeen: contact.lastSeen,
|
|
||||||
lastMessageFromMe: contact.lastMessageFromMe,
|
|
||||||
assignmentStatus: contact.assignment?.status || null,
|
|
||||||
assignmentUserId: contact.assignment?.user_id || null,
|
|
||||||
assignmentAreaId: contact.assignment?.area_id || null,
|
|
||||||
transferNote: contact.assignment?.transfer_note || null,
|
|
||||||
awaitingCustomerReply: contact.assignment?.awaiting_customer_reply || false,
|
|
||||||
contactName: contact.contactProfile?.name || null,
|
|
||||||
contactCompany: contact.contactProfile?.company || null,
|
|
||||||
contactNote: contact.contactProfile?.note || null,
|
|
||||||
contactPhone: contact.contactProfile?.phone || null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function areContactListsEqual(currentContacts, nextContacts) {
|
|
||||||
if (currentContacts.length !== nextContacts.length) return false;
|
|
||||||
return currentContacts.every((contact, index) => {
|
|
||||||
const currentComparable = normalizeComparableContact(contact);
|
|
||||||
const nextComparable = normalizeComparableContact(nextContacts[index]);
|
|
||||||
return JSON.stringify(currentComparable) === JSON.stringify(nextComparable);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getUserId(user) {
|
|
||||||
const value = user?.databaseId || user?.id;
|
|
||||||
const numeric = Number(value);
|
|
||||||
return Number.isFinite(numeric) ? numeric : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getUserDisplayName(user) {
|
|
||||||
return user?.name || user?.nome || user?.username || user?.email || 'Atendente';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useChat() {
|
export function useChat() {
|
||||||
const currentUser = getCurrentUser();
|
const [contacts, setContacts] = useState(chatContacts);
|
||||||
const currentUserProfile = getCurrentUserProfile();
|
const [activeContactId, setActiveContactId] = useState(chatContacts[0].id);
|
||||||
const currentUserId = getUserId(currentUser);
|
const [messagesByContact, setMessagesByContact] = useState(buildInitialMessages);
|
||||||
const currentUserAreas = getUserAreas(currentUser);
|
|
||||||
const isAdminUser = currentUserProfile === 'admin';
|
|
||||||
const { status: whatsappStatus, incomingMessage, clearIncomingMessage } = useWhatsappSocket();
|
|
||||||
const [contacts, setContacts] = useState([]);
|
|
||||||
const [activeContactId, setActiveContactId] = useState('');
|
|
||||||
const [messagesByContact, setMessagesByContact] = useState({});
|
|
||||||
const [draft, setDraft] = useState('');
|
const [draft, setDraft] = useState('');
|
||||||
const [attachedFile, setAttachedFile] = useState(null);
|
const [selectedArea, setSelectedArea] = useState(chatContacts[0].area);
|
||||||
const [areaOptions, setAreaOptions] = useState([]);
|
|
||||||
const [accessUsers, setAccessUsers] = useState([]);
|
|
||||||
const [presenceList, setPresenceList] = useState([]);
|
|
||||||
const [agentPresence, setAgentPresence] = useState(null);
|
|
||||||
const [pauseSeconds, setPauseSeconds] = useState(0);
|
|
||||||
const [isPresenceLoading, setIsPresenceLoading] = useState(false);
|
|
||||||
const [selectedArea, setSelectedArea] = useState('Sem fila');
|
|
||||||
const [isTransferOpen, setIsTransferOpen] = useState(false);
|
const [isTransferOpen, setIsTransferOpen] = useState(false);
|
||||||
const [transferArea, setTransferArea] = useState(currentUserAreas[0] || 'Suporte');
|
const [transferArea, setTransferArea] = useState('Suporte');
|
||||||
const [transferAttendant, setTransferAttendant] = useState('');
|
const [transferAttendant, setTransferAttendant] = useState(attendantsByArea.Suporte[0]);
|
||||||
const [transferNote, setTransferNote] = useState('');
|
const [transferNote, setTransferNote] = useState('');
|
||||||
const [isReplying] = useState(false);
|
const [isReplying, setIsReplying] = useState(false);
|
||||||
const [isLoadingChats, setIsLoadingChats] = useState(false);
|
const replyTimeoutRef = useRef(null);
|
||||||
const [isLoadingMessages, setIsLoadingMessages] = useState(false);
|
|
||||||
const [apiError, setApiError] = useState(null);
|
|
||||||
const activeContactRef = useRef(activeContactId);
|
|
||||||
const contactsRef = useRef(contacts);
|
|
||||||
const isPaused = agentPresence?.status === 'paused';
|
|
||||||
|
|
||||||
const activeContact = useMemo(
|
const activeContact = useMemo(
|
||||||
() => {
|
() => contacts.find((contact) => contact.id === activeContactId) || contacts[0],
|
||||||
const contact = contacts.find((item) => item.id === activeContactId) || contacts[0];
|
[contacts, activeContactId],
|
||||||
if (!contact || typeof contact.lastMessageFromMe === 'boolean') return contact;
|
|
||||||
return {
|
|
||||||
...contact,
|
|
||||||
lastMessageFromMe: getLastMessageFromMe(messagesByContact[contact.id] || []),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[contacts, activeContactId, messagesByContact],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const messages = messagesByContact[activeContactId] || [];
|
const messages = messagesByContact[activeContactId] || [];
|
||||||
const transferAreas = areaOptions.length ? areaOptions.map((area) => area.nome) : fallbackTransferAreas;
|
const attendants = attendantsByArea[transferArea] || [];
|
||||||
const selectedTransferArea = areaOptions.find((area) => area.nome === transferArea) || null;
|
|
||||||
const usersInTransferArea = accessUsers.filter((user) =>
|
|
||||||
user.areas?.some((area) => area.nome === transferArea) || user.areaPrincipal?.nome === transferArea,
|
|
||||||
);
|
|
||||||
const availableUsersInTransferArea = usersInTransferArea.filter((user) => {
|
|
||||||
const presence = getPresenceByUserId(presenceList, user.id);
|
|
||||||
return !presence || presence.status === 'available';
|
|
||||||
});
|
|
||||||
const isSameUserArea = currentUserAreas.includes(transferArea);
|
|
||||||
const attendants = isSameUserArea ? availableUsersInTransferArea : [];
|
|
||||||
const activeAssignment = activeContact?.assignment || null;
|
|
||||||
const isAssignedToCurrentUser = Boolean(
|
|
||||||
activeAssignment?.user_id && currentUserId && Number(activeAssignment.user_id) === currentUserId,
|
|
||||||
);
|
|
||||||
const isWaitingCustomerReply = Boolean(activeAssignment?.awaiting_customer_reply);
|
|
||||||
const isQueuedForUserArea = Boolean(
|
|
||||||
activeAssignment?.status === 'queued' &&
|
|
||||||
(isAdminUser || !activeAssignment.area_nome || currentUserAreas.includes(activeAssignment.area_nome)),
|
|
||||||
);
|
|
||||||
const canAssumeChat = Boolean(!isPaused && activeContact?.id?.includes('@') && currentUserId && isQueuedForUserArea);
|
|
||||||
const canReply = Boolean(!isPaused && isAssignedToCurrentUser && !isWaitingCustomerReply);
|
|
||||||
const assignmentLabel = activeAssignment?.user_id
|
|
||||||
? isWaitingCustomerReply
|
|
||||||
? 'Aguardando resposta do cliente para liberar novas mensagens'
|
|
||||||
: `Atendimento com ${activeAssignment.user_nome || 'outro atendente'}`
|
|
||||||
: activeAssignment?.area_nome
|
|
||||||
? `Na fila de ${activeAssignment.area_nome}`
|
|
||||||
: 'Sem fila definida';
|
|
||||||
const transferNoteLabel = activeAssignment?.transfer_note || '';
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedArea(activeContact?.area || 'Sem fila');
|
setSelectedArea(activeContact.area);
|
||||||
}, [activeContact]);
|
}, [activeContact]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTransferAttendant(attendants[0]?.id ? String(attendants[0].id) : '');
|
setTransferAttendant(attendants[0] || '');
|
||||||
}, [transferArea, accessUsers, presenceList]);
|
}, [transferArea]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
activeContactRef.current = activeContactId;
|
|
||||||
}, [activeContactId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
contactsRef.current = contacts;
|
|
||||||
}, [contacts]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let isMounted = true;
|
|
||||||
|
|
||||||
async function loadAccessData() {
|
|
||||||
try {
|
|
||||||
const [options, users, presences, currentPresence] = await Promise.all([
|
|
||||||
getAccessOptions(),
|
|
||||||
getAccessUsers(),
|
|
||||||
listAgentPresence(),
|
|
||||||
currentUserId ? getAgentPresence(currentUserId) : Promise.resolve(null),
|
|
||||||
]);
|
|
||||||
if (!isMounted) return;
|
|
||||||
setAreaOptions(options.areas || []);
|
|
||||||
setAccessUsers(users || []);
|
|
||||||
setPresenceList(Array.isArray(presences) ? presences : []);
|
|
||||||
setAgentPresence(currentPresence);
|
|
||||||
setPauseSeconds(Number(currentPresence?.paused_seconds || 0));
|
|
||||||
} catch {
|
|
||||||
if (isMounted) {
|
|
||||||
setAreaOptions([]);
|
|
||||||
setAccessUsers([]);
|
|
||||||
setPresenceList([]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadAccessData();
|
|
||||||
return () => {
|
return () => {
|
||||||
isMounted = false;
|
if (replyTimeoutRef.current) {
|
||||||
|
window.clearTimeout(replyTimeoutRef.current);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [currentUserId]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
function updateContactPreview(contactId, preview) {
|
||||||
if (!currentUserId) return undefined;
|
|
||||||
|
|
||||||
let isMounted = true;
|
|
||||||
async function refreshPresence() {
|
|
||||||
try {
|
|
||||||
const [presences, currentPresence] = await Promise.all([
|
|
||||||
listAgentPresence(),
|
|
||||||
getAgentPresence(currentUserId),
|
|
||||||
]);
|
|
||||||
if (!isMounted) return;
|
|
||||||
setPresenceList(Array.isArray(presences) ? presences : []);
|
|
||||||
setAgentPresence(currentPresence);
|
|
||||||
setPauseSeconds(Number(currentPresence?.paused_seconds || 0));
|
|
||||||
} catch {
|
|
||||||
if (isMounted) setPresenceList([]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const intervalId = window.setInterval(refreshPresence, 30000);
|
|
||||||
return () => {
|
|
||||||
isMounted = false;
|
|
||||||
window.clearInterval(intervalId);
|
|
||||||
};
|
|
||||||
}, [currentUserId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isPaused) return undefined;
|
|
||||||
|
|
||||||
const intervalId = window.setInterval(() => {
|
|
||||||
setPauseSeconds((current) => current + 1);
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
return () => window.clearInterval(intervalId);
|
|
||||||
}, [isPaused]);
|
|
||||||
|
|
||||||
function canSeeContact(contact) {
|
|
||||||
if (isAdminUser) {
|
|
||||||
return Boolean(contact.assignment && contact.assignment.status !== 'bot_triage');
|
|
||||||
}
|
|
||||||
if (!currentUserAreas.length) return false;
|
|
||||||
if (!contact.assignment) return false;
|
|
||||||
if (contact.assignment.status === 'bot_triage') return false;
|
|
||||||
if (!contact.assignment.area_nome) return false;
|
|
||||||
if (contact.assignment.user_id && Number(contact.assignment.user_id) === currentUserId) return true;
|
|
||||||
return currentUserAreas.includes(contact.assignment.area_nome);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadChats({ showLoading = false, ignorePause = false } = {}) {
|
|
||||||
if (isPaused && !ignorePause) {
|
|
||||||
setContacts((current) => (current.length ? [] : current));
|
|
||||||
setActiveContactId('');
|
|
||||||
setMessagesByContact({});
|
|
||||||
setIsLoadingChats(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (whatsappStatus !== 'CONNECTED') {
|
|
||||||
setContacts((current) => (current.length ? [] : current));
|
|
||||||
setActiveContactId('');
|
|
||||||
setMessagesByContact({});
|
|
||||||
setIsLoadingChats(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showLoading) {
|
|
||||||
setIsLoadingChats(true);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE_URL}/whatsapp/chats`);
|
|
||||||
if (!response.ok) throw new Error('Falha ao carregar chats do WhatsApp.');
|
|
||||||
const data = await response.json();
|
|
||||||
if (!Array.isArray(data)) return;
|
|
||||||
|
|
||||||
const nextContacts = data.map(normalizeChat).filter(canSeeContact);
|
|
||||||
setContacts((current) => (areContactListsEqual(current, nextContacts) ? current : nextContacts));
|
|
||||||
setActiveContactId((current) =>
|
|
||||||
nextContacts.some((contact) => contact.id === current) ? current : nextContacts[0]?.id || '',
|
|
||||||
);
|
|
||||||
setApiError(null);
|
|
||||||
} catch (error) {
|
|
||||||
setApiError(error.message);
|
|
||||||
} finally {
|
|
||||||
setIsLoadingChats(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let isMounted = true;
|
|
||||||
|
|
||||||
async function guardedLoadChats() {
|
|
||||||
if (!isMounted) return;
|
|
||||||
await loadChats({ showLoading: contactsRef.current.length === 0 });
|
|
||||||
}
|
|
||||||
|
|
||||||
guardedLoadChats();
|
|
||||||
const intervalId = window.setInterval(guardedLoadChats, 30000);
|
|
||||||
return () => {
|
|
||||||
isMounted = false;
|
|
||||||
window.clearInterval(intervalId);
|
|
||||||
};
|
|
||||||
}, [currentUserId, currentUserAreas.join('|'), isAdminUser, isPaused, whatsappStatus]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!activeContactId) return;
|
|
||||||
let isMounted = true;
|
|
||||||
|
|
||||||
async function loadMessages() {
|
|
||||||
if (!activeContactId.includes('@')) return;
|
|
||||||
setIsLoadingMessages(true);
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE_URL}/whatsapp/messages/${encodeURIComponent(activeContactId)}`);
|
|
||||||
if (!response.ok) throw new Error('Falha ao carregar mensagens do WhatsApp.');
|
|
||||||
const data = await response.json();
|
|
||||||
if (!isMounted || !Array.isArray(data)) return;
|
|
||||||
const normalizedMessages = dedupeMessages(
|
|
||||||
data
|
|
||||||
.map((message) => ({
|
|
||||||
...normalizeMessage(message),
|
|
||||||
chatId: activeContactId,
|
|
||||||
}))
|
|
||||||
.filter(isDisplayableMessage),
|
|
||||||
);
|
|
||||||
setMessagesByContact((current) => ({
|
|
||||||
...current,
|
|
||||||
[activeContactId]: normalizedMessages,
|
|
||||||
}));
|
|
||||||
updateContact(activeContactId, (contact) => ({
|
|
||||||
...contact,
|
|
||||||
lastMessageFromMe: getLastMessageFromMe(normalizedMessages),
|
|
||||||
}));
|
|
||||||
setApiError(null);
|
|
||||||
} catch (error) {
|
|
||||||
if (isMounted) setApiError(error.message);
|
|
||||||
} finally {
|
|
||||||
if (isMounted) setIsLoadingMessages(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadMessages();
|
|
||||||
return () => {
|
|
||||||
isMounted = false;
|
|
||||||
};
|
|
||||||
}, [activeContactId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!incomingMessage) return;
|
|
||||||
const contactId = incomingMessage.from || incomingMessage.to || incomingMessage.chatId;
|
|
||||||
if (!contactId) return;
|
|
||||||
|
|
||||||
const message = {
|
|
||||||
...normalizeMessage(incomingMessage),
|
|
||||||
chatId: contactId,
|
|
||||||
};
|
|
||||||
if (!isDisplayableMessage(message)) {
|
|
||||||
clearIncomingMessage();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const preview = getPreviewFromMessage(message);
|
|
||||||
|
|
||||||
setMessagesByContact((current) => {
|
|
||||||
const currentMessages = current[contactId] || [];
|
|
||||||
const nextMessages = mergeMessageList(currentMessages, message);
|
|
||||||
if (nextMessages === currentMessages) return current;
|
|
||||||
return {
|
|
||||||
...current,
|
|
||||||
[contactId]: nextMessages,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
setContacts((current) => {
|
|
||||||
const existing = current.find((contact) => contact.id === contactId);
|
|
||||||
if (!existing) {
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
const nextContact = {
|
|
||||||
...(existing || {
|
|
||||||
id: contactId,
|
|
||||||
name: incomingMessage.notifyName || contactId.split('@')[0],
|
|
||||||
channel: 'WhatsApp',
|
|
||||||
status: 'away',
|
|
||||||
area: 'Sem fila',
|
|
||||||
lastSeen: 'Visto agora',
|
|
||||||
unread: 0,
|
|
||||||
assignment: null,
|
|
||||||
}),
|
|
||||||
preview,
|
|
||||||
time: 'Agora',
|
|
||||||
status: 'away',
|
|
||||||
lastSeen: 'Última atividade agora',
|
|
||||||
lastMessageFromMe: Boolean(incomingMessage.fromMe),
|
|
||||||
unread:
|
|
||||||
incomingMessage.fromMe || contactId === activeContactRef.current
|
|
||||||
? 0
|
|
||||||
: (existing?.unread || 0) + 1,
|
|
||||||
};
|
|
||||||
return [nextContact, ...current.filter((contact) => contact.id !== contactId)];
|
|
||||||
});
|
|
||||||
|
|
||||||
clearIncomingMessage();
|
|
||||||
window.setTimeout(() => loadChats({ showLoading: false }), 1200);
|
|
||||||
}, [incomingMessage, clearIncomingMessage]);
|
|
||||||
|
|
||||||
function updateContact(contactId, updater) {
|
|
||||||
setContacts((current) =>
|
setContacts((current) =>
|
||||||
current.map((contact) => (contact.id === contactId ? updater(contact) : contact)),
|
current.map((contact) =>
|
||||||
|
contact.id === contactId ? { ...contact, preview, time: 'Agora', unread: 0 } : contact,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateContactPreview(contactId, preview, media) {
|
function sendMessage() {
|
||||||
updateContact(contactId, (contact) => ({
|
const trimmed = draft.trim();
|
||||||
...contact,
|
if (!trimmed) {
|
||||||
preview: media ? `[Mídia: ${media.filename || 'Arquivo'}]` : preview,
|
|
||||||
time: 'Agora',
|
|
||||||
unread: 0,
|
|
||||||
lastMessageFromMe: true,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateContactProfile(contactId, profile) {
|
|
||||||
updateContact(contactId, (contact) => ({
|
|
||||||
...contact,
|
|
||||||
name: profile.name || contact.name,
|
|
||||||
contactProfile: profile,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function attachFile(file) {
|
|
||||||
if (!file) return;
|
|
||||||
if (file.size > MAX_ATTACHMENT_SIZE_BYTES) {
|
|
||||||
setApiError('Arquivo muito grande. Envie uma mídia de até 15 MB.');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await fileToBase64(file);
|
|
||||||
setAttachedFile({
|
|
||||||
name: file.name,
|
|
||||||
type: file.type || 'application/octet-stream',
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
setApiError(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeAttachedFile() {
|
|
||||||
setAttachedFile(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function hydrateMessageMedia(contactId, messageId) {
|
|
||||||
if (!contactId || !messageId) return;
|
|
||||||
|
|
||||||
setMessagesByContact((current) => ({
|
|
||||||
...current,
|
|
||||||
[contactId]: (current[contactId] || []).map((message) =>
|
|
||||||
message.id === messageId ? { ...message, mediaLoading: true, mediaError: null } : message,
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
`${API_BASE_URL}/whatsapp/media/${encodeURIComponent(contactId)}/${encodeURIComponent(messageId)}`,
|
|
||||||
);
|
|
||||||
if (!response.ok) throw new Error('Falha ao carregar mídia.');
|
|
||||||
const media = await response.json();
|
|
||||||
setMessagesByContact((current) => ({
|
|
||||||
...current,
|
|
||||||
[contactId]: (current[contactId] || []).map((message) =>
|
|
||||||
message.id === messageId ? { ...message, media, mediaLoading: false } : message,
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
} catch (error) {
|
|
||||||
setMessagesByContact((current) => ({
|
|
||||||
...current,
|
|
||||||
[contactId]: (current[contactId] || []).map((message) =>
|
|
||||||
message.id === messageId
|
|
||||||
? { ...message, mediaLoading: false, mediaError: error.message || 'Erro ao carregar mídia.' }
|
|
||||||
: message,
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function assumeChat(contactId = activeContactId) {
|
|
||||||
if (!contactId?.includes('@') || !currentUserId) return null;
|
|
||||||
const targetContact = contacts.find((contact) => contact.id === contactId) || activeContact;
|
|
||||||
const targetAssignment = targetContact?.assignment || null;
|
|
||||||
const areaId = targetContact?.areaId || targetAssignment?.area_id || areaOptions.find((area) => currentUserAreas.includes(area.nome))?.id;
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE_URL}/whatsapp/assign`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
chatId: contactId,
|
|
||||||
userId: String(currentUserId),
|
|
||||||
areaId,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Não foi possível assumir o atendimento.');
|
|
||||||
const assignment = await response.json();
|
|
||||||
updateContact(contactId, (contact) => ({
|
|
||||||
...contact,
|
|
||||||
assignment,
|
|
||||||
area: assignment.area_nome || contact.area,
|
|
||||||
areaId: assignment.area_id || contact.areaId,
|
|
||||||
}));
|
|
||||||
setApiError(null);
|
|
||||||
return assignment;
|
|
||||||
} catch (error) {
|
|
||||||
setApiError(error.message);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function releaseChat() {
|
|
||||||
if (!activeContactId?.includes('@')) return;
|
|
||||||
const response = await fetch(`${API_BASE_URL}/whatsapp/release/${encodeURIComponent(activeContactId)}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Não foi possível sair do atendimento.');
|
|
||||||
const assignment = await response.json();
|
|
||||||
updateContact(activeContactId, (contact) => ({
|
|
||||||
...contact,
|
|
||||||
assignment,
|
|
||||||
area: assignment?.area_nome || contact.area,
|
|
||||||
areaId: assignment?.area_id || contact.areaId,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function closeChat() {
|
|
||||||
if (!activeContactId?.includes('@')) return;
|
|
||||||
|
|
||||||
const confirmed = window.confirm('Tem certeza que deseja encerrar este atendimento?');
|
|
||||||
if (!confirmed) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE_URL}/whatsapp/close`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
chatId: activeContactId,
|
|
||||||
userId: currentUserId,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Não foi possível encerrar o atendimento.');
|
|
||||||
|
|
||||||
setContacts((current) => current.filter((contact) => contact.id !== activeContactId));
|
|
||||||
setMessagesByContact((current) => {
|
|
||||||
const next = { ...current };
|
|
||||||
delete next[activeContactId];
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
setActiveContactId('');
|
|
||||||
setDraft('');
|
|
||||||
setAttachedFile(null);
|
|
||||||
setIsTransferOpen(false);
|
|
||||||
setApiError(null);
|
|
||||||
} catch (error) {
|
|
||||||
setApiError(error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pauseAttendance() {
|
|
||||||
if (!currentUserId) return;
|
|
||||||
setIsPresenceLoading(true);
|
|
||||||
try {
|
|
||||||
const result = await pauseAgent(currentUserId);
|
|
||||||
setAgentPresence(result.presence);
|
|
||||||
setPauseSeconds(0);
|
|
||||||
setContacts([]);
|
|
||||||
setActiveContactId('');
|
|
||||||
setMessagesByContact({});
|
|
||||||
setIsTransferOpen(false);
|
|
||||||
setApiError(null);
|
|
||||||
} catch (error) {
|
|
||||||
setApiError(error.message);
|
|
||||||
} finally {
|
|
||||||
setIsPresenceLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resumeAttendance() {
|
|
||||||
if (!currentUserId) return;
|
|
||||||
setIsPresenceLoading(true);
|
|
||||||
try {
|
|
||||||
const result = await resumeAgent(currentUserId);
|
|
||||||
setAgentPresence(result.presence);
|
|
||||||
setPauseSeconds(0);
|
|
||||||
setApiError(null);
|
|
||||||
await loadChats({ showLoading: true, ignorePause: true });
|
|
||||||
const presences = await listAgentPresence();
|
|
||||||
setPresenceList(Array.isArray(presences) ? presences : []);
|
|
||||||
} catch (error) {
|
|
||||||
setApiError(error.message);
|
|
||||||
} finally {
|
|
||||||
setIsPresenceLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendMessage(messageText = draft, contactId = activeContactId) {
|
|
||||||
const rawMessage = typeof messageText === 'string' ? messageText : draft;
|
|
||||||
const trimmed = rawMessage.trim();
|
|
||||||
if (!trimmed && !attachedFile) return;
|
|
||||||
|
|
||||||
const targetContact = contacts.find((contact) => contact.id === contactId) || activeContact;
|
|
||||||
const targetAssignment = targetContact?.assignment || null;
|
|
||||||
const targetIsAssignedToCurrentUser = Boolean(
|
|
||||||
targetAssignment?.user_id && currentUserId && Number(targetAssignment.user_id) === currentUserId,
|
|
||||||
);
|
|
||||||
const targetIsWaitingCustomerReply = Boolean(targetAssignment?.awaiting_customer_reply);
|
|
||||||
try {
|
|
||||||
if (!targetIsAssignedToCurrentUser) {
|
|
||||||
setApiError('Assuma o atendimento antes de responder.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (targetIsWaitingCustomerReply) {
|
|
||||||
setApiError('Aguarde o cliente responder antes de enviar novas mensagens.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
setApiError(error.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const media = attachedFile
|
|
||||||
? {
|
|
||||||
data: attachedFile.data,
|
|
||||||
mimetype: attachedFile.type,
|
|
||||||
filename: attachedFile.name,
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
const newMessage = {
|
const newMessage = {
|
||||||
id: `temp-${Date.now()}`,
|
id: Date.now(),
|
||||||
chatId: contactId,
|
|
||||||
sender: 'agent',
|
sender: 'agent',
|
||||||
text: trimmed,
|
text: trimmed,
|
||||||
timestamp: Math.floor(Date.now() / 1000),
|
|
||||||
hasMedia: Boolean(media),
|
|
||||||
media,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
setMessagesByContact((current) => ({
|
setMessagesByContact((current) => ({
|
||||||
...current,
|
...current,
|
||||||
[contactId]: mergeMessageList(current[contactId] || [], newMessage),
|
[activeContactId]: [...(current[activeContactId] || []), newMessage],
|
||||||
}));
|
}));
|
||||||
updateContactPreview(contactId, trimmed || '[Mídia]', media);
|
updateContactPreview(activeContactId, trimmed);
|
||||||
if (contactId === activeContactId) {
|
|
||||||
setDraft('');
|
setDraft('');
|
||||||
}
|
setIsReplying(true);
|
||||||
setAttachedFile(null);
|
|
||||||
|
|
||||||
if (!contactId.includes('@')) return;
|
replyTimeoutRef.current = window.setTimeout(() => {
|
||||||
|
const reply = {
|
||||||
|
id: Date.now() + 1,
|
||||||
|
sender: 'customer',
|
||||||
|
text: getMockReply(activeContact.name),
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
setMessagesByContact((current) => ({
|
||||||
const response = await fetch(`${API_BASE_URL}/whatsapp/send`, {
|
...current,
|
||||||
method: 'POST',
|
[activeContactId]: [...(current[activeContactId] || []), reply],
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
to: contactId,
|
|
||||||
message: trimmed,
|
|
||||||
senderName: getUserDisplayName(currentUser),
|
|
||||||
media,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const message =
|
|
||||||
response.status === 413
|
|
||||||
? 'Arquivo muito grande para envio. Tente uma mídia menor.'
|
|
||||||
: 'Não foi possível enviar a mensagem.';
|
|
||||||
throw new Error(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
setApiError(null);
|
|
||||||
updateContact(contactId, (contact) => ({
|
|
||||||
...contact,
|
|
||||||
assignment: contact.assignment
|
|
||||||
? { ...contact.assignment, transfer_note: null }
|
|
||||||
: contact.assignment,
|
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
setContacts((current) =>
|
||||||
setApiError(error.message);
|
current.map((contact) =>
|
||||||
}
|
contact.id === activeContactId
|
||||||
|
? { ...contact, preview: reply.text, time: 'Agora', unread: contact.unread + 1 }
|
||||||
|
: contact,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setIsReplying(false);
|
||||||
|
}, 1400);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitTransfer() {
|
function submitTransfer() {
|
||||||
const note = transferNote.trim();
|
const note = transferNote.trim();
|
||||||
const areaId = selectedTransferArea?.id;
|
const transferMessage = note
|
||||||
|
? `Transferencia solicitada para ${transferArea} com ${transferAttendant}. Obs: ${note}`
|
||||||
if (!areaId) {
|
: `Transferencia solicitada para ${transferArea} com ${transferAttendant}.`;
|
||||||
setApiError('Selecione uma especialidade válida para transferência.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isAssignedToCurrentUser) {
|
|
||||||
setApiError('Assuma o atendimento antes de transferir.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetUserId = isSameUserArea && transferAttendant ? Number(transferAttendant) : null;
|
|
||||||
const response = await fetch(`${API_BASE_URL}/whatsapp/transfer`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
chatId: activeContactId,
|
|
||||||
areaId,
|
|
||||||
userId: targetUserId,
|
|
||||||
note,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
setApiError('Não foi possível transferir o atendimento.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const assignment = await response.json();
|
|
||||||
const transferMessage = targetUserId
|
|
||||||
? `Transferência solicitada para ${transferArea}. Obs: ${note || 'Sem observação.'}`
|
|
||||||
: `Transferência enviada para a fila de ${transferArea}. Obs: ${note || 'Sem observação.'}`;
|
|
||||||
|
|
||||||
setMessagesByContact((current) => ({
|
setMessagesByContact((current) => ({
|
||||||
...current,
|
...current,
|
||||||
@ -873,20 +114,17 @@ export function useChat() {
|
|||||||
],
|
],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
updateContact(activeContactId, (contact) => ({
|
setContacts((current) =>
|
||||||
...contact,
|
current.map((contact) =>
|
||||||
area: assignment.area_nome || transferArea,
|
contact.id === activeContactId ? { ...contact, area: transferArea } : contact,
|
||||||
areaId: assignment.area_id || areaId,
|
),
|
||||||
assignment,
|
);
|
||||||
}));
|
|
||||||
setSelectedArea(transferArea);
|
setSelectedArea(transferArea);
|
||||||
setIsTransferOpen(false);
|
setIsTransferOpen(false);
|
||||||
setTransferNote('');
|
setTransferNote('');
|
||||||
setApiError(null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
currentUserId,
|
|
||||||
contacts,
|
contacts,
|
||||||
activeContact,
|
activeContact,
|
||||||
activeContactId,
|
activeContactId,
|
||||||
@ -894,25 +132,8 @@ export function useChat() {
|
|||||||
messages,
|
messages,
|
||||||
draft,
|
draft,
|
||||||
setDraft,
|
setDraft,
|
||||||
attachedFile,
|
|
||||||
attachFile,
|
|
||||||
removeAttachedFile,
|
|
||||||
sendMessage,
|
sendMessage,
|
||||||
hydrateMessageMedia,
|
|
||||||
assumeChat,
|
|
||||||
releaseChat,
|
|
||||||
closeChat,
|
|
||||||
canAssumeChat,
|
|
||||||
canReply,
|
|
||||||
assignmentLabel,
|
|
||||||
transferNoteLabel,
|
|
||||||
isAssignedToCurrentUser,
|
|
||||||
activeAssignment,
|
|
||||||
updateContactProfile,
|
|
||||||
isReplying,
|
isReplying,
|
||||||
isLoadingChats,
|
|
||||||
isLoadingMessages,
|
|
||||||
apiError,
|
|
||||||
selectedArea,
|
selectedArea,
|
||||||
setSelectedArea,
|
setSelectedArea,
|
||||||
isTransferOpen,
|
isTransferOpen,
|
||||||
@ -921,18 +142,10 @@ export function useChat() {
|
|||||||
setTransferArea,
|
setTransferArea,
|
||||||
transferAreas,
|
transferAreas,
|
||||||
attendants,
|
attendants,
|
||||||
isSameUserArea,
|
|
||||||
transferAttendant,
|
transferAttendant,
|
||||||
setTransferAttendant,
|
setTransferAttendant,
|
||||||
transferNote,
|
transferNote,
|
||||||
setTransferNote,
|
setTransferNote,
|
||||||
submitTransfer,
|
submitTransfer,
|
||||||
agentPresence,
|
|
||||||
isPaused,
|
|
||||||
pauseSeconds,
|
|
||||||
pauseDurationLabel: formatPauseDuration(pauseSeconds),
|
|
||||||
isPresenceLoading,
|
|
||||||
pauseAttendance,
|
|
||||||
resumeAttendance,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,19 +1,15 @@
|
|||||||
import { Link, useSearchParams } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import { BrandMark } from '../../../shared/components/BrandMark';
|
import { BrandMark } from '../../../shared/components/BrandMark';
|
||||||
import { useViewport } from '../../../shared/hooks/useViewport';
|
import { useViewport } from '../../../shared/hooks/useViewport';
|
||||||
import { ChatConversationList } from '../components/ChatConversationList';
|
import { ChatConversationList } from '../components/ChatConversationList';
|
||||||
import { ChatTransferPanel } from '../components/ChatTransferPanel';
|
import { ChatTransferPanel } from '../components/ChatTransferPanel';
|
||||||
import { ContactProfilePanel } from '../components/ContactProfilePanel';
|
|
||||||
import { ChatWindow } from '../components/ChatWindow';
|
import { ChatWindow } from '../components/ChatWindow';
|
||||||
import { useChat } from '../hooks/useChat';
|
import { useChat } from '../hooks/useChat';
|
||||||
import { quickReplies } from '../services/chatMocks';
|
import { quickReplies } from '../services/chatMocks';
|
||||||
|
|
||||||
export function ChatPage() {
|
export function ChatPage() {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
|
||||||
const { isWideDesktop, isDesktop, isTablet, isMobile } = useViewport();
|
const { isWideDesktop, isDesktop, isTablet, isMobile } = useViewport();
|
||||||
const {
|
const {
|
||||||
currentUserId,
|
|
||||||
contacts,
|
contacts,
|
||||||
activeContact,
|
activeContact,
|
||||||
activeContactId,
|
activeContactId,
|
||||||
@ -21,19 +17,7 @@ export function ChatPage() {
|
|||||||
messages,
|
messages,
|
||||||
draft,
|
draft,
|
||||||
setDraft,
|
setDraft,
|
||||||
attachedFile,
|
|
||||||
attachFile,
|
|
||||||
removeAttachedFile,
|
|
||||||
sendMessage,
|
sendMessage,
|
||||||
hydrateMessageMedia,
|
|
||||||
assumeChat,
|
|
||||||
releaseChat,
|
|
||||||
closeChat,
|
|
||||||
canAssumeChat,
|
|
||||||
canReply,
|
|
||||||
assignmentLabel,
|
|
||||||
transferNoteLabel,
|
|
||||||
updateContactProfile,
|
|
||||||
isReplying,
|
isReplying,
|
||||||
selectedArea,
|
selectedArea,
|
||||||
setSelectedArea,
|
setSelectedArea,
|
||||||
@ -43,34 +27,12 @@ export function ChatPage() {
|
|||||||
setTransferArea,
|
setTransferArea,
|
||||||
transferAreas,
|
transferAreas,
|
||||||
attendants,
|
attendants,
|
||||||
isSameUserArea,
|
|
||||||
transferAttendant,
|
transferAttendant,
|
||||||
setTransferAttendant,
|
setTransferAttendant,
|
||||||
transferNote,
|
transferNote,
|
||||||
setTransferNote,
|
setTransferNote,
|
||||||
submitTransfer,
|
submitTransfer,
|
||||||
isPaused,
|
|
||||||
pauseDurationLabel,
|
|
||||||
} = useChat();
|
} = 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
|
const gridTemplateColumns = isMobile
|
||||||
? '1fr'
|
? '1fr'
|
||||||
@ -114,7 +76,7 @@ export function ChatPage() {
|
|||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isPaused ? `Pausado ha ${pauseDurationLabel}` : 'Atendimento em tempo real'}
|
Atendimento em tempo real
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
to="/home"
|
to="/home"
|
||||||
@ -137,22 +99,17 @@ export function ChatPage() {
|
|||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns,
|
gridTemplateColumns,
|
||||||
gap: '1rem',
|
gap: '1rem',
|
||||||
alignItems: 'stretch',
|
alignItems: 'start',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ChatConversationList
|
<ChatConversationList
|
||||||
contacts={contacts}
|
contacts={contacts}
|
||||||
activeContactId={activeContactId}
|
activeContactId={activeContactId}
|
||||||
onSelectContact={selectContact}
|
onSelectContact={setActiveContactId}
|
||||||
onOpenContact={() => {
|
|
||||||
setIsTransferOpen(false);
|
|
||||||
setIsContactPanelOpen(true);
|
|
||||||
}}
|
|
||||||
currentUserId={currentUserId}
|
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gap: '1rem', minWidth: 0, alignContent: 'start' }}>
|
<div style={{ display: 'grid', gap: '1rem', minWidth: 0 }}>
|
||||||
<ChatWindow
|
<ChatWindow
|
||||||
contact={activeContact}
|
contact={activeContact}
|
||||||
messages={messages}
|
messages={messages}
|
||||||
@ -160,25 +117,9 @@ export function ChatPage() {
|
|||||||
setSelectedArea={setSelectedArea}
|
setSelectedArea={setSelectedArea}
|
||||||
draft={draft}
|
draft={draft}
|
||||||
setDraft={setDraft}
|
setDraft={setDraft}
|
||||||
attachedFile={attachedFile}
|
|
||||||
onAttachFile={attachFile}
|
|
||||||
onRemoveAttachedFile={removeAttachedFile}
|
|
||||||
onLoadMedia={hydrateMessageMedia}
|
|
||||||
onSend={sendMessage}
|
onSend={sendMessage}
|
||||||
onToggleTransfer={() => {
|
onToggleTransfer={() => setIsTransferOpen((current) => !current)}
|
||||||
setIsContactPanelOpen(false);
|
|
||||||
setIsTransferOpen((current) => !current);
|
|
||||||
}}
|
|
||||||
onAssumeChat={assumeChat}
|
|
||||||
onReleaseChat={releaseChat}
|
|
||||||
onCloseChat={closeChat}
|
|
||||||
canAssumeChat={canAssumeChat}
|
|
||||||
canReply={canReply}
|
|
||||||
assignmentLabel={assignmentLabel}
|
|
||||||
transferNote={transferNoteLabel}
|
|
||||||
isReplying={isReplying}
|
isReplying={isReplying}
|
||||||
isPaused={isPaused}
|
|
||||||
pauseDurationLabel={pauseDurationLabel}
|
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -194,7 +135,6 @@ export function ChatPage() {
|
|||||||
key={reply}
|
key={reply}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setDraft(reply)}
|
onClick={() => setDraft(reply)}
|
||||||
disabled={isPaused}
|
|
||||||
style={{
|
style={{
|
||||||
border: '1px solid var(--color-border)',
|
border: '1px solid var(--color-border)',
|
||||||
borderRadius: '18px',
|
borderRadius: '18px',
|
||||||
@ -203,7 +143,6 @@ export function ChatPage() {
|
|||||||
color: 'var(--color-primary)',
|
color: 'var(--color-primary)',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
opacity: isPaused ? 0.55 : 1,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{reply}
|
{reply}
|
||||||
@ -213,14 +152,12 @@ export function ChatPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isWideDesktop ? (
|
{isWideDesktop ? (
|
||||||
<>
|
|
||||||
<ChatTransferPanel
|
<ChatTransferPanel
|
||||||
isOpen={isTransferOpen}
|
isOpen={isTransferOpen}
|
||||||
transferArea={transferArea}
|
transferArea={transferArea}
|
||||||
setTransferArea={setTransferArea}
|
setTransferArea={setTransferArea}
|
||||||
transferAreas={transferAreas}
|
transferAreas={transferAreas}
|
||||||
attendants={attendants}
|
attendants={attendants}
|
||||||
isSameUserArea={isSameUserArea}
|
|
||||||
transferAttendant={transferAttendant}
|
transferAttendant={transferAttendant}
|
||||||
setTransferAttendant={setTransferAttendant}
|
setTransferAttendant={setTransferAttendant}
|
||||||
transferNote={transferNote}
|
transferNote={transferNote}
|
||||||
@ -228,25 +165,16 @@ export function ChatPage() {
|
|||||||
onSubmit={submitTransfer}
|
onSubmit={submitTransfer}
|
||||||
onClose={() => setIsTransferOpen(false)}
|
onClose={() => setIsTransferOpen(false)}
|
||||||
/>
|
/>
|
||||||
<ContactProfilePanel
|
|
||||||
isOpen={isContactPanelOpen}
|
|
||||||
contact={activeContact}
|
|
||||||
onClose={() => setIsContactPanelOpen(false)}
|
|
||||||
onSaved={updateContactProfile}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{!isWideDesktop ? (
|
{!isWideDesktop ? (
|
||||||
<>
|
|
||||||
<ChatTransferPanel
|
<ChatTransferPanel
|
||||||
isOpen={isTransferOpen}
|
isOpen={isTransferOpen}
|
||||||
transferArea={transferArea}
|
transferArea={transferArea}
|
||||||
setTransferArea={setTransferArea}
|
setTransferArea={setTransferArea}
|
||||||
transferAreas={transferAreas}
|
transferAreas={transferAreas}
|
||||||
attendants={attendants}
|
attendants={attendants}
|
||||||
isSameUserArea={isSameUserArea}
|
|
||||||
transferAttendant={transferAttendant}
|
transferAttendant={transferAttendant}
|
||||||
setTransferAttendant={setTransferAttendant}
|
setTransferAttendant={setTransferAttendant}
|
||||||
transferNote={transferNote}
|
transferNote={transferNote}
|
||||||
@ -254,13 +182,6 @@ export function ChatPage() {
|
|||||||
onSubmit={submitTransfer}
|
onSubmit={submitTransfer}
|
||||||
onClose={() => setIsTransferOpen(false)}
|
onClose={() => setIsTransferOpen(false)}
|
||||||
/>
|
/>
|
||||||
<ContactProfilePanel
|
|
||||||
isOpen={isContactPanelOpen}
|
|
||||||
contact={activeContact}
|
|
||||||
onClose={() => setIsContactPanelOpen(false)}
|
|
||||||
onSaved={updateContactProfile}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@ -1,46 +0,0 @@
|
|||||||
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,7 +1,74 @@
|
|||||||
|
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 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 = [
|
export const quickReplies = [
|
||||||
'Recebi sua mensagem e ja vou verificar.',
|
'Recebi sua mensagem e ja vou verificar.',
|
||||||
'Consegue me confirmar o numero do protocolo?',
|
'Consegue me confirmar o numero do protocolo?',
|
||||||
'Posso seguir com essa atualizacao por aqui.',
|
'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)];
|
||||||
|
}
|
||||||
|
|||||||
@ -1,23 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
@ -1,135 +0,0 @@
|
|||||||
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>
|
<div>
|
||||||
<strong style={{ display: 'block', fontSize: '1.1rem' }}>Ligações recentes</strong>
|
<strong style={{ display: 'block', fontSize: '1.1rem' }}>Ligacoes recentes</strong>
|
||||||
<span style={{ color: 'var(--color-text-soft)' }}>
|
<span style={{ color: 'var(--color-text-soft)' }}>
|
||||||
Visualização rápida do fluxo de voz do time.
|
Visualizacao rapida do fluxo de voz do time.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -42,7 +42,7 @@ export function CallsWorkspace({ calls, isWideDesktop = false, isDesktop = false
|
|||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Nova ligação
|
Nova ligacao
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { clearSession } from '../../auth/services/sessionService';
|
|
||||||
|
|
||||||
export function HomeSidebar({ items, activeItem, isMobile = false }) {
|
export function HomeSidebar({ items, activeItem, isMobile = false }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@ -18,7 +17,7 @@ export function HomeSidebar({ items, activeItem, isMobile = false }) {
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => navigate('/home')}
|
onClick={() => navigate('/new-attendance')}
|
||||||
style={{
|
style={{
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '20px',
|
borderRadius: '20px',
|
||||||
@ -29,7 +28,7 @@ export function HomeSidebar({ items, activeItem, isMobile = false }) {
|
|||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Home
|
+ Novo Atendimento
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<nav
|
<nav
|
||||||
@ -78,27 +77,6 @@ export function HomeSidebar({ items, activeItem, isMobile = false }) {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
clearSession();
|
|
||||||
navigate('/login');
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
|
||||||
borderRadius: '18px',
|
|
||||||
padding: '0.9rem 1rem',
|
|
||||||
background: 'transparent',
|
|
||||||
color: '#ef4444',
|
|
||||||
fontWeight: 700,
|
|
||||||
marginTop: 'auto',
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'all 0.2s',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Sair
|
|
||||||
</button>
|
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,16 +1,3 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { getCurrentUserDisplay } from '../../auth/services/sessionService';
|
|
||||||
|
|
||||||
function formatCurrentDateTime(date) {
|
|
||||||
return new Intl.DateTimeFormat('pt-BR', {
|
|
||||||
day: '2-digit',
|
|
||||||
month: '2-digit',
|
|
||||||
year: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
}).format(date);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HomeTopbar({
|
export function HomeTopbar({
|
||||||
activeTab,
|
activeTab,
|
||||||
onTabChange,
|
onTabChange,
|
||||||
@ -21,29 +8,19 @@ export function HomeTopbar({
|
|||||||
isTablet = false,
|
isTablet = false,
|
||||||
isMobile = false,
|
isMobile = false,
|
||||||
}) {
|
}) {
|
||||||
const userDisplay = getCurrentUserDisplay();
|
|
||||||
const [currentDateTime, setCurrentDateTime] = useState(() => formatCurrentDateTime(new Date()));
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'messages', label: 'Mensagens' },
|
{ id: 'messages', label: 'Mensagens' },
|
||||||
{ id: 'calls', label: 'Ligações' },
|
{ id: 'calls', label: 'Ligacoes' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const gridTemplateColumns = isMobile
|
const gridTemplateColumns = isMobile
|
||||||
? '1fr'
|
? '1fr'
|
||||||
: isWideDesktop
|
: isWideDesktop
|
||||||
? 'max-content minmax(150px, 190px) minmax(280px, 1fr) max-content'
|
? 'max-content minmax(180px, 220px) minmax(280px, 1fr) max-content'
|
||||||
: isDesktop || isTablet
|
: isDesktop || isTablet
|
||||||
? 'repeat(2, minmax(0, 1fr))'
|
? 'repeat(2, minmax(0, 1fr))'
|
||||||
: '1fr';
|
: '1fr';
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const intervalId = window.setInterval(() => {
|
|
||||||
setCurrentDateTime(formatCurrentDateTime(new Date()));
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
return () => window.clearInterval(intervalId);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
style={{
|
style={{
|
||||||
@ -98,13 +75,9 @@ export function HomeTopbar({
|
|||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
width: isMobile ? '100%' : 'auto',
|
width: isMobile ? '100%' : 'auto',
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
textAlign: 'center',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{currentDateTime}
|
Sexta, 19 de marco
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
@ -135,9 +108,9 @@ export function HomeTopbar({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ textAlign: 'right', minWidth: 0 }}>
|
<div style={{ textAlign: 'right', minWidth: 0 }}>
|
||||||
<strong style={{ display: 'block' }}>{userDisplay.name}</strong>
|
<strong style={{ display: 'block' }}>Ana Camolesi</strong>
|
||||||
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.92rem' }}>
|
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.92rem' }}>
|
||||||
{userDisplay.subtitle}
|
Atendimento omnichannel
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@ -153,7 +126,7 @@ export function HomeTopbar({
|
|||||||
fontWeight: 800,
|
fontWeight: 800,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{userDisplay.initials}
|
AM
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@ -1,9 +1,4 @@
|
|||||||
import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 }) {
|
function ChannelBadge({ channel }) {
|
||||||
const colors = {
|
const colors = {
|
||||||
@ -30,295 +25,20 @@ 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({
|
export function MessagesWorkspace({
|
||||||
conversations,
|
conversations,
|
||||||
activeConversationId,
|
activeConversationId,
|
||||||
onSelectConversation,
|
onSelectConversation,
|
||||||
onSendSuggestedReply,
|
actionItems,
|
||||||
isWideDesktop = false,
|
isWideDesktop = false,
|
||||||
isDesktop = false,
|
isDesktop = false,
|
||||||
isTablet = false,
|
isTablet = false,
|
||||||
isMobile = false,
|
isMobile = false,
|
||||||
isPaused = false,
|
|
||||||
pauseDurationLabel = '00:00',
|
|
||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const messagesRef = useRef(null);
|
|
||||||
const currentUser = getCurrentUser();
|
|
||||||
const currentUserId = getUserId(currentUser);
|
|
||||||
const recentConversations = conversations.slice(0, 3);
|
|
||||||
const activeConversation =
|
const activeConversation =
|
||||||
recentConversations.find((conversation) => conversation.id === activeConversationId) ||
|
conversations.find((conversation) => conversation.id === activeConversationId) ||
|
||||||
recentConversations[0] ||
|
|
||||||
conversations[0];
|
conversations[0];
|
||||||
const safeActiveConversation = activeConversation || {
|
|
||||||
id: 'empty',
|
|
||||||
name: 'Nenhuma conversa',
|
|
||||||
status: 'offline',
|
|
||||||
messages: [],
|
|
||||||
};
|
|
||||||
const suggestedReplies = useMemo(
|
|
||||||
() => buildSuggestedReplies(safeActiveConversation),
|
|
||||||
[safeActiveConversation],
|
|
||||||
);
|
|
||||||
const [selectedReplyIndex, setSelectedReplyIndex] = useState(0);
|
|
||||||
const [noteDraft, setNoteDraft] = useState('');
|
|
||||||
const [notes, setNotes] = useState([]);
|
|
||||||
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
|
const gridTemplateColumns = isMobile
|
||||||
? '1fr'
|
? '1fr'
|
||||||
@ -328,15 +48,13 @@ export function MessagesWorkspace({
|
|||||||
? 'minmax(260px, 320px) minmax(0, 1fr)'
|
? 'minmax(260px, 320px) minmax(0, 1fr)'
|
||||||
: '1fr';
|
: '1fr';
|
||||||
|
|
||||||
const panelHeight = isMobile ? 'auto' : WORKSPACE_HEIGHT;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns,
|
gridTemplateColumns,
|
||||||
gap: '1rem',
|
gap: '1rem',
|
||||||
alignItems: 'stretch',
|
alignItems: 'start',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<section
|
<section
|
||||||
@ -347,20 +65,18 @@ export function MessagesWorkspace({
|
|||||||
padding: '1rem',
|
padding: '1rem',
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gap: '0.75rem',
|
gap: '0.75rem',
|
||||||
alignContent: 'start',
|
|
||||||
height: panelHeight,
|
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<strong style={{ fontSize: '1.05rem' }}>Conversas</strong>
|
<strong style={{ fontSize: '1.05rem' }}>Conversas</strong>
|
||||||
<p style={{ margin: '0.35rem 0 0', color: 'var(--color-text-soft)' }}>
|
<p style={{ margin: '0.35rem 0 0', color: 'var(--color-text-soft)' }}>
|
||||||
Últimos 3 atendimentos em tempo real.
|
Atendimento em tempo real por canal.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{recentConversations.map((conversation) => {
|
{conversations.map((conversation) => {
|
||||||
const isActive = conversation.id === safeActiveConversation.id;
|
const isActive = conversation.id === activeConversation.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@ -376,59 +92,37 @@ export function MessagesWorkspace({
|
|||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gap: '0.6rem',
|
gap: '0.6rem',
|
||||||
minWidth: 0,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', minWidth: 0 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
|
||||||
<strong style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
<strong>{conversation.name}</strong>
|
||||||
{conversation.name}
|
|
||||||
</strong>
|
|
||||||
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.86rem' }}>
|
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.86rem' }}>
|
||||||
{conversation.time}
|
{conversation.time}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.75rem' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.75rem' }}>
|
||||||
<ChannelBadge channel={conversation.channel} />
|
<ChannelBadge channel={conversation.channel} />
|
||||||
<UnreadBadge count={conversation.unread} />
|
{conversation.unread ? (
|
||||||
</div>
|
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
color: 'var(--color-text-soft)',
|
minWidth: 24,
|
||||||
display: '-webkit-box',
|
borderRadius: 999,
|
||||||
WebkitLineClamp: 2,
|
padding: '0.15rem 0.45rem',
|
||||||
WebkitBoxOrient: 'vertical',
|
background: 'var(--color-secondary)',
|
||||||
overflow: 'hidden',
|
color: '#fff',
|
||||||
overflowWrap: 'anywhere',
|
fontSize: '0.78rem',
|
||||||
lineHeight: 1.35,
|
fontWeight: 700,
|
||||||
|
textAlign: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{conversation.lastMessage}
|
{conversation.unread}
|
||||||
</span>
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<span style={{ color: 'var(--color-text-soft)' }}>{conversation.lastMessage}</span>
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
@ -437,9 +131,8 @@ export function MessagesWorkspace({
|
|||||||
borderRadius: '26px',
|
borderRadius: '26px',
|
||||||
border: '1px solid var(--color-border)',
|
border: '1px solid var(--color-border)',
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateRows: 'auto minmax(0, 1fr) auto',
|
gridTemplateRows: 'auto 1fr auto',
|
||||||
height: panelHeight,
|
minHeight: 580,
|
||||||
minHeight: isMobile ? 580 : 'auto',
|
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
}}
|
}}
|
||||||
@ -455,20 +148,15 @@ export function MessagesWorkspace({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<strong style={{ display: 'block', fontSize: '1.08rem' }}>
|
<strong style={{ display: 'block', fontSize: '1.08rem' }}>{activeConversation.name}</strong>
|
||||||
{safeActiveConversation.name}
|
|
||||||
</strong>
|
|
||||||
<span style={{ color: 'var(--color-text-soft)' }}>
|
<span style={{ color: 'var(--color-text-soft)' }}>
|
||||||
{safeActiveConversation.lastSeen || 'Sem atividade recente'}
|
{activeConversation.status === 'online' ? 'Online agora' : 'Offline'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: '0.6rem', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: '0.6rem', flexWrap: 'wrap' }}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => navigate('/chat')}
|
||||||
if (!isPaused) navigate('/chat');
|
|
||||||
}}
|
|
||||||
disabled={isPaused}
|
|
||||||
style={{
|
style={{
|
||||||
border: '1px solid var(--color-border)',
|
border: '1px solid var(--color-border)',
|
||||||
borderRadius: '14px',
|
borderRadius: '14px',
|
||||||
@ -476,8 +164,6 @@ export function MessagesWorkspace({
|
|||||||
background: '#fff',
|
background: '#fff',
|
||||||
color: 'var(--color-primary)',
|
color: 'var(--color-primary)',
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
opacity: isPaused ? 0.55 : 1,
|
|
||||||
cursor: isPaused ? 'not-allowed' : 'pointer',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Abrir chat
|
Abrir chat
|
||||||
@ -499,31 +185,21 @@ export function MessagesWorkspace({
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={messagesRef}
|
|
||||||
style={{
|
style={{
|
||||||
padding: '1.25rem',
|
padding: '1.25rem',
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gap: '0.9rem',
|
gap: '0.9rem',
|
||||||
alignContent: 'start',
|
alignContent: 'start',
|
||||||
overflowY: 'auto',
|
|
||||||
background:
|
background:
|
||||||
'linear-gradient(180deg, rgba(245, 248, 251, 0.45), rgba(255, 255, 255, 0.9))',
|
'linear-gradient(180deg, rgba(245, 248, 251, 0.45), rgba(255, 255, 255, 0.9))',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{safeActiveConversation.messages.map((message, index) => {
|
{activeConversation.messages.map((message) => {
|
||||||
const isAgent = message.from === 'agent';
|
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 (
|
return (
|
||||||
<Fragment key={message.id}>
|
|
||||||
{shouldShowDateSeparator ? <DateSeparator label={dateSeparator} /> : null}
|
|
||||||
<div
|
<div
|
||||||
|
key={message.id}
|
||||||
style={{
|
style={{
|
||||||
justifySelf: isAgent ? 'end' : 'start',
|
justifySelf: isAgent ? 'end' : 'start',
|
||||||
maxWidth: '72%',
|
maxWidth: '72%',
|
||||||
@ -532,125 +208,47 @@ export function MessagesWorkspace({
|
|||||||
background: isAgent ? 'var(--color-primary)' : '#edf1f5',
|
background: isAgent ? 'var(--color-primary)' : '#edf1f5',
|
||||||
color: isAgent ? '#fff' : 'var(--color-text)',
|
color: isAgent ? '#fff' : 'var(--color-text)',
|
||||||
boxShadow: 'var(--shadow-md)',
|
boxShadow: 'var(--shadow-md)',
|
||||||
display: 'grid',
|
|
||||||
gap: '0.55rem',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{parsedText.senderLabel ? (
|
{message.text}
|
||||||
<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>
|
</div>
|
||||||
</Fragment>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer
|
<footer
|
||||||
style={{
|
style={{
|
||||||
padding: '0.85rem 1.25rem 1rem',
|
padding: '1rem 1.25rem 1.25rem',
|
||||||
borderTop: '1px solid var(--color-border)',
|
borderTop: '1px solid var(--color-border)',
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gap: '0.65rem',
|
gridTemplateColumns: '1fr auto',
|
||||||
|
gap: '0.75rem',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<strong style={{ display: 'block', fontSize: '0.94rem' }}>Resposta sugerida</strong>
|
<input
|
||||||
|
type="text"
|
||||||
<div
|
value="Posso acionar o time responsavel e te retorno em seguida."
|
||||||
style={{
|
readOnly
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: '40px minmax(0, 1fr) 40px',
|
|
||||||
gap: '0.6rem',
|
|
||||||
alignItems: 'stretch',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={selectPreviousReply}
|
|
||||||
title="Resposta anterior"
|
|
||||||
disabled={isPaused}
|
|
||||||
style={{
|
style={{
|
||||||
border: '1px solid var(--color-border)',
|
border: '1px solid var(--color-border)',
|
||||||
borderRadius: '14px',
|
borderRadius: '18px',
|
||||||
|
padding: '0.95rem 1rem',
|
||||||
background: '#fff',
|
background: '#fff',
|
||||||
color: 'var(--color-primary)',
|
|
||||||
fontWeight: 900,
|
|
||||||
opacity: isPaused ? 0.55 : 1,
|
|
||||||
cursor: isPaused ? 'not-allowed' : 'pointer',
|
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
‹
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={sendSuggestedReply}
|
|
||||||
disabled={isPaused}
|
|
||||||
style={{
|
style={{
|
||||||
border: '1px solid rgba(0, 164, 183, 0.32)',
|
border: 'none',
|
||||||
borderRadius: '16px',
|
borderRadius: '18px',
|
||||||
padding: '0.75rem 0.9rem',
|
padding: '0.95rem 1.2rem',
|
||||||
background: 'rgba(0, 164, 183, 0.07)',
|
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
|
||||||
color: 'var(--color-text)',
|
color: '#fff',
|
||||||
fontWeight: 600,
|
fontWeight: 700,
|
||||||
textAlign: 'left',
|
|
||||||
lineHeight: 1.35,
|
|
||||||
minWidth: 0,
|
|
||||||
overflow: 'hidden',
|
|
||||||
display: '-webkit-box',
|
|
||||||
WebkitLineClamp: 2,
|
|
||||||
WebkitBoxOrient: 'vertical',
|
|
||||||
opacity: isPaused ? 0.55 : 1,
|
|
||||||
cursor: isPaused ? 'not-allowed' : 'pointer',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isPaused ? `Voce esta em pausa ha ${pauseDurationLabel}. Retome para responder.` : selectedReply}
|
Enviar
|
||||||
</button>
|
</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>
|
</footer>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@ -661,128 +259,49 @@ export function MessagesWorkspace({
|
|||||||
border: '1px solid var(--color-border)',
|
border: '1px solid var(--color-border)',
|
||||||
padding: '1.2rem',
|
padding: '1.2rem',
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateRows: 'auto minmax(0, 1fr)',
|
|
||||||
gap: '1rem',
|
gap: '1rem',
|
||||||
|
alignContent: 'start',
|
||||||
gridColumn: isWideDesktop ? 'auto' : '1 / -1',
|
gridColumn: isWideDesktop ? 'auto' : '1 / -1',
|
||||||
height: panelHeight,
|
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<strong style={{ fontSize: '1.05rem' }}>Comunicados e notas</strong>
|
<strong style={{ fontSize: '1.05rem' }}>Painel de acoes</strong>
|
||||||
|
<p style={{ margin: '0.35rem 0 0', color: 'var(--color-text-soft)' }}>
|
||||||
|
Contexto rapido do atendimento selecionado.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
{actionItems.map((item) => (
|
||||||
style={{
|
|
||||||
display: 'grid',
|
|
||||||
gap: '0.85rem',
|
|
||||||
alignContent: 'start',
|
|
||||||
overflowY: 'auto',
|
|
||||||
paddingRight: '0.15rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{managerMessages.map((message) => (
|
|
||||||
<article
|
<article
|
||||||
key={message.id}
|
key={item.title}
|
||||||
style={{
|
style={{
|
||||||
borderRadius: '18px',
|
borderRadius: '20px',
|
||||||
padding: '0.95rem',
|
padding: '1rem',
|
||||||
background: 'rgba(0, 49, 80, 0.04)',
|
background: 'rgba(0, 49, 80, 0.04)',
|
||||||
display: 'grid',
|
|
||||||
gap: '0.4rem',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<strong>{message.title}</strong>
|
<span style={{ color: 'var(--color-text-soft)', display: 'block', marginBottom: '0.35rem' }}>
|
||||||
<p style={{ margin: 0, color: 'var(--color-text-soft)', lineHeight: 1.5 }}>
|
{item.title}
|
||||||
{message.text}
|
</span>
|
||||||
</p>
|
<strong>{item.value}</strong>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<label style={{ display: 'grid', gap: '0.45rem' }}>
|
|
||||||
<span style={{ color: 'var(--color-text-soft)', fontWeight: 700 }}>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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={saveNote}
|
onClick={() => navigate('/new-attendance')}
|
||||||
disabled={!currentUserId}
|
|
||||||
style={{
|
style={{
|
||||||
border: 'none',
|
border: '1px solid var(--color-border)',
|
||||||
borderRadius: '18px',
|
borderRadius: '18px',
|
||||||
padding: '0.95rem 1rem',
|
padding: '0.95rem 1rem',
|
||||||
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
|
|
||||||
color: '#fff',
|
|
||||||
fontWeight: 800,
|
|
||||||
opacity: currentUserId ? 1 : 0.55,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
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',
|
background: '#fff',
|
||||||
display: 'grid',
|
color: 'var(--color-primary)',
|
||||||
gap: '0.35rem',
|
fontWeight: 700,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.75rem' }}>
|
Criar novo fluxo
|
||||||
<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>
|
</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>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,174 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,451 +0,0 @@
|
|||||||
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,85 +1,17 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { BrandMark } from '../../../shared/components/BrandMark';
|
import { BrandMark } from '../../../shared/components/BrandMark';
|
||||||
import { HomeSidebar } from '../components/HomeSidebar';
|
import { HomeSidebar } from '../components/HomeSidebar';
|
||||||
import { HomeTopbar } from '../components/HomeTopbar';
|
import { HomeTopbar } from '../components/HomeTopbar';
|
||||||
import { MessagesWorkspace } from '../components/MessagesWorkspace';
|
import { MessagesWorkspace } from '../components/MessagesWorkspace';
|
||||||
import { CallsWorkspace } from '../components/CallsWorkspace';
|
import { CallsWorkspace } from '../components/CallsWorkspace';
|
||||||
import { AttendantOpsPanel } from '../components/AttendantOpsPanel';
|
import { actionItems, conversations, recentCalls, sidebarItems } from '../services/homeMocks';
|
||||||
import { recentCalls, sidebarItems } from '../services/homeMocks';
|
|
||||||
import { useViewport } from '../../../shared/hooks/useViewport';
|
import { useViewport } from '../../../shared/hooks/useViewport';
|
||||||
import { useChat } from '../../chat/hooks/useChat';
|
|
||||||
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() {
|
export function HomePage() {
|
||||||
const { isWideDesktop, isDesktop, isTablet, isMobile } = useViewport();
|
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 [activeTab, setActiveTab] = useState('messages');
|
||||||
const [searchValue, setSearchValue] = useState('');
|
const [searchValue, setSearchValue] = useState('');
|
||||||
const [contactCount, setContactCount] = useState(0);
|
const [activeConversationId, setActiveConversationId] = useState(conversations[0].id);
|
||||||
|
|
||||||
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 search = searchValue.trim().toLowerCase();
|
||||||
const filteredConversations = !search
|
const filteredConversations = !search
|
||||||
@ -90,9 +22,9 @@ export function HomePage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const safeConversationId =
|
const safeConversationId =
|
||||||
filteredConversations.find((conversation) => conversation.id === activeContactId)?.id ||
|
filteredConversations.find((conversation) => conversation.id === activeConversationId)?.id ||
|
||||||
filteredConversations[0]?.id ||
|
filteredConversations[0]?.id ||
|
||||||
conversations[0]?.id;
|
conversations[0].id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main
|
<main
|
||||||
@ -137,7 +69,7 @@ export function HomePage() {
|
|||||||
>
|
>
|
||||||
<BrandMark size="lg" />
|
<BrandMark size="lg" />
|
||||||
</div>
|
</div>
|
||||||
<HomeSidebar items={sidebarWithContactCount} activeItem="dashboard" isMobile={!isDesktop} />
|
<HomeSidebar items={sidebarItems} activeItem="dashboard" isMobile={!isDesktop} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gap: '1.25rem', minWidth: 0 }}>
|
<div style={{ display: 'grid', gap: '1.25rem', minWidth: 0 }}>
|
||||||
@ -158,44 +90,50 @@ export function HomePage() {
|
|||||||
gap: '1rem',
|
gap: '1rem',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AttendantOpsPanel
|
|
||||||
activeChatsCount={filteredConversations.length}
|
|
||||||
isPaused={isPaused}
|
|
||||||
pauseDurationLabel={pauseDurationLabel}
|
|
||||||
isPresenceLoading={isPresenceLoading}
|
|
||||||
onTogglePause={isPaused ? resumeAttendance : pauseAttendance}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isLoadingChats ? (
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
border: '1px solid var(--color-border)',
|
display: 'grid',
|
||||||
borderRadius: 18,
|
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
|
||||||
padding: '0.85rem 1rem',
|
gap: '1rem',
|
||||||
background: '#fff',
|
|
||||||
color: 'var(--color-text-soft)',
|
|
||||||
fontWeight: 700,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Atualizando conversas do WhatsApp...
|
{[
|
||||||
|
{ label: 'Atendimentos ativos', value: '18', detail: '7 aguardando retorno' },
|
||||||
|
{ label: 'Primeira resposta', value: '2m 14s', detail: 'Dentro do SLA' },
|
||||||
|
{ label: 'Fila de voz', value: '4 chamadas', detail: '1 prioridade alta' },
|
||||||
|
].map((item) => (
|
||||||
|
<article
|
||||||
|
key={item.label}
|
||||||
|
style={{
|
||||||
|
padding: '1.15rem',
|
||||||
|
borderRadius: '22px',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
background: '#fff',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: 'var(--color-text-soft)', display: 'block' }}>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
<strong style={{ display: 'block', fontSize: '1.4rem', marginTop: '0.45rem' }}>
|
||||||
|
{item.value}
|
||||||
|
</strong>
|
||||||
|
<span style={{ color: 'var(--color-text-soft)', display: 'block', marginTop: '0.45rem' }}>
|
||||||
|
{item.detail}
|
||||||
|
</span>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
|
|
||||||
{activeTab === 'messages' ? (
|
{activeTab === 'messages' ? (
|
||||||
<MessagesWorkspace
|
<MessagesWorkspace
|
||||||
conversations={filteredConversations}
|
conversations={filteredConversations}
|
||||||
activeConversationId={safeConversationId}
|
activeConversationId={safeConversationId}
|
||||||
onSelectConversation={setActiveContactId}
|
onSelectConversation={setActiveConversationId}
|
||||||
onSendSuggestedReply={async (conversationId, reply) => {
|
actionItems={actionItems}
|
||||||
setActiveContactId(conversationId);
|
|
||||||
await sendMessage(reply, conversationId);
|
|
||||||
}}
|
|
||||||
isWideDesktop={isWideDesktop}
|
isWideDesktop={isWideDesktop}
|
||||||
isDesktop={isDesktop}
|
isDesktop={isDesktop}
|
||||||
isTablet={isTablet}
|
isTablet={isTablet}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
isPaused={isPaused}
|
|
||||||
pauseDurationLabel={pauseDurationLabel}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<CallsWorkspace
|
<CallsWorkspace
|
||||||
|
|||||||
@ -1,23 +0,0 @@
|
|||||||
import { AdminPage } from '../../management/pages/AdminPage';
|
|
||||||
import { SupervisorPage } from '../../management/pages/SupervisorPage';
|
|
||||||
import { getCurrentUserProfile } from '../../auth/services/sessionService';
|
|
||||||
import { HomePage } from './HomePage';
|
|
||||||
import { UnassignedHomePage } from './UnassignedHomePage';
|
|
||||||
|
|
||||||
export function ProfileHomePage() {
|
|
||||||
const profile = getCurrentUserProfile();
|
|
||||||
|
|
||||||
if (profile === 'admin') {
|
|
||||||
return <AdminPage />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (profile === 'supervisor') {
|
|
||||||
return <SupervisorPage />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (profile === 'unassigned') {
|
|
||||||
return <UnassignedHomePage />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <HomePage />;
|
|
||||||
}
|
|
||||||
@ -1,91 +0,0 @@
|
|||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { BrandMark } from '../../../shared/components/BrandMark';
|
|
||||||
import { clearSession, getCurrentUser } from '../../auth/services/sessionService';
|
|
||||||
|
|
||||||
export function UnassignedHomePage() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const user = getCurrentUser();
|
|
||||||
|
|
||||||
function handleLogout() {
|
|
||||||
clearSession();
|
|
||||||
navigate('/login', { replace: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main
|
|
||||||
style={{
|
|
||||||
minHeight: '100vh',
|
|
||||||
display: 'grid',
|
|
||||||
placeItems: 'center',
|
|
||||||
padding: '2rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<section
|
|
||||||
style={{
|
|
||||||
width: 'min(760px, 100%)',
|
|
||||||
background: '#fff',
|
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
borderRadius: '32px',
|
|
||||||
boxShadow: 'var(--shadow-lg)',
|
|
||||||
padding: '2rem',
|
|
||||||
display: 'grid',
|
|
||||||
gap: '1.5rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<BrandMark size="lg" />
|
|
||||||
|
|
||||||
<div style={{ display: 'grid', gap: '0.75rem' }}>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
width: 'fit-content',
|
|
||||||
padding: '0.4rem 0.75rem',
|
|
||||||
borderRadius: 999,
|
|
||||||
background: 'rgba(229, 162, 42, 0.14)',
|
|
||||||
color: '#8a5a00',
|
|
||||||
fontWeight: 800,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Acesso aguardando 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
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 = [
|
export const sidebarItems = [
|
||||||
{ id: 'new-attendance', label: 'Abrir atendimento', route: '/new-attendance' },
|
{ id: 'dashboard', label: 'Dashboard' },
|
||||||
{ id: 'mass-message', label: 'Disparo em massa', route: '/mass-message' },
|
{ id: 'new-attendance', label: 'Novos Atendimentos', route: '/new-attendance' },
|
||||||
{ id: 'knowledge-base', label: 'Base de conhecimento' },
|
{ id: 'in-progress', label: 'Em andamento', count: 8 },
|
||||||
{ id: 'scripts', label: 'Scripts e respostas prontas' },
|
{ id: 'completed', label: 'Finalizados', count: 24 },
|
||||||
{ id: 'contacts', label: 'Contatos', route: '/contacts' },
|
{ id: 'contacts', label: 'Contatos', count: 128 },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const conversations = [
|
export const conversations = [
|
||||||
@ -30,28 +30,28 @@ export const conversations = [
|
|||||||
unread: 0,
|
unread: 0,
|
||||||
time: 'Ontem',
|
time: 'Ontem',
|
||||||
messages: [
|
messages: [
|
||||||
{ id: 1, from: 'customer', text: 'Precisamos rever os valores da última proposta.' },
|
{ id: 1, from: 'customer', text: 'Precisamos rever os valores da ultima proposta.' },
|
||||||
{ id: 2, from: 'agent', text: 'Perfeito, vou encaminhar para o time comercial.' },
|
{ id: 2, from: 'agent', text: 'Perfeito, vou encaminhar para o time comercial.' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'joao-pedro',
|
id: 'joao-pedro',
|
||||||
name: 'João Pedro',
|
name: 'Joao Pedro',
|
||||||
channel: 'SMS',
|
channel: 'SMS',
|
||||||
status: 'online',
|
status: 'online',
|
||||||
lastMessage: 'Pode me ligar em 10 minutos?',
|
lastMessage: 'Pode me ligar em 10 minutos?',
|
||||||
unread: 1,
|
unread: 1,
|
||||||
time: '08:15',
|
time: '08:15',
|
||||||
messages: [
|
messages: [
|
||||||
{ id: 1, from: 'customer', text: 'Recebi a cobrança em duplicidade.' },
|
{ id: 1, from: 'customer', text: 'Recebi a cobranca em duplicidade.' },
|
||||||
{ id: 2, from: 'agent', text: 'Vou analisar isso agora para você.' },
|
{ id: 2, from: 'agent', text: 'Vou analisar isso agora para voce.' },
|
||||||
{ id: 3, from: 'customer', text: 'Pode me ligar em 10 minutos?' },
|
{ id: 3, from: 'customer', text: 'Pode me ligar em 10 minutos?' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const actionItems = [
|
export const actionItems = [
|
||||||
{ title: 'Especialidade atual', value: 'Suporte' },
|
{ title: 'Area atual', value: 'Suporte' },
|
||||||
{ title: 'SLA restante', value: '18 min' },
|
{ title: 'SLA restante', value: '18 min' },
|
||||||
{ title: 'Prioridade', value: 'Alta' },
|
{ title: 'Prioridade', value: 'Alta' },
|
||||||
];
|
];
|
||||||
|
|||||||
@ -1,49 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,861 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,284 +0,0 @@
|
|||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { BrandMark } from '../../../shared/components/BrandMark';
|
|
||||||
import { clearSession } from '../../auth/services/sessionService';
|
|
||||||
|
|
||||||
const navigationBySection = {
|
|
||||||
supervisor: [
|
|
||||||
{ id: 'dashboard', label: '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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,59 +0,0 @@
|
|||||||
export function ManagementTable({ columns, rows, getRowId, isMobile = false }) {
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'grid', gap: '0.75rem' }}>
|
|
||||||
{!isMobile ? (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: `repeat(${columns.length}, minmax(0, 1fr))`,
|
|
||||||
gap: '1rem',
|
|
||||||
padding: '0 1rem',
|
|
||||||
color: 'var(--color-text-soft)',
|
|
||||||
fontWeight: 700,
|
|
||||||
fontSize: '0.88rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{columns.map((column) => (
|
|
||||||
<span key={column.key}>{column.label}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{rows.map((row) => (
|
|
||||||
<article
|
|
||||||
key={getRowId(row)}
|
|
||||||
style={{
|
|
||||||
borderRadius: '20px',
|
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
padding: '1rem',
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: isMobile ? '1fr' : `repeat(${columns.length}, minmax(0, 1fr))`,
|
|
||||||
gap: isMobile ? '0.65rem' : '1rem',
|
|
||||||
alignItems: 'center',
|
|
||||||
background: '#fff',
|
|
||||||
minWidth: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{columns.map((column) => (
|
|
||||||
<div key={column.key} style={{ minWidth: 0 }}>
|
|
||||||
{isMobile ? (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
display: 'block',
|
|
||||||
color: 'var(--color-text-soft)',
|
|
||||||
fontSize: '0.82rem',
|
|
||||||
fontWeight: 700,
|
|
||||||
marginBottom: '0.2rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{column.label}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{column.render ? column.render(row) : <span>{row[column.key]}</span>}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,493 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,342 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,445 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,119 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,66 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,97 +0,0 @@
|
|||||||
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',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,124 +0,0 @@
|
|||||||
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',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,106 +0,0 @@
|
|||||||
export const supervisorMetrics = [
|
|
||||||
{ label: 'Atendimentos abertos', value: '42', detail: '12 aguardando agente' },
|
|
||||||
{ label: 'SLA em risco', value: '7', detail: 'Financeiro concentra 4 casos' },
|
|
||||||
{ label: 'Agentes online', value: '18', detail: '3 em pausa operacional' },
|
|
||||||
{ label: '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',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@ -1,53 +0,0 @@
|
|||||||
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,12 +1,9 @@
|
|||||||
import { createBrowserRouter, Navigate } from 'react-router-dom';
|
import { createBrowserRouter, Navigate } from 'react-router-dom';
|
||||||
import { LoginPage } from '../modules/auth/pages/LoginPage';
|
import { LoginPage } from '../modules/auth/pages/LoginPage';
|
||||||
import { ProfileHomePage } from '../modules/home/pages/ProfileHomePage';
|
import { HomePage } from '../modules/home/pages/HomePage';
|
||||||
import { AgentMassMessagePage } from '../modules/home/pages/AgentMassMessagePage';
|
|
||||||
import { ContactsPage } from '../modules/home/pages/ContactsPage';
|
|
||||||
import { ChatPage } from '../modules/chat/pages/ChatPage';
|
import { ChatPage } from '../modules/chat/pages/ChatPage';
|
||||||
import { CallPage } from '../modules/call/pages/CallPage';
|
import { CallPage } from '../modules/call/pages/CallPage';
|
||||||
import { AgentNewAttendancePage } from '../modules/attendance/pages/AgentNewAttendancePage';
|
import { NewAttendancePage } from '../modules/attendance/pages/NewAttendancePage';
|
||||||
import { WhatsappAdminPage } from '../modules/management/pages/WhatsappAdminPage';
|
|
||||||
|
|
||||||
export const router = createBrowserRouter([
|
export const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
@ -19,7 +16,7 @@ export const router = createBrowserRouter([
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/home',
|
path: '/home',
|
||||||
element: <ProfileHomePage />,
|
element: <HomePage />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/chat',
|
path: '/chat',
|
||||||
@ -31,18 +28,6 @@ export const router = createBrowserRouter([
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/new-attendance',
|
path: '/new-attendance',
|
||||||
element: <AgentNewAttendancePage />,
|
element: <NewAttendancePage />,
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/mass-message',
|
|
||||||
element: <AgentMassMessagePage />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/contacts',
|
|
||||||
element: <ContactsPage />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/admin/whatsapp',
|
|
||||||
element: <WhatsappAdminPage />,
|
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@ -1,59 +0,0 @@
|
|||||||
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),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
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