Merge branch 'dev'

This commit is contained in:
Rafael Alves Lopes 2026-05-27 17:48:09 -03:00
commit dfc47ce7e8
62 changed files with 10598 additions and 764 deletions

3
.env.example Normal file
View File

@ -0,0 +1,3 @@
# Frontend environment variables (Vite)
VITE_API_URL=http://localhost:3001

32
.gitignore vendored
View File

@ -1 +1,31 @@
node_modules
# Dependencies
node_modules/
# Build output
dist/
# Local environment files
.env
.env.local
.env.development
.env.development.local
.env.production
.env.production.local
.env.test
.env.test.local
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Vite cache
.vite/
# Editor and OS files
.DS_Store
Thumbs.db
.idea/
.vscode/

89
README.md Normal file
View File

@ -0,0 +1,89 @@
# Omnichannel Frontend
![React](https://img.shields.io/badge/React-18.x-61DAFB)
![Vite](https://img.shields.io/badge/Vite-5.x-646CFF)
![JavaScript](https://img.shields.io/badge/JavaScript-ESM-yellow)
![React Router](https://img.shields.io/badge/Router-React%20Router-red)
![Socket.IO](https://img.shields.io/badge/Realtime-Socket.IO-black)
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`.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
:root{font-family:Segoe UI,Helvetica Neue,sans-serif;color:#122230;background:radial-gradient(circle at top left,rgba(0,164,183,.12),transparent 28%),radial-gradient(circle at bottom right,rgba(229,162,42,.14),transparent 24%),#f5f8fb;color-scheme:light;--color-primary: #003150;--color-secondary: #b51f1f;--color-accent: #00a4b7;--color-highlight: #e5a22a;--color-surface: rgba(255, 255, 255, .9);--color-surface-strong: #ffffff;--color-text: #122230;--color-text-soft: #5e6d7b;--color-border: rgba(0, 49, 80, .12);--shadow-lg: 0 24px 60px rgba(0, 49, 80, .12);--shadow-md: 0 12px 28px rgba(0, 49, 80, .08)}*{box-sizing:border-box}html,body,#root{min-height:100%;margin:0}body{min-height:100vh}body,button,input{font:inherit}button{cursor:pointer}a{color:inherit;text-decoration:none}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

14
dist/index.html vendored
View File

@ -1,14 +0,0 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" href="/assets/favicon_blue-CzkOczz3.png" />
<title>Omnichannel Sothis</title>
<script type="module" crossorigin src="/assets/index-1xjqdjIG.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BsY34Fgu.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

88
docs/chat-whatsapp.md Normal file
View File

@ -0,0 +1,88 @@
# Modulo de Chat WhatsApp (Frontend)
## Visao geral
O modulo de Chat no frontend integra as conversas em tempo real do WhatsApp diretamente na tela de atendimento do operador.
A interface e altamente responsiva, provendo feedback instantaneo de envio (zero latencia) e sincronizando com o backend via WebSockets (Socket.io) para atualizar estados de de-duplicacao, novas mensagens, midias e controle de posse do atendimento.
---
## Componentes Principais
### 1. Hook de Negocio (`useChat.js`)
Centraliza todo o estado das conversas, conexao WebSocket e operacoes de rede:
* **`contacts`**: Lista de chats ativos sincronizados. Cada contato possui um objeto `assignment` (atribuicao) normalizado.
* **`messagesByContact`**: Map de historico de mensagens por JID/contato.
* **`takeChat()`**: Dispara a requisicao de rede `/whatsapp/assign` enviando o ID do atendente e o ID numerico da area do usuario logado (convertido com seguranca para inteiro).
* **`sendMessage()`**: Trata a de-duplicacao de mensagens em milissegundos e gerencia a concorrência (race condition).
### 2. Painel de Atendimento (`ChatWindow.jsx`)
O container principal da conversa selecionada. Ele renderiza:
* **Header**: Mostra o nome resolvido do cliente, canal (WhatsApp) e o indicador de quem esta atendendo.
* **Historico**: Area de scroll contendo as bolhas de mensagens do atendente (`agent`) e do cliente (`customer`), incluindo visualizadores para imagens, audios e links de arquivos.
* **Footer de Input**: Caixa de texto com suporte a tecla Enter e icone de anexo de midia (com validacao automatica de tamanho).
---
## Mecanismos de UX e Estabilidade
### 1. Insercao Instantanea (UX Zero-Latency)
Para evitar que o atendente perceba qualquer latencia de rede, o envio e dividido em duas etapas:
1. **Fase Local**: A bolha de mensagem e inserida na tela imediatamente com um ID temporario (`temp-` + timestamp) e o texto digitado. O input de texto e arquivos e limpo na mesma hora.
2. **Fase de Disparo**: A requisicao HTTP POST e disparada para o backend em segundo plano.
### 2. De-duplicacao de Mensagens (Prevecao de Race Condition)
Como o backend envia a mensagem recebida via WebSocket assim que o Puppeteer a dispara, a bolha poderia aparecer duplicada na tela se a requisição de envio original ainda estivesse processando.
* **A Solucao**: O hook de WebSocket compara as mensagens recebidas em tempo real. Se o texto bater e a diferenca temporal de timestamp for inferior a 4 segundos, ele identifica a bolha `temp-...` local, remove o prefixo temporario e atualiza-a com o ID oficial do WhatsApp gerado no servidor. **Zero duplicacoes, zero flashes na tela.**
### 3. Validação de Posse (Type-Safe User IDs)
Para evitar conflitos na exibicao do banner *"⚠️ Atendido por outro colaborador"*, realizamos casting explicito dos IDs dos usuarios envolvidos:
```javascript
const isAssignedToMe = activeContact?.assignment?.userId && String(activeContact.assignment.userId) === String(currentUser.id);
const isAssignedToOthers = activeContact?.assignment && String(activeContact.assignment.userId) !== String(currentUser.id);
```
Isso impede que comparacoes como `4 === "4"` (inteiro vindo do banco relacional vs string vindo do localStorage/JWT) avaliem incorretamente como falso, mantendo a tela bloqueada ou liberada com precisao.
### 4. Layout e Rolagem Estrita (680px Scroll)
A interface de mensagens possui limitacoes verticais restritas para evitar que a tela se alongue infinitamente para baixo.
* A bolha de historico e fixada com altura proporcional (`height: 680px` ou `calc`) e controle de transbordo `overflow-y: auto`.
* O hook de chat escuta mudancas na lista de mensagens e realiza rolagem automatica suave (`smooth`) para o fim da tela sempre que uma nova bolha e adicionada.
---
## Novos Fluxos Homologados (WhatsApp / Meta)
### 1. Novo Atendimento Inteligente (`NewAttendancePage.jsx`)
* **Remoção do Seletor de Área**: O seletor manual foi removido da tela para simplificar a operação. O sistema resolve a área dinamicamente a partir do atendente logado (`currentUser.areaPrincipal` ou `areas[0]`).
* **Bloqueio de Campo**: Ao escolher um contato dos recentes ou da busca lateral, o input do telefone e do nome do cliente ficam bloqueados para escrita.
* **Modo "Novo Número"**: Ao clicar no botão, o operador habilita os inputs de nome e telefone. Caso inicie o chat sem digitar um nome personalizado, o sistema aplica um fallback limpo no formato `Contato Novo (+55...)`.
### 2. Bloqueio e Envio de Templates Meta (`ChatWindow.jsx`)
Como a API oficial do WhatsApp/Meta exige uma mensagem pré-aprovada para iniciar conversas ativas (sem histórico prévio), a interface aplica travas estritas:
* **Travamento do Input**: Se a conversa selecionada possuir histórico de envio vazio (`!hasAgentMessages`), a caixa de texto principal e o botão "Enviar" ficam bloqueados.
* **Painel de Templates**: Logo acima do rodapé de digitação, renderiza-se um seletor horizontal com os templates oficiais Meta ativos no banco (buscados de `GET /whatsapp/templates`).
* **Substituição Dinâmica**: Ao clicar em um template, as variáveis `|NOME|`, `|DATA|` ou `|PROTOCOLO|` são interpoladas em tempo real com os dados do cliente, populando o input principal e liberando o fluxo de envio da primeira mensagem.
### 3. Gerenciamento de Templates para Supervisores (`SupervisorPage.jsx`)
Supervisores possuem controle administrativo total sobre as mensagens homologadas:
* **CRUD de Modelos**: Exibe todos os templates de WhatsApp em formato de cards visuais.
* **Painel de Edição**: Permite criar novos templates ou editar identificadores/conteúdos de templates existentes. As alterações persistem imediatamente no banco PostgreSQL por meio dos endpoints `/whatsapp/templates`.
---
## Como Integrar e Rodar
### Variaveis de Ambiente
O frontend conecta no WebSocket e na API do backend usando a porta padrao do NestJS:
```env
VITE_API_URL=http://localhost:3001
VITE_WS_URL=http://localhost:3001
```
### Compilando e Rodando localmente
```bash
cd frontend
npm run dev
```
Ao selecionar uma conversa de canal "WhatsApp" que esteja livre, basta digitar uma mensagem e pressionar Enter. O chat sera automaticamente assumido por voce em tempo real, gravando no PostgreSQL e desbloqueando a janela de chat de forma instantanea.

95
package-lock.json generated
View File

@ -10,7 +10,8 @@
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.1"
"react-router-dom": "^6.30.1",
"socket.io-client": "^4.8.3"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.3",
@ -48,6 +49,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@ -1106,6 +1108,12 @@
"win32"
]
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@ -1212,6 +1220,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@ -1258,7 +1267,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@ -1279,6 +1287,28 @@
"dev": true,
"license": "ISC"
},
"node_modules/engine.io-client": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz",
"integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.18.3",
"xmlhttprequest-ssl": "~2.1.1"
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
@ -1411,7 +1441,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
@ -1481,6 +1510,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@ -1493,6 +1523,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@ -1607,6 +1638,34 @@
"semver": "bin/semver.js"
}
},
"node_modules/socket.io-client": {
"version": "4.8.3",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser": {
"version": "4.2.6",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -1654,6 +1713,7 @@
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
@ -1708,6 +1768,35 @@
}
}
},
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@ -11,7 +11,8 @@
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.1"
"react-router-dom": "^6.30.1",
"socket.io-client": "^4.8.3"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.3",

View File

@ -2,7 +2,6 @@ export function RecentContactsList({
contacts,
activeContactId,
onSelectContact,
selectedChannel,
}) {
return (
<aside
@ -25,11 +24,6 @@ export function RecentContactsList({
<div style={{ display: 'grid', gap: '0.75rem' }}>
{contacts.map((contact) => {
const isActive = contact.id === activeContactId;
const isPreferred = selectedChannel === 'call'
? contact.channel === 'Ligacao'
: selectedChannel === 'sms'
? contact.channel === 'SMS'
: contact.channel === 'WhatsApp';
return (
<button
@ -49,24 +43,22 @@ export function RecentContactsList({
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
<strong>{contact.name}</strong>
{isPreferred ? (
<span
style={{
padding: '0.2rem 0.5rem',
borderRadius: 999,
background: 'rgba(0, 164, 183, 0.12)',
color: 'var(--color-primary)',
fontSize: '0.76rem',
fontWeight: 700,
}}
>
Sugerido
</span>
) : null}
<span
style={{
padding: '0.2rem 0.5rem',
borderRadius: 999,
background: 'rgba(43, 183, 65, 0.12)',
color: '#25883a',
fontSize: '0.76rem',
fontWeight: 700,
}}
>
Agenda
</span>
</div>
<span style={{ color: 'var(--color-text-soft)' }}>{contact.phone}</span>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
<span style={{ color: 'var(--color-primary)', fontWeight: 700 }}>{contact.channel}</span>
<span style={{ color: 'var(--color-primary)', fontWeight: 700 }}>WhatsApp</span>
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.85rem' }}>
{contact.lastContact}
</span>
@ -74,6 +66,22 @@ export function RecentContactsList({
</button>
);
})}
{contacts.length === 0 ? (
<div
style={{
border: '1px solid var(--color-border)',
borderRadius: '18px',
padding: '1rem',
background: 'rgba(0, 49, 80, 0.04)',
color: 'var(--color-text-soft)',
fontWeight: 700,
lineHeight: 1.45,
}}
>
Nenhum contato encontrado na agenda.
</div>
) : null}
</div>
</aside>
);

View File

@ -0,0 +1,138 @@
import { useEffect, useMemo, useState } from 'react';
import { BrandMark } from '../../../shared/components/BrandMark';
import { useViewport } from '../../../shared/hooks/useViewport';
import { getCurrentUserDisplay } from '../../auth/services/sessionService';
import { listContactProfiles } from '../../chat/services/contactProfileService';
import { HomeSidebar } from '../../home/components/HomeSidebar';
import { sidebarItems } from '../../home/services/homeMocks';
import { NewAttendancePage } from './NewAttendancePage';
export function AgentNewAttendancePage() {
const { isDesktop, isMobile } = useViewport();
const userDisplay = getCurrentUserDisplay();
const [contactCount, setContactCount] = useState(0);
useEffect(() => {
let isMounted = true;
listContactProfiles()
.then((items) => {
if (isMounted) setContactCount(Array.isArray(items) ? items.length : 0);
})
.catch(() => {
if (isMounted) setContactCount(0);
});
return () => {
isMounted = false;
};
}, []);
const sidebarWithContactCount = useMemo(
() => sidebarItems.map((item) => (item.id === 'contacts' ? { ...item, count: contactCount } : item)),
[contactCount],
);
return (
<main style={{ minHeight: '100vh', padding: '1.5rem' }}>
<section
style={{
width: 'min(1680px, calc(100vw - 3rem))',
margin: '0 auto',
background: 'var(--color-surface-strong)',
borderRadius: '32px',
boxShadow: 'var(--shadow-lg)',
padding: '1.5rem',
display: 'grid',
gap: '1.5rem',
}}
>
<div
style={{
display: 'grid',
gridTemplateColumns: isDesktop ? 'minmax(340px, 380px) minmax(0, 1fr)' : '1fr',
gap: '1.5rem',
alignItems: 'start',
}}
>
<div style={{ display: 'grid', gap: '1.25rem' }}>
<div
style={{
background: '#fff',
border: '1px solid var(--color-border)',
borderRadius: '28px',
padding: '1.5rem',
}}
>
<BrandMark size="lg" />
</div>
<HomeSidebar items={sidebarWithContactCount} activeItem="new-attendance" isMobile={!isDesktop} />
</div>
<div style={{ display: 'grid', gap: '1.25rem', minWidth: 0 }}>
<header
style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 1fr) auto',
gap: '1rem',
alignItems: 'center',
}}
>
<div
style={{
padding: '1.1rem 1.25rem',
borderRadius: '22px',
background: '#fff',
border: '1px solid var(--color-border)',
}}
>
<h1 style={{ margin: 0, fontSize: '1.65rem' }}>Abrir Atendimento</h1>
<p style={{ margin: '0.45rem 0 0', color: 'var(--color-text-soft)' }}>
Inicie um contato ativo por WhatsApp usando mensagens pré-aprovadas.
</p>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.9rem',
justifySelf: isMobile ? 'stretch' : 'end',
justifyContent: isMobile ? 'space-between' : 'flex-end',
padding: '0.85rem 1rem',
borderRadius: '22px',
background: '#fff',
border: '1px solid var(--color-border)',
}}
>
<div style={{ textAlign: 'right' }}>
<strong style={{ display: 'block' }}>{userDisplay.name}</strong>
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.92rem' }}>
Atendimento omnichannel
</span>
</div>
<div
aria-hidden="true"
style={{
width: 48,
height: 48,
borderRadius: '16px',
display: 'grid',
placeItems: 'center',
background: 'linear-gradient(135deg, var(--color-accent), var(--color-primary))',
color: '#fff',
fontWeight: 800,
}}
>
{userDisplay.initials}
</div>
</div>
</header>
<NewAttendancePage embedded />
</div>
</div>
</section>
</main>
);
}

View File

@ -1,69 +1,363 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { BrandMark } from '../../../shared/components/BrandMark';
import { useViewport } from '../../../shared/hooks/useViewport';
import { RecentContactsList } from '../components/RecentContactsList';
import {
attendanceAreas,
attendanceChannels,
recentContacts,
} from '../services/attendanceMocks';
import { API_BASE_URL } from '../../../shared/services/apiConfig';
import { getCurrentUser } from '../../auth/services/sessionService';
import { listContactProfiles, saveContactProfile } from '../../chat/services/contactProfileService';
import { getAccessOptions } from '../../management/services/adminAccessService';
import { attendanceChannels } from '../services/attendanceMocks';
export function NewAttendancePage() {
const countryOptions = [
{ id: 'br', label: 'Brasil', dialCode: '55', placeholder: '(11) 99999-9999' },
{ id: 'us', label: 'EUA', dialCode: '1', placeholder: '(212) 555-0199' },
{ id: 'ar', label: 'Argentina', dialCode: '54', placeholder: '11 1234-5678' },
{ id: 'cl', label: 'Chile', dialCode: '56', placeholder: '9 1234 5678' },
{ id: 'mx', label: 'Mexico', dialCode: '52', placeholder: '55 1234 5678' },
];
function getUserId(user) {
const value = user?.databaseId || user?.id;
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : null;
}
function normalizePhone(phone) {
return String(phone || '').replace(/\D/g, '');
}
function getCountryById(countryId) {
return countryOptions.find((country) => country.id === countryId) || countryOptions[0];
}
function inferCountryId(phone) {
const digits = normalizePhone(phone);
const matchedCountry = countryOptions.find((country) => digits.startsWith(country.dialCode));
return matchedCountry?.id || 'br';
}
function stripCountryCode(phone, countryId) {
const digits = normalizePhone(phone);
const country = getCountryById(countryId);
return digits.startsWith(country.dialCode) ? digits.slice(country.dialCode.length) : digits;
}
function buildInternationalPhone(phone, countryId) {
const country = getCountryById(countryId);
const nationalDigits = stripCountryCode(phone, country.id);
return nationalDigits ? `${country.dialCode}${nationalDigits}` : '';
}
function formatPhone(phone, countryId = inferCountryId(phone)) {
const country = getCountryById(countryId);
const digits = stripCountryCode(phone, country.id);
if (!digits) return '';
if (country.id === 'br') {
if (digits.length >= 11) {
return `(${digits.slice(0, 2)}) ${digits.slice(2, 7)}-${digits.slice(7, 11)}`;
}
if (digits.length >= 10) {
return `(${digits.slice(0, 2)}) ${digits.slice(2, 6)}-${digits.slice(6, 10)}`;
}
if (digits.length > 2) {
return `(${digits.slice(0, 2)}) ${digits.slice(2)}`;
}
return digits;
}
if (country.id === 'us') {
if (digits.length >= 10) {
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6, 10)}`;
}
if (digits.length > 3) {
return `(${digits.slice(0, 3)}) ${digits.slice(3)}`;
}
return digits;
}
if (country.id === 'ar') {
if (digits.length >= 10) {
return `${digits.slice(0, 2)} ${digits.slice(2, 6)}-${digits.slice(6, 10)}`;
}
if (digits.length > 2) {
return `${digits.slice(0, 2)} ${digits.slice(2)}`;
}
return digits;
}
if (country.id === 'cl') {
if (digits.length >= 9) {
return `${digits.slice(0, 1)} ${digits.slice(1, 5)} ${digits.slice(5, 9)}`;
}
if (digits.length > 1) {
return `${digits.slice(0, 1)} ${digits.slice(1)}`;
}
return digits;
}
if (country.id === 'mx') {
if (digits.length >= 10) {
return `${digits.slice(0, 2)} ${digits.slice(2, 6)} ${digits.slice(6, 10)}`;
}
if (digits.length > 2) {
return `${digits.slice(0, 2)} ${digits.slice(2)}`;
}
return digits;
}
return digits;
}
function applyPhoneMask(value, countryId) {
const digits = stripCountryCode(value, countryId);
if (!digits) return '';
return formatPhone(digits, countryId);
}
function requiresUnsupportedTemplateFields(template) {
const allowedFields = new Set(['nome', 'cliente', 'data', 'link', 'variavel']);
const placeholders = String(template?.content || '').matchAll(/\{([^{}]+)\}/g);
return Array.from(placeholders).some((match) => {
const key = String(match[1]).trim().toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '');
return !allowedFields.has(key);
});
}
function renderTemplatePreview(content, form, variables) {
return String(content || '')
.replace(/\{nome\}/gi, form.name.trim() || 'cliente')
.replace(/\{cliente\}/gi, form.name.trim() || 'cliente')
.replace(/\{data\}/gi, variables.date.trim() || '{data}')
.replace(/\{link\}/gi, variables.link.trim() || '{link}')
.replace(/\{variavel\}/gi, variables.custom.trim() || '{variavel}')
.replace(/\{variável\}/gi, variables.custom.trim() || '{variável}');
}
function formatLastContact(value) {
if (!value) return 'Sem data';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return 'Sem data';
return date.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' });
}
function buildChatId(phone, countryId) {
const digits = buildInternationalPhone(phone, countryId);
if (!digits) return '';
return `${digits}@c.us`;
}
function normalizeAgendaContact(contact) {
const phone = contact.phone || '';
return {
id: contact.chat_id,
chatId: contact.chat_id,
name: contact.name || phone || 'Contato sem nome',
phone: formatPhone(phone),
rawPhone: phone,
countryId: inferCountryId(phone),
company: contact.company || '',
note: contact.note || '',
lastContact: formatLastContact(contact.updated_at || contact.created_at),
};
}
function getUserAreas(user) {
const normalizeArea = (area) => {
if (!area) return null;
if (typeof area === 'string') return area;
return area.nome || area.name || null;
};
const areas = (Array.isArray(user?.areas) ? user.areas : []).map(normalizeArea).filter(Boolean);
const primaryArea = normalizeArea(user?.areaPrincipal);
if (primaryArea && !areas.includes(primaryArea)) {
return [primaryArea, ...areas];
}
return areas;
}
async function listWhatsappTemplates() {
const response = await fetch(`${API_BASE_URL}/whatsapp/templates`);
if (!response.ok) throw new Error('Falha ao carregar templates do WhatsApp.');
return response.json();
}
async function startWhatsappAttendance(payload) {
const response = await fetch(`${API_BASE_URL}/whatsapp/start-attendance`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) throw new Error('Falha ao iniciar atendimento pelo WhatsApp.');
return response.json();
}
export function NewAttendancePage({ embedded = false }) {
const navigate = useNavigate();
const { isWideDesktop, isDesktop, isTablet, isMobile } = useViewport();
const currentUser = getCurrentUser();
const currentUserId = getUserId(currentUser);
const currentUserAreas = getUserAreas(currentUser);
const [contacts, setContacts] = useState([]);
const [templates, setTemplates] = useState([]);
const [areaOptions, setAreaOptions] = useState([]);
const [searchValue, setSearchValue] = useState('');
const [selectedChannelId, setSelectedChannelId] = useState('whatsapp');
const [selectedArea, setSelectedArea] = useState('');
const [selectedContactId, setSelectedContactId] = useState(recentContacts[0].id);
const [customNumber, setCustomNumber] = useState('');
const [selectedContactId, setSelectedContactId] = useState('');
const [selectedTemplateId, setSelectedTemplateId] = useState('');
const [selectedCountryId, setSelectedCountryId] = useState('br');
const [form, setForm] = useState({ phone: '', name: '', company: '', note: '' });
const [templateVariables, setTemplateVariables] = useState({ date: '', link: '', custom: '' });
const [isLoadingContacts, setIsLoadingContacts] = useState(false);
const [isStarting, setIsStarting] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
let isMounted = true;
async function loadContacts() {
setIsLoadingContacts(true);
try {
const [contactsData, templatesData, accessOptions] = await Promise.all([
listContactProfiles(),
listWhatsappTemplates(),
getAccessOptions(),
]);
if (!isMounted) return;
setContacts(Array.isArray(contactsData) ? contactsData.map(normalizeAgendaContact) : []);
const supportedTemplates = Array.isArray(templatesData)
? templatesData.filter((template) => template.status === 'approved' && !requiresUnsupportedTemplateFields(template))
: [];
setTemplates(supportedTemplates);
setSelectedTemplateId((current) => current || (supportedTemplates?.[0]?.id ? String(supportedTemplates[0].id) : ''));
setAreaOptions(accessOptions.areas || []);
setError('');
} catch (err) {
if (isMounted) setError(err.message);
} finally {
if (isMounted) setIsLoadingContacts(false);
}
}
loadContacts();
return () => {
isMounted = false;
};
}, []);
const search = searchValue.trim().toLowerCase();
const filteredContacts = useMemo(() => {
if (!search) {
return recentContacts;
return contacts;
}
return recentContacts.filter((contact) => {
const haystack = `${contact.name} ${contact.phone} ${contact.channel}`.toLowerCase();
return contacts.filter((contact) => {
const haystack = `${contact.name} ${contact.phone} ${contact.rawPhone} ${contact.company} ${contact.note}`.toLowerCase();
return haystack.includes(search);
});
}, [search]);
const selectedContact =
filteredContacts.find((contact) => contact.id === selectedContactId) ||
recentContacts.find((contact) => contact.id === selectedContactId) ||
recentContacts[0];
}, [contacts, search]);
const selectedChannel =
attendanceChannels.find((channel) => channel.id === selectedChannelId) || attendanceChannels[0];
const selectedCountry = getCountryById(selectedCountryId);
const selectedTemplate = templates.find((template) => String(template.id) === String(selectedTemplateId));
const primaryArea = areaOptions.find((area) => currentUserAreas.includes(area.nome));
const isWhatsapp = selectedChannel.id === 'whatsapp';
const canStartAttendance = isWhatsapp && Boolean(buildInternationalPhone(form.phone, selectedCountryId)) && Boolean(selectedTemplateId);
const gridTemplateColumns = isMobile
? '1fr'
: isWideDesktop
? 'minmax(300px, 360px) minmax(0, 1fr)'
? 'minmax(0, 1fr) minmax(340px, 0.8fr)'
: isDesktop || isTablet
? 'minmax(280px, 340px) minmax(0, 1fr)'
? 'minmax(0, 1fr) minmax(320px, 0.85fr)'
: '1fr';
function handleStartAttendance() {
navigate(selectedChannel.route);
function selectContact(contactId) {
const contact = contacts.find((item) => item.id === contactId);
if (!contact) return;
const contactCountryId = contact.countryId || inferCountryId(contact.rawPhone);
setSelectedContactId(contactId);
setSelectedCountryId(contactCountryId);
setForm({
phone: applyPhoneMask(contact.rawPhone || contact.phone || '', contactCountryId),
name: contact.name || '',
company: contact.company || '',
note: contact.note || '',
});
}
return (
<main style={{ minHeight: '100vh', padding: '1.5rem' }}>
<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.25rem',
}}
>
function clearSelection() {
setSelectedContactId('');
setSelectedCountryId('br');
setForm({ phone: '', name: '', company: '', note: '' });
}
async function handleStartAttendance() {
if (!canStartAttendance) {
setError('Informe um número de WhatsApp para iniciar o atendimento.');
return;
}
if (!selectedTemplateId) {
setError('Selecione uma mensagem pré-aprovada para iniciar o atendimento.');
return;
}
if (!currentUserId) {
setError('Não foi possível identificar o usuário logado.');
return;
}
setIsStarting(true);
try {
const fullPhone = buildInternationalPhone(form.phone, selectedCountryId);
const chatId = selectedContactId || buildChatId(form.phone, selectedCountryId);
const saved = await saveContactProfile(chatId, {
phone: fullPhone,
name: form.name,
company: form.company,
note: form.note,
userId: currentUserId,
});
const startedAttendance = await startWhatsappAttendance({
to: saved.chat_id || chatId,
templateId: Number(selectedTemplateId),
userId: currentUserId,
areaId: primaryArea?.id || null,
variables: {
nome: form.name,
cliente: form.name,
data: templateVariables.date,
link: templateVariables.link,
variavel: templateVariables.custom,
'variável': templateVariables.custom,
},
});
setError('');
navigate(`/chat?chatId=${encodeURIComponent(startedAttendance?.chatId || saved.chat_id || chatId)}`);
} catch (err) {
setError(err.message);
} finally {
setIsStarting(false);
}
}
const content = (
<section
style={{
width: embedded ? '100%' : 'min(1680px, calc(100vw - 3rem))',
margin: embedded ? 0 : '0 auto',
background: 'var(--color-surface-strong)',
borderRadius: embedded ? 0 : '32px',
boxShadow: embedded ? 'none' : 'var(--shadow-lg)',
padding: embedded ? 0 : '1.5rem',
display: 'grid',
gap: '1.25rem',
}}
>
{!embedded ? (
<header
style={{
display: 'grid',
@ -84,7 +378,7 @@ export function NewAttendancePage() {
textAlign: 'center',
}}
>
Criacao rapida de atendimento
Criação pida de atendimento
</div>
<Link
to="/home"
@ -101,6 +395,7 @@ export function NewAttendancePage() {
Voltar para home
</Link>
</header>
) : null}
<section
style={{
@ -110,13 +405,6 @@ export function NewAttendancePage() {
alignItems: 'start',
}}
>
<RecentContactsList
contacts={filteredContacts}
activeContactId={selectedContact.id}
onSelectContact={setSelectedContactId}
selectedChannel={selectedChannelId}
/>
<section
style={{
background: '#fff',
@ -130,47 +418,11 @@ export function NewAttendancePage() {
<div>
<strong style={{ display: 'block', fontSize: '1.18rem' }}>Novo atendimento</strong>
<p style={{ margin: '0.45rem 0 0', color: 'var(--color-text-soft)', lineHeight: 1.6 }}>
Escolha o contato, o canal e a area opcional antes de iniciar. O fluxo e mockado
e leva voce direto para chat ou ligacao.
Informe um contato de WhatsApp ou selecione alguém da agenda para iniciar o atendimento.
Para conversas novas, o primeiro envio usa uma mensagem pré-aprovada da Meta.
</p>
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 1fr) auto',
gap: '0.85rem',
}}
>
<input
type="search"
value={searchValue}
onChange={(event) => setSearchValue(event.target.value)}
placeholder="Buscar contato por nome ou numero"
style={{
border: '1px solid var(--color-border)',
borderRadius: '18px',
padding: '0.95rem 1rem',
background: '#fff',
outline: 'none',
}}
/>
<button
type="button"
onClick={() => setCustomNumber(selectedContact.phone)}
style={{
border: '1px solid var(--color-border)',
borderRadius: '18px',
padding: '0.95rem 1rem',
background: '#fff',
color: 'var(--color-primary)',
fontWeight: 700,
}}
>
Novo numero
</button>
</div>
<div
style={{
display: 'grid',
@ -180,12 +432,16 @@ export function NewAttendancePage() {
>
{attendanceChannels.map((channel) => {
const isActive = channel.id === selectedChannelId;
const isDisabled = Boolean(channel.disabled);
return (
<button
key={channel.id}
type="button"
onClick={() => setSelectedChannelId(channel.id)}
onClick={() => {
if (!isDisabled) setSelectedChannelId(channel.id);
}}
disabled={isDisabled}
style={{
border: '1px solid',
borderColor: isActive ? `${channel.accent}44` : 'var(--color-border)',
@ -195,15 +451,15 @@ export function NewAttendancePage() {
textAlign: 'left',
display: 'grid',
gap: '0.45rem',
opacity: isDisabled ? 0.58 : 1,
cursor: isDisabled ? 'not-allowed' : 'pointer',
}}
>
<strong style={{ color: isActive ? channel.accent : 'var(--color-text)' }}>
{channel.label}
</strong>
<span style={{ color: 'var(--color-text-soft)' }}>
{channel.id === 'call'
? 'Inicia uma ligacao mock em tela cheia.'
: 'Abre o fluxo de conversa em tempo real.'}
{isDisabled ? 'Canal em construção.' : 'Inicia uma conversa pelo WhatsApp.'}
</span>
</button>
);
@ -213,15 +469,22 @@ export function NewAttendancePage() {
<div
style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'repeat(2, minmax(0, 1fr))',
gridTemplateColumns: isMobile ? '1fr' : 'minmax(170px, 0.45fr) minmax(0, 1fr) minmax(0, 1fr)',
gap: '1rem',
}}
>
<label style={{ display: 'grid', gap: '0.45rem' }}>
<span style={{ fontWeight: 600 }}>Area (opcional)</span>
<span style={{ fontWeight: 600 }}>País</span>
<select
value={selectedArea}
onChange={(event) => setSelectedArea(event.target.value)}
value={selectedCountryId}
onChange={(event) => {
const nextCountryId = event.target.value;
setSelectedCountryId(nextCountryId);
setForm((current) => ({
...current,
phone: applyPhoneMask(current.phone, nextCountryId),
}));
}}
style={{
border: '1px solid var(--color-border)',
borderRadius: '18px',
@ -230,22 +493,38 @@ export function NewAttendancePage() {
outline: 'none',
}}
>
<option value="">Selecionar depois</option>
{attendanceAreas.map((area) => (
<option key={area} value={area}>
{area}
{countryOptions.map((country) => (
<option key={country.id} value={country.id}>
{country.label} +{country.dialCode}
</option>
))}
</select>
</label>
<label style={{ display: 'grid', gap: '0.45rem' }}>
<span style={{ fontWeight: 600 }}>Numero selecionado</span>
<span style={{ fontWeight: 600 }}>Número do WhatsApp</span>
<input
type="text"
value={customNumber || selectedContact.phone}
onChange={(event) => setCustomNumber(event.target.value)}
placeholder="+55 11 99999-9999"
value={form.phone}
onChange={(event) => setForm((current) => ({ ...current, phone: applyPhoneMask(event.target.value, selectedCountryId) }))}
placeholder={selectedCountry.placeholder}
style={{
border: '1px solid var(--color-border)',
borderRadius: '18px',
padding: '0.95rem 1rem',
background: '#fff',
outline: 'none',
}}
/>
</label>
<label style={{ display: 'grid', gap: '0.45rem' }}>
<span style={{ fontWeight: 600 }}>Nome do cliente</span>
<input
type="text"
value={form.name}
onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))}
placeholder="Nome para salvar na agenda"
style={{
border: '1px solid var(--color-border)',
borderRadius: '18px',
@ -257,6 +536,179 @@ export function NewAttendancePage() {
</label>
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'repeat(3, minmax(0, 1fr))',
gap: '1rem',
}}
>
<label style={{ display: 'grid', gap: '0.45rem' }}>
<span style={{ fontWeight: 600 }}>Etiqueta de identificação</span>
<input
type="text"
value={form.company}
onChange={(event) => setForm((current) => ({ ...current, company: event.target.value }))}
placeholder="Ex: Departamento, vaga ou conta vinculada"
style={{
border: '1px solid var(--color-border)',
borderRadius: '18px',
padding: '0.95rem 1rem',
background: '#fff',
outline: 'none',
}}
/>
</label>
<label style={{ display: 'grid', gap: '0.45rem' }}>
<span style={{ fontWeight: 600 }}>Mensagem pré-aprovada</span>
<select
value={selectedTemplateId}
onChange={(event) => setSelectedTemplateId(event.target.value)}
style={{
border: '1px solid var(--color-border)',
borderRadius: '18px',
padding: '0.95rem 1rem',
background: '#fff',
outline: 'none',
}}
>
<option value="">Selecione um template</option>
{templates.map((template) => (
<option key={template.id} value={template.id}>
{template.name}
</option>
))}
</select>
</label>
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'repeat(2, minmax(0, 1fr))',
gap: '1rem',
}}
>
<label style={{ display: 'grid', gap: '0.45rem' }}>
<span style={{ fontWeight: 600 }}>Data do template</span>
<input
type="text"
value={templateVariables.date}
onChange={(event) => setTemplateVariables((current) => ({ ...current, date: event.target.value }))}
placeholder="Ex: 26/05/2026"
style={{
border: '1px solid var(--color-border)',
borderRadius: '18px',
padding: '0.95rem 1rem',
background: '#fff',
outline: 'none',
}}
/>
</label>
<label style={{ display: 'grid', gap: '0.45rem' }}>
<span style={{ fontWeight: 600 }}>Link do template</span>
<input
type="text"
value={templateVariables.link}
onChange={(event) => setTemplateVariables((current) => ({ ...current, link: event.target.value }))}
placeholder="https://..."
style={{
border: '1px solid var(--color-border)',
borderRadius: '18px',
padding: '0.95rem 1rem',
background: '#fff',
outline: 'none',
}}
/>
</label>
<label style={{ display: 'grid', gap: '0.45rem' }}>
<span style={{ fontWeight: 600 }}>Variável do template</span>
<input
type="text"
value={templateVariables.custom}
onChange={(event) => setTemplateVariables((current) => ({ ...current, custom: event.target.value }))}
placeholder="Valor livre"
style={{
border: '1px solid var(--color-border)',
borderRadius: '18px',
padding: '0.95rem 1rem',
background: '#fff',
outline: 'none',
}}
/>
</label>
</div>
{selectedTemplate ? (
<div
style={{
border: '1px solid rgba(0, 49, 80, 0.08)',
borderRadius: '22px',
padding: '1rem',
background: 'linear-gradient(180deg, #e8f3ee, #dcefe8)',
minHeight: 220,
display: 'grid',
alignContent: 'end',
}}
>
<div style={{ display: 'grid', gap: '0.45rem' }}>
<strong style={{ display: 'block', color: 'var(--color-text-soft)', fontSize: '0.82rem' }}>
Preview WhatsApp
</strong>
<div
style={{
justifySelf: 'end',
maxWidth: '92%',
borderRadius: '16px 16px 4px 16px',
padding: '0.85rem 0.95rem',
background: '#d9fdd3',
color: '#1f2c33',
boxShadow: '0 6px 18px rgba(0, 49, 80, 0.08)',
whiteSpace: 'pre-wrap',
lineHeight: 1.45,
fontSize: '0.94rem',
}}
>
{renderTemplatePreview(selectedTemplate.content, form, templateVariables)}
<span
style={{
display: 'block',
marginTop: '0.5rem',
textAlign: 'right',
color: 'rgba(31, 44, 51, 0.58)',
fontSize: '0.72rem',
fontWeight: 700,
}}
>
10:42
</span>
</div>
</div>
</div>
) : null}
<label style={{ display: 'grid', gap: '0.45rem' }}>
<span style={{ fontWeight: 600 }}>Observação</span>
<textarea
rows={5}
value={form.note}
onChange={(event) => setForm((current) => ({ ...current, note: event.target.value }))}
placeholder="Contexto inicial deste atendimento."
style={{
border: '1px solid var(--color-border)',
borderRadius: '18px',
padding: '0.95rem 1rem',
background: '#fff',
outline: 'none',
resize: 'vertical',
}}
/>
</label>
{error ? <span style={{ color: '#b42318', fontWeight: 700 }}>{error}</span> : null}
{isLoadingContacts ? <span style={{ color: 'var(--color-text-soft)' }}>Carregando agenda...</span> : null}
<section
style={{
display: 'grid',
@ -285,17 +737,20 @@ export function NewAttendancePage() {
fontSize: '0.84rem',
}}
>
Resumo do fluxo
Resumo
</span>
<strong style={{ fontSize: '1.25rem' }}>{selectedContact.name}</strong>
<strong style={{ fontSize: '1.25rem' }}>{form.name || 'Cliente sem nome'}</strong>
<span style={{ color: 'rgba(255, 255, 255, 0.74)' }}>
Canal escolhido: {selectedChannel.label}
Canal: {selectedChannel.label}
</span>
<span style={{ color: 'rgba(255, 255, 255, 0.74)' }}>
Numero: {customNumber || selectedContact.phone}
Número: {buildInternationalPhone(form.phone, selectedCountryId) ? `+${buildInternationalPhone(form.phone, selectedCountryId)}` : 'Não informado'}
</span>
<span style={{ color: 'rgba(255, 255, 255, 0.74)' }}>
Area: {selectedArea || 'Definir depois'}
Etiqueta de identificação: {form.company || 'Não informada'}
</span>
<span style={{ color: 'rgba(255, 255, 255, 0.74)' }}>
Origem: {selectedContactId ? 'Agenda' : 'Novo contato'}
</span>
</article>
@ -309,32 +764,181 @@ export function NewAttendancePage() {
gap: '0.7rem',
}}
>
<strong>Proxima rota</strong>
<strong>Próxima rota</strong>
<span style={{ color: 'var(--color-text-soft)' }}>
{selectedChannel.route === '/call'
? 'Ao iniciar, voce vai para a tela de ligacao.'
: 'Ao iniciar, voce vai para a tela de chat.'}
O contato será salvo, o template será enviado e a conversa abrirá atribuída a você no chat.
</span>
<button
type="button"
onClick={handleStartAttendance}
style={{
border: 'none',
borderRadius: '18px',
padding: '1rem 1.1rem',
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
color: '#fff',
fontWeight: 800,
marginTop: '0.4rem',
}}
>
Iniciar atendimento
</button>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', marginTop: '0.4rem' }}>
{selectedContactId ? (
<button
type="button"
onClick={clearSelection}
style={{
border: '1px solid var(--color-border)',
borderRadius: '18px',
padding: '1rem 1.1rem',
background: '#fff',
color: 'var(--color-primary)',
fontWeight: 800,
}}
>
Limpar seleção
</button>
) : null}
<button
type="button"
onClick={handleStartAttendance}
disabled={!canStartAttendance || isStarting}
style={{
border: 'none',
borderRadius: '18px',
padding: '1rem 1.1rem',
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
color: '#fff',
fontWeight: 800,
opacity: canStartAttendance && !isStarting ? 1 : 0.55,
}}
>
{isStarting ? 'Iniciando...' : 'Iniciar atendimento'}
</button>
</div>
</article>
</section>
</section>
<aside style={{ display: 'grid', gap: '0.85rem', alignContent: 'start' }}>
<article
style={{
border: '1px solid var(--color-border)',
borderRadius: 24,
padding: '1rem',
background: '#fff',
display: 'grid',
gap: '0.75rem',
}}
>
<div>
<strong style={{ display: 'block' }}>Agenda de contatos</strong>
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.9rem' }}>
Selecione um contato salvo para preencher o atendimento.
</span>
</div>
<input
type="search"
value={searchValue}
onChange={(event) => setSearchValue(event.target.value)}
placeholder="Buscar por nome, telefone ou etiqueta"
style={{
border: '1px solid var(--color-border)',
borderRadius: '16px',
padding: '0.85rem 0.9rem',
background: '#fff',
outline: 'none',
fontWeight: 600,
}}
/>
<div style={{ display: 'grid', gap: '0.45rem', maxHeight: 460, overflowY: 'auto', paddingRight: '0.2rem' }}>
{filteredContacts.map((contact) => {
const isSelected = selectedContactId === contact.id;
return (
<button
key={contact.id}
type="button"
onClick={() => selectContact(contact.id)}
style={{
border: '1px solid',
borderColor: isSelected ? 'rgba(0, 164, 183, 0.36)' : 'var(--color-border)',
borderRadius: 16,
padding: '0.78rem',
background: isSelected ? 'rgba(0, 164, 183, 0.08)' : '#fff',
textAlign: 'left',
display: 'grid',
gap: '0.25rem',
cursor: 'pointer',
}}
>
<strong style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{contact.name}
</strong>
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.88rem' }}>
+{contact.rawPhone || normalizePhone(contact.phone)}
</span>
{contact.company ? (
<span
style={{
width: 'fit-content',
borderRadius: 999,
padding: '0.16rem 0.48rem',
background: 'rgba(0,49,80,0.06)',
color: 'var(--color-text-soft)',
fontSize: '0.76rem',
fontWeight: 800,
}}
>
{contact.company}
</span>
) : null}
</button>
);
})}
{!filteredContacts.length ? (
<span style={{ color: 'var(--color-text-soft)', fontWeight: 700 }}>
Nenhum contato encontrado na agenda.
</span>
) : null}
</div>
{selectedContactId ? (
<button
type="button"
onClick={clearSelection}
style={{
border: '1px solid var(--color-border)',
borderRadius: 14,
padding: '0.75rem',
background: '#fff',
color: 'var(--color-primary)',
fontWeight: 800,
}}
>
Limpar contato selecionado
</button>
) : null}
</article>
<article
style={{
border: '1px solid rgba(0, 164, 183, 0.24)',
borderRadius: 24,
padding: '1rem',
background: 'rgba(0, 164, 183, 0.06)',
color: 'var(--color-text-soft)',
lineHeight: 1.45,
fontWeight: 700,
}}
>
{selectedContactId
? 'Contato carregado da agenda. Você ainda pode ajustar nome, etiqueta e observação antes de iniciar.'
: 'Você também pode digitar um novo número manualmente no formulário.'}
</article>
</aside>
</section>
</section>
);
if (embedded) {
return content;
}
return (
<main style={{ minHeight: '100vh', padding: '1.5rem' }}>
{content}
</main>
);
}

View File

@ -1,38 +1,5 @@
export const attendanceChannels = [
{ id: 'whatsapp', label: 'WhatsApp', route: '/chat', accent: '#2bb741' },
{ id: 'sms', label: 'SMS', route: '/chat', accent: '#00a4b7' },
{ id: 'call', label: 'Ligacao', route: '/call', accent: '#e5a22a' },
];
export const attendanceAreas = ['Suporte', 'Financeiro', 'Comercial'];
export const recentContacts = [
{
id: 'maria-souza',
name: 'Maria Souza',
phone: '+55 11 99888-7766',
channel: 'WhatsApp',
lastContact: 'Hoje, 09:42',
},
{
id: 'empresa-alpha',
name: 'Empresa Alpha',
phone: '+55 11 4002-2020',
channel: 'Email',
lastContact: 'Ontem, 16:18',
},
{
id: 'joao-pedro',
name: 'Joao Pedro',
phone: '+55 31 98877-1102',
channel: 'SMS',
lastContact: 'Hoje, 08:15',
},
{
id: 'beatriz-lima',
name: 'Beatriz Lima',
phone: '+55 21 99701-4455',
channel: 'Ligacao',
lastContact: 'Hoje, 07:51',
},
{ id: 'sms', label: 'SMS', route: '/chat', accent: '#00a4b7', disabled: true },
{ id: 'email', label: 'E-mail', route: '/chat', accent: '#e5a22a', disabled: true },
];

View File

@ -39,7 +39,7 @@ const initialFormData = {
export function LoginForm() {
const [formData, setFormData] = useState(initialFormData);
const { login, isSubmitting } = useLogin();
const { login, startMicrosoftLogin, providers, error, isSubmitting } = useLogin();
async function handleSubmit(event) {
event.preventDefault();
@ -48,50 +48,89 @@ export function LoginForm() {
return (
<form onSubmit={handleSubmit} style={{ display: 'grid', gap: '1rem' }}>
<label style={{ display: 'grid', gap: '0.5rem' }}>
<span style={{ fontWeight: 600 }}>Usuario AD</span>
<input
style={fieldStyle}
type="text"
placeholder="seu.usuario"
value={formData.username}
onChange={(event) =>
setFormData((current) => ({ ...current, username: event.target.value }))
}
/>
</label>
{providers.ldap ? (
<>
<label style={{ display: 'grid', gap: '0.5rem' }}>
<span style={{ fontWeight: 600 }}>Usuario AD</span>
<input
style={fieldStyle}
type="text"
placeholder="seu.usuario"
autoComplete="username"
value={formData.username}
onChange={(event) =>
setFormData((current) => ({ ...current, username: event.target.value }))
}
/>
</label>
<label style={{ display: 'grid', gap: '0.5rem' }}>
<span style={{ fontWeight: 600 }}>Senha</span>
<input
style={fieldStyle}
type="password"
placeholder="Digite sua senha"
value={formData.password}
onChange={(event) =>
setFormData((current) => ({ ...current, password: event.target.value }))
}
/>
</label>
<label style={{ display: 'grid', gap: '0.5rem' }}>
<span style={{ fontWeight: 600 }}>Senha</span>
<input
style={fieldStyle}
type="password"
placeholder="Digite sua senha"
autoComplete="current-password"
value={formData.password}
onChange={(event) =>
setFormData((current) => ({ ...current, password: event.target.value }))
}
/>
</label>
<button style={primaryButtonStyle} type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Entrando...' : 'Entrar'}
</button>
<button style={primaryButtonStyle} type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Entrando...' : 'Entrar com AD'}
</button>
</>
) : null}
<button style={secondaryButtonStyle} type="button">
Entrar com Microsoft
</button>
{providers.microsoft ? (
<button style={secondaryButtonStyle} type="button" onClick={startMicrosoftLogin}>
Entrar com Microsoft
</button>
) : null}
<a
href="#forgot-password"
{error ? (
<div
role="alert"
style={{
border: '1px solid rgba(180, 35, 24, 0.24)',
borderRadius: 14,
padding: '0.85rem 1rem',
background: 'rgba(180, 35, 24, 0.08)',
color: '#b42318',
fontWeight: 700,
}}
>
{error}
</div>
) : null}
{!providers.ldap && !providers.microsoft ? (
<div
role="alert"
style={{
border: '1px solid var(--color-border)',
borderRadius: 14,
padding: '0.85rem 1rem',
background: '#fff',
color: 'var(--color-text-soft)',
fontWeight: 700,
}}
>
Nenhum provedor de login esta habilitado.
</div>
) : null}
<span
style={{
justifySelf: 'center',
color: 'var(--color-secondary)',
color: 'var(--color-text-soft)',
fontWeight: 600,
}}
>
Esqueci minha senha
</a>
Acesso somente via AD ou Microsoft corporativo.
</span>
</form>
);
}

View File

@ -1,17 +1,51 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { mockLogin } from '../services/authService';
import {
getAuthConfig,
loginWithAd,
startMicrosoftLogin,
storeAuthSession,
} from '../services/authService';
export function useLogin() {
const navigate = useNavigate();
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState('');
const [providers, setProviders] = useState({ ldap: true, microsoft: false });
async function login() {
setIsSubmitting(true);
useEffect(() => {
getAuthConfig()
.then((config) => setProviders(config.providers || { ldap: true, microsoft: false }))
.catch(() => setProviders({ ldap: true, microsoft: false }));
}, []);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const token = params.get('token');
const rawUser = params.get('user');
if (!token || !rawUser) return;
try {
await mockLogin();
const user = JSON.parse(rawUser);
storeAuthSession({ token, user });
window.history.replaceState({}, document.title, window.location.pathname);
navigate('/home', { replace: true });
} catch {
setError('Não foi possível concluir o login Microsoft.');
}
}, [navigate]);
async function login(credentials) {
setIsSubmitting(true);
setError('');
try {
const authResult = await loginWithAd(credentials);
storeAuthSession(authResult);
navigate('/home');
} catch (loginError) {
setError(loginError.message || 'Falha ao autenticar.');
} finally {
setIsSubmitting(false);
}
@ -19,6 +53,9 @@ export function useLogin() {
return {
isSubmitting,
error,
providers,
login,
startMicrosoftLogin,
};
}

View File

@ -58,7 +58,7 @@ export function LoginPage() {
margin: 0,
}}
>
MVP de atendimento
Atendimento Múltiplos canais
</p>
<h1
style={{
@ -67,7 +67,7 @@ export function LoginPage() {
lineHeight: 1.05,
}}
>
Conecte-se com seu cliente em uma unica tela.
Conexão multiatendimento em um único lugar.
</h1>
<p
style={{
@ -91,9 +91,9 @@ export function LoginPage() {
}}
>
{[
{ label: 'Canais', value: 'WhatsApp, SMS e Voz' },
{ label: 'Fila', value: 'Distribuicao rapida' },
{ label: 'UX', value: 'Padrao SaaS responsivo' },
{ label: 'Canais', value: 'WhatsApp, SMS e E-mail' },
{ label: 'Fila', value: 'Distribuição rápida' },
{ label: 'UX', value: 'Padrão SaaS responsivo' },
].map((item) => (
<div
key={item.label}
@ -147,8 +147,7 @@ export function LoginPage() {
lineHeight: 1.6,
}}
>
Use seu usuario corporativo para acessar o MVP. A autenticacao e mockada
nesta etapa e leva voce diretamente para a dashboard principal.
Use seu usuário corporativo para acessar o MVP com Active Directory ou Microsoft.
</p>
</div>
</div>

View File

@ -1,11 +1,35 @@
const networkDelay = 450;
import { API_BASE_URL } from '../../../shared/services/apiConfig';
export async function mockLogin() {
await new Promise((resolve) => window.setTimeout(resolve, networkDelay));
async function parseJsonResponse(response) {
const data = await response.json().catch(() => null);
return {
id: 'agent-001',
name: 'Ana Camolesi',
email: 'ana.camolesi@sothis.local',
};
if (!response.ok) {
throw new Error(data?.message || 'Não foi possível autenticar.');
}
return data;
}
export async function getAuthConfig() {
const response = await fetch(`${API_BASE_URL}/auth/config`);
return parseJsonResponse(response);
}
export async function loginWithAd(credentials) {
const response = await fetch(`${API_BASE_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials),
});
return parseJsonResponse(response);
}
export function startMicrosoftLogin() {
window.location.href = `${API_BASE_URL}/auth/oauth/microsoft/start`;
}
export function storeAuthSession(authResult) {
window.localStorage.setItem('authToken', authResult.token);
window.localStorage.setItem('authUser', JSON.stringify(authResult.user));
}

View File

@ -0,0 +1,135 @@
const PROFILE_ALIASES = {
admin: 'admin',
administrador: 'admin',
supervisor: 'supervisor',
gestor: 'supervisor',
agente: 'agent',
atendente: 'agent',
agent: 'agent',
};
const DEMO_PROFILE_BY_USERNAME = {
admin: 'admin',
'lucas.admin': 'admin',
supervisor: 'supervisor',
'marina.alves': 'supervisor',
'rafael.nunes': 'supervisor',
};
function readStoredUser() {
const rawUser = window.localStorage.getItem('authUser');
if (!rawUser) {
return null;
}
try {
return JSON.parse(rawUser);
} catch {
return null;
}
}
function normalizeProfile(value) {
if (!value) {
return null;
}
if (typeof value === 'string') {
return PROFILE_ALIASES[value.trim().toLowerCase()] || null;
}
if (typeof value === 'object') {
return normalizeProfile(value.nome || value.name || value.role || value.perfil);
}
return null;
}
function resolveProfileFromList(values) {
if (!Array.isArray(values)) {
return normalizeProfile(values);
}
const normalizedProfiles = values.map(normalizeProfile).filter(Boolean);
if (normalizedProfiles.includes('admin')) {
return 'admin';
}
if (normalizedProfiles.includes('supervisor')) {
return 'supervisor';
}
return normalizedProfiles[0] || null;
}
export function getCurrentUser() {
return readStoredUser();
}
export function getCurrentUserDisplay() {
const user = getCurrentUser();
const fullName = user?.name || user?.nome || user?.username || 'Ana Camolesi';
const nameParts = fullName.split(' ').filter(Boolean);
const name =
nameParts.length > 1 ? `${nameParts[0]} ${nameParts[nameParts.length - 1]}` : fullName;
const areas = Array.isArray(user?.areas) ? user.areas : [];
const profiles = Array.isArray(user?.perfis)
? user.perfis
: Array.isArray(user?.profiles)
? user.profiles
: [];
const area = user?.areaPrincipal || areas[0] || null;
const profile = profiles[0] || user?.perfil || user?.role || null;
const subtitle = [profile, area].filter(Boolean).join(' - ') || 'Atendimento omnichannel';
const initials = name
.split(' ')
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0])
.join('')
.toUpperCase();
return {
name,
subtitle,
initials: initials || 'AM',
};
}
export function getCurrentUserProfile() {
const user = getCurrentUser();
if (!user) {
return 'agent';
}
if (user.accessStatus === 'unassigned') {
return 'unassigned';
}
const backendProfile =
resolveProfileFromList(user.role) ||
resolveProfileFromList(user.perfil) ||
resolveProfileFromList(user.perfis) ||
resolveProfileFromList(user.profiles);
if (backendProfile) {
return backendProfile;
}
const username = String(user.username || user.email || user.name || '').trim().toLowerCase();
const demoProfile = DEMO_PROFILE_BY_USERNAME[username];
if (demoProfile) {
return demoProfile;
}
return 'agent';
}
export function clearSession() {
window.localStorage.removeItem('authToken');
window.localStorage.removeItem('authUser');
}

View File

@ -21,7 +21,7 @@ export function CallHeader({ isMobile = false }) {
textAlign: 'center',
}}
>
Ligacao ativa
Ligação ativa
</div>
<div

View File

@ -98,9 +98,9 @@ export function CallPage() {
}}
>
{[
{ label: 'Numero', value: activeCall.number },
{ label: 'Número', value: activeCall.number },
{ label: 'Canal original', value: 'Atendimento omnichannel' },
{ label: 'Responsavel atual', value: 'Ana Camolesi' },
{ label: 'Responsável atual', value: 'Ana Camolesi' },
].map((item) => (
<article
key={item.label}
@ -164,8 +164,8 @@ export function CallPage() {
lineHeight: 1.6,
}}
>
Voce esta em uma ligacao ativa com a cliente. Os controles abaixo sao visuais
neste MVP e ajudam a demonstrar a experiencia de voz do produto.
Você está em uma ligação ativa com a cliente. Os controles abaixo são visuais
neste MVP e ajudam a demonstrar a experiência de voz do produto.
</p>
</div>
@ -187,7 +187,7 @@ export function CallPage() {
color: 'rgba(255, 255, 255, 0.72)',
}}
>
Qualidade da chamada: Estavel
Qualidade da chamada: Estável
</div>
<div
style={{
@ -197,7 +197,7 @@ export function CallPage() {
color: 'rgba(255, 255, 255, 0.72)',
}}
>
Gravacao mock: Habilitada
Gravação: Habilitada
</div>
<button
type="button"

View File

@ -23,10 +23,133 @@ function ChannelBadge({ channel }) {
);
}
function AssignmentDot({ contact, currentUserId }) {
const assignment = contact.assignment;
const assignedUserId = assignment?.user_id ? Number(assignment.user_id) : null;
const isQueued = assignment?.status === 'queued' && !assignedUserId;
const isMine = assignedUserId && currentUserId && assignedUserId === Number(currentUserId);
const meta = isQueued
? {
color: '#e5a22a',
label: 'Chamado na fila da especialidade, ainda sem atribuição',
}
: isMine
? {
color: '#00a4b7',
label: 'Chamado atribuído a mim',
}
: assignedUserId
? {
color: '#d62828',
label: `Chamado atribuído a ${assignment?.user_nome || 'outra pessoa'}`,
}
: null;
if (!meta) return null;
return (
<span
title={meta.label}
aria-label={meta.label}
style={{
width: 10,
height: 10,
borderRadius: 999,
background: meta.color,
boxShadow: `0 0 0 3px ${meta.color}22`,
flex: '0 0 auto',
}}
/>
);
}
function SpecialtyBadge({ contact }) {
const specialty = contact.assignment?.area_nome || contact.area;
if (!specialty || specialty === 'Sem fila') return null;
return (
<span
title={`Especialidade: ${specialty}`}
style={{
color: 'var(--color-primary)',
flex: '0 0 auto',
fontSize: '0.72rem',
fontWeight: 800,
lineHeight: 1,
borderRadius: 999,
padding: '0.2rem 0.5rem',
background: 'rgba(0, 49, 80, 0.08)',
}}
>
{specialty}
</span>
);
}
function UnreadBadge({ count }) {
if (!count) return null;
return (
<span
style={{
width: 26,
height: 26,
borderRadius: '50%',
background: 'var(--color-secondary)',
color: '#fff',
fontSize: '0.78rem',
fontWeight: 800,
display: 'inline-grid',
placeItems: 'center',
lineHeight: 1,
flex: '0 0 auto',
}}
>
{count > 99 ? '99+' : count}
</span>
);
}
function SavedContactIcon({ contact }) {
const profile = contact.contactProfile;
const hasSavedContact = Boolean(profile?.created_at || profile?.name || profile?.company || profile?.note);
if (!hasSavedContact) return null;
return (
<span
title="Contato salvo na agenda"
aria-label="Contato salvo na agenda"
style={{
width: 24,
height: 24,
borderRadius: '50%',
border: '1px solid rgba(183, 121, 31, 0.28)',
background: 'rgba(183, 121, 31, 0.1)',
backgroundImage:
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23b7791f' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M16 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2'/%3E%3Ccircle cx='10' cy='7' r='4'/%3E%3Cpath d='m17 11 2 2 4-4'/%3E%3C/svg%3E\")",
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
backgroundSize: 14,
color: '#b7791f',
flex: '0 0 auto',
display: 'inline-grid',
placeItems: 'center',
fontSize: 0,
}}
>
Salvo
</span>
);
}
const CHAT_LIST_HEIGHT = 'min(760px, calc(100vh - 160px))';
export function ChatConversationList({
contacts,
activeContactId,
onSelectContact,
onOpenContact,
currentUserId,
isMobile = false,
}) {
return (
@ -37,13 +160,18 @@ export function ChatConversationList({
borderRadius: '28px',
padding: '1rem',
display: 'grid',
gridTemplateRows: 'auto minmax(0, 1fr)',
gap: '0.85rem',
height: isMobile ? 'auto' : CHAT_LIST_HEIGHT,
maxHeight: isMobile ? 'none' : CHAT_LIST_HEIGHT,
alignSelf: 'start',
minHeight: 0,
}}
>
<div>
<strong style={{ display: 'block', fontSize: '1.08rem' }}>Conversas ativas</strong>
<span style={{ color: 'var(--color-text-soft)' }}>
WhatsApp, SMS e email em uma fila visual.
WhatsApp, SMS e e-mail em uma fila visual.
</span>
</div>
@ -52,6 +180,11 @@ export function ChatConversationList({
display: 'grid',
gap: '0.75rem',
gridTemplateColumns: isMobile ? '1fr' : '1fr',
gridAutoRows: 'max-content',
alignContent: 'start',
overflowY: 'auto',
minHeight: 0,
paddingRight: '0.15rem',
}}
>
{contacts.map((contact) => {
@ -62,6 +195,11 @@ export function ChatConversationList({
key={contact.id}
type="button"
onClick={() => onSelectContact(contact.id)}
onContextMenu={(event) => {
event.preventDefault();
onSelectContact(contact.id);
onOpenContact?.(contact);
}}
style={{
border: '1px solid',
borderColor: isActive ? 'rgba(0, 164, 183, 0.26)' : 'var(--color-border)',
@ -74,34 +212,44 @@ export function ChatConversationList({
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
<strong>{contact.name}</strong>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.5rem', minWidth: 0 }}>
<AssignmentDot contact={contact} currentUserId={currentUserId} />
<strong style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{contact.name}
</strong>
</span>
<span style={{ fontSize: '0.82rem', color: 'var(--color-text-soft)' }}>
{contact.time}
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.75rem' }}>
<ChannelBadge channel={contact.channel} />
{contact.unread ? (
<span
style={{
minWidth: 24,
borderRadius: 999,
padding: '0.15rem 0.45rem',
background: 'var(--color-secondary)',
color: '#fff',
fontSize: '0.78rem',
fontWeight: 700,
textAlign: 'center',
}}
>
{contact.unread}
</span>
) : null}
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.45rem', minWidth: 0 }}>
<SavedContactIcon contact={contact} />
<ChannelBadge channel={contact.channel} />
<SpecialtyBadge contact={contact} />
</span>
<UnreadBadge count={contact.unread} />
</div>
<span style={{ color: 'var(--color-text-soft)' }}>{contact.preview}</span>
</button>
);
})}
{contacts.length === 0 ? (
<div
style={{
border: '1px solid var(--color-border)',
borderRadius: '18px',
padding: '1rem',
background: 'rgba(0, 49, 80, 0.04)',
color: 'var(--color-text-soft)',
fontWeight: 700,
lineHeight: 1.45,
}}
>
Nenhuma conversa ativa na sua fila. Conversas em triagem do Agente Virtual Sothis aparecem aqui depois de classificadas.
</div>
) : null}
</div>
</aside>
);

View File

@ -4,6 +4,7 @@ export function ChatTransferPanel({
setTransferArea,
transferAreas,
attendants,
isSameUserArea = true,
transferAttendant,
setTransferAttendant,
transferNote,
@ -39,7 +40,7 @@ export function ChatTransferPanel({
<div>
<strong style={{ display: 'block', fontSize: '1.06rem' }}>Transferir atendimento</strong>
<span style={{ color: 'var(--color-text-soft)' }}>
Reencaminhe a conversa para a area ideal.
Reencaminhe a conversa para a especialidade ideal.
</span>
</div>
<button
@ -57,7 +58,7 @@ export function ChatTransferPanel({
</div>
<label style={{ display: 'grid', gap: '0.45rem' }}>
<span style={{ fontWeight: 600 }}>Area</span>
<span style={{ fontWeight: 600 }}>Especialidade</span>
<select value={transferArea} onChange={(event) => setTransferArea(event.target.value)} style={fieldStyle}>
{transferAreas.map((area) => (
<option key={area} value={area}>
@ -69,26 +70,39 @@ export function ChatTransferPanel({
<label style={{ display: 'grid', gap: '0.45rem' }}>
<span style={{ fontWeight: 600 }}>Atendente</span>
<select
value={transferAttendant}
onChange={(event) => setTransferAttendant(event.target.value)}
style={fieldStyle}
>
{attendants.map((attendant) => (
<option key={attendant} value={attendant}>
{attendant}
</option>
))}
</select>
{isSameUserArea ? (
<select
value={transferAttendant}
onChange={(event) => setTransferAttendant(event.target.value)}
style={fieldStyle}
>
{attendants.map((attendant) => (
<option key={attendant.id} value={attendant.id}>
{attendant.nome}
</option>
))}
</select>
) : (
<div
style={{
...fieldStyle,
color: 'var(--color-text-soft)',
fontWeight: 700,
background: 'rgba(0, 49, 80, 0.04)',
}}
>
Ao transferir para outra especialidade, a conversa cairá na fila dessa especialidade.
</div>
)}
</label>
<label style={{ display: 'grid', gap: '0.45rem' }}>
<span style={{ fontWeight: 600 }}>Observacao</span>
<span style={{ fontWeight: 600 }}>Observação</span>
<textarea
rows={5}
value={transferNote}
onChange={(event) => setTransferNote(event.target.value)}
placeholder="Contexto opcional para ajudar o proximo atendente."
placeholder="Contexto opcional para ajudar o próximo atendente."
style={{ ...fieldStyle, resize: 'vertical' }}
/>
</label>
@ -105,7 +119,7 @@ export function ChatTransferPanel({
fontWeight: 700,
}}
>
Confirmar transferencia
Confirmar transferência
</button>
</aside>
);

View File

@ -1,4 +1,302 @@
import { useEffect, useRef } from 'react';
import { Fragment, useEffect, useMemo, useRef } from 'react';
function getMediaUrl(media) {
if (!media?.data || !media?.mimetype) return '';
return `data:${media.mimetype};base64,${media.data}`;
}
function parseMessageText(text) {
const rawText = String(text || '');
const match = rawText.match(/^\*(Atendente(?: virtual)?:\s*[^*]+)\*\s*\n+/i);
if (!match) {
return {
senderLabel: null,
body: rawText,
};
}
return {
senderLabel: match[1],
body: rawText.slice(match[0].length),
};
}
function formatMessageTime(timestamp) {
if (!timestamp) return '';
const numericTimestamp = Number(timestamp);
const date = new Date(numericTimestamp > 1000000000000 ? numericTimestamp : numericTimestamp * 1000);
return date.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
}
function getMessageDate(timestamp) {
if (!timestamp) return null;
const numericTimestamp = Number(timestamp);
const date = new Date(numericTimestamp > 1000000000000 ? numericTimestamp : numericTimestamp * 1000);
if (Number.isNaN(date.getTime())) return null;
return date;
}
function getDateKey(timestamp) {
const date = getMessageDate(timestamp);
if (!date) return '';
return date.toISOString().slice(0, 10);
}
function formatDateSeparator(timestamp) {
const date = getMessageDate(timestamp);
if (!date) return '';
const today = new Date();
const isToday =
date.getFullYear() === today.getFullYear() &&
date.getMonth() === today.getMonth() &&
date.getDate() === today.getDate();
if (isToday) return 'Hoje';
return date.toLocaleDateString('pt-BR');
}
function DateSeparator({ label }) {
if (!label) return null;
return (
<div
style={{
width: '100%',
display: 'grid',
gridTemplateColumns: '1fr auto 1fr',
gap: '0.75rem',
alignItems: 'center',
color: 'var(--color-text-soft)',
fontSize: '0.78rem',
fontWeight: 800,
textTransform: 'uppercase',
}}
>
<span style={{ height: 1, background: 'var(--color-border)' }} />
<span
style={{
border: '1px solid var(--color-border)',
borderRadius: 999,
padding: '0.28rem 0.7rem',
background: 'rgba(255,255,255,0.88)',
}}
>
{label}
</span>
<span style={{ height: 1, background: 'var(--color-border)' }} />
</div>
);
}
function MediaRenderer({ message, contactId, onLoadMedia, isAgent }) {
const mediaUrl = useMemo(() => getMediaUrl(message.media), [message.media]);
const mimetype = message.media?.mimetype || '';
const filename = message.media?.filename || 'arquivo';
useEffect(() => {
if (!message.hasMedia || message.media?.data || message.mediaLoading || message.mediaError) {
return;
}
onLoadMedia?.(contactId, message.id);
}, [contactId, message, onLoadMedia]);
if (!message.hasMedia && !message.media) return null;
if (message.mediaLoading || (!message.media?.data && !message.mediaError)) {
return (
<div
style={{
width: 260,
maxWidth: '100%',
height: 150,
borderRadius: 14,
background: isAgent ? 'rgba(255,255,255,0.18)' : 'rgba(0,49,80,0.08)',
display: 'grid',
placeItems: 'center',
color: isAgent ? '#fff' : 'var(--color-text-soft)',
fontWeight: 700,
}}
>
Carregando mídia...
</div>
);
}
if (message.mediaError) {
return (
<span style={{ color: isAgent ? '#fff' : 'var(--color-text-soft)', fontWeight: 700 }}>
Não foi possível carregar a mídia.
</span>
);
}
if (mimetype.startsWith('image/')) {
return (
<a href={mediaUrl} target="_blank" rel="noreferrer" style={{ display: 'block' }}>
<img
src={mediaUrl}
alt={filename}
style={{
display: 'block',
width: 280,
maxWidth: '100%',
maxHeight: 340,
objectFit: 'cover',
borderRadius: 14,
boxShadow: '0 14px 30px rgba(0,0,0,0.18)',
transition: 'transform 160ms ease',
}}
onMouseEnter={(event) => {
event.currentTarget.style.transform = 'scale(1.015)';
}}
onMouseLeave={(event) => {
event.currentTarget.style.transform = 'scale(1)';
}}
/>
</a>
);
}
if (mimetype.startsWith('video/')) {
return (
<video
src={mediaUrl}
controls
style={{
width: 320,
maxWidth: '100%',
borderRadius: 14,
background: '#111',
}}
/>
);
}
if (mimetype.startsWith('audio/') || mimetype.includes('ogg')) {
return <audio src={mediaUrl} controls style={{ width: 280, maxWidth: '100%' }} />;
}
return (
<a
href={mediaUrl}
download={filename}
style={{
display: 'grid',
gridTemplateColumns: 'auto 1fr',
gap: '0.75rem',
alignItems: 'center',
padding: '0.85rem',
borderRadius: 14,
background: isAgent ? 'rgba(255,255,255,0.16)' : '#fff',
color: isAgent ? '#fff' : 'var(--color-primary)',
fontWeight: 700,
}}
>
<span aria-hidden="true">📄</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{filename}</span>
</a>
);
}
function AttachmentPreview({ file, onRemove }) {
if (!file) return null;
const mediaUrl = getMediaUrl({ data: file.data, mimetype: file.type });
return (
<div
style={{
border: '1px solid var(--color-border)',
borderRadius: 16,
padding: '0.75rem',
display: 'grid',
gridTemplateColumns: 'auto 1fr auto',
gap: '0.75rem',
alignItems: 'center',
background: '#fff',
}}
>
{file.type?.startsWith('image/') ? (
<img
src={mediaUrl}
alt={file.name}
style={{ width: 54, height: 54, objectFit: 'cover', borderRadius: 12 }}
/>
) : (
<span
style={{
width: 54,
height: 54,
borderRadius: 12,
display: 'grid',
placeItems: 'center',
background: 'rgba(0,49,80,0.08)',
}}
aria-hidden="true"
>
📎
</span>
)}
<div style={{ minWidth: 0 }}>
<strong style={{ display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{file.name}
</strong>
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.86rem' }}>{file.type || 'arquivo'}</span>
</div>
<button
type="button"
onClick={onRemove}
title="Remover anexo"
style={{
border: 'none',
borderRadius: 12,
width: 36,
height: 36,
background: 'rgba(214, 40, 40, 0.1)',
color: '#b42318',
fontWeight: 900,
}}
>
x
</button>
</div>
);
}
function ContactActivity({ contact }) {
if (!contact) {
return null;
}
const status = contact.status || 'offline';
const color = status === 'away' ? '#e5a22a' : '#dc2626';
const label = contact.lastSeen || 'Sem atividade recente';
return (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.5rem',
color: 'var(--color-text-soft)',
}}
>
<span
aria-hidden="true"
style={{
width: 10,
height: 10,
borderRadius: 999,
background: color,
boxShadow: `0 0 0 3px ${color}22`,
}}
/>
{label}
</span>
);
}
export function ChatWindow({
contact,
@ -7,12 +305,31 @@ export function ChatWindow({
setSelectedArea,
draft,
setDraft,
attachedFile,
onAttachFile,
onRemoveAttachedFile,
onLoadMedia,
onSend,
onToggleTransfer,
onAssumeChat,
onReleaseChat,
onCloseChat,
canAssumeChat = false,
canReply = true,
assignmentLabel,
transferNote,
isReplying,
isPaused = false,
pauseDurationLabel = '00:00',
isMobile = false,
}) {
const messagesRef = useRef(null);
const safeContact = contact || {
id: '',
name: isPaused ? 'Atendimento pausado' : 'Nenhuma conversa ativa',
status: 'offline',
lastSeen: isPaused ? `Pausa em andamento: ${pauseDurationLabel}` : 'Aguardando fila do Agente Virtual Sothis',
};
useEffect(() => {
const container = messagesRef.current;
@ -22,10 +339,66 @@ export function ChatWindow({
container.scrollTo({
top: container.scrollHeight,
behavior: 'smooth',
behavior: 'auto',
});
}, [messages, isReplying]);
if (isPaused) {
return (
<section
style={{
background: '#fff',
border: '1px solid var(--color-border)',
borderRadius: '28px',
overflow: 'hidden',
display: 'grid',
gridTemplateRows: 'auto minmax(0, 1fr)',
height: isMobile ? 'auto' : 'min(760px, calc(100vh - 190px))',
minHeight: isMobile ? 420 : 0,
minWidth: 0,
}}
>
<header
style={{
padding: '1.25rem 1.5rem',
borderBottom: '1px solid var(--color-border)',
}}
>
<strong style={{ display: 'block', fontSize: '1.15rem' }}>Atendimento pausado</strong>
<span style={{ color: 'var(--color-text-soft)' }}>Pausa em andamento: {pauseDurationLabel}</span>
</header>
<div
style={{
padding: '1.5rem',
display: 'grid',
placeItems: 'center',
minHeight: 0,
background:
'radial-gradient(circle at top left, rgba(0, 164, 183, 0.06), transparent 22%), linear-gradient(180deg, rgba(245, 248, 251, 0.8), rgba(255, 255, 255, 0.95))',
}}
>
<div
style={{
maxWidth: 460,
border: '1px solid var(--color-border)',
borderRadius: 20,
padding: '1.2rem',
background: '#fff',
color: 'var(--color-text-soft)',
fontWeight: 700,
lineHeight: 1.5,
textAlign: 'center',
}}
>
Voce esta em pausa ha {pauseDurationLabel}. Retome o atendimento pela Home para visualizar a fila,
assumir chamados e responder clientes.
</div>
</div>
</section>
);
}
return (
<section
style={{
@ -34,8 +407,10 @@ export function ChatWindow({
borderRadius: '28px',
overflow: 'hidden',
display: 'grid',
gridTemplateRows: 'auto 1fr auto',
minHeight: 680,
gridTemplateRows: 'auto minmax(0, 1fr) auto',
height: isMobile ? 'auto' : 'min(760px, calc(100vh - 190px))',
minHeight: isMobile ? 640 : 0,
minWidth: 0,
}}
>
<header
@ -49,10 +424,8 @@ export function ChatWindow({
}}
>
<div>
<strong style={{ display: 'block', fontSize: '1.15rem' }}>{contact.name}</strong>
<span style={{ color: 'var(--color-text-soft)' }}>
{contact.status === 'online' ? 'Online' : 'Offline'} {contact.lastSeen}
</span>
<strong style={{ display: 'block', fontSize: '1.15rem' }}>{safeContact.name}</strong>
<ContactActivity contact={safeContact} />
</div>
<div
@ -66,6 +439,7 @@ export function ChatWindow({
<select
value={selectedArea}
onChange={(event) => setSelectedArea(event.target.value)}
disabled
style={{
border: '1px solid var(--color-border)',
borderRadius: '14px',
@ -74,13 +448,63 @@ export function ChatWindow({
fontWeight: 600,
}}
>
<option>{selectedArea}</option>
<option>Suporte</option>
<option>Financeiro</option>
<option>Comercial</option>
</select>
{canAssumeChat ? (
<button
type="button"
onClick={() => onAssumeChat?.()}
style={{
border: 'none',
borderRadius: '14px',
padding: '0.8rem 1rem',
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
color: '#fff',
fontWeight: 700,
}}
>
Assumir atendimento
</button>
) : null}
{canReply ? (
<>
<button
type="button"
onClick={onReleaseChat}
style={{
border: '1px solid var(--color-border)',
borderRadius: '14px',
padding: '0.8rem 1rem',
background: '#fff',
color: 'var(--color-primary)',
fontWeight: 700,
}}
>
Sair do atendimento
</button>
<button
type="button"
onClick={onCloseChat}
style={{
border: 'none',
borderRadius: '14px',
padding: '0.8rem 1rem',
background: 'rgba(181, 31, 31, 0.1)',
color: 'var(--color-secondary)',
fontWeight: 800,
}}
>
Encerrar atendimento
</button>
</>
) : null}
<button
type="button"
onClick={onToggleTransfer}
disabled={!canReply}
style={{
border: 'none',
borderRadius: '14px',
@ -88,11 +512,30 @@ export function ChatWindow({
background: 'rgba(0, 49, 80, 0.08)',
color: 'var(--color-primary)',
fontWeight: 700,
opacity: canReply ? 1 : 0.55,
}}
>
Transferir
</button>
</div>
{transferNote ? (
<div
style={{
gridColumn: '1 / -1',
border: '1px solid rgba(0, 164, 183, 0.24)',
borderRadius: 16,
padding: '0.85rem 1rem',
background: 'rgba(0, 164, 183, 0.07)',
color: 'var(--color-text)',
lineHeight: 1.45,
}}
>
<strong style={{ display: 'block', color: 'var(--color-primary)', marginBottom: '0.25rem' }}>
Observação da transferência
</strong>
{transferNote}
</div>
) : null}
</header>
<div
@ -103,51 +546,124 @@ export function ChatWindow({
gap: '0.9rem',
alignContent: 'start',
overflowY: 'auto',
minHeight: 0,
background:
'radial-gradient(circle at top left, rgba(0, 164, 183, 0.06), transparent 22%), linear-gradient(180deg, rgba(245, 248, 251, 0.8), rgba(255, 255, 255, 0.95))',
}}
>
{messages.map((message) => {
{messages.map((message, index) => {
const isAgent = message.sender === 'agent';
const isSystem = message.sender === 'system';
const parsedText = parseMessageText(message.text);
const messageTime = formatMessageTime(message.timestamp);
const dateKey = getDateKey(message.timestamp);
const previousDateKey = index > 0 ? getDateKey(messages[index - 1]?.timestamp) : '';
const shouldShowDateSeparator = dateKey && dateKey !== previousDateKey;
const dateSeparator = formatDateSeparator(message.timestamp);
if (isSystem) {
return (
<div
key={message.id}
style={{
justifySelf: 'center',
padding: '0.7rem 1rem',
borderRadius: '999px',
background: 'rgba(0, 49, 80, 0.08)',
color: 'var(--color-primary)',
fontSize: '0.88rem',
fontWeight: 600,
}}
>
{message.text}
</div>
<Fragment key={message.id}>
{shouldShowDateSeparator ? <DateSeparator label={dateSeparator} /> : null}
<div
style={{
justifySelf: 'center',
padding: '0.7rem 1rem',
borderRadius: '999px',
background: 'rgba(0, 49, 80, 0.08)',
color: 'var(--color-primary)',
fontSize: '0.88rem',
fontWeight: 600,
}}
>
{message.text}
</div>
</Fragment>
);
}
return (
<div
key={message.id}
style={{
justifySelf: isAgent ? 'end' : 'start',
maxWidth: isMobile ? '88%' : '72%',
padding: '0.95rem 1rem',
borderRadius: isAgent ? '18px 18px 6px 18px' : '18px 18px 18px 6px',
background: isAgent ? 'var(--color-primary)' : '#edf1f5',
color: isAgent ? '#fff' : 'var(--color-text)',
boxShadow: 'var(--shadow-md)',
}}
>
{message.text}
</div>
<Fragment key={message.id}>
{shouldShowDateSeparator ? <DateSeparator label={dateSeparator} /> : null}
<div
style={{
justifySelf: isAgent ? 'end' : 'start',
maxWidth: isMobile ? '88%' : '72%',
padding: '0.95rem 1rem',
borderRadius: isAgent ? '18px 18px 6px 18px' : '18px 18px 18px 6px',
background: isAgent ? 'var(--color-primary)' : '#edf1f5',
color: isAgent ? '#fff' : 'var(--color-text)',
boxShadow: 'var(--shadow-md)',
display: 'grid',
gap: '0.65rem',
}}
>
<MediaRenderer
message={message}
contactId={safeContact.id}
onLoadMedia={onLoadMedia}
isAgent={isAgent}
/>
{parsedText.senderLabel ? (
<strong
style={{
display: 'block',
fontSize: '0.78rem',
lineHeight: 1.2,
letterSpacing: '0.02em',
textTransform: 'uppercase',
color: isAgent ? 'rgba(255,255,255,0.78)' : 'var(--color-primary)',
}}
>
{parsedText.senderLabel}
</strong>
) : null}
{parsedText.body ? (
<span
style={{
display: 'block',
whiteSpace: 'pre-wrap',
lineHeight: 1.45,
overflowWrap: 'anywhere',
}}
>
{parsedText.body}
</span>
) : null}
{messageTime ? (
<span
style={{
justifySelf: 'end',
fontSize: '0.72rem',
lineHeight: 1,
color: isAgent ? 'rgba(255,255,255,0.7)' : 'var(--color-text-soft)',
}}
>
{messageTime}
</span>
) : null}
</div>
</Fragment>
);
})}
{messages.length === 0 ? (
<div
style={{
justifySelf: 'center',
padding: '0.8rem 1rem',
borderRadius: 16,
background: 'rgba(0,49,80,0.06)',
color: 'var(--color-text-soft)',
fontWeight: 700,
}}
>
{isPaused
? `Voce esta em pausa ha ${pauseDurationLabel}. Volte da pausa para visualizar a fila e seus atendimentos.`
: 'Nenhuma mensagem carregada.'}
</div>
) : null}
{isReplying ? (
<div
style={{
@ -169,42 +685,121 @@ export function ChatWindow({
padding: '1rem 1.25rem 1.25rem',
borderTop: '1px solid var(--color-border)',
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : '1fr auto',
gridTemplateColumns: '1fr',
gap: '0.75rem',
}}
>
<input
type="text"
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
onSend();
}
}}
placeholder="Escreva sua mensagem..."
<AttachmentPreview file={attachedFile} onRemove={onRemoveAttachedFile} />
{!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={{
border: '1px solid var(--color-border)',
borderRadius: '18px',
padding: '0.95rem 1rem',
background: '#fff',
outline: 'none',
}}
/>
<button
type="button"
onClick={onSend}
style={{
border: 'none',
borderRadius: '18px',
padding: '0.95rem 1.2rem',
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
color: '#fff',
fontWeight: 700,
display: 'grid',
gridTemplateColumns: isMobile ? 'auto 1fr' : 'auto 1fr auto',
gap: '0.75rem',
alignItems: 'center',
}}
>
Enviar
</button>
<label
title="Anexar arquivo"
style={{
border: '1px solid var(--color-border)',
borderRadius: 16,
width: 48,
height: 48,
display: 'grid',
placeItems: 'center',
background: '#fff',
cursor: 'pointer',
fontWeight: 900,
}}
>
📎
<input
type="file"
accept="image/png,image/jpeg,image/jpg,image/webp,video/mp4,video/webm,audio/mp3,audio/mpeg,audio/ogg,audio/wav,application/pdf"
onChange={(event) => {
onAttachFile?.(event.target.files?.[0]);
event.target.value = '';
}}
style={{ display: 'none' }}
disabled={!safeContact.id}
/>
</label>
<input
type="text"
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
onSend?.(draft);
}
}}
disabled={!safeContact.id || !canReply}
placeholder={
isPaused
? 'Voce esta em pausa'
: !safeContact.id
? 'Aguardando conversa entrar em uma fila'
: canReply
? 'Escreva sua mensagem...'
: assignmentLabel?.includes('Aguardando resposta')
? 'Aguardando resposta do cliente'
: canAssumeChat
? 'Assuma o atendimento para responder'
: 'Atendimento bloqueado para resposta'
}
style={{
border: '1px solid var(--color-border)',
borderRadius: '18px',
padding: '0.95rem 1rem',
background: '#fff',
outline: 'none',
minWidth: 0,
opacity: safeContact.id && canReply ? 1 : 0.6,
}}
/>
<button
type="button"
onClick={() => onSend?.(draft)}
disabled={!safeContact.id || !canReply}
style={{
border: 'none',
borderRadius: '18px',
padding: '0.95rem 1.2rem',
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
color: '#fff',
fontWeight: 700,
gridColumn: isMobile ? '1 / -1' : 'auto',
opacity: safeContact.id && canReply ? 1 : 0.6,
}}
>
Enviar
</button>
</div>
</footer>
</section>
);

View File

@ -0,0 +1,197 @@
import { useEffect, useState } from 'react';
import { getCurrentUser } from '../../auth/services/sessionService';
import { getContactProfile, saveContactProfile } from '../services/contactProfileService';
function getUserId(user) {
const value = user?.databaseId || user?.id;
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : null;
}
function formatPhone(phone) {
const digits = String(phone || '').replace(/\D/g, '');
if (!digits) return 'Telefone não disponível';
if (digits.startsWith('55') && digits.length === 13) {
return `+55 (${digits.slice(2, 4)}) ${digits.slice(4, 9)}-${digits.slice(9)}`;
}
if (digits.startsWith('55') && digits.length === 12) {
return `+55 (${digits.slice(2, 4)}) ${digits.slice(4, 8)}-${digits.slice(8)}`;
}
if (digits.length === 11) {
return `(${digits.slice(0, 2)}) ${digits.slice(2, 7)}-${digits.slice(7)}`;
}
if (digits.length === 10) {
return `(${digits.slice(0, 2)}) ${digits.slice(2, 6)}-${digits.slice(6)}`;
}
return phone;
}
export function ContactProfilePanel({ isOpen, contact, onClose, onSaved }) {
const [profile, setProfile] = useState(null);
const [form, setForm] = useState({ name: '', company: '', note: '' });
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
let isMounted = true;
async function loadProfile() {
if (!isOpen || !contact?.id) return;
try {
const data = await getContactProfile(contact.id);
if (!isMounted) return;
setProfile(data);
setForm({
name: data.name || contact.name || '',
company: data.company || '',
note: data.note || '',
});
setError('');
} catch (err) {
if (isMounted) setError(err.message);
}
}
loadProfile();
return () => {
isMounted = false;
};
}, [isOpen, contact?.id]);
if (!isOpen) {
return null;
}
const fieldStyle = {
width: '100%',
border: '1px solid var(--color-border)',
borderRadius: '16px',
padding: '0.9rem 1rem',
background: '#fff',
outline: 'none',
};
async function submit() {
if (!contact?.id) return;
setIsSaving(true);
try {
const userId = getUserId(getCurrentUser());
const saved = await saveContactProfile(contact.id, {
phone: profile?.phone || contact?.contactProfile?.phone || '',
name: form.name,
company: form.company,
note: form.note,
userId,
});
setProfile(saved);
onSaved?.(contact.id, saved);
setError('');
} catch (err) {
setError(err.message);
} finally {
setIsSaving(false);
}
}
return (
<aside
style={{
background: '#fff',
border: '1px solid var(--color-border)',
borderRadius: '28px',
padding: '1.25rem',
display: 'grid',
gap: '1rem',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
<div>
<strong style={{ display: 'block', fontSize: '1.06rem' }}>Contato do cliente</strong>
<span style={{ color: 'var(--color-text-soft)' }}>
Atualize os dados de agenda deste atendimento.
</span>
</div>
<button
type="button"
onClick={onClose}
style={{
border: 'none',
background: 'transparent',
color: 'var(--color-text-soft)',
fontWeight: 700,
}}
>
Fechar
</button>
</div>
<label style={{ display: 'grid', gap: '0.45rem' }}>
<span style={{ fontWeight: 600 }}>Nome</span>
<input
value={form.name}
onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))}
style={fieldStyle}
/>
</label>
<label style={{ display: 'grid', gap: '0.45rem' }}>
<span style={{ fontWeight: 600 }}>Etiqueta de identificação</span>
<input
value={form.company}
onChange={(event) => setForm((current) => ({ ...current, company: event.target.value }))}
placeholder="Ex: Departamento, vaga ou conta vinculada"
style={fieldStyle}
/>
</label>
<label style={{ display: 'grid', gap: '0.45rem' }}>
<span style={{ fontWeight: 600 }}>Telefone</span>
<input
value={formatPhone(profile?.phone || contact?.contactProfile?.phone)}
disabled
style={{
...fieldStyle,
background: 'rgba(0, 49, 80, 0.04)',
color: 'var(--color-text-soft)',
fontWeight: 700,
}}
/>
</label>
<label style={{ display: 'grid', gap: '0.45rem' }}>
<span style={{ fontWeight: 600 }}>Observação</span>
<textarea
rows={5}
value={form.note}
onChange={(event) => setForm((current) => ({ ...current, note: event.target.value }))}
placeholder="Informações relevantes do cliente."
style={{ ...fieldStyle, resize: 'vertical' }}
/>
</label>
{error ? <span style={{ color: '#b42318', fontWeight: 700 }}>{error}</span> : null}
<button
type="button"
onClick={submit}
disabled={isSaving}
style={{
border: 'none',
borderRadius: '16px',
padding: '0.95rem 1rem',
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
color: '#fff',
fontWeight: 700,
opacity: isSaving ? 0.65 : 1,
}}
>
{isSaving ? 'Salvando...' : 'Salvar contato'}
</button>
</aside>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -1,15 +1,19 @@
import { Link } from 'react-router-dom';
import { Link, useSearchParams } from 'react-router-dom';
import { useEffect, useRef, useState } from 'react';
import { BrandMark } from '../../../shared/components/BrandMark';
import { useViewport } from '../../../shared/hooks/useViewport';
import { ChatConversationList } from '../components/ChatConversationList';
import { ChatTransferPanel } from '../components/ChatTransferPanel';
import { ContactProfilePanel } from '../components/ContactProfilePanel';
import { ChatWindow } from '../components/ChatWindow';
import { useChat } from '../hooks/useChat';
import { quickReplies } from '../services/chatMocks';
export function ChatPage() {
const [searchParams, setSearchParams] = useSearchParams();
const { isWideDesktop, isDesktop, isTablet, isMobile } = useViewport();
const {
currentUserId,
contacts,
activeContact,
activeContactId,
@ -17,7 +21,19 @@ export function ChatPage() {
messages,
draft,
setDraft,
attachedFile,
attachFile,
removeAttachedFile,
sendMessage,
hydrateMessageMedia,
assumeChat,
releaseChat,
closeChat,
canAssumeChat,
canReply,
assignmentLabel,
transferNoteLabel,
updateContactProfile,
isReplying,
selectedArea,
setSelectedArea,
@ -27,12 +43,34 @@ export function ChatPage() {
setTransferArea,
transferAreas,
attendants,
isSameUserArea,
transferAttendant,
setTransferAttendant,
transferNote,
setTransferNote,
submitTransfer,
isPaused,
pauseDurationLabel,
} = useChat();
const requestedChatId = searchParams.get('chatId');
const handledRequestedChatIdRef = useRef('');
const [isContactPanelOpen, setIsContactPanelOpen] = useState(false);
useEffect(() => {
if (!requestedChatId) return;
if (handledRequestedChatIdRef.current === requestedChatId) return;
if (!contacts.some((contact) => contact.id === requestedChatId)) return;
handledRequestedChatIdRef.current = requestedChatId;
setActiveContactId(requestedChatId);
setSearchParams({}, { replace: true });
}, [requestedChatId, contacts, setActiveContactId, setSearchParams]);
function selectContact(contactId) {
setActiveContactId(contactId);
if (requestedChatId) {
setSearchParams({}, { replace: true });
}
}
const gridTemplateColumns = isMobile
? '1fr'
@ -76,7 +114,7 @@ export function ChatPage() {
textAlign: 'center',
}}
>
Atendimento em tempo real
{isPaused ? `Pausado ha ${pauseDurationLabel}` : 'Atendimento em tempo real'}
</div>
<Link
to="/home"
@ -99,17 +137,22 @@ export function ChatPage() {
display: 'grid',
gridTemplateColumns,
gap: '1rem',
alignItems: 'start',
alignItems: 'stretch',
}}
>
<ChatConversationList
contacts={contacts}
activeContactId={activeContactId}
onSelectContact={setActiveContactId}
onSelectContact={selectContact}
onOpenContact={() => {
setIsTransferOpen(false);
setIsContactPanelOpen(true);
}}
currentUserId={currentUserId}
isMobile={isMobile}
/>
<div style={{ display: 'grid', gap: '1rem', minWidth: 0 }}>
<div style={{ display: 'grid', gap: '1rem', minWidth: 0, alignContent: 'start' }}>
<ChatWindow
contact={activeContact}
messages={messages}
@ -117,9 +160,25 @@ export function ChatPage() {
setSelectedArea={setSelectedArea}
draft={draft}
setDraft={setDraft}
attachedFile={attachedFile}
onAttachFile={attachFile}
onRemoveAttachedFile={removeAttachedFile}
onLoadMedia={hydrateMessageMedia}
onSend={sendMessage}
onToggleTransfer={() => setIsTransferOpen((current) => !current)}
onToggleTransfer={() => {
setIsContactPanelOpen(false);
setIsTransferOpen((current) => !current);
}}
onAssumeChat={assumeChat}
onReleaseChat={releaseChat}
onCloseChat={closeChat}
canAssumeChat={canAssumeChat}
canReply={canReply}
assignmentLabel={assignmentLabel}
transferNote={transferNoteLabel}
isReplying={isReplying}
isPaused={isPaused}
pauseDurationLabel={pauseDurationLabel}
isMobile={isMobile}
/>
@ -135,6 +194,7 @@ export function ChatPage() {
key={reply}
type="button"
onClick={() => setDraft(reply)}
disabled={isPaused}
style={{
border: '1px solid var(--color-border)',
borderRadius: '18px',
@ -143,6 +203,7 @@ export function ChatPage() {
color: 'var(--color-primary)',
fontWeight: 600,
textAlign: 'left',
opacity: isPaused ? 0.55 : 1,
}}
>
{reply}
@ -152,12 +213,40 @@ export function ChatPage() {
</div>
{isWideDesktop ? (
<>
<ChatTransferPanel
isOpen={isTransferOpen}
transferArea={transferArea}
setTransferArea={setTransferArea}
transferAreas={transferAreas}
attendants={attendants}
isSameUserArea={isSameUserArea}
transferAttendant={transferAttendant}
setTransferAttendant={setTransferAttendant}
transferNote={transferNote}
setTransferNote={setTransferNote}
onSubmit={submitTransfer}
onClose={() => setIsTransferOpen(false)}
/>
<ContactProfilePanel
isOpen={isContactPanelOpen}
contact={activeContact}
onClose={() => setIsContactPanelOpen(false)}
onSaved={updateContactProfile}
/>
</>
) : null}
</section>
{!isWideDesktop ? (
<>
<ChatTransferPanel
isOpen={isTransferOpen}
transferArea={transferArea}
setTransferArea={setTransferArea}
transferAreas={transferAreas}
attendants={attendants}
isSameUserArea={isSameUserArea}
transferAttendant={transferAttendant}
setTransferAttendant={setTransferAttendant}
transferNote={transferNote}
@ -165,23 +254,13 @@ export function ChatPage() {
onSubmit={submitTransfer}
onClose={() => setIsTransferOpen(false)}
/>
) : null}
</section>
{!isWideDesktop ? (
<ChatTransferPanel
isOpen={isTransferOpen}
transferArea={transferArea}
setTransferArea={setTransferArea}
transferAreas={transferAreas}
attendants={attendants}
transferAttendant={transferAttendant}
setTransferAttendant={setTransferAttendant}
transferNote={transferNote}
setTransferNote={setTransferNote}
onSubmit={submitTransfer}
onClose={() => setIsTransferOpen(false)}
/>
<ContactProfilePanel
isOpen={isContactPanelOpen}
contact={activeContact}
onClose={() => setIsContactPanelOpen(false)}
onSaved={updateContactProfile}
/>
</>
) : null}
</section>
</main>

View File

@ -0,0 +1,46 @@
import { API_BASE_URL } from '../../../shared/services/apiConfig';
async function request(path, options = {}) {
const response = await fetch(`${API_BASE_URL}${path}`, {
headers: {
'Content-Type': 'application/json',
...options.headers,
},
...options,
});
if (!response.ok) {
throw new Error('Falha ao atualizar presenca do agente');
}
return response.json();
}
export async function getAgentPresence(userId) {
return request(`/agent/presence/me?userId=${encodeURIComponent(userId)}`);
}
export async function listAgentPresence() {
return request('/agent/presence');
}
export async function pauseAgent(userId) {
return request('/agent/presence/pause', {
method: 'POST',
body: JSON.stringify({ userId }),
});
}
export async function resumeAgent(userId) {
return request('/agent/presence/resume', {
method: 'POST',
body: JSON.stringify({ userId }),
});
}
export async function markAgentOffline(userId) {
return request('/agent/presence/offline', {
method: 'POST',
body: JSON.stringify({ userId }),
});
}

View File

@ -1,74 +1,7 @@
export const chatContacts = [
{
id: 'maria-souza',
name: 'Maria Souza',
channel: 'WhatsApp',
status: 'online',
area: 'Suporte',
lastSeen: 'Online agora',
preview: 'Preciso atualizar o cadastro do meu pedido.',
time: '09:42',
unread: 2,
messages: [
{ id: 1, sender: 'customer', text: 'Oi, bom dia! Preciso de ajuda com meu pedido.' },
{ id: 2, sender: 'agent', text: 'Bom dia, Maria! Claro, me conta o que aconteceu.' },
{ id: 3, sender: 'customer', text: 'Quero confirmar se o endereco foi alterado.' },
{ id: 4, sender: 'agent', text: 'Estou verificando aqui e te atualizo em instantes.' },
],
},
{
id: 'joao-pedro',
name: 'Joao Pedro',
channel: 'SMS',
status: 'offline',
area: 'Financeiro',
lastSeen: 'Visto ha 12 min',
preview: 'Pode me ligar em 10 minutos?',
time: '08:15',
unread: 1,
messages: [
{ id: 1, sender: 'customer', text: 'Recebi a cobranca em duplicidade.' },
{ id: 2, sender: 'agent', text: 'Vou analisar isso agora para voce.' },
{ id: 3, sender: 'customer', text: 'Pode me ligar em 10 minutos?' },
],
},
{
id: 'empresa-alpha',
name: 'Empresa Alpha',
channel: 'Email',
status: 'offline',
area: 'Comercial',
lastSeen: 'Visto ontem',
preview: 'Aguardando retorno sobre a proposta comercial.',
time: 'Ontem',
unread: 0,
messages: [
{ id: 1, sender: 'customer', text: 'Precisamos rever os valores da ultima proposta.' },
{ id: 2, sender: 'agent', text: 'Perfeito, vou encaminhar para o time comercial.' },
],
},
];
export const transferAreas = ['Suporte', 'Financeiro', 'Comercial'];
export const attendantsByArea = {
Suporte: ['Ana Camolesi', 'Rafael Lopes', 'Romero Britto'],
Financeiro: ['Roberto Pêra', 'Monica Limoeira', 'Edson Arantes'],
Comercial: ['Natasha Homanoff', 'Helena Pêra', 'Pedro Parque'],
};
export const quickReplies = [
'Recebi sua mensagem e ja vou verificar.',
'Consegue me confirmar o numero do protocolo?',
'Posso seguir com essa atualizacao por aqui.',
];
export function getMockReply(contactName) {
const replies = [
`Perfeito, obrigado pelo retorno, ${contactName.split(' ')[0]}.`,
'Tudo bem, fico no aguardo dessa confirmacao.',
'Entendi. Se precisar, posso encaminhar para a area responsavel.',
];
return replies[Math.floor(Math.random() * replies.length)];
}

View File

@ -0,0 +1,23 @@
import { API_BASE_URL } from '../../../shared/services/apiConfig';
export async function listContactProfiles() {
const response = await fetch(`${API_BASE_URL}/contacts`);
if (!response.ok) throw new Error('Falha ao carregar agenda.');
return response.json();
}
export async function getContactProfile(chatId) {
const response = await fetch(`${API_BASE_URL}/contacts/${encodeURIComponent(chatId)}`);
if (!response.ok) throw new Error('Falha ao carregar contato.');
return response.json();
}
export async function saveContactProfile(chatId, payload) {
const response = await fetch(`${API_BASE_URL}/contacts/${encodeURIComponent(chatId)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) throw new Error('Falha ao salvar contato.');
return response.json();
}

View File

@ -0,0 +1,135 @@
import { useEffect, useState } from 'react';
export function AttendantOpsPanel({
activeChatsCount,
isPaused = false,
pauseDurationLabel = '00:00',
isPresenceLoading = false,
onTogglePause,
}) {
const [secondsOnline, setSecondsOnline] = useState(0);
useEffect(() => {
if (isPaused) return undefined;
const intervalId = window.setInterval(() => {
setSecondsOnline((current) => current + 1);
}, 1000);
return () => window.clearInterval(intervalId);
}, [isPaused]);
const formatTime = (totalSeconds) => {
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
return [hours, minutes, seconds]
.map((value) => value.toString().padStart(2, '0'))
.filter((value, index) => value !== '00' || index > 0)
.join(':');
};
const presenceLabel = isPaused ? 'Tempo em pausa' : 'Tempo online';
const presenceTime = isPaused ? pauseDurationLabel : formatTime(secondsOnline);
const statusColor = isPaused ? '#ef4444' : '#10b981';
return (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: '1rem',
}}
>
<article
style={{
padding: '1.25rem',
borderRadius: '24px',
border: '1px solid var(--color-border)',
background: 'linear-gradient(145deg, #ffffff, #f8fafc)',
boxShadow: 'var(--shadow-sm)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<div>
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.9rem', fontWeight: 600 }}>
{presenceLabel}
</span>
<strong style={{ display: 'block', fontSize: '1.6rem', marginTop: '0.2rem', color: 'var(--color-text)' }}>
{presenceTime}
</strong>
</div>
<div
title={isPaused ? 'Agente pausado' : 'Agente disponivel'}
style={{
width: 12,
height: 12,
borderRadius: '50%',
background: statusColor,
boxShadow: `0 0 10px ${statusColor}`,
}}
/>
</article>
<article
style={{
padding: '1.25rem',
borderRadius: '24px',
border: '1px solid var(--color-border)',
background: 'linear-gradient(145deg, #ffffff, #f8fafc)',
boxShadow: 'var(--shadow-sm)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<div>
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.9rem', fontWeight: 600 }}>
Atendimentos abertos
</span>
<strong style={{ display: 'block', fontSize: '1.6rem', marginTop: '0.2rem', color: 'var(--color-text)' }}>
{isPaused ? 0 : activeChatsCount}
</strong>
</div>
</article>
<article
style={{
padding: '1.25rem',
borderRadius: '24px',
border: '1px solid var(--color-border)',
background: 'linear-gradient(145deg, #ffffff, #f8fafc)',
boxShadow: 'var(--shadow-sm)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<button
type="button"
onClick={onTogglePause}
disabled={isPresenceLoading}
style={{
width: '100%',
height: '100%',
padding: '1rem',
borderRadius: '16px',
border: 'none',
background: isPaused ? 'rgba(16, 185, 129, 0.1)' : 'rgba(239, 68, 68, 0.1)',
color: isPaused ? '#10b981' : '#ef4444',
fontSize: '1rem',
fontWeight: 800,
cursor: isPresenceLoading ? 'wait' : 'pointer',
transition: 'all 0.2s ease',
opacity: isPresenceLoading ? 0.7 : 1,
}}
>
{isPaused ? 'Retomar Atendimento' : 'Pausar'}
</button>
</article>
</div>
);
}

View File

@ -24,9 +24,9 @@ export function CallsWorkspace({ calls, isWideDesktop = false, isDesktop = false
}}
>
<div>
<strong style={{ display: 'block', fontSize: '1.1rem' }}>Ligacoes recentes</strong>
<strong style={{ display: 'block', fontSize: '1.1rem' }}>Ligações recentes</strong>
<span style={{ color: 'var(--color-text-soft)' }}>
Visualizacao rapida do fluxo de voz do time.
Visualização pida do fluxo de voz do time.
</span>
</div>
@ -42,7 +42,7 @@ export function CallsWorkspace({ calls, isWideDesktop = false, isDesktop = false
fontWeight: 700,
}}
>
Nova ligacao
Nova ligação
</button>
</div>

View File

@ -1,4 +1,5 @@
import { useNavigate } from 'react-router-dom';
import { clearSession } from '../../auth/services/sessionService';
export function HomeSidebar({ items, activeItem, isMobile = false }) {
const navigate = useNavigate();
@ -17,7 +18,7 @@ export function HomeSidebar({ items, activeItem, isMobile = false }) {
>
<button
type="button"
onClick={() => navigate('/new-attendance')}
onClick={() => navigate('/home')}
style={{
border: 'none',
borderRadius: '20px',
@ -28,7 +29,7 @@ export function HomeSidebar({ items, activeItem, isMobile = false }) {
textAlign: 'left',
}}
>
+ Novo Atendimento
Home
</button>
<nav
@ -77,6 +78,27 @@ export function HomeSidebar({ items, activeItem, isMobile = false }) {
);
})}
</nav>
<button
type="button"
onClick={() => {
clearSession();
navigate('/login');
}}
style={{
border: '1px solid rgba(255, 255, 255, 0.2)',
borderRadius: '18px',
padding: '0.9rem 1rem',
background: 'transparent',
color: '#ef4444',
fontWeight: 700,
marginTop: 'auto',
cursor: 'pointer',
transition: 'all 0.2s',
}}
>
Sair
</button>
</aside>
);
}

View File

@ -1,3 +1,16 @@
import { useEffect, useState } from 'react';
import { getCurrentUserDisplay } from '../../auth/services/sessionService';
function formatCurrentDateTime(date) {
return new Intl.DateTimeFormat('pt-BR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(date);
}
export function HomeTopbar({
activeTab,
onTabChange,
@ -8,19 +21,29 @@ export function HomeTopbar({
isTablet = false,
isMobile = false,
}) {
const userDisplay = getCurrentUserDisplay();
const [currentDateTime, setCurrentDateTime] = useState(() => formatCurrentDateTime(new Date()));
const tabs = [
{ id: 'messages', label: 'Mensagens' },
{ id: 'calls', label: 'Ligacoes' },
{ id: 'calls', label: 'Ligações' },
];
const gridTemplateColumns = isMobile
? '1fr'
: isWideDesktop
? 'max-content minmax(180px, 220px) minmax(280px, 1fr) max-content'
? 'max-content minmax(150px, 190px) minmax(280px, 1fr) max-content'
: isDesktop || isTablet
? 'repeat(2, minmax(0, 1fr))'
: '1fr';
useEffect(() => {
const intervalId = window.setInterval(() => {
setCurrentDateTime(formatCurrentDateTime(new Date()));
}, 1000);
return () => window.clearInterval(intervalId);
}, []);
return (
<header
style={{
@ -75,9 +98,13 @@ export function HomeTopbar({
fontWeight: 600,
width: isMobile ? '100%' : 'auto',
minWidth: 0,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
textAlign: 'center',
}}
>
Sexta, 19 de marco
{currentDateTime}
</div>
<input
@ -108,9 +135,9 @@ export function HomeTopbar({
}}
>
<div style={{ textAlign: 'right', minWidth: 0 }}>
<strong style={{ display: 'block' }}>Ana Camolesi</strong>
<strong style={{ display: 'block' }}>{userDisplay.name}</strong>
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.92rem' }}>
Atendimento omnichannel
{userDisplay.subtitle}
</span>
</div>
<div
@ -126,7 +153,7 @@ export function HomeTopbar({
fontWeight: 800,
}}
>
AM
{userDisplay.initials}
</div>
</div>
</header>

View File

@ -1,4 +1,9 @@
import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { createAgentNote, deleteAgentNote, listAgentNotes } from '../services/agentNotesService';
import { getCurrentUser } from '../../auth/services/sessionService';
const WORKSPACE_HEIGHT = 660;
function ChannelBadge({ channel }) {
const colors = {
@ -25,28 +30,305 @@ function ChannelBadge({ channel }) {
);
}
function UnreadBadge({ count }) {
if (!count) return null;
return (
<span
style={{
width: 26,
height: 26,
borderRadius: '50%',
background: 'var(--color-secondary)',
color: '#fff',
fontSize: '0.78rem',
fontWeight: 800,
display: 'inline-grid',
placeItems: 'center',
lineHeight: 1,
flex: '0 0 auto',
}}
>
{count > 99 ? '99+' : count}
</span>
);
}
function buildSuggestedReplies(conversation) {
const lastMessage = conversation?.lastMessage || conversation?.messages?.at(-1)?.text || '';
const firstName = conversation?.name?.split(' ')?.[0] || 'você';
const lowerContext = lastMessage.toLowerCase();
if (
lowerContext.includes('fatura') ||
lowerContext.includes('cobranca') ||
lowerContext.includes('pagamento')
) {
return [
`${firstName}, vou conferir os dados financeiros e já te retorno com a posição correta.`,
'Recebi sua mensagem sobre cobrança. Vou validar o histórico antes de seguir com a orientação.',
'Consigo te ajudar com isso. Pode me confirmar o CPF/CNPJ ou protocolo vinculado ao atendimento?',
];
}
if (
lowerContext.includes('endereco') ||
lowerContext.includes('cadastro') ||
lowerContext.includes('atualizar')
) {
return [
`${firstName}, vou validar seu cadastro e confirmar se a alteração já foi registrada.`,
'Para seguir com a atualização, me confirme por favor os dados que precisam ser ajustados.',
'Entendi. Vou verificar o cadastro atual e te retorno com o próximo passo.',
];
}
if (
lowerContext.includes('ligar') ||
lowerContext.includes('telefone') ||
lowerContext.includes('retorno')
) {
return [
`${firstName}, consigo organizar esse retorno. Qual o melhor horário para contato?`,
'Vou registrar sua solicitação e direcionar o retorno para o time responsável.',
'Obrigado pelo aviso. Vou confirmar disponibilidade e te retorno por aqui.',
];
}
return [
`${firstName}, recebi sua mensagem e vou verificar o contexto para te orientar corretamente.`,
'Entendi. Vou analisar as informações do atendimento e retorno com o melhor encaminhamento.',
'Posso acionar o time responsável e te atualizar por aqui assim que tiver uma posição.',
];
}
function parseMessageText(text) {
const rawText = String(text || '');
const match = rawText.match(/^\*(Atendente(?: virtual)?:\s*[^*]+)\*\s*\n+/i);
if (!match) {
return { senderLabel: null, body: rawText };
}
return {
senderLabel: match[1],
body: rawText.slice(match[0].length),
};
}
function formatMessageTime(timestamp) {
if (!timestamp) return '';
const numericTimestamp = Number(timestamp);
const date = new Date(numericTimestamp > 1000000000000 ? numericTimestamp : numericTimestamp * 1000);
return date.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
}
function getMessageDate(timestamp) {
if (!timestamp) return null;
const numericTimestamp = Number(timestamp);
const date = new Date(numericTimestamp > 1000000000000 ? numericTimestamp : numericTimestamp * 1000);
if (Number.isNaN(date.getTime())) return null;
return date;
}
function getDateKey(timestamp) {
const date = getMessageDate(timestamp);
if (!date) return '';
return date.toISOString().slice(0, 10);
}
function formatDateSeparator(timestamp) {
const date = getMessageDate(timestamp);
if (!date) return '';
const today = new Date();
const isToday =
date.getFullYear() === today.getFullYear() &&
date.getMonth() === today.getMonth() &&
date.getDate() === today.getDate();
if (isToday) return 'Hoje';
return date.toLocaleDateString('pt-BR');
}
function DateSeparator({ label }) {
if (!label) return null;
return (
<div
style={{
width: '100%',
display: 'grid',
gridTemplateColumns: '1fr auto 1fr',
gap: '0.7rem',
alignItems: 'center',
color: 'var(--color-text-soft)',
fontSize: '0.74rem',
fontWeight: 800,
textTransform: 'uppercase',
}}
>
<span style={{ height: 1, background: 'var(--color-border)' }} />
<span
style={{
border: '1px solid var(--color-border)',
borderRadius: 999,
padding: '0.24rem 0.62rem',
background: 'rgba(255,255,255,0.9)',
}}
>
{label}
</span>
<span style={{ height: 1, background: 'var(--color-border)' }} />
</div>
);
}
function getUserId(user) {
const value = user?.databaseId || user?.id;
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : null;
}
export function MessagesWorkspace({
conversations,
activeConversationId,
onSelectConversation,
actionItems,
onSendSuggestedReply,
isWideDesktop = false,
isDesktop = false,
isTablet = false,
isMobile = false,
isPaused = false,
pauseDurationLabel = '00:00',
}) {
const navigate = useNavigate();
const messagesRef = useRef(null);
const currentUser = getCurrentUser();
const currentUserId = getUserId(currentUser);
const recentConversations = conversations.slice(0, 3);
const activeConversation =
conversations.find((conversation) => conversation.id === activeConversationId) ||
recentConversations.find((conversation) => conversation.id === activeConversationId) ||
recentConversations[0] ||
conversations[0];
const safeActiveConversation = activeConversation || {
id: 'empty',
name: 'Nenhuma conversa',
status: 'offline',
messages: [],
};
const suggestedReplies = useMemo(
() => buildSuggestedReplies(safeActiveConversation),
[safeActiveConversation],
);
const [selectedReplyIndex, setSelectedReplyIndex] = useState(0);
const [noteDraft, setNoteDraft] = useState('');
const [notes, setNotes] = useState([]);
const [notesError, setNotesError] = useState('');
const selectedReply = suggestedReplies[selectedReplyIndex] || suggestedReplies[0];
const managerMessages = [
{
id: 'sla',
title: 'Comunicado do supervisor',
text: 'Priorizar atendimentos com SLA abaixo de 15 minutos antes de abrir novos casos.',
},
{
id: 'script',
title: 'Atualização de script',
text: 'Use o novo roteiro de confirmação de dados em atendimentos financeiros.',
},
];
useEffect(() => {
setSelectedReplyIndex(0);
}, [safeActiveConversation.id]);
useEffect(() => {
const container = messagesRef.current;
if (!container) return;
container.scrollTo({
top: container.scrollHeight,
behavior: 'auto',
});
}, [safeActiveConversation.id, safeActiveConversation.messages]);
useEffect(() => {
let isMounted = true;
async function loadNotes() {
try {
const data = await listAgentNotes(currentUserId);
if (isMounted) {
setNotes(Array.isArray(data) ? data : []);
setNotesError('');
}
} catch (error) {
if (isMounted) setNotesError(error.message);
}
}
loadNotes();
return () => {
isMounted = false;
};
}, [currentUserId]);
function selectPreviousReply() {
setSelectedReplyIndex((current) =>
current === 0 ? suggestedReplies.length - 1 : current - 1,
);
}
function selectNextReply() {
setSelectedReplyIndex((current) => (current + 1) % suggestedReplies.length);
}
async function saveNote() {
const text = noteDraft.trim();
if (!text || !currentUserId) return;
try {
const note = await createAgentNote(currentUserId, text);
setNotes((current) => [note, ...current]);
setNoteDraft('');
setNotesError('');
} catch (error) {
setNotesError(error.message);
}
}
async function removeNote(noteId) {
if (!currentUserId) return;
try {
await deleteAgentNote(currentUserId, noteId);
setNotes((current) => current.filter((note) => note.id !== noteId));
setNotesError('');
} catch (error) {
setNotesError(error.message);
}
}
async function sendSuggestedReply() {
if (isPaused) return;
if (!safeActiveConversation.id || safeActiveConversation.id === 'empty') return;
await onSendSuggestedReply?.(safeActiveConversation.id, selectedReply);
navigate(`/chat?chatId=${encodeURIComponent(safeActiveConversation.id)}`);
}
const gridTemplateColumns = isMobile
? '1fr'
: isWideDesktop
? 'minmax(240px, 0.95fr) minmax(360px, 1.8fr) minmax(220px, 0.8fr)'
: isDesktop || isTablet
? 'minmax(260px, 320px) minmax(0, 1fr)'
: '1fr';
? 'minmax(260px, 320px) minmax(0, 1fr)'
: '1fr';
const panelHeight = isMobile ? 'auto' : WORKSPACE_HEIGHT;
return (
<div
@ -54,7 +336,7 @@ export function MessagesWorkspace({
display: 'grid',
gridTemplateColumns,
gap: '1rem',
alignItems: 'start',
alignItems: 'stretch',
}}
>
<section
@ -65,18 +347,20 @@ export function MessagesWorkspace({
padding: '1rem',
display: 'grid',
gap: '0.75rem',
alignContent: 'start',
height: panelHeight,
minWidth: 0,
}}
>
<div>
<strong style={{ fontSize: '1.05rem' }}>Conversas</strong>
<p style={{ margin: '0.35rem 0 0', color: 'var(--color-text-soft)' }}>
Atendimento em tempo real por canal.
Últimos 3 atendimentos em tempo real.
</p>
</div>
{conversations.map((conversation) => {
const isActive = conversation.id === activeConversation.id;
{recentConversations.map((conversation) => {
const isActive = conversation.id === safeActiveConversation.id;
return (
<button
@ -92,37 +376,59 @@ export function MessagesWorkspace({
textAlign: 'left',
display: 'grid',
gap: '0.6rem',
minWidth: 0,
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
<strong>{conversation.name}</strong>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', minWidth: 0 }}>
<strong style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{conversation.name}
</strong>
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.86rem' }}>
{conversation.time}
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.75rem' }}>
<ChannelBadge channel={conversation.channel} />
{conversation.unread ? (
<span
style={{
minWidth: 24,
borderRadius: 999,
padding: '0.15rem 0.45rem',
background: 'var(--color-secondary)',
color: '#fff',
fontSize: '0.78rem',
fontWeight: 700,
textAlign: 'center',
}}
>
{conversation.unread}
</span>
) : null}
<UnreadBadge count={conversation.unread} />
</div>
<span style={{ color: 'var(--color-text-soft)' }}>{conversation.lastMessage}</span>
<span
style={{
color: 'var(--color-text-soft)',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
overflowWrap: 'anywhere',
lineHeight: 1.35,
}}
>
{conversation.lastMessage}
</span>
</button>
);
})}
{conversations.length > 3 ? (
<button
type="button"
onClick={() => {
if (!isPaused) navigate('/chat');
}}
disabled={isPaused}
style={{
border: '1px solid var(--color-border)',
borderRadius: '16px',
padding: '0.85rem 1rem',
background: '#fff',
color: 'var(--color-primary)',
fontWeight: 700,
opacity: isPaused ? 0.55 : 1,
cursor: isPaused ? 'not-allowed' : 'pointer',
}}
>
Ver todos no chat
</button>
) : null}
</section>
<section
@ -131,8 +437,9 @@ export function MessagesWorkspace({
borderRadius: '26px',
border: '1px solid var(--color-border)',
display: 'grid',
gridTemplateRows: 'auto 1fr auto',
minHeight: 580,
gridTemplateRows: 'auto minmax(0, 1fr) auto',
height: panelHeight,
minHeight: isMobile ? 580 : 'auto',
overflow: 'hidden',
minWidth: 0,
}}
@ -148,15 +455,20 @@ export function MessagesWorkspace({
}}
>
<div>
<strong style={{ display: 'block', fontSize: '1.08rem' }}>{activeConversation.name}</strong>
<strong style={{ display: 'block', fontSize: '1.08rem' }}>
{safeActiveConversation.name}
</strong>
<span style={{ color: 'var(--color-text-soft)' }}>
{activeConversation.status === 'online' ? 'Online agora' : 'Offline'}
{safeActiveConversation.lastSeen || 'Sem atividade recente'}
</span>
</div>
<div style={{ display: 'flex', gap: '0.6rem', flexWrap: 'wrap' }}>
<button
type="button"
onClick={() => navigate('/chat')}
onClick={() => {
if (!isPaused) navigate('/chat');
}}
disabled={isPaused}
style={{
border: '1px solid var(--color-border)',
borderRadius: '14px',
@ -164,6 +476,8 @@ export function MessagesWorkspace({
background: '#fff',
color: 'var(--color-primary)',
fontWeight: 700,
opacity: isPaused ? 0.55 : 1,
cursor: isPaused ? 'not-allowed' : 'pointer',
}}
>
Abrir chat
@ -185,70 +499,158 @@ export function MessagesWorkspace({
</header>
<div
ref={messagesRef}
style={{
padding: '1.25rem',
display: 'grid',
gap: '0.9rem',
alignContent: 'start',
overflowY: 'auto',
background:
'linear-gradient(180deg, rgba(245, 248, 251, 0.45), rgba(255, 255, 255, 0.9))',
}}
>
{activeConversation.messages.map((message) => {
{safeActiveConversation.messages.map((message, index) => {
const isAgent = message.from === 'agent';
const parsedText = parseMessageText(message.text);
const messageTime = formatMessageTime(message.timestamp);
const dateKey = getDateKey(message.timestamp);
const previousDateKey =
index > 0 ? getDateKey(safeActiveConversation.messages[index - 1]?.timestamp) : '';
const shouldShowDateSeparator = dateKey && dateKey !== previousDateKey;
const dateSeparator = formatDateSeparator(message.timestamp);
return (
<div
key={message.id}
style={{
justifySelf: isAgent ? 'end' : 'start',
maxWidth: '72%',
padding: '0.95rem 1rem',
borderRadius: isAgent ? '18px 18px 6px 18px' : '18px 18px 18px 6px',
background: isAgent ? 'var(--color-primary)' : '#edf1f5',
color: isAgent ? '#fff' : 'var(--color-text)',
boxShadow: 'var(--shadow-md)',
}}
>
{message.text}
</div>
<Fragment key={message.id}>
{shouldShowDateSeparator ? <DateSeparator label={dateSeparator} /> : null}
<div
style={{
justifySelf: isAgent ? 'end' : 'start',
maxWidth: '72%',
padding: '0.95rem 1rem',
borderRadius: isAgent ? '18px 18px 6px 18px' : '18px 18px 18px 6px',
background: isAgent ? 'var(--color-primary)' : '#edf1f5',
color: isAgent ? '#fff' : 'var(--color-text)',
boxShadow: 'var(--shadow-md)',
display: 'grid',
gap: '0.55rem',
}}
>
{parsedText.senderLabel ? (
<strong
style={{
display: 'block',
fontSize: '0.76rem',
lineHeight: 1.2,
letterSpacing: '0.02em',
textTransform: 'uppercase',
color: isAgent ? 'rgba(255,255,255,0.78)' : 'var(--color-primary)',
}}
>
{parsedText.senderLabel}
</strong>
) : null}
<span
style={{
whiteSpace: 'pre-wrap',
lineHeight: 1.45,
overflowWrap: 'anywhere',
}}
>
{parsedText.body}
</span>
{messageTime ? (
<span
style={{
justifySelf: 'end',
fontSize: '0.72rem',
lineHeight: 1,
color: isAgent ? 'rgba(255,255,255,0.7)' : 'var(--color-text-soft)',
}}
>
{messageTime}
</span>
) : null}
</div>
</Fragment>
);
})}
</div>
<footer
style={{
padding: '1rem 1.25rem 1.25rem',
padding: '0.85rem 1.25rem 1rem',
borderTop: '1px solid var(--color-border)',
display: 'grid',
gridTemplateColumns: '1fr auto',
gap: '0.75rem',
gap: '0.65rem',
}}
>
<input
type="text"
value="Posso acionar o time responsavel e te retorno em seguida."
readOnly
<strong style={{ display: 'block', fontSize: '0.94rem' }}>Resposta sugerida</strong>
<div
style={{
border: '1px solid var(--color-border)',
borderRadius: '18px',
padding: '0.95rem 1rem',
background: '#fff',
}}
/>
<button
type="button"
style={{
border: 'none',
borderRadius: '18px',
padding: '0.95rem 1.2rem',
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
color: '#fff',
fontWeight: 700,
display: 'grid',
gridTemplateColumns: '40px minmax(0, 1fr) 40px',
gap: '0.6rem',
alignItems: 'stretch',
}}
>
Enviar
</button>
<button
type="button"
onClick={selectPreviousReply}
title="Resposta anterior"
disabled={isPaused}
style={{
border: '1px solid var(--color-border)',
borderRadius: '14px',
background: '#fff',
color: 'var(--color-primary)',
fontWeight: 900,
opacity: isPaused ? 0.55 : 1,
cursor: isPaused ? 'not-allowed' : 'pointer',
}}
>
</button>
<button
type="button"
onClick={sendSuggestedReply}
disabled={isPaused}
style={{
border: '1px solid rgba(0, 164, 183, 0.32)',
borderRadius: '16px',
padding: '0.75rem 0.9rem',
background: 'rgba(0, 164, 183, 0.07)',
color: 'var(--color-text)',
fontWeight: 600,
textAlign: 'left',
lineHeight: 1.35,
minWidth: 0,
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
opacity: isPaused ? 0.55 : 1,
cursor: isPaused ? 'not-allowed' : 'pointer',
}}
>
{isPaused ? `Voce esta em pausa ha ${pauseDurationLabel}. Retome para responder.` : selectedReply}
</button>
<button
type="button"
onClick={selectNextReply}
title="Próxima resposta"
style={{
border: '1px solid var(--color-border)',
borderRadius: '14px',
background: '#fff',
color: 'var(--color-primary)',
fontWeight: 900,
}}
>
</button>
</div>
</footer>
</section>
@ -259,49 +661,128 @@ export function MessagesWorkspace({
border: '1px solid var(--color-border)',
padding: '1.2rem',
display: 'grid',
gridTemplateRows: 'auto minmax(0, 1fr)',
gap: '1rem',
alignContent: 'start',
gridColumn: isWideDesktop ? 'auto' : '1 / -1',
height: panelHeight,
minWidth: 0,
}}
>
<div>
<strong style={{ fontSize: '1.05rem' }}>Painel de acoes</strong>
<p style={{ margin: '0.35rem 0 0', color: 'var(--color-text-soft)' }}>
Contexto rapido do atendimento selecionado.
</p>
<strong style={{ fontSize: '1.05rem' }}>Comunicados e notas</strong>
</div>
{actionItems.map((item) => (
<article
key={item.title}
style={{
borderRadius: '20px',
padding: '1rem',
background: 'rgba(0, 49, 80, 0.04)',
}}
>
<span style={{ color: 'var(--color-text-soft)', display: 'block', marginBottom: '0.35rem' }}>
{item.title}
</span>
<strong>{item.value}</strong>
</article>
))}
<button
type="button"
onClick={() => navigate('/new-attendance')}
<div
style={{
border: '1px solid var(--color-border)',
borderRadius: '18px',
padding: '0.95rem 1rem',
background: '#fff',
color: 'var(--color-primary)',
fontWeight: 700,
display: 'grid',
gap: '0.85rem',
alignContent: 'start',
overflowY: 'auto',
paddingRight: '0.15rem',
}}
>
Criar novo fluxo
</button>
{managerMessages.map((message) => (
<article
key={message.id}
style={{
borderRadius: '18px',
padding: '0.95rem',
background: 'rgba(0, 49, 80, 0.04)',
display: 'grid',
gap: '0.4rem',
}}
>
<strong>{message.title}</strong>
<p style={{ margin: 0, color: 'var(--color-text-soft)', lineHeight: 1.5 }}>
{message.text}
</p>
</article>
))}
<label style={{ display: 'grid', gap: '0.45rem' }}>
<span style={{ color: 'var(--color-text-soft)', fontWeight: 700 }}>Anotação rápida</span>
<textarea
value={noteDraft}
onChange={(event) => setNoteDraft(event.target.value)}
placeholder="Ex: cliente pediu retorno após as 15h"
rows={4}
style={{
border: '1px solid var(--color-border)',
borderRadius: '14px',
padding: '0.85rem 0.9rem',
background: '#fff',
color: 'var(--color-text)',
resize: 'none',
outline: 'none',
lineHeight: 1.45,
}}
/>
</label>
<button
type="button"
onClick={saveNote}
disabled={!currentUserId}
style={{
border: 'none',
borderRadius: '18px',
padding: '0.95rem 1rem',
background: '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',
display: 'grid',
gap: '0.35rem',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.75rem' }}>
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.82rem' }}>
{formatMessageTime(new Date(note.created_at).getTime())}
</span>
<button
type="button"
onClick={() => removeNote(note.id)}
title="Excluir anotação"
style={{
border: 'none',
borderRadius: 999,
width: 26,
height: 26,
background: 'rgba(214, 40, 40, 0.1)',
color: '#b42318',
fontWeight: 900,
}}
>
x
</button>
</div>
<p style={{ margin: 0, lineHeight: 1.45 }}>{note.text}</p>
</article>
))
) : (
<span style={{ color: 'var(--color-text-soft)' }}>Nenhuma anotação salva.</span>
)}
</div>
</div>
</aside>
</div>
);

View File

@ -0,0 +1,174 @@
import { useEffect, useState } from 'react';
import { BrandMark } from '../../../shared/components/BrandMark';
import { useViewport } from '../../../shared/hooks/useViewport';
import { getCurrentUser, getCurrentUserDisplay } from '../../auth/services/sessionService';
import { listContactProfiles } from '../../chat/services/contactProfileService';
import { MassMessagePanel } from '../../management/components/MassMessagePanel';
import { getAccessOptions } from '../../management/services/adminAccessService';
import { HomeSidebar } from '../components/HomeSidebar';
import { sidebarItems } from '../services/homeMocks';
function getUserSpecialties(user) {
const normalize = (area) => {
if (!area) return null;
if (typeof area === 'string') return area;
return area.nome || area.name || null;
};
const areas = Array.isArray(user?.areas) ? user.areas.map(normalize).filter(Boolean) : [];
const primary = normalize(user?.areaPrincipal);
return primary && !areas.includes(primary) ? [primary, ...areas] : areas;
}
export function AgentMassMessagePage() {
const { isDesktop, isMobile } = useViewport();
const userDisplay = getCurrentUserDisplay();
const currentUser = getCurrentUser();
const specialties = getUserSpecialties(currentUser);
const [areas, setAreas] = useState([]);
const [contactCount, setContactCount] = useState(0);
useEffect(() => {
let isMounted = true;
getAccessOptions()
.then((options) => {
if (isMounted) setAreas(options.areas || []);
})
.catch(() => {
if (isMounted) setAreas([]);
});
return () => {
isMounted = false;
};
}, []);
useEffect(() => {
let isMounted = true;
listContactProfiles()
.then((items) => {
if (isMounted) setContactCount(Array.isArray(items) ? items.length : 0);
})
.catch(() => {
if (isMounted) setContactCount(0);
});
return () => {
isMounted = false;
};
}, []);
const sidebarWithContactCount = sidebarItems.map((item) =>
item.id === 'contacts' ? { ...item, count: contactCount } : item,
);
return (
<main style={{ minHeight: '100vh', padding: '1.5rem' }}>
<section
style={{
width: 'min(1680px, calc(100vw - 3rem))',
margin: '0 auto',
background: 'var(--color-surface-strong)',
borderRadius: '32px',
boxShadow: 'var(--shadow-lg)',
padding: '1.5rem',
display: 'grid',
gap: '1.5rem',
}}
>
<div
style={{
display: 'grid',
gridTemplateColumns: isDesktop ? 'minmax(340px, 380px) minmax(0, 1fr)' : '1fr',
gap: '1.5rem',
alignItems: 'start',
}}
>
<div style={{ display: 'grid', gap: '1.25rem' }}>
<div
style={{
background: '#fff',
border: '1px solid var(--color-border)',
borderRadius: '28px',
padding: '1.5rem',
}}
>
<BrandMark size="lg" />
</div>
<HomeSidebar items={sidebarWithContactCount} activeItem="mass-message" isMobile={!isDesktop} />
</div>
<div style={{ display: 'grid', gap: '1.25rem', minWidth: 0 }}>
<header
style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 1fr) auto',
gap: '1rem',
alignItems: 'center',
}}
>
<div
style={{
padding: '1.1rem 1.25rem',
borderRadius: '22px',
background: '#fff',
border: '1px solid var(--color-border)',
}}
>
<h1 style={{ margin: 0, fontSize: '1.65rem' }}>Disparo em massa</h1>
<p style={{ margin: '0.45rem 0 0', color: 'var(--color-text-soft)' }}>
Envie templates aprovados para contatos da agenda ou numeros informados manualmente.
</p>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.9rem',
justifySelf: isMobile ? 'stretch' : 'end',
justifyContent: isMobile ? 'space-between' : 'flex-end',
padding: '0.85rem 1rem',
borderRadius: '22px',
background: '#fff',
border: '1px solid var(--color-border)',
}}
>
<div style={{ textAlign: 'right' }}>
<strong style={{ display: 'block' }}>{userDisplay.name}</strong>
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.92rem' }}>
Atendimento omnichannel
</span>
</div>
<div
aria-hidden="true"
style={{
width: 48,
height: 48,
borderRadius: '16px',
display: 'grid',
placeItems: 'center',
background: 'linear-gradient(135deg, var(--color-accent), var(--color-primary))',
color: '#fff',
fontWeight: 800,
}}
>
{userDisplay.initials}
</div>
</div>
</header>
<MassMessagePanel
areas={areas}
mode="agent"
managedAreaNames={specialties}
isMobile={isMobile}
/>
</div>
</div>
</section>
</main>
);
}

View File

@ -0,0 +1,451 @@
import { useEffect, useMemo, useState } from 'react';
import { BrandMark } from '../../../shared/components/BrandMark';
import { useViewport } from '../../../shared/hooks/useViewport';
import { getCurrentUser, getCurrentUserDisplay } from '../../auth/services/sessionService';
import { listContactProfiles, saveContactProfile } from '../../chat/services/contactProfileService';
import { HomeSidebar } from '../components/HomeSidebar';
import { sidebarItems } from '../services/homeMocks';
const inputStyle = {
width: '100%',
border: '1px solid var(--color-border)',
borderRadius: 14,
padding: '0.85rem 0.9rem',
background: '#fff',
color: 'var(--color-text)',
fontWeight: 600,
};
function getUserId(user) {
const value = user?.databaseId || user?.id;
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : null;
}
function onlyDigits(value) {
return String(value || '').replace(/\D/g, '');
}
function buildChatId(phone) {
const digits = onlyDigits(phone);
return digits ? `${digits}@c.us` : '';
}
function normalizeContact(contact) {
return {
chatId: contact.chat_id || buildChatId(contact.phone),
name: contact.name || contact.phone || 'Contato sem nome',
whatsappPhone: contact.phone || '',
callSmsPhone: contact.call_sms_phone || contact.callSmsPhone || '',
email: contact.email || '',
tag: contact.company || '',
note: contact.note || '',
updatedAt: contact.updated_at || contact.created_at || null,
};
}
function emptyDraft() {
return {
chatId: '',
name: '',
whatsappPhone: '',
callSmsPhone: '',
email: '',
tag: '',
note: '',
};
}
export function ContactsPanel({ embedded = false }) {
const { isDesktop, isMobile } = useViewport();
const currentUser = getCurrentUser();
const currentUserId = getUserId(currentUser);
const userDisplay = getCurrentUserDisplay();
const [contacts, setContacts] = useState([]);
const [search, setSearch] = useState('');
const [draft, setDraft] = useState(emptyDraft());
const [selectedChatId, setSelectedChatId] = useState('');
const [status, setStatus] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
async function loadContacts() {
setIsLoading(true);
try {
const data = await listContactProfiles();
setContacts(Array.isArray(data) ? data.map(normalizeContact) : []);
setStatus('');
} catch (error) {
setStatus(error.message);
} finally {
setIsLoading(false);
}
}
useEffect(() => {
loadContacts();
}, []);
const sidebarWithCount = useMemo(
() => sidebarItems.map((item) => (item.id === 'contacts' ? { ...item, count: contacts.length } : item)),
[contacts.length],
);
const filteredContacts = useMemo(() => {
const value = search.trim().toLowerCase();
if (!value) return contacts;
return contacts.filter((contact) =>
`${contact.name} ${contact.whatsappPhone} ${contact.callSmsPhone} ${contact.email} ${contact.tag} ${contact.note}`
.toLowerCase()
.includes(value),
);
}, [contacts, search]);
function selectContact(contact) {
setSelectedChatId(contact.chatId);
setDraft({ ...contact });
setStatus('');
}
function startNewContact() {
setSelectedChatId('');
setDraft(emptyDraft());
setStatus('');
}
async function handleSave(event) {
event.preventDefault();
const whatsappPhone = onlyDigits(draft.whatsappPhone);
const chatId = selectedChatId || draft.chatId || buildChatId(whatsappPhone);
if (!chatId || !whatsappPhone) {
setStatus('Informe o número de WhatsApp para salvar o contato.');
return;
}
setIsSaving(true);
try {
await saveContactProfile(chatId, {
phone: whatsappPhone,
whatsappPhone,
callSmsPhone: onlyDigits(draft.callSmsPhone),
email: draft.email,
name: draft.name,
company: draft.tag,
note: draft.note,
userId: currentUserId,
});
setStatus('Contato salvo com sucesso.');
await loadContacts();
setSelectedChatId(chatId);
setDraft((current) => ({ ...current, chatId, whatsappPhone }));
} catch (error) {
setStatus(error.message);
} finally {
setIsSaving(false);
}
}
const content = (
<section
style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'minmax(320px, 0.85fr) minmax(0, 1fr)',
gap: '1rem',
alignItems: 'start',
}}
>
<aside
style={{
border: '1px solid var(--color-border)',
borderRadius: 24,
padding: '1rem',
background: '#fff',
display: 'grid',
gap: '0.8rem',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.75rem', alignItems: 'center' }}>
<div>
<strong style={{ display: 'block' }}>Agenda</strong>
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.9rem' }}>
{contacts.length} contato(s)
</span>
</div>
<button
type="button"
onClick={startNewContact}
style={{
border: 'none',
borderRadius: 14,
padding: '0.7rem 0.85rem',
background: 'var(--color-highlight)',
color: '#132534',
fontWeight: 900,
}}
>
Novo
</button>
</div>
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Buscar contato"
style={inputStyle}
/>
<div style={{ display: 'grid', gap: '0.45rem', maxHeight: 560, overflowY: 'auto', paddingRight: '0.2rem' }}>
{filteredContacts.map((contact) => {
const isSelected = selectedChatId === contact.chatId;
return (
<button
key={contact.chatId}
type="button"
onClick={() => selectContact(contact)}
style={{
border: '1px solid',
borderColor: isSelected ? 'rgba(0, 164, 183, 0.36)' : 'var(--color-border)',
borderRadius: 16,
padding: '0.8rem',
background: isSelected ? 'rgba(0, 164, 183, 0.08)' : '#fff',
textAlign: 'left',
display: 'grid',
gap: '0.25rem',
}}
>
<strong>{contact.name}</strong>
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.88rem' }}>
WhatsApp: +{contact.whatsappPhone}
</span>
{contact.email ? (
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.84rem' }}>
{contact.email}
</span>
) : null}
</button>
);
})}
{!filteredContacts.length ? (
<span style={{ color: 'var(--color-text-soft)', fontWeight: 700 }}>
Nenhum contato encontrado.
</span>
) : null}
</div>
</aside>
<form
onSubmit={handleSave}
style={{
border: '1px solid var(--color-border)',
borderRadius: 24,
padding: '1.2rem',
background: '#fff',
display: 'grid',
gap: '0.9rem',
}}
>
<div>
<strong style={{ display: 'block', fontSize: '1.08rem' }}>
{selectedChatId ? 'Editar contato' : 'Novo contato'}
</strong>
<span style={{ color: 'var(--color-text-soft)' }}>
O WhatsApp é usado para vincular o contato à conversa.
</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : 'repeat(2, minmax(0, 1fr))', gap: '0.85rem' }}>
<label style={{ display: 'grid', gap: '0.4rem' }}>
<span style={{ fontWeight: 700 }}>Nome</span>
<input
value={draft.name}
onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))}
style={inputStyle}
/>
</label>
<label style={{ display: 'grid', gap: '0.4rem' }}>
<span style={{ fontWeight: 700 }}>Etiqueta de identificação</span>
<input
value={draft.tag}
onChange={(event) => setDraft((current) => ({ ...current, tag: event.target.value }))}
placeholder="Ex: Departamento, vaga ou conta vinculada"
style={inputStyle}
/>
</label>
<label style={{ display: 'grid', gap: '0.4rem' }}>
<span style={{ fontWeight: 700 }}>Número WhatsApp</span>
<input
value={draft.whatsappPhone}
onChange={(event) => setDraft((current) => ({ ...current, whatsappPhone: event.target.value }))}
placeholder="5511988267544"
style={inputStyle}
/>
</label>
<label style={{ display: 'grid', gap: '0.4rem' }}>
<span style={{ fontWeight: 700 }}>Ligação/SMS</span>
<input
value={draft.callSmsPhone}
onChange={(event) => setDraft((current) => ({ ...current, callSmsPhone: event.target.value }))}
placeholder="5511988267544"
style={inputStyle}
/>
</label>
<label style={{ display: 'grid', gap: '0.4rem' }}>
<span style={{ fontWeight: 700 }}>E-mail</span>
<input
type="email"
value={draft.email}
onChange={(event) => setDraft((current) => ({ ...current, email: event.target.value }))}
placeholder="nome@empresa.com"
style={inputStyle}
/>
</label>
</div>
<label style={{ display: 'grid', gap: '0.4rem' }}>
<span style={{ fontWeight: 700 }}>Observação</span>
<textarea
rows={5}
value={draft.note}
onChange={(event) => setDraft((current) => ({ ...current, note: event.target.value }))}
style={{ ...inputStyle, resize: 'vertical', lineHeight: 1.5 }}
/>
</label>
{status ? <span style={{ color: status.includes('sucesso') ? 'var(--color-primary)' : '#b42318', fontWeight: 800 }}>{status}</span> : null}
{isLoading ? <span style={{ color: 'var(--color-text-soft)', fontWeight: 700 }}>Carregando agenda...</span> : null}
<button
type="submit"
disabled={isSaving}
style={{
border: 'none',
borderRadius: 16,
padding: '0.95rem 1rem',
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
color: '#fff',
fontWeight: 900,
opacity: isSaving ? 0.7 : 1,
}}
>
{isSaving ? 'Salvando...' : 'Salvar contato'}
</button>
</form>
</section>
);
if (embedded) {
return content;
}
return (
<main style={{ minHeight: '100vh', padding: '1.5rem' }}>
<section
style={{
width: 'min(1680px, calc(100vw - 3rem))',
margin: '0 auto',
background: 'var(--color-surface-strong)',
borderRadius: '32px',
boxShadow: 'var(--shadow-lg)',
padding: '1.5rem',
display: 'grid',
gap: '1.5rem',
}}
>
<div
style={{
display: 'grid',
gridTemplateColumns: isDesktop ? 'minmax(340px, 380px) minmax(0, 1fr)' : '1fr',
gap: '1.5rem',
alignItems: 'start',
}}
>
<div style={{ display: 'grid', gap: '1.25rem' }}>
<div
style={{
background: '#fff',
border: '1px solid var(--color-border)',
borderRadius: '28px',
padding: '1.5rem',
}}
>
<BrandMark size="lg" />
</div>
<HomeSidebar items={sidebarWithCount} activeItem="contacts" isMobile={!isDesktop} />
</div>
<div style={{ display: 'grid', gap: '1.25rem', minWidth: 0 }}>
<header
style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 1fr) auto',
gap: '1rem',
alignItems: 'center',
}}
>
<div
style={{
padding: '1.1rem 1.25rem',
borderRadius: '22px',
background: '#fff',
border: '1px solid var(--color-border)',
}}
>
<h1 style={{ margin: 0, fontSize: '1.65rem' }}>Contatos</h1>
<p style={{ margin: '0.45rem 0 0', color: 'var(--color-text-soft)' }}>
Agenda geral com WhatsApp, telefone para ligação/SMS, e-mail, etiqueta e observação.
</p>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.9rem',
justifySelf: isMobile ? 'stretch' : 'end',
justifyContent: isMobile ? 'space-between' : 'flex-end',
padding: '0.85rem 1rem',
borderRadius: '22px',
background: '#fff',
border: '1px solid var(--color-border)',
}}
>
<div style={{ textAlign: 'right' }}>
<strong style={{ display: 'block' }}>{userDisplay.name}</strong>
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.92rem' }}>
Atendimento omnichannel
</span>
</div>
<div
aria-hidden="true"
style={{
width: 48,
height: 48,
borderRadius: '16px',
display: 'grid',
placeItems: 'center',
background: 'linear-gradient(135deg, var(--color-accent), var(--color-primary))',
color: '#fff',
fontWeight: 800,
}}
>
{userDisplay.initials}
</div>
</div>
</header>
{content}
</div>
</div>
</section>
</main>
);
}
export function ContactsPage() {
return <ContactsPanel />;
}

View File

@ -1,17 +1,85 @@
import { useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { BrandMark } from '../../../shared/components/BrandMark';
import { HomeSidebar } from '../components/HomeSidebar';
import { HomeTopbar } from '../components/HomeTopbar';
import { MessagesWorkspace } from '../components/MessagesWorkspace';
import { CallsWorkspace } from '../components/CallsWorkspace';
import { actionItems, conversations, recentCalls, sidebarItems } from '../services/homeMocks';
import { AttendantOpsPanel } from '../components/AttendantOpsPanel';
import { recentCalls, sidebarItems } from '../services/homeMocks';
import { useViewport } from '../../../shared/hooks/useViewport';
import { useChat } from '../../chat/hooks/useChat';
import { listContactProfiles } from '../../chat/services/contactProfileService';
function truncatePreview(value, limit = 96) {
const text = String(value || '').replace(/\s+/g, ' ').trim();
if (text.length <= limit) return text;
return `${text.slice(0, limit).trim()}...`;
}
function toHomeConversation(contact, messages = []) {
const lastMessage = contact.preview || messages[messages.length - 1]?.text || '';
return {
id: contact.id,
name: contact.name,
channel: contact.channel || 'WhatsApp',
status: contact.status || 'online',
lastMessage: truncatePreview(lastMessage),
unread: contact.unread || 0,
time: contact.time || 'Agora',
lastSeen: contact.lastSeen,
messages: messages.map((message) => ({
id: message.id,
from: message.sender === 'agent' ? 'agent' : 'customer',
text: message.text || (message.hasMedia ? '[Mídia]' : ''),
timestamp: message.timestamp,
})),
};
}
export function HomePage() {
const { isWideDesktop, isDesktop, isTablet, isMobile } = useViewport();
const {
contacts,
activeContactId,
setActiveContactId,
messages,
sendMessage,
isLoadingChats,
isPaused,
pauseDurationLabel,
isPresenceLoading,
pauseAttendance,
resumeAttendance,
} = useChat();
const [activeTab, setActiveTab] = useState('messages');
const [searchValue, setSearchValue] = useState('');
const [activeConversationId, setActiveConversationId] = useState(conversations[0].id);
const [contactCount, setContactCount] = useState(0);
useEffect(() => {
let isMounted = true;
listContactProfiles()
.then((items) => {
if (isMounted) setContactCount(Array.isArray(items) ? items.length : 0);
})
.catch(() => {
if (isMounted) setContactCount(0);
});
return () => {
isMounted = false;
};
}, []);
const sidebarWithContactCount = useMemo(
() => sidebarItems.map((item) => (item.id === 'contacts' ? { ...item, count: contactCount } : item)),
[contactCount],
);
const conversations = contacts.map((contact) =>
toHomeConversation(contact, contact.id === activeContactId ? messages : []),
);
const search = searchValue.trim().toLowerCase();
const filteredConversations = !search
@ -22,9 +90,9 @@ export function HomePage() {
});
const safeConversationId =
filteredConversations.find((conversation) => conversation.id === activeConversationId)?.id ||
filteredConversations.find((conversation) => conversation.id === activeContactId)?.id ||
filteredConversations[0]?.id ||
conversations[0].id;
conversations[0]?.id;
return (
<main
@ -69,7 +137,7 @@ export function HomePage() {
>
<BrandMark size="lg" />
</div>
<HomeSidebar items={sidebarItems} activeItem="dashboard" isMobile={!isDesktop} />
<HomeSidebar items={sidebarWithContactCount} activeItem="dashboard" isMobile={!isDesktop} />
</div>
<div style={{ display: 'grid', gap: '1.25rem', minWidth: 0 }}>
@ -90,50 +158,44 @@ export function HomePage() {
gap: '1rem',
}}
>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
gap: '1rem',
}}
>
{[
{ label: 'Atendimentos ativos', value: '18', detail: '7 aguardando retorno' },
{ label: 'Primeira resposta', value: '2m 14s', detail: 'Dentro do SLA' },
{ label: 'Fila de voz', value: '4 chamadas', detail: '1 prioridade alta' },
].map((item) => (
<article
key={item.label}
style={{
padding: '1.15rem',
borderRadius: '22px',
border: '1px solid var(--color-border)',
background: '#fff',
}}
>
<span style={{ color: 'var(--color-text-soft)', display: 'block' }}>
{item.label}
</span>
<strong style={{ display: 'block', fontSize: '1.4rem', marginTop: '0.45rem' }}>
{item.value}
</strong>
<span style={{ color: 'var(--color-text-soft)', display: 'block', marginTop: '0.45rem' }}>
{item.detail}
</span>
</article>
))}
</div>
<AttendantOpsPanel
activeChatsCount={filteredConversations.length}
isPaused={isPaused}
pauseDurationLabel={pauseDurationLabel}
isPresenceLoading={isPresenceLoading}
onTogglePause={isPaused ? resumeAttendance : pauseAttendance}
/>
{isLoadingChats ? (
<div
style={{
border: '1px solid var(--color-border)',
borderRadius: 18,
padding: '0.85rem 1rem',
background: '#fff',
color: 'var(--color-text-soft)',
fontWeight: 700,
}}
>
Atualizando conversas do WhatsApp...
</div>
) : null}
{activeTab === 'messages' ? (
<MessagesWorkspace
conversations={filteredConversations}
activeConversationId={safeConversationId}
onSelectConversation={setActiveConversationId}
actionItems={actionItems}
onSelectConversation={setActiveContactId}
onSendSuggestedReply={async (conversationId, reply) => {
setActiveContactId(conversationId);
await sendMessage(reply, conversationId);
}}
isWideDesktop={isWideDesktop}
isDesktop={isDesktop}
isTablet={isTablet}
isMobile={isMobile}
isPaused={isPaused}
pauseDurationLabel={pauseDurationLabel}
/>
) : (
<CallsWorkspace

View File

@ -0,0 +1,23 @@
import { AdminPage } from '../../management/pages/AdminPage';
import { SupervisorPage } from '../../management/pages/SupervisorPage';
import { getCurrentUserProfile } from '../../auth/services/sessionService';
import { HomePage } from './HomePage';
import { UnassignedHomePage } from './UnassignedHomePage';
export function ProfileHomePage() {
const profile = getCurrentUserProfile();
if (profile === 'admin') {
return <AdminPage />;
}
if (profile === 'supervisor') {
return <SupervisorPage />;
}
if (profile === 'unassigned') {
return <UnassignedHomePage />;
}
return <HomePage />;
}

View File

@ -0,0 +1,91 @@
import { useNavigate } from 'react-router-dom';
import { BrandMark } from '../../../shared/components/BrandMark';
import { clearSession, getCurrentUser } from '../../auth/services/sessionService';
export function UnassignedHomePage() {
const navigate = useNavigate();
const user = getCurrentUser();
function handleLogout() {
clearSession();
navigate('/login', { replace: true });
}
return (
<main
style={{
minHeight: '100vh',
display: 'grid',
placeItems: 'center',
padding: '2rem',
}}
>
<section
style={{
width: 'min(760px, 100%)',
background: '#fff',
border: '1px solid var(--color-border)',
borderRadius: '32px',
boxShadow: 'var(--shadow-lg)',
padding: '2rem',
display: 'grid',
gap: '1.5rem',
}}
>
<BrandMark size="lg" />
<div style={{ display: 'grid', gap: '0.75rem' }}>
<span
style={{
width: 'fit-content',
padding: '0.4rem 0.75rem',
borderRadius: 999,
background: 'rgba(229, 162, 42, 0.14)',
color: '#8a5a00',
fontWeight: 800,
}}
>
Acesso aguardando configuração
</span>
<h1 style={{ margin: 0, fontSize: '2rem' }}>Seu usuário ainda não tem atribuições</h1>
<p style={{ margin: 0, color: 'var(--color-text-soft)', lineHeight: 1.7 }}>
O login foi realizado, mas um administrador ainda precisa vincular seu usuário a um
perfil de acesso e uma especialidade operacional antes de liberar a plataforma.
</p>
</div>
<div
style={{
borderRadius: '24px',
background: 'rgba(0, 49, 80, 0.04)',
padding: '1.25rem',
display: 'grid',
gap: '0.65rem',
}}
>
<span style={{ color: 'var(--color-text-soft)' }}>Usuário autenticado</span>
<strong>{user?.name || user?.username || 'Usuário'}</strong>
<span style={{ color: 'var(--color-text-soft)' }}>
{user?.email || user?.username || 'Sem email informado'}
</span>
</div>
<button
type="button"
onClick={handleLogout}
style={{
border: 'none',
borderRadius: '18px',
padding: '0.95rem 1.15rem',
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
color: '#fff',
fontWeight: 800,
width: 'fit-content',
}}
>
Sair
</button>
</section>
</main>
);
}

View File

@ -0,0 +1,27 @@
import { API_BASE_URL } from '../../../shared/services/apiConfig';
export async function listAgentNotes(userId) {
if (!userId) return [];
const response = await fetch(`${API_BASE_URL}/agent/notes?userId=${encodeURIComponent(userId)}`);
if (!response.ok) throw new Error('Falha ao carregar anotações.');
return response.json();
}
export async function createAgentNote(userId, text) {
const response = await fetch(`${API_BASE_URL}/agent/notes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId, text }),
});
if (!response.ok) throw new Error('Falha ao salvar anotação.');
return response.json();
}
export async function deleteAgentNote(userId, noteId) {
const response = await fetch(
`${API_BASE_URL}/agent/notes/${encodeURIComponent(noteId)}?userId=${encodeURIComponent(userId)}`,
{ method: 'DELETE' },
);
if (!response.ok) throw new Error('Falha ao excluir anotação.');
return response.json();
}

View File

@ -1,9 +1,9 @@
export const sidebarItems = [
{ id: 'dashboard', label: 'Dashboard' },
{ id: 'new-attendance', label: 'Novos Atendimentos', route: '/new-attendance' },
{ id: 'in-progress', label: 'Em andamento', count: 8 },
{ id: 'completed', label: 'Finalizados', count: 24 },
{ id: 'contacts', label: 'Contatos', count: 128 },
export const sidebarItems = [
{ id: 'new-attendance', label: 'Abrir atendimento', route: '/new-attendance' },
{ id: 'mass-message', label: 'Disparo em massa', route: '/mass-message' },
{ id: 'knowledge-base', label: 'Base de conhecimento' },
{ id: 'scripts', label: 'Scripts e respostas prontas' },
{ id: 'contacts', label: 'Contatos', route: '/contacts' },
];
export const conversations = [
@ -30,28 +30,28 @@ export const conversations = [
unread: 0,
time: 'Ontem',
messages: [
{ id: 1, from: 'customer', text: 'Precisamos rever os valores da ultima proposta.' },
{ id: 1, from: 'customer', text: 'Precisamos rever os valores da última proposta.' },
{ id: 2, from: 'agent', text: 'Perfeito, vou encaminhar para o time comercial.' },
],
},
{
id: 'joao-pedro',
name: 'Joao Pedro',
name: 'João Pedro',
channel: 'SMS',
status: 'online',
lastMessage: 'Pode me ligar em 10 minutos?',
unread: 1,
time: '08:15',
messages: [
{ id: 1, from: 'customer', text: 'Recebi a cobranca em duplicidade.' },
{ id: 2, from: 'agent', text: 'Vou analisar isso agora para voce.' },
{ id: 1, from: 'customer', text: 'Recebi a cobrança em duplicidade.' },
{ id: 2, from: 'agent', text: 'Vou analisar isso agora para você.' },
{ id: 3, from: 'customer', text: 'Pode me ligar em 10 minutos?' },
],
},
];
export const actionItems = [
{ title: 'Area atual', value: 'Suporte' },
{ title: 'Especialidade atual', value: 'Suporte' },
{ title: 'SLA restante', value: '18 min' },
{ title: 'Prioridade', value: 'Alta' },
];

View File

@ -0,0 +1,49 @@
export function DataPanel({ title, description, actionLabel, onAction, children }) {
return (
<section
style={{
background: '#fff',
borderRadius: '26px',
border: '1px solid var(--color-border)',
padding: '1.25rem',
display: 'grid',
gap: '1rem',
minWidth: 0,
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '1rem',
flexWrap: 'wrap',
}}
>
<div>
<strong style={{ display: 'block', fontSize: '1.08rem' }}>{title}</strong>
<span style={{ color: 'var(--color-text-soft)' }}>{description}</span>
</div>
{actionLabel ? (
<button
type="button"
onClick={onAction}
style={{
border: 'none',
borderRadius: '18px',
padding: '0.9rem 1rem',
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
color: '#fff',
fontWeight: 700,
}}
>
{actionLabel}
</button>
) : null}
</div>
{children}
</section>
);
}

View File

@ -0,0 +1,861 @@
import { useEffect, useMemo, useState } from 'react';
import { DataPanel } from './DataPanel';
import {
createBotFlowNode,
deleteBotFlowNode,
getBotFlow,
listBotFlowVersions,
publishBotFlow,
updateBotFlowNode,
} from '../services/knowledgeService';
const fieldStyle = {
width: '100%',
border: '1px solid var(--color-border)',
borderRadius: 14,
padding: '0.78rem 0.9rem',
background: '#fff',
color: 'var(--color-text)',
fontWeight: 600,
};
const primaryButton = {
border: 'none',
borderRadius: 14,
padding: '0.78rem 1rem',
background: 'var(--color-primary)',
color: '#fff',
fontWeight: 800,
};
const ghostButton = {
border: '1px solid var(--color-border)',
borderRadius: 14,
padding: '0.72rem 0.9rem',
background: '#fff',
color: 'var(--color-text)',
fontWeight: 800,
};
const emptyDraft = {
nodeType: 'question',
title: '',
messageText: '',
keywords: '',
fallbackMessage: '',
fallbackAttempts: 2,
fallbackAreaId: '',
areaId: '',
};
const closeDefaultMessage = 'Perfeito, vou encerrar por aqui. Se precisar de algo mais, é só chamar novamente.';
function nodeTypeLabel(type) {
if (type === 'greeting') return 'Saudação';
if (type === 'agent') return 'Enviar para agente';
if (type === 'close') return 'Encerrar pelo bot';
return 'Pergunta';
}
function splitKeywords(value) {
return String(value || '')
.split(',')
.map((keyword) => keyword.trim())
.filter(Boolean);
}
function collectPublishWarnings(node, warnings = []) {
if (!node) return warnings;
const children = node.children || [];
const isTerminal = node.node_type === 'agent' || node.node_type === 'close';
if (!isTerminal && children.length === 0) {
warnings.push(`"${node.title}" precisa ter pelo menos um filho.`);
}
if (node.node_type === 'agent' && !node.area_id) {
warnings.push(`"${node.title}" precisa de uma especialidade.`);
}
children.forEach((child) => collectPublishWarnings(child, warnings));
return warnings;
}
function getFlowNodeWidth(level) {
return level >= 2 ? 260 : 300;
}
function getFlowChildGap(level) {
return level >= 2 ? 56 : 40;
}
function getFlowSubtreeWidth(node, level = 0) {
if (!node) return getFlowNodeWidth(level);
const children = node.children || [];
const nodeWidth = getFlowNodeWidth(level);
const horizontalPadding = level >= 2 ? 56 : 72;
if (!children.length) {
return nodeWidth + horizontalPadding;
}
const gap = getFlowChildGap(level);
const childrenWidth =
children.reduce((total, child) => total + getFlowSubtreeWidth(child, level + 1), 0) +
Math.max(0, children.length - 1) * gap;
return Math.max(nodeWidth + horizontalPadding, childrenWidth);
}
function WhatsAppPreview({ message }) {
return (
<div
style={{
background: '#e7f5ef',
borderRadius: 18,
padding: '0.85rem',
display: 'grid',
gap: '0.45rem',
}}
>
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.78rem', fontWeight: 800 }}>Preview WhatsApp</span>
<div
style={{
justifySelf: 'start',
maxWidth: 420,
borderRadius: '0 14px 14px 14px',
background: '#fff',
padding: '0.75rem 0.85rem',
boxShadow: '0 8px 22px rgba(0, 49, 80, 0.08)',
whiteSpace: 'pre-wrap',
lineHeight: 1.45,
}}
>
{message || 'Digite a mensagem para visualizar aqui.'}
</div>
</div>
);
}
function FlowNode({ node, areasById, onAdd, onEdit, onDelete, level = 0, parentTitle = '' }) {
const keywords = splitKeywords(node.keywords);
const isRoot = node.node_type === 'greeting';
const isAgent = node.node_type === 'agent';
const isClose = node.node_type === 'close';
const isDeep = level >= 2;
const nodeWidth = getFlowNodeWidth(level);
const visibleKeywordLimit = isDeep ? 4 : 8;
const childGap = getFlowChildGap(level);
const subtreeWidth = getFlowSubtreeWidth(node, level);
const firstChildWidth = node.children?.length ? getFlowSubtreeWidth(node.children[0], level + 1) : 0;
const lastChildWidth = node.children?.length
? getFlowSubtreeWidth(node.children[node.children.length - 1], level + 1)
: 0;
const accentColor = isRoot
? 'var(--color-primary)'
: isAgent
? '#3260b3'
: isClose
? '#0f8f77'
: 'var(--color-highlight)';
const nodeMessage = node.message_text || (isAgent ? '' : 'Sem mensagem configurada.');
return (
<div
style={{
display: 'grid',
justifyItems: 'center',
gap: '0.95rem',
minWidth: subtreeWidth,
width: subtreeWidth,
}}
>
<article
style={{
width: nodeWidth,
border: '1px solid var(--color-border)',
borderTop: `5px solid ${accentColor}`,
borderRadius: 18,
background: isRoot
? 'linear-gradient(180deg, #fff, rgba(0,164,183,0.09))'
: isAgent
? 'linear-gradient(180deg, #fff, rgba(50,96,179,0.09))'
: isClose
? 'linear-gradient(180deg, #fff, rgba(0,164,183,0.1))'
: '#fff',
boxShadow: '0 12px 28px rgba(0, 49, 80, 0.08)',
padding: isDeep ? '0.8rem' : '0.95rem',
display: 'grid',
gap: '0.7rem',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.7rem', alignItems: 'start' }}>
<div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', gap: '0.4rem', alignItems: 'center', flexWrap: 'wrap', marginBottom: '0.15rem' }}>
<span style={{ color: 'var(--color-primary)', fontSize: '0.74rem', fontWeight: 900, textTransform: 'uppercase' }}>
{nodeTypeLabel(node.node_type)}
</span>
<span
style={{
borderRadius: 999,
padding: '0.12rem 0.42rem',
background: 'rgba(0,49,80,0.06)',
color: 'var(--color-text-soft)',
fontSize: '0.68rem',
fontWeight: 900,
}}
>
Nível {level + 1}
</span>
</div>
<strong style={{ display: 'block', lineHeight: 1.25 }}>{node.title}</strong>
{!isRoot && parentTitle ? (
<span style={{ display: 'block', marginTop: '0.22rem', color: 'var(--color-text-soft)', fontSize: '0.78rem', fontWeight: 700 }}>
abaixo de: {parentTitle}
</span>
) : null}
</div>
{!isAgent && !isClose && onAdd ? (
<button
type="button"
onClick={() => onAdd(node)}
title="Adicionar filho"
style={{
width: 34,
height: 34,
borderRadius: 12,
border: 'none',
background: 'var(--color-highlight)',
color: '#132534',
fontWeight: 900,
}}
>
+
</button>
) : null}
</div>
{isAgent ? (
<span style={{ color: 'var(--color-text-soft)', lineHeight: 1.35 }}>
Fila: {node.area_nome || areasById.get(Number(node.area_id))?.nome || 'não definida'}
</span>
) : isClose ? (
<span style={{ color: 'var(--color-text-soft)', lineHeight: 1.35, whiteSpace: 'pre-wrap' }}>
{isDeep && nodeMessage.length > 96 ? `${nodeMessage.slice(0, 96)}...` : nodeMessage}
</span>
) : (
<span style={{ color: 'var(--color-text-soft)', whiteSpace: isDeep ? 'normal' : 'pre-wrap', lineHeight: 1.35 }}>
{isDeep && nodeMessage.length > 96 ? `${nodeMessage.slice(0, 96)}...` : nodeMessage}
</span>
)}
{!isRoot && keywords.length ? (
<div style={{ display: 'grid', gap: '0.35rem' }}>
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.72rem', fontWeight: 900 }}>
Respostas que chegam aqui
</span>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem' }}>
{keywords.slice(0, visibleKeywordLimit).map((keyword) => (
<span
key={keyword}
style={{
borderRadius: 999,
background: 'rgba(0,49,80,0.07)',
padding: '0.22rem 0.5rem',
fontSize: '0.75rem',
fontWeight: 800,
}}
>
{keyword}
</span>
))}
{keywords.length > visibleKeywordLimit ? (
<span
style={{
borderRadius: 999,
background: 'rgba(0,49,80,0.04)',
padding: '0.22rem 0.5rem',
fontSize: '0.75rem',
fontWeight: 800,
color: 'var(--color-text-soft)',
}}
>
+{keywords.length - visibleKeywordLimit}
</span>
) : null}
</div>
</div>
) : null}
<div style={{ display: 'flex', gap: '0.45rem', flexWrap: 'wrap' }}>
{onEdit ? (
<button type="button" onClick={() => onEdit(node)} style={{ ...ghostButton, padding: '0.55rem 0.7rem' }}>
Editar
</button>
) : null}
{!isRoot && onDelete ? (
<button
type="button"
onClick={() => onDelete(node)}
style={{
...ghostButton,
padding: '0.55rem 0.7rem',
color: 'var(--color-secondary)',
}}
>
Remover
</button>
) : null}
</div>
</article>
{node.children?.length ? (
<>
<div
style={{
width: 2,
height: 38,
background: 'linear-gradient(180deg, rgba(0,49,80,0.28), rgba(0,49,80,0.1))',
}}
/>
<div
style={{
display: 'flex',
gap: childGap,
alignItems: 'start',
justifyContent: 'center',
flexWrap: 'nowrap',
paddingTop: 34,
position: 'relative',
width: '100%',
}}
>
{node.children.length > 1 ? (
<div
aria-hidden="true"
style={{
position: 'absolute',
top: 0,
left: firstChildWidth / 2,
right: lastChildWidth / 2,
height: 2,
background: 'rgba(0,49,80,0.16)',
}}
/>
) : null}
{node.children.map((child) => {
const childWidth = getFlowSubtreeWidth(child, level + 1);
return (
<div
key={child.id}
style={{
position: 'relative',
display: 'grid',
justifyItems: 'center',
minWidth: childWidth,
width: childWidth,
}}
>
<div
aria-hidden="true"
style={{
position: 'absolute',
top: -34,
left: '50%',
width: 2,
height: 34,
background: 'rgba(0,49,80,0.2)',
transform: 'translateX(-50%)',
}}
/>
<FlowNode
node={child}
areasById={areasById}
onAdd={onAdd}
onEdit={onEdit}
onDelete={onDelete}
level={level + 1}
parentTitle={node.title}
/>
</div>
);
})}
</div>
</>
) : null}
</div>
);
}
function NodeModal({ mode, node, parent, areas, draft, onDraftChange, onClose, onSave }) {
if (!mode) return null;
const isEdit = mode === 'edit';
const isRoot = node?.node_type === 'greeting';
const isAgent = draft.nodeType === 'agent' || node?.node_type === 'agent';
const isClose = draft.nodeType === 'close' || node?.node_type === 'close';
const canChooseType = !isEdit;
function change(key, value) {
onDraftChange((current) => ({ ...current, [key]: value }));
}
return (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0, 20, 32, 0.42)',
display: 'grid',
placeItems: 'center',
zIndex: 50,
padding: '1rem',
}}
>
<section
style={{
width: 'min(760px, calc(100vw - 2rem))',
maxHeight: 'calc(100vh - 2rem)',
overflowY: 'auto',
borderRadius: 24,
background: '#fff',
boxShadow: 'var(--shadow-lg)',
padding: '1.25rem',
display: 'grid',
gap: '1rem',
}}
>
<header style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', alignItems: 'start' }}>
<div>
<h2 style={{ margin: 0, fontSize: '1.25rem' }}>
{isEdit ? 'Editar nó' : `Adicionar filho em ${parent?.title}`}
</h2>
<p style={{ margin: '0.35rem 0 0', color: 'var(--color-text-soft)' }}>
Configure a mensagem, as palavras que ativam o caminho e o destino quando for terminal.
</p>
</div>
<button type="button" onClick={onClose} style={ghostButton}>Fechar</button>
</header>
{canChooseType ? (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
{[
['question', 'Adicionar pergunta'],
['agent', 'Enviar para agente'],
['close', 'Encerrar pelo bot'],
].map(([type, label]) => (
<button
key={type}
type="button"
onClick={() => change('nodeType', type)}
style={{
border: `1px solid ${draft.nodeType === type ? 'var(--color-primary)' : 'var(--color-border)'}`,
borderRadius: 16,
padding: '0.9rem',
background: draft.nodeType === type ? 'rgba(0,164,183,0.08)' : '#fff',
color: 'var(--color-text)',
fontWeight: 900,
}}
>
{label}
</button>
))}
</div>
) : null}
<div style={{ display: 'grid', gap: '0.75rem' }}>
<label style={{ display: 'grid', gap: '0.35rem' }}>
<span style={{ fontWeight: 800 }}>Título interno</span>
<input value={draft.title || ''} onChange={(event) => change('title', event.target.value)} style={fieldStyle} />
</label>
{!isRoot ? (
<label style={{ display: 'grid', gap: '0.35rem' }}>
<span style={{ fontWeight: 800 }}>Keywords que ativam este </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>
);
}

View File

@ -0,0 +1,284 @@
import { useNavigate } from 'react-router-dom';
import { BrandMark } from '../../../shared/components/BrandMark';
import { clearSession } from '../../auth/services/sessionService';
const navigationBySection = {
supervisor: [
{ id: 'dashboard', label: 'Home' },
{ id: 'templates', label: 'Templates' },
{ id: 'knowledge', label: 'Fluxo do Bot' },
{ id: 'ai-contents', label: 'Conteúdos da IA' },
{ id: 'audit', label: 'Auditoria' },
{ type: 'separator' },
{ id: 'attendance', label: 'Atendimento' },
{ id: 'new-attendance', label: 'Abrir Atendimento' },
{ id: 'mass-message', label: 'Disparo em Massa' },
{ id: 'contacts', label: 'Contatos' },
],
admin: [
{ id: 'today', label: 'Operação' },
{ type: 'separator' },
{ id: 'users-access', label: 'Usuários & Acessos' },
{ id: 'templates', label: 'Templates' },
{ id: 'knowledge', label: 'Fluxo do Bot' },
{ id: 'ai-contents', label: 'Conteúdos da IA' },
{ id: 'audit', label: 'Auditoria' },
{ id: 'channels', label: 'Canais e Integração' },
{ type: 'separator' },
{ id: 'attendance', label: 'Atendimento' },
{ id: 'new-attendance', label: 'Abrir Atendimento' },
{ id: 'mass-message', label: 'Disparo em Massa' },
{ id: 'contacts', label: 'Contatos' },
{ type: 'separator' },
{ id: 'settings', label: 'Configurações' },
],
};
const actionLabelBySection = {
supervisor: '+ Redistribuir atendimento',
admin: 'Home',
};
export function ManagementLayout({
title,
subtitle,
activeSection,
profileLabel,
initials,
children,
isDesktop,
isMobile,
activeNavItem,
onNavItemChange,
}) {
const navigate = useNavigate();
const navItems = navigationBySection[activeSection] || navigationBySection.supervisor;
const actionLabel = actionLabelBySection[activeSection] || 'Home';
function handleLogout() {
clearSession();
navigate('/login', { replace: true });
}
return (
<main style={{ minHeight: '100vh', padding: '1.5rem' }}>
<section
style={{
width: 'min(1680px, calc(100vw - 3rem))',
margin: '0 auto',
background: 'var(--color-surface-strong)',
borderRadius: '32px',
boxShadow: 'var(--shadow-lg)',
padding: '1.5rem',
display: 'grid',
gap: '1.5rem',
}}
>
<div
style={{
display: 'grid',
gridTemplateColumns: isDesktop ? 'minmax(300px, 360px) minmax(0, 1fr)' : '1fr',
gap: '1.5rem',
alignItems: 'start',
}}
>
<div style={{ display: 'grid', gap: '1.25rem' }}>
<div
style={{
background: '#fff',
border: '1px solid var(--color-border)',
borderRadius: '28px',
padding: '1.5rem',
}}
>
<BrandMark size="lg" />
</div>
<aside
style={{
background: 'linear-gradient(180deg, rgba(0, 49, 80, 0.98), rgba(7, 64, 98, 0.96))',
color: '#fff',
borderRadius: '28px',
padding: '1.5rem',
display: 'grid',
gap: '1.25rem',
alignContent: 'start',
}}
>
<button
type="button"
onClick={() => {
if (activeSection === 'admin') {
onNavItemChange?.('home');
return;
}
navigate('/home');
}}
style={{
border: 'none',
borderRadius: '20px',
padding: '1rem 1.15rem',
background: 'linear-gradient(135deg, var(--color-highlight), #f3b94d)',
color: '#132534',
fontWeight: 800,
textAlign: 'left',
}}
>
{actionLabel}
</button>
<nav
style={{
display: 'grid',
gap: '0.5rem',
gridTemplateColumns: isMobile ? 'repeat(auto-fit, minmax(180px, 1fr))' : '1fr',
}}
>
{navItems.map((item, index) => {
if (item.type === 'separator') {
return (
<div
key={`separator-${index}`}
style={{
height: 1,
background: 'rgba(255, 255, 255, 0.16)',
margin: '0.35rem 0',
}}
/>
);
}
const isActive = activeNavItem ? item.id === activeNavItem : index === 0;
return (
<button
key={item.id}
type="button"
onClick={() => {
if (item.path) {
navigate(item.path);
return;
}
onNavItemChange?.(item.id);
}}
style={{
border: 'none',
borderRadius: '18px',
padding: '0.9rem 1rem',
background: isActive ? 'rgba(255, 255, 255, 0.14)' : 'transparent',
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
fontWeight: isActive ? 700 : 500,
width: '100%',
textAlign: 'left',
}}
>
<span>{item.label}</span>
{item.count ? (
<span
style={{
minWidth: 30,
borderRadius: 999,
padding: '0.2rem 0.5rem',
background: 'rgba(255, 255, 255, 0.12)',
fontSize: '0.82rem',
textAlign: 'center',
}}
>
{item.count}
</span>
) : null}
</button>
);
})}
</nav>
<button
type="button"
onClick={handleLogout}
style={{
border: '1px solid rgba(255, 255, 255, 0.18)',
borderRadius: '18px',
padding: '0.9rem 1rem',
background: 'transparent',
color: '#ef4444',
fontWeight: 700,
textAlign: 'left',
}}
>
Sair
</button>
</aside>
</div>
<div style={{ display: 'grid', gap: '1.25rem', minWidth: 0 }}>
<header
style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 1fr) auto',
gap: '1rem',
alignItems: 'center',
}}
>
<div
style={{
padding: '1.1rem 1.25rem',
borderRadius: '22px',
background: '#fff',
border: '1px solid var(--color-border)',
minWidth: 0,
}}
>
<h1 style={{ margin: 0, fontSize: '1.65rem' }}>{title}</h1>
<p style={{ margin: '0.45rem 0 0', color: 'var(--color-text-soft)' }}>
{subtitle}
</p>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.9rem',
justifySelf: isMobile ? 'stretch' : 'end',
justifyContent: isMobile ? 'space-between' : 'flex-end',
padding: '0.85rem 1rem',
borderRadius: '22px',
background: '#fff',
border: '1px solid var(--color-border)',
}}
>
<div style={{ textAlign: 'right' }}>
<strong style={{ display: 'block' }}>{profileLabel}</strong>
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.92rem' }}>
Ambiente de gestão
</span>
</div>
<div
aria-hidden="true"
style={{
width: 48,
height: 48,
borderRadius: '16px',
display: 'grid',
placeItems: 'center',
background: 'linear-gradient(135deg, var(--color-accent), var(--color-primary))',
color: '#fff',
fontWeight: 800,
}}
>
{initials}
</div>
</div>
</header>
{children}
</div>
</div>
</section>
</main>
);
}

View File

@ -0,0 +1,59 @@
export function ManagementTable({ columns, rows, getRowId, isMobile = false }) {
return (
<div style={{ display: 'grid', gap: '0.75rem' }}>
{!isMobile ? (
<div
style={{
display: 'grid',
gridTemplateColumns: `repeat(${columns.length}, minmax(0, 1fr))`,
gap: '1rem',
padding: '0 1rem',
color: 'var(--color-text-soft)',
fontWeight: 700,
fontSize: '0.88rem',
}}
>
{columns.map((column) => (
<span key={column.key}>{column.label}</span>
))}
</div>
) : null}
{rows.map((row) => (
<article
key={getRowId(row)}
style={{
borderRadius: '20px',
border: '1px solid var(--color-border)',
padding: '1rem',
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : `repeat(${columns.length}, minmax(0, 1fr))`,
gap: isMobile ? '0.65rem' : '1rem',
alignItems: 'center',
background: '#fff',
minWidth: 0,
}}
>
{columns.map((column) => (
<div key={column.key} style={{ minWidth: 0 }}>
{isMobile ? (
<span
style={{
display: 'block',
color: 'var(--color-text-soft)',
fontSize: '0.82rem',
fontWeight: 700,
marginBottom: '0.2rem',
}}
>
{column.label}
</span>
) : null}
{column.render ? column.render(row) : <span>{row[column.key]}</span>}
</div>
))}
</article>
))}
</div>
);
}

View File

@ -0,0 +1,493 @@
import { useEffect, useMemo, useState } from 'react';
import { API_BASE_URL } from '../../../shared/services/apiConfig';
import { getCurrentUser } from '../../auth/services/sessionService';
import { listContactProfiles } from '../../chat/services/contactProfileService';
import { DataPanel } from './DataPanel';
import { listTemplates } from '../services/templateService';
const inputStyle = {
width: '100%',
border: '1px solid var(--color-border)',
borderRadius: 14,
padding: '0.85rem 0.9rem',
background: '#fff',
color: 'var(--color-text)',
fontWeight: 600,
};
function getUserId(user) {
const value = user?.databaseId || user?.id;
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : null;
}
function normalizePhoneToChatId(value) {
const digits = String(value || '').replace(/\D/g, '');
if (!digits) return '';
return `${digits}@c.us`;
}
function getPhoneFromChatId(chatId) {
return String(chatId || '').split('@')[0].replace(/\D/g, '');
}
function normalizeContact(contact) {
const phone = String(contact.phone || getPhoneFromChatId(contact.chat_id)).replace(/\D/g, '');
return {
id: contact.chat_id || normalizePhoneToChatId(phone),
name: contact.name || phone || 'Contato sem nome',
company: contact.company || '',
phone,
chatId: contact.chat_id || normalizePhoneToChatId(phone),
};
}
function renderPreview(content, variables) {
const name = variables?.nome || 'colaborador';
return String(content || '')
.replace(/\{nome\}/gi, name || 'colaborador')
.replace(/\{cliente\}/gi, name || 'colaborador')
.replace(/\{data\}/gi, variables?.data || '{data}')
.replace(/\{link\}/gi, variables?.link || '{link}')
.replace(/\{variavel\}/gi, variables?.variavel || '{variavel}')
.replace(/\{variável\}/gi, variables?.variavel || '{variável}');
}
async function startAttendance(payload) {
const response = await fetch(`${API_BASE_URL}/whatsapp/start-attendance`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error('Falha ao enviar disparo.');
}
return response.json();
}
export function MassMessagePanel({
areas,
managedAreaNames = [],
mode = 'admin',
isMobile = false,
}) {
const currentUserId = getUserId(getCurrentUser());
const isAdmin = mode === 'admin';
const visibleAreaNames = isAdmin ? [] : managedAreaNames;
const [templates, setTemplates] = useState([]);
const [contacts, setContacts] = useState([]);
const [selectedAreaId, setSelectedAreaId] = useState('');
const [selectedTemplateId, setSelectedTemplateId] = useState('');
const [defaultName, setDefaultName] = useState('colaborador');
const [templateDate, setTemplateDate] = useState('');
const [templateLink, setTemplateLink] = useState('');
const [templateCustomVariable, setTemplateCustomVariable] = useState('');
const [numbersText, setNumbersText] = useState('');
const [contactSearch, setContactSearch] = useState('');
const [selectedContactIds, setSelectedContactIds] = useState([]);
const [isSending, setIsSending] = useState(false);
const [results, setResults] = useState([]);
const [status, setStatus] = useState('');
useEffect(() => {
let isMounted = true;
listTemplates()
.then((data) => {
if (!isMounted) return;
const approved = Array.isArray(data)
? data.filter((template) => {
const isApproved = template.status === 'approved';
const isManaged = !visibleAreaNames.length || visibleAreaNames.includes(template.area_nome);
return isApproved && isManaged;
})
: [];
setTemplates(approved);
setSelectedTemplateId((current) => current || (approved[0]?.id ? String(approved[0].id) : ''));
})
.catch((error) => {
if (isMounted) setStatus(error.message);
});
return () => {
isMounted = false;
};
}, [visibleAreaNames.join('|')]);
useEffect(() => {
let isMounted = true;
listContactProfiles()
.then((data) => {
if (!isMounted) return;
setContacts(Array.isArray(data) ? data.map(normalizeContact).filter((contact) => contact.phone) : []);
})
.catch((error) => {
if (isMounted) setStatus(error.message);
});
return () => {
isMounted = false;
};
}, []);
const selectedTemplate = templates.find((template) => String(template.id) === String(selectedTemplateId));
const filteredTemplates = useMemo(() => {
if (!selectedAreaId) return templates;
return templates.filter((template) => String(template.area_id || '') === String(selectedAreaId));
}, [templates, selectedAreaId]);
const numbers = useMemo(
() =>
numbersText
.split(/\r?\n|,|;/)
.map((item) => item.trim())
.filter(Boolean),
[numbersText],
);
const selectedContacts = useMemo(
() => contacts.filter((contact) => selectedContactIds.includes(contact.id)),
[contacts, selectedContactIds],
);
const filteredContacts = useMemo(() => {
const search = contactSearch.trim().toLowerCase();
if (!search) return contacts;
return contacts.filter((contact) =>
`${contact.name} ${contact.company} ${contact.phone}`.toLowerCase().includes(search),
);
}, [contacts, contactSearch]);
const recipients = useMemo(() => {
const items = [];
const seen = new Set();
numbers.forEach((number) => {
const chatId = normalizePhoneToChatId(number);
if (!chatId || seen.has(chatId)) return;
seen.add(chatId);
items.push({
id: chatId,
number,
chatId,
name: defaultName,
});
});
return items;
}, [defaultName, numbers]);
useEffect(() => {
if (selectedTemplate && filteredTemplates.some((template) => String(template.id) === String(selectedTemplateId))) {
return;
}
setSelectedTemplateId(filteredTemplates[0]?.id ? String(filteredTemplates[0].id) : '');
}, [filteredTemplates, selectedTemplate, selectedTemplateId]);
function toggleContact(contact) {
const phone = String(contact.phone || '').replace(/\D/g, '');
if (!phone) return;
setSelectedContactIds((current) => {
const isSelected = current.includes(contact.id);
return isSelected ? current.filter((id) => id !== contact.id) : [...current, contact.id];
});
setNumbersText((current) => {
const currentNumbers = current
.split(/\r?\n|,|;/)
.map((item) => item.trim())
.filter(Boolean);
const exists = currentNumbers.some((item) => String(item).replace(/\D/g, '') === phone);
const nextNumbers = exists
? currentNumbers.filter((item) => String(item).replace(/\D/g, '') !== phone)
: [...currentNumbers, phone];
return nextNumbers.join('\n');
});
}
function clearSelectedContacts() {
const selectedPhones = new Set(selectedContacts.map((contact) => contact.phone));
setSelectedContactIds([]);
setNumbersText((current) =>
current
.split(/\r?\n|,|;/)
.map((item) => item.trim())
.filter(Boolean)
.filter((item) => !selectedPhones.has(String(item).replace(/\D/g, '')))
.join('\n'),
);
}
async function sendSelectedRecipients() {
if (!currentUserId) {
setStatus('Não foi possível identificar o usuário logado.');
return;
}
if (!selectedTemplateId) {
setStatus('Selecione um template aprovado.');
return;
}
if (!recipients.length) {
setStatus('Informe ao menos um número ou selecione contatos da agenda.');
return;
}
setIsSending(true);
setResults([]);
setStatus('');
const nextResults = [];
for (const recipient of recipients) {
if (!recipient.chatId) {
nextResults.push({ number: recipient.number, status: 'erro', detail: 'Número inválido' });
continue;
}
try {
await startAttendance({
to: recipient.chatId,
templateId: Number(selectedTemplateId),
userId: currentUserId,
areaId: selectedTemplate?.area_id || null,
variables: {
nome: defaultName,
cliente: defaultName,
data: templateDate,
link: templateLink,
variavel: templateCustomVariable,
'variável': templateCustomVariable,
},
});
nextResults.push({
number: recipient.number,
status: 'enviado',
detail: `${recipient.name || 'Contato'} - template enviado e atendimento iniciado`,
});
} catch (error) {
nextResults.push({ number: recipient.number, status: 'erro', detail: error.message });
}
setResults([...nextResults]);
}
setStatus(`Disparo finalizado: ${nextResults.filter((item) => item.status === 'enviado').length} enviados.`);
setIsSending(false);
}
return (
<DataPanel
title="Disparo em massa"
description="Envie templates aprovados para uma lista de colaboradores. Após o envio, a conversa aguarda resposta do cliente."
>
<div
style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 1fr) minmax(320px, 0.85fr)',
gap: '1rem',
}}
>
<div style={{ display: 'grid', gap: '0.85rem' }}>
<label style={{ display: 'grid', gap: '0.4rem' }}>
<span style={{ fontWeight: 700 }}>Especialidade</span>
<select value={selectedAreaId} onChange={(event) => setSelectedAreaId(event.target.value)} style={inputStyle}>
<option value="">Todas as especialidades</option>
{areas
.filter((area) => isAdmin || !managedAreaNames.length || managedAreaNames.includes(area.nome))
.map((area) => (
<option key={area.id} value={area.id}>
{area.nome}
</option>
))}
</select>
</label>
<label style={{ display: 'grid', gap: '0.4rem' }}>
<span style={{ fontWeight: 700 }}>Template aprovado</span>
<select value={selectedTemplateId} onChange={(event) => setSelectedTemplateId(event.target.value)} style={inputStyle}>
<option value="">Selecione</option>
{filteredTemplates.map((template) => (
<option key={template.id} value={template.id}>
{template.name}
</option>
))}
</select>
</label>
<label style={{ display: 'grid', gap: '0.4rem' }}>
<span style={{ fontWeight: 700 }}>Nome usado no preview</span>
<input value={defaultName} onChange={(event) => setDefaultName(event.target.value)} style={inputStyle} />
</label>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : 'repeat(3, minmax(0, 1fr))', gap: '0.85rem' }}>
<label style={{ display: 'grid', gap: '0.4rem' }}>
<span style={{ fontWeight: 700 }}>Data</span>
<input value={templateDate} onChange={(event) => setTemplateDate(event.target.value)} placeholder="Ex: 26/05/2026" style={inputStyle} />
</label>
<label style={{ display: 'grid', gap: '0.4rem' }}>
<span style={{ fontWeight: 700 }}>Link</span>
<input value={templateLink} onChange={(event) => setTemplateLink(event.target.value)} placeholder="https://..." style={inputStyle} />
</label>
<label style={{ display: 'grid', gap: '0.4rem' }}>
<span style={{ fontWeight: 700 }}>Variável</span>
<input value={templateCustomVariable} onChange={(event) => setTemplateCustomVariable(event.target.value)} placeholder="Valor livre" style={inputStyle} />
</label>
</div>
<label style={{ display: 'grid', gap: '0.4rem' }}>
<span style={{ fontWeight: 700 }}>Números manuais</span>
<textarea
rows={8}
value={numbersText}
onChange={(event) => setNumbersText(event.target.value)}
placeholder="5511999999999&#10;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>
);
}

View File

@ -0,0 +1,31 @@
export function MetricGrid({ metrics, minCardWidth = '180px' }) {
return (
<section
style={{
display: 'grid',
gridTemplateColumns: `repeat(auto-fit, minmax(${minCardWidth}, 1fr))`,
gap: '1rem',
}}
>
{metrics.map((item) => (
<article
key={item.label}
style={{
padding: '1.15rem',
borderRadius: '22px',
border: '1px solid var(--color-border)',
background: '#fff',
}}
>
<span style={{ color: 'var(--color-text-soft)', display: 'block' }}>{item.label}</span>
<strong style={{ display: 'block', fontSize: '1.4rem', marginTop: '0.45rem' }}>
{item.value}
</strong>
<span style={{ color: 'var(--color-text-soft)', display: 'block', marginTop: '0.45rem' }}>
{item.detail}
</span>
</article>
))}
</section>
);
}

View File

@ -0,0 +1,342 @@
import { useMemo, useState } from 'react';
import { DataPanel } from './DataPanel';
import { MetricGrid } from './MetricGrid';
const selectStyle = {
width: '100%',
border: '1px solid var(--color-border)',
borderRadius: '14px',
padding: '0.75rem 0.85rem',
background: '#fff',
color: 'var(--color-text)',
fontWeight: 600,
};
const areaOptions = [
{ value: 'all', label: 'Todas as especialidades' },
{ value: 'Suporte', label: 'Suporte' },
{ value: 'Comercial', label: 'Comercial' },
{ value: 'Financeiro', label: 'Financeiro' },
];
const operationMockByArea = {
all: {
kpis: [
{ label: 'Finalizados hoje', value: '126', detail: 'todos os canais' },
{ label: 'Em aberto agora', value: '42', detail: '18 em atendimento' },
{ label: 'Na fila', value: '12', detail: 'aguardando assumir' },
{ label: 'Tempo médio do dia', value: '8m 12s', detail: 'TMA operacional' },
{ label: 'Atendentes online', value: '8 de 11', detail: '3 em pausa/offline' },
],
team: [
{ id: 'ana', name: 'Ana Camolesi', status: 'online', open: 4, lastClosed: '6 min' },
{ id: 'rafael', name: 'Rafael Lopes', status: 'online', open: 3, lastClosed: '12 min' },
{ id: 'marina', name: 'Marina Alves', status: 'paused', open: 1, lastClosed: '18 min' },
{ id: 'camila', name: 'Camila Rocha', status: 'online', open: 5, lastClosed: '22 min' },
{ id: 'joao', name: 'Joao Pedro', status: 'offline', open: 0, lastClosed: '1h 05min' },
],
queue: [
{ id: 'q1', channel: 'WhatsApp', contact: 'Maria Souza', waitingMinutes: 24 },
{ id: 'q2', channel: 'Email', contact: 'Empresa Alpha', waitingMinutes: 18 },
{ id: 'q3', channel: 'SMS', contact: 'Carlos Nunes', waitingMinutes: 11 },
{ id: 'q4', channel: 'WhatsApp', contact: 'Grupo Solaris', waitingMinutes: 7 },
],
hourly: [8, 11, 15, 13, 19, 22, 18, 26, 24, 31, 28],
},
Suporte: {
kpis: [
{ label: 'Finalizados hoje', value: '58', detail: 'suporte técnico' },
{ label: 'Em aberto agora', value: '21', detail: '9 em atendimento' },
{ label: 'Na fila', value: '7', detail: 'aguardando assumir' },
{ label: 'Tempo médio do dia', value: '9m 04s', detail: 'TMA suporte' },
{ label: 'Atendentes online', value: '4 de 6', detail: '1 pausa, 1 offline' },
],
team: [
{ id: 'ana', name: 'Ana Camolesi', status: 'online', open: 4, lastClosed: '6 min' },
{ id: 'rafael', name: 'Rafael Lopes', status: 'online', open: 3, lastClosed: '12 min' },
{ id: 'beatriz', name: 'Beatriz Lima', status: 'paused', open: 2, lastClosed: '21 min' },
{ id: 'pedro', name: 'Pedro Santos', status: 'offline', open: 0, lastClosed: '48 min' },
],
queue: [
{ id: 's1', channel: 'WhatsApp', contact: 'Maria Souza', waitingMinutes: 24 },
{ id: 's2', channel: 'WhatsApp', contact: 'Bruno Matos', waitingMinutes: 15 },
{ id: 's3', channel: 'Email', contact: 'TI Alpha', waitingMinutes: 9 },
],
hourly: [4, 5, 7, 6, 10, 12, 9, 15, 13, 18, 16],
},
Comercial: {
kpis: [
{ label: 'Finalizados hoje', value: '39', detail: 'leads e propostas' },
{ label: 'Em aberto agora', value: '13', detail: '6 em atendimento' },
{ label: 'Na fila', value: '3', detail: 'aguardando assumir' },
{ label: 'Tempo médio do dia', value: '7m 38s', detail: 'TMA comercial' },
{ label: 'Atendentes online', value: '3 de 3', detail: 'time completo' },
],
team: [
{ id: 'camila', name: 'Camila Rocha', status: 'online', open: 5, lastClosed: '22 min' },
{ id: 'lucas', name: 'Lucas Nunes', status: 'online', open: 4, lastClosed: '14 min' },
{ id: 'helena', name: 'Helena Costa', status: 'online', open: 4, lastClosed: '31 min' },
],
queue: [
{ id: 'c1', channel: 'WhatsApp', contact: 'Grupo Solaris', waitingMinutes: 17 },
{ id: 'c2', channel: 'Email', contact: 'Empresa Beta', waitingMinutes: 10 },
{ id: 'c3', channel: 'SMS', contact: 'Renata Prado', waitingMinutes: 4 },
],
hourly: [2, 4, 6, 5, 8, 7, 8, 10, 11, 13, 12],
},
Financeiro: {
kpis: [
{ label: 'Finalizados hoje', value: '29', detail: 'faturas e boletos' },
{ label: 'Em aberto agora', value: '8', detail: '3 em atendimento' },
{ label: 'Na fila', value: '2', detail: 'aguardando assumir' },
{ label: 'Tempo médio do dia', value: '6m 51s', detail: 'TMA financeiro' },
{ label: 'Atendentes online', value: '1 de 2', detail: '1 offline' },
],
team: [
{ id: 'marina', name: 'Marina Alves', status: 'paused', open: 1, lastClosed: '18 min' },
{ id: 'joao', name: 'Joao Pedro', status: 'offline', open: 0, lastClosed: '1h 05min' },
{ id: 'roberto', name: 'Roberto Pera', status: 'online', open: 3, lastClosed: '9 min' },
],
queue: [
{ id: 'f1', channel: 'WhatsApp', contact: 'Joao Pedro', waitingMinutes: 22 },
{ id: 'f2', channel: 'Email', contact: 'Financeiro Omega', waitingMinutes: 8 },
],
hourly: [2, 2, 2, 2, 4, 5, 4, 6, 5, 7, 6],
},
};
const statusMeta = {
online: { label: 'Online', background: 'rgba(34, 197, 94, 0.12)', color: '#15803d' },
paused: { label: 'Pausado', background: 'rgba(229, 162, 42, 0.16)', color: '#8a5a00' },
offline: { label: 'Offline', background: 'rgba(100, 116, 139, 0.14)', color: '#475569' },
};
const channelColors = {
WhatsApp: { background: 'rgba(43, 183, 65, 0.12)', color: '#15803d' },
Email: { background: 'rgba(229, 162, 42, 0.16)', color: '#8a5a00' },
SMS: { background: 'rgba(0, 164, 183, 0.12)', color: 'var(--color-primary)' },
};
function Badge({ children, tone }) {
return (
<span
style={{
width: 'fit-content',
borderRadius: 999,
padding: '0.25rem 0.6rem',
background: tone.background,
color: tone.color,
fontWeight: 800,
fontSize: '0.82rem',
}}
>
{children}
</span>
);
}
function HourlyBarChart({ values }) {
const labels = ['08h', '09h', '10h', '11h', '12h', '13h', '14h', '15h', '16h', '17h', '18h'];
const maxValue = Math.max(...values, 1);
return (
<div style={{ display: 'grid', gap: '0.75rem' }}>
<div
style={{
height: 260,
display: 'grid',
gridTemplateColumns: `repeat(${values.length}, minmax(10px, 1fr))`,
gap: '0.65rem',
alignItems: 'end',
padding: '0.5rem 0 0',
}}
>
{values.map((value, index) => (
<div
key={`${value}-${index}`}
title={`${labels[index]}: ${value} atendimentos`}
style={{
height: `${Math.max(8, (value / maxValue) * 100)}%`,
borderRadius: '10px 10px 4px 4px',
background: 'linear-gradient(180deg, var(--color-secondary), rgba(181, 31, 31, 0.62))',
}}
/>
))}
</div>
<div style={{ display: 'grid', gridTemplateColumns: `repeat(${labels.length}, 1fr)`, gap: '0.25rem', color: 'var(--color-text-soft)', fontSize: '0.78rem', fontWeight: 700 }}>
{labels.map((label) => (
<span key={label} style={{ textAlign: 'center' }}>{label}</span>
))}
</div>
</div>
);
}
export function OperationalDashboard({ isDesktop, isMobile }) {
const [selectedArea, setSelectedArea] = useState('all');
const [assignmentTarget, setAssignmentTarget] = useState(null);
const data = operationMockByArea[selectedArea] || operationMockByArea.all;
const onlineAgents = useMemo(
() => data.team.filter((agent) => agent.status === 'online'),
[data.team],
);
const sortedQueue = useMemo(
() => [...data.queue].sort((a, b) => b.waitingMinutes - a.waitingMinutes),
[data.queue],
);
return (
<section style={{ display: 'grid', gap: '1rem' }}>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<label style={{ display: 'grid', gap: '0.35rem', width: isMobile ? '100%' : 260 }}>
<span style={{ fontWeight: 700 }}>Filtro por especialidade</span>
<select value={selectedArea} onChange={(event) => setSelectedArea(event.target.value)} style={selectStyle}>
{areaOptions.map((area) => (
<option key={area.value} value={area.value}>{area.label}</option>
))}
</select>
</label>
</div>
<MetricGrid metrics={data.kpis} minCardWidth="160px" />
<div style={{ display: 'grid', gridTemplateColumns: isDesktop ? 'minmax(0, 1.15fr) minmax(360px, 0.85fr)' : '1fr', gap: '1rem', alignItems: 'start' }}>
<DataPanel title="Painel do time" description="Status operacional em tempo real simulado.">
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: 620 }}>
<thead>
<tr style={{ textAlign: 'left', color: 'var(--color-text-soft)', fontSize: '0.82rem' }}>
<th style={{ padding: '0.65rem 0.75rem' }}>Nome</th>
<th style={{ padding: '0.65rem 0.75rem' }}>Status</th>
<th style={{ padding: '0.65rem 0.75rem' }}>Atendimentos abertos</th>
<th style={{ padding: '0.65rem 0.75rem' }}>Último finalizado </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 {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>
);
}

View File

@ -0,0 +1,445 @@
import { useEffect, useMemo, useState } from 'react';
import { DataPanel } from './DataPanel';
import {
approveTemplateByAdmin,
deleteTemplate,
listTemplates,
rejectTemplateByAdmin,
saveTemplate,
} from '../services/templateService';
const fieldStyle = {
width: '100%',
border: '1px solid var(--color-border)',
borderRadius: '14px',
padding: '0.75rem 0.85rem',
background: '#fff',
color: 'var(--color-text)',
fontWeight: 600,
};
const statusMeta = {
approved: {
label: 'Aprovado pela Meta',
background: 'rgba(34, 197, 94, 0.12)',
color: '#15803d',
},
meta_review: {
label: 'Em análise pela Meta',
background: 'rgba(229, 162, 42, 0.16)',
color: '#8a5a00',
},
admin_review: {
label: 'Aguardando aprovação do admin',
background: 'rgba(0, 49, 80, 0.1)',
color: 'var(--color-primary)',
},
rejected: {
label: 'Reprovado pelo admin',
background: 'rgba(181, 31, 31, 0.1)',
color: 'var(--color-secondary)',
},
};
const templateCategories = [
{ id: 'UTILITY', label: 'UTILITY', detail: 'Confirmações, lembretes e atualizações', cost: 'Menor custo' },
{ id: 'MARKETING', label: 'MARKETING', detail: 'Promoções, ofertas e engajamento', cost: 'Maior custo' },
{ id: 'AUTHENTICATION', label: 'AUTHENTICATION', detail: 'OTP e códigos de verificação', cost: 'Menor custo' },
];
function getTemplateStatus(template) {
return statusMeta[template.status] || statusMeta.approved;
}
function getRemainingMetaText(template) {
if (template.status !== 'meta_review' || !template.meta_submitted_at) return '';
const submittedAt = new Date(template.meta_submitted_at).getTime();
const approvedAt = submittedAt + 15 * 60 * 1000;
const remainingMs = approvedAt - Date.now();
if (remainingMs <= 0) return 'Aprovação disponível ao atualizar.';
const minutes = Math.ceil(remainingMs / 60000);
return `Aprovação em aproximadamente ${minutes} min.`;
}
function renderTemplatePreview(content) {
return String(content || 'Digite a mensagem do template...')
.replace(/\{nome\}/gi, 'Ana Paula')
.replace(/\{cliente\}/gi, 'Ana Paula')
.replace(/\{data\}/gi, '26/05/2026')
.replace(/\{link\}/gi, 'https://sothis.com.br/rh')
.replace(/\{variavel\}/gi, 'informação personalizada')
.replace(/\{variável\}/gi, 'informação personalizada');
}
export function TemplateManagementPanel({
areas = [],
mode = 'admin',
managedAreaNames = [],
isMobile = false,
}) {
const [templates, setTemplates] = useState([]);
const [selectedArea, setSelectedArea] = useState('all');
const [form, setForm] = useState({ name: '', content: '', areaId: '', category: 'UTILITY' });
const [statusMessage, setStatusMessage] = useState('');
const [isLoading, setIsLoading] = useState(true);
const isAdmin = mode === 'admin';
const visibleAreas = isAdmin
? areas
: areas.filter((area) => managedAreaNames.includes(area.nome));
const filteredTemplates = useMemo(() => {
return templates.filter((template) => {
const areaMatches = selectedArea === 'all' || String(template.area_id || '') === selectedArea;
const supervisorAreaMatches =
isAdmin ||
!template.area_nome ||
managedAreaNames.includes(template.area_nome);
return areaMatches && supervisorAreaMatches;
});
}, [templates, selectedArea, isAdmin, managedAreaNames]);
async function loadTemplates() {
setIsLoading(true);
try {
const data = await listTemplates();
setTemplates(Array.isArray(data) ? data : []);
setStatusMessage('');
} catch (error) {
setStatusMessage(error.message);
} finally {
setIsLoading(false);
}
}
useEffect(() => {
loadTemplates();
}, []);
async function submitTemplate(event) {
event.preventDefault();
const name = form.name.trim();
const content = form.content.trim();
if (!name || !content) return;
try {
await saveTemplate({
name,
content,
category: form.category,
areaId: Number(form.areaId) || null,
requestedByRole: isAdmin ? 'admin' : 'supervisor',
});
setForm({ name: '', content: '', areaId: '', category: 'UTILITY' });
setStatusMessage(
isAdmin
? 'Template enviado para aprovação.'
: 'Template enviado para aprovação do admin.',
);
await loadTemplates();
} catch (error) {
setStatusMessage(error.message);
}
}
async function approveTemplate(templateId) {
try {
await approveTemplateByAdmin(templateId);
setStatusMessage('Template aprovado pelo admin e enviado para análise da Meta.');
await loadTemplates();
} catch (error) {
setStatusMessage(error.message);
}
}
async function rejectTemplate(templateId) {
try {
await rejectTemplateByAdmin(templateId);
setStatusMessage('Template reprovado pelo admin.');
await loadTemplates();
} catch (error) {
setStatusMessage(error.message);
}
}
async function removeTemplate(templateId) {
try {
await deleteTemplate(templateId);
setStatusMessage('Template excluído.');
await loadTemplates();
} catch (error) {
setStatusMessage(error.message);
}
}
return (
<section style={{ display: 'grid', gap: '1rem' }}>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<label style={{ display: 'grid', gap: '0.35rem', width: isMobile ? '100%' : 280 }}>
<span style={{ fontWeight: 800 }}>Filtrar por especialidade</span>
<select value={selectedArea} onChange={(event) => setSelectedArea(event.target.value)} style={fieldStyle}>
<option value="all">Todas as especialidades</option>
{visibleAreas.map((area) => (
<option key={area.id} value={area.id}>
{area.nome}
</option>
))}
</select>
</label>
</div>
<DataPanel
title={isAdmin ? 'Templates WhatsApp' : 'Solicitar template'}
description={
isAdmin
? 'Crie templates e aprove solicitações de supervisores antes do envio para a Meta.'
: 'Templates enviados por supervisor passam primeiro pela aprovação do admin.'
}
>
<form onSubmit={submitTemplate} style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 1fr) minmax(300px, 0.55fr)', gap: '1rem', alignItems: 'start' }}>
<div style={{ display: 'grid', gap: '0.85rem' }}>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 0.8fr) minmax(0, 0.7fr) minmax(220px, 0.55fr)', gap: '0.85rem' }}>
<input
type="text"
value={form.name}
onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))}
placeholder="Identificador do template"
style={fieldStyle}
/>
<select
value={form.areaId}
onChange={(event) => setForm((current) => ({ ...current, areaId: event.target.value }))}
style={fieldStyle}
>
<option value="">Sem especialidade</option>
{visibleAreas.map((area) => (
<option key={area.id} value={area.id}>
{area.nome}
</option>
))}
</select>
<select
value={form.category}
onChange={(event) => setForm((current) => ({ ...current, category: event.target.value }))}
style={fieldStyle}
>
{templateCategories.map((category) => (
<option key={category.id} value={category.id}>
{category.label} - {category.cost}
</option>
))}
</select>
</div>
<div style={{ display: 'flex', gap: '0.45rem', flexWrap: 'wrap' }}>
{['{nome}', '{data}', '{link}', '{variavel}'].map((variable) => (
<button
key={variable}
type="button"
onClick={() => setForm((current) => ({ ...current, content: `${current.content}${current.content ? ' ' : ''}${variable}` }))}
style={{
border: '1px solid var(--color-border)',
borderRadius: 999,
padding: '0.45rem 0.7rem',
background: '#fff',
color: 'var(--color-primary)',
fontWeight: 800,
}}
>
Adicionar {variable}
</button>
))}
</div>
<textarea
value={form.content}
onChange={(event) => setForm((current) => ({ ...current, content: event.target.value }))}
placeholder="Mensagem do template. Ex: Olá, {nome}. Podemos seguir com seu atendimento por aqui?"
rows={6}
style={{ ...fieldStyle, resize: 'vertical', lineHeight: 1.5 }}
/>
<button
type="submit"
style={{
border: 'none',
borderRadius: 16,
padding: '0.9rem 1rem',
background: 'var(--color-primary)',
color: '#fff',
fontWeight: 800,
width: 'fit-content',
}}
>
{isAdmin ? 'Enviar para aprovação' : 'Enviar para admin'}
</button>
</div>
<aside
aria-label="Preview do template no WhatsApp"
style={{
borderRadius: 22,
padding: '1rem',
background: 'linear-gradient(180deg, #e8f3ee, #dcefe8)',
border: '1px solid rgba(0, 49, 80, 0.08)',
minHeight: 260,
display: 'grid',
alignContent: 'end',
}}
>
<div style={{ display: 'grid', gap: '0.45rem' }}>
<span style={{ color: 'var(--color-text-soft)', fontWeight: 800, fontSize: '0.82rem' }}>
Preview WhatsApp
</span>
<div
style={{
justifySelf: 'end',
maxWidth: '92%',
borderRadius: '16px 16px 4px 16px',
padding: '0.85rem 0.95rem',
background: '#d9fdd3',
color: '#1f2c33',
boxShadow: '0 6px 18px rgba(0, 49, 80, 0.08)',
whiteSpace: 'pre-wrap',
lineHeight: 1.45,
fontSize: '0.94rem',
}}
>
{renderTemplatePreview(form.content)}
<span
style={{
display: 'block',
marginTop: '0.5rem',
textAlign: 'right',
color: 'rgba(31, 44, 51, 0.58)',
fontSize: '0.72rem',
fontWeight: 700,
}}
>
10:42
</span>
</div>
</div>
</aside>
</form>
{statusMessage ? (
<div style={{ marginTop: '0.85rem', color: 'var(--color-primary)', fontWeight: 800 }}>
{statusMessage}
</div>
) : null}
</DataPanel>
<DataPanel title="Lista de templates" description={isLoading ? 'Carregando templates...' : 'Status do fluxo de aprovação.'}>
<div style={{ display: 'grid', gap: '0.75rem', maxHeight: 520, overflowY: 'auto', paddingRight: '0.2rem' }}>
{filteredTemplates.map((template) => {
const status = getTemplateStatus(template);
const remainingMetaText = getRemainingMetaText(template);
return (
<article
key={template.id}
style={{
border: '1px solid var(--color-border)',
borderRadius: 18,
padding: '1rem',
background: '#fff',
display: 'grid',
gap: '0.65rem',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap' }}>
<div>
<strong style={{ display: 'block' }}>{template.name}</strong>
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.9rem' }}>
{template.area_nome || 'Sem especialidade'} · {template.category || 'UTILITY'}
</span>
</div>
<span
style={{
width: 'fit-content',
borderRadius: 999,
padding: '0.3rem 0.65rem',
background: status.background,
color: status.color,
fontWeight: 800,
fontSize: '0.82rem',
}}
>
{status.label}
</span>
</div>
<p style={{ margin: 0, color: 'var(--color-text-soft)', lineHeight: 1.5 }}>
{template.content}
</p>
{remainingMetaText ? (
<span style={{ color: '#8a5a00', fontWeight: 700 }}>{remainingMetaText}</span>
) : null}
{isAdmin ? (
<div style={{ display: 'flex', gap: '0.55rem', flexWrap: 'wrap' }}>
{template.status === 'admin_review' ? (
<>
<button
type="button"
onClick={() => approveTemplate(template.id)}
style={{
border: 'none',
borderRadius: 14,
padding: '0.75rem 0.9rem',
background: 'var(--color-primary)',
color: '#fff',
fontWeight: 800,
}}
>
Aprovar e enviar para Meta
</button>
<button
type="button"
onClick={() => rejectTemplate(template.id)}
style={{
border: 'none',
borderRadius: 14,
padding: '0.75rem 0.9rem',
background: 'rgba(181, 31, 31, 0.1)',
color: 'var(--color-secondary)',
fontWeight: 800,
}}
>
Reprovar
</button>
</>
) : null}
<button
type="button"
onClick={() => removeTemplate(template.id)}
style={{
border: '1px solid rgba(181, 31, 31, 0.22)',
borderRadius: 14,
padding: '0.75rem 0.9rem',
background: '#fff',
color: 'var(--color-secondary)',
fontWeight: 800,
}}
>
Excluir
</button>
</div>
) : null}
</article>
);
})}
{!filteredTemplates.length ? (
<span style={{ color: 'var(--color-text-soft)', fontWeight: 700 }}>
Nenhum template encontrado para o filtro atual.
</span>
) : null}
</div>
</DataPanel>
</section>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,119 @@
import { useEffect, useState } from 'react';
import { ManagementLayout } from '../components/ManagementLayout';
import { OperationalDashboard } from '../components/OperationalDashboard';
import { TemplateManagementPanel } from '../components/TemplateManagementPanel';
import { KnowledgeBasePanel } from '../components/KnowledgeBasePanel';
import { MassMessagePanel } from '../components/MassMessagePanel';
import { DataPanel } from '../components/DataPanel';
import { NewAttendancePage } from '../../attendance/pages/NewAttendancePage';
import { ContactsPanel } from '../../home/pages/ContactsPage';
import { getAccessOptions } from '../services/adminAccessService';
import { useViewport } from '../../../shared/hooks/useViewport';
import { getCurrentUser, getCurrentUserDisplay } from '../../auth/services/sessionService';
import { AdminAttendanceWorkspace } from './AdminPage';
function getUserSpecialties(user) {
const normalize = (area) => {
if (!area) return null;
if (typeof area === 'string') return area;
return area.nome || area.name || null;
};
const areas = Array.isArray(user?.areas) ? user.areas.map(normalize).filter(Boolean) : [];
const primary = normalize(user?.areaPrincipal);
return primary && !areas.includes(primary) ? [primary, ...areas] : areas;
}
export function SupervisorPage() {
const { isWideDesktop, isDesktop, isTablet, isMobile } = useViewport();
const userDisplay = getCurrentUserDisplay();
const currentUser = getCurrentUser();
const managedSpecialties = getUserSpecialties(currentUser);
const [activeSection, setActiveSection] = useState('dashboard');
const [areas, setAreas] = useState([]);
useEffect(() => {
let isMounted = true;
getAccessOptions()
.then((options) => {
if (isMounted) setAreas(options.areas || []);
})
.catch(() => {
if (isMounted) setAreas([]);
});
return () => {
isMounted = false;
};
}, []);
function renderPlaceholder(title, description) {
return (
<DataPanel title={title} description={description}>
<div style={{ border: '1px solid var(--color-border)', borderRadius: 18, padding: '1rem', background: '#fff', color: 'var(--color-text-soft)', fontWeight: 700 }}>
Secao em preparacao.
</div>
</DataPanel>
);
}
const sectionContent = {
dashboard: <OperationalDashboard isDesktop={isDesktop} isMobile={isMobile} />,
templates: (
<TemplateManagementPanel
areas={areas}
mode="supervisor"
managedAreaNames={managedSpecialties}
isMobile={isMobile}
/>
),
knowledge: (
<KnowledgeBasePanel
areas={areas}
mode="supervisor"
managedAreaNames={managedSpecialties}
isMobile={isMobile}
/>
),
'ai-contents': renderPlaceholder(
'Conteúdos da IA',
'A IA está em fase de testes. O cadastro e a curadoria da base ficam centralizados no admin.',
),
audit: renderPlaceholder('Auditoria', 'Eventos do time supervisionado serao consolidados aqui.'),
attendance: (
<AdminAttendanceWorkspace
isWideDesktop={isWideDesktop}
isDesktop={isDesktop}
isTablet={isTablet}
isMobile={isMobile}
/>
),
'new-attendance': <NewAttendancePage embedded />,
'mass-message': (
<MassMessagePanel
areas={areas}
mode="supervisor"
managedAreaNames={managedSpecialties}
isMobile={isMobile}
/>
),
contacts: <ContactsPanel embedded />,
};
return (
<ManagementLayout
title="Painel do supervisor"
subtitle="Indicadores do dia, fila de espera e acompanhamento operacional do time."
activeSection="supervisor"
profileLabel={userDisplay.name}
initials={userDisplay.initials}
isDesktop={isDesktop}
isMobile={isMobile}
activeNavItem={activeSection}
onNavItemChange={setActiveSection}
>
{sectionContent[activeSection] || sectionContent.dashboard}
</ManagementLayout>
);
}

View File

@ -0,0 +1,66 @@
import React, { useEffect, useState } from 'react';
import { io } from 'socket.io-client';
import { API_BASE_URL, WHATSAPP_SOCKET_URL } from '../../../shared/services/apiConfig';
export const WhatsappAdminPage = () => {
const [qrCode, setQrCode] = useState(null);
const [status, setStatus] = useState('DISCONNECTED');
useEffect(() => {
// Conecta ao namespace /whatsapp
const socket = io(WHATSAPP_SOCKET_URL);
socket.on('connect', () => {
console.log('Connected to WhatsApp WebSocket');
fetch(`${API_BASE_URL}/whatsapp/status`)
.then((response) => response.json())
.then((data) => setStatus(data.status))
.catch(console.error);
});
socket.on('qr', (qrDataUrl) => {
setQrCode(qrDataUrl);
setStatus('AWAITING_QR');
});
socket.on('status', (newStatus) => {
setStatus(newStatus);
if (newStatus === 'CONNECTED') {
setQrCode(null);
}
});
return () => {
socket.disconnect();
};
}, []);
return (
<div className="p-8">
<h1 className="text-2xl font-bold mb-4">Configuração do WhatsApp</h1>
<div className="bg-white p-6 rounded-lg shadow-md max-w-md">
<h2 className="text-lg font-semibold mb-2">Status da Conexão: <span className={status === 'CONNECTED' ? 'text-green-600' : 'text-red-600'}>{status}</span></h2>
{status === 'AWAITING_QR' && qrCode && (
<div className="mt-4 flex flex-col items-center">
<p className="mb-2 text-gray-600">Escaneie o QR Code abaixo com seu WhatsApp:</p>
<img src={qrCode} alt="WhatsApp QR Code" className="border p-2 rounded" />
</div>
)}
{status === 'CONNECTED' && (
<div className="mt-4 p-4 bg-green-50 text-green-700 rounded border border-green-200">
O WhatsApp está conectado e pronto para uso!
</div>
)}
{status === 'DISCONNECTED' && (
<div className="mt-4 p-4 bg-yellow-50 text-yellow-700 rounded border border-yellow-200">
Aguardando inicialização do cliente WhatsApp no servidor...
</div>
)}
</div>
</div>
);
};

View File

@ -0,0 +1,97 @@
import { API_BASE_URL } from '../../../shared/services/apiConfig';
async function request(path, options = {}) {
const response = await fetch(`${API_BASE_URL}${path}`, {
headers: {
'Content-Type': 'application/json',
...options.headers,
},
...options,
});
if (!response.ok) {
let message = 'Falha ao consultar acessos';
try {
const payload = await response.json();
message = payload?.message || payload?.error || message;
} catch {
// Mantem mensagem padrao.
}
throw new Error(message);
}
return response.json();
}
export async function getAccessOptions() {
return request('/admin/access/options');
}
export async function getAdminOverview() {
return request('/admin/access/overview');
}
export async function getAttendantRanking(areaId) {
const query = areaId ? `?areaId=${encodeURIComponent(areaId)}` : '';
return request(`/admin/access/ranking${query}`);
}
export async function getAuditLogs(page = 1, limit = 100) {
return request(`/admin/access/audit?page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`);
}
export async function getAiContents() {
return request('/admin/access/ai-contents');
}
export async function getAiContentFile(id) {
return request(`/admin/access/ai-contents/${id}/file`);
}
export async function createAiContent(payload) {
return request('/admin/access/ai-contents', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export async function deleteAiContent(id) {
return request(`/admin/access/ai-contents/${id}`, {
method: 'DELETE',
});
}
export async function getAccessUsers() {
return request('/admin/access/users');
}
export async function getAccessAreas() {
return request('/admin/access/areas');
}
export async function updateUserAccess(userId, access) {
return request(`/admin/access/users/${userId}`, {
method: 'PUT',
body: JSON.stringify(access),
});
}
export async function createAccessArea(payload) {
return request('/admin/access/areas', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export async function updateAccessArea(areaId, payload) {
return request(`/admin/access/areas/${areaId}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
}
export async function deleteAccessArea(areaId) {
return request(`/admin/access/areas/${areaId}`, {
method: 'DELETE',
});
}

View File

@ -0,0 +1,124 @@
import { API_BASE_URL } from '../../../shared/services/apiConfig';
async function request(path, options = {}) {
const response = await fetch(`${API_BASE_URL}${path}`, {
headers: {
'Content-Type': 'application/json',
...options.headers,
},
...options,
});
if (!response.ok) {
let message = 'Falha ao consultar base de conhecimento.';
try {
const payload = await response.json();
message = Array.isArray(payload?.message)
? payload.message.join(' ')
: payload?.message || payload?.error || message;
} catch {
// Mantem a mensagem padrao quando a API nao devolve JSON.
}
throw new Error(message);
}
return response.json();
}
export function listRoutingKeywords(areaId) {
const query = areaId ? `?areaId=${encodeURIComponent(areaId)}` : '';
return request(`/admin/knowledge/routing-keywords${query}`);
}
export function getBotFlow() {
return request('/admin/knowledge/bot-flow');
}
export function listBotFlowVersions() {
return request('/admin/knowledge/bot-flow/versions');
}
export function createBotFlowNode(payload) {
return request('/admin/knowledge/bot-flow/nodes', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function updateBotFlowNode(id, payload) {
return request(`/admin/knowledge/bot-flow/nodes/${id}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
}
export function deleteBotFlowNode(id) {
return request(`/admin/knowledge/bot-flow/nodes/${id}`, {
method: 'DELETE',
});
}
export function publishBotFlow() {
return request('/admin/knowledge/bot-flow/publish', {
method: 'POST',
});
}
export function getTriageFlow() {
return request('/admin/knowledge/triage-flow');
}
export function updateTriageFlow(payload) {
return request('/admin/knowledge/triage-flow', {
method: 'PUT',
body: JSON.stringify(payload),
});
}
export function createTriageAudience(payload) {
return request('/admin/knowledge/triage-flow/audiences', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function updateTriageAudience(id, payload) {
return request(`/admin/knowledge/triage-flow/audiences/${id}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
}
export function createTriageIntent(payload) {
return request('/admin/knowledge/triage-flow/intents', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function updateTriageIntent(id, payload) {
return request(`/admin/knowledge/triage-flow/intents/${id}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
}
export function createRoutingKeyword(payload) {
return request('/admin/knowledge/routing-keywords', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function updateRoutingKeyword(id, payload) {
return request(`/admin/knowledge/routing-keywords/${id}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
}
export function deleteRoutingKeyword(id) {
return request(`/admin/knowledge/routing-keywords/${id}`, {
method: 'DELETE',
});
}

View File

@ -0,0 +1,106 @@
export const supervisorMetrics = [
{ label: 'Atendimentos abertos', value: '42', detail: '12 aguardando agente' },
{ label: 'SLA em risco', value: '7', detail: 'Financeiro concentra 4 casos' },
{ label: 'Agentes online', value: '18', detail: '3 em pausa operacional' },
{ label: 'Transferências hoje', value: '23', detail: 'Tempo médio 4m 20s' },
];
export const adminMetrics = [
{ label: 'Usuários ativos', value: '64', detail: '8 supervisores configurados' },
{ label: 'Especialidades cadastradas', value: '3', detail: 'Suporte, Financeiro e Comercial' },
{ label: 'Conteúdos IA', value: '28', detail: '6 aguardando revisão' },
{ label: 'Canais conectados', value: '1', detail: 'WhatsApp em homologação' },
];
export const areaRows = [
{
id: 'suporte',
name: 'Suporte',
owner: 'Marina Alves',
members: 22,
openTickets: 18,
status: 'Ativa',
},
{
id: 'financeiro',
name: 'Financeiro',
owner: 'Rafael Nunes',
members: 11,
openTickets: 9,
status: 'Ativa',
},
{
id: 'comercial',
name: 'Comercial',
owner: 'Camila Rocha',
members: 14,
openTickets: 15,
status: 'Ativa',
},
];
export const userRows = [
{
id: 'ana-camolesi',
name: 'Ana Camolesi',
email: 'ana.camolesi@sothis.com.br',
role: 'Agente',
area: 'Suporte',
status: 'Ativo',
},
{
id: 'marina-alves',
name: 'Marina Alves',
email: 'marina.alves@sothis.com.br',
role: 'Supervisor',
area: 'Suporte',
status: 'Ativo',
},
{
id: 'rafael-nunes',
name: 'Rafael Nunes',
email: 'rafael.nunes@sothis.com.br',
role: 'Supervisor',
area: 'Financeiro',
status: 'Ativo',
},
{
id: 'lucas-admin',
name: 'Lucas Admin',
email: 'lucas.admin@sothis.com.br',
role: 'Admin',
area: 'Todas',
status: 'Ativo',
},
];
export const queueRows = [
{ id: 'q1', customer: 'Maria Souza', channel: 'WhatsApp', area: 'Suporte', wait: '8 min', priority: 'Alta' },
{ id: 'q2', customer: 'Empresa Alpha', channel: 'Email', area: 'Comercial', wait: '14 min', priority: 'Media' },
{ id: 'q3', customer: 'Joao Pedro', channel: 'WhatsApp', area: 'Financeiro', wait: '5 min', priority: 'Alta' },
{ id: 'q4', customer: 'Grupo Solaris', channel: 'Voz', area: 'Comercial', wait: '2 min', priority: 'Normal' },
];
export const aiContentRows = [
{
id: 'c1',
title: 'Politica de segunda via de boleto',
area: 'Financeiro',
status: 'Publicado',
updatedAt: 'Hoje',
},
{
id: 'c2',
title: 'Passo a passo para troca de senha',
area: 'Suporte',
status: 'Rascunho',
updatedAt: 'Ontem',
},
{
id: 'c3',
title: 'Argumentario de proposta comercial',
area: 'Comercial',
status: 'Revisão',
updatedAt: 'Segunda',
},
];

View File

@ -0,0 +1,53 @@
import { API_BASE_URL } from '../../../shared/services/apiConfig';
async function request(path, options = {}) {
const response = await fetch(`${API_BASE_URL}${path}`, {
headers: {
'Content-Type': 'application/json',
...options.headers,
},
...options,
});
if (!response.ok) {
throw new Error('Falha ao consultar templates.');
}
return response.json();
}
export function listTemplates() {
return request('/whatsapp/templates');
}
export function saveTemplate(payload) {
return request('/whatsapp/templates', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function updateTemplate(id, payload) {
return request(`/whatsapp/templates/update/${id}`, {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function approveTemplateByAdmin(id) {
return request(`/whatsapp/templates/approve-admin/${id}`, {
method: 'POST',
});
}
export function rejectTemplateByAdmin(id) {
return request(`/whatsapp/templates/reject-admin/${id}`, {
method: 'POST',
});
}
export function deleteTemplate(id) {
return request(`/whatsapp/templates/${id}`, {
method: 'DELETE',
});
}

View File

@ -1,9 +1,12 @@
import { createBrowserRouter, Navigate } from 'react-router-dom';
import { LoginPage } from '../modules/auth/pages/LoginPage';
import { HomePage } from '../modules/home/pages/HomePage';
import { ProfileHomePage } from '../modules/home/pages/ProfileHomePage';
import { AgentMassMessagePage } from '../modules/home/pages/AgentMassMessagePage';
import { ContactsPage } from '../modules/home/pages/ContactsPage';
import { ChatPage } from '../modules/chat/pages/ChatPage';
import { CallPage } from '../modules/call/pages/CallPage';
import { NewAttendancePage } from '../modules/attendance/pages/NewAttendancePage';
import { AgentNewAttendancePage } from '../modules/attendance/pages/AgentNewAttendancePage';
import { WhatsappAdminPage } from '../modules/management/pages/WhatsappAdminPage';
export const router = createBrowserRouter([
{
@ -16,7 +19,7 @@ export const router = createBrowserRouter([
},
{
path: '/home',
element: <HomePage />,
element: <ProfileHomePage />,
},
{
path: '/chat',
@ -28,6 +31,18 @@ export const router = createBrowserRouter([
},
{
path: '/new-attendance',
element: <NewAttendancePage />,
element: <AgentNewAttendancePage />,
},
{
path: '/mass-message',
element: <AgentMassMessagePage />,
},
{
path: '/contacts',
element: <ContactsPage />,
},
{
path: '/admin/whatsapp',
element: <WhatsappAdminPage />,
},
]);

View File

@ -0,0 +1,59 @@
import { useEffect, useState, useRef } from 'react';
import io from 'socket.io-client';
import { API_BASE_URL, WHATSAPP_SOCKET_URL } from '../services/apiConfig';
export function useWhatsappSocket() {
const [socket, setSocket] = useState(null);
const [qrCode, setQrCode] = useState(null);
const [status, setStatus] = useState('DISCONNECTED');
const [incomingMessage, setIncomingMessage] = useState(null);
const socketRef = useRef(null);
useEffect(() => {
if (socketRef.current) return;
// Conectar ao namespace /whatsapp
const newSocket = io(WHATSAPP_SOCKET_URL, {
reconnectionAttempts: 5,
});
socketRef.current = newSocket;
setSocket(newSocket);
newSocket.on('connect', () => {
console.log('Conectado ao WebSocket do WhatsApp');
// Fetch status atual
fetch(`${API_BASE_URL}/whatsapp/status`)
.then(res => res.json())
.then(data => setStatus(data.status))
.catch(console.error);
});
newSocket.on('qr', (qr) => {
setQrCode(qr);
setStatus('AWAITING_QR');
});
newSocket.on('status', (newStatus) => {
setStatus(newStatus);
});
newSocket.on('message', (message) => {
console.log('Nova mensagem recebida:', message);
setIncomingMessage(message);
});
return () => {
newSocket.disconnect();
socketRef.current = null;
};
}, []);
return {
socket,
qrCode,
status,
incomingMessage,
clearIncomingMessage: () => setIncomingMessage(null),
};
}

View File

@ -0,0 +1,2 @@
export const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
export const WHATSAPP_SOCKET_URL = `${API_BASE_URL}/whatsapp`;