Compare commits

..

2 Commits

Author SHA1 Message Date
2229a29af1 FEAT: Ajusta home page do atendente
- Adicionado aba de comunicados e notas
- Alterado aba lateral para exibir apenas as opções de atendimento
- Removido arquivos de build do repositório
2026-05-18 19:11:01 -03:00
de1e4f518b WIP: Reconstrução e refatoração do frontend 2026-05-18 17:34:23 -03:00
37 changed files with 2954 additions and 329 deletions

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/

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.

90
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",
@ -1107,6 +1108,12 @@
"win32"
]
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@ -1260,7 +1267,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@ -1281,6 +1287,28 @@
"dev": true,
"license": "ISC"
},
"node_modules/engine.io-client": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz",
"integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.18.3",
"xmlhttprequest-ssl": "~2.1.1"
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
@ -1413,7 +1441,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
@ -1611,6 +1638,34 @@
"semver": "bin/semver.js"
}
},
"node_modules/socket.io-client": {
"version": "4.8.3",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser": {
"version": "4.2.6",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -1713,6 +1768,35 @@
}
}
},
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

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

@ -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 }}>Usuário 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('Nao foi possivel 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 única tela.
Conexão multiatendimento em um único lugar.
</h1>
<p
style={{
@ -91,7 +91,7 @@ export function LoginPage() {
}}
>
{[
{ label: 'Canais', value: 'WhatsApp, SMS e Voz' },
{ label: 'Canais', value: 'WhatsApp, SMS e E-mail' },
{ label: 'Fila', value: 'Distribuição rápida' },
{ label: 'UX', value: 'Padrão SaaS responsivo' },
].map((item) => (
@ -147,8 +147,7 @@ export function LoginPage() {
lineHeight: 1.6,
}}
>
Use seu usuário corporativo para acessar o MVP. A autenticação e mockada
nesta etapa e leva você diretamente para a dashboard principal.
Use seu usuario corporativo para acessar o MVP com Active Directory ou Microsoft.
</p>
</div>
</div>

View File

@ -1,11 +1,35 @@
const networkDelay = 450;
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
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 || 'Nao foi possivel 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

@ -1,4 +1,183 @@
import { useEffect, useRef } from 'react';
import { useEffect, useMemo, useRef } from 'react';
function getMediaUrl(media) {
if (!media?.data || !media?.mimetype) return '';
return `data:${media.mimetype};base64,${media.data}`;
}
function MediaRenderer({ message, contactId, onLoadMedia, isAgent }) {
const mediaUrl = useMemo(() => getMediaUrl(message.media), [message.media]);
const mimetype = message.media?.mimetype || '';
const filename = message.media?.filename || 'arquivo';
useEffect(() => {
if (!message.hasMedia || message.media?.data || message.mediaLoading || message.mediaError) {
return;
}
onLoadMedia?.(contactId, message.id);
}, [contactId, message, onLoadMedia]);
if (!message.hasMedia && !message.media) return null;
if (message.mediaLoading || (!message.media?.data && !message.mediaError)) {
return (
<div
style={{
width: 260,
maxWidth: '100%',
height: 150,
borderRadius: 14,
background: isAgent ? 'rgba(255,255,255,0.18)' : 'rgba(0,49,80,0.08)',
display: 'grid',
placeItems: 'center',
color: isAgent ? '#fff' : 'var(--color-text-soft)',
fontWeight: 700,
}}
>
Carregando midia...
</div>
);
}
if (message.mediaError) {
return (
<span style={{ color: isAgent ? '#fff' : 'var(--color-text-soft)', fontWeight: 700 }}>
Nao foi possivel carregar a midia.
</span>
);
}
if (mimetype.startsWith('image/')) {
return (
<a href={mediaUrl} target="_blank" rel="noreferrer" style={{ display: 'block' }}>
<img
src={mediaUrl}
alt={filename}
style={{
display: 'block',
width: 280,
maxWidth: '100%',
maxHeight: 340,
objectFit: 'cover',
borderRadius: 14,
boxShadow: '0 14px 30px rgba(0,0,0,0.18)',
transition: 'transform 160ms ease',
}}
onMouseEnter={(event) => {
event.currentTarget.style.transform = 'scale(1.015)';
}}
onMouseLeave={(event) => {
event.currentTarget.style.transform = 'scale(1)';
}}
/>
</a>
);
}
if (mimetype.startsWith('video/')) {
return (
<video
src={mediaUrl}
controls
style={{
width: 320,
maxWidth: '100%',
borderRadius: 14,
background: '#111',
}}
/>
);
}
if (mimetype.startsWith('audio/') || mimetype.includes('ogg')) {
return <audio src={mediaUrl} controls style={{ width: 280, maxWidth: '100%' }} />;
}
return (
<a
href={mediaUrl}
download={filename}
style={{
display: 'grid',
gridTemplateColumns: 'auto 1fr',
gap: '0.75rem',
alignItems: 'center',
padding: '0.85rem',
borderRadius: 14,
background: isAgent ? 'rgba(255,255,255,0.16)' : '#fff',
color: isAgent ? '#fff' : 'var(--color-primary)',
fontWeight: 700,
}}
>
<span aria-hidden="true">📄</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{filename}</span>
</a>
);
}
function AttachmentPreview({ file, onRemove }) {
if (!file) return null;
const mediaUrl = getMediaUrl({ data: file.data, mimetype: file.type });
return (
<div
style={{
border: '1px solid var(--color-border)',
borderRadius: 16,
padding: '0.75rem',
display: 'grid',
gridTemplateColumns: 'auto 1fr auto',
gap: '0.75rem',
alignItems: 'center',
background: '#fff',
}}
>
{file.type?.startsWith('image/') ? (
<img
src={mediaUrl}
alt={file.name}
style={{ width: 54, height: 54, objectFit: 'cover', borderRadius: 12 }}
/>
) : (
<span
style={{
width: 54,
height: 54,
borderRadius: 12,
display: 'grid',
placeItems: 'center',
background: 'rgba(0,49,80,0.08)',
}}
aria-hidden="true"
>
📎
</span>
)}
<div style={{ minWidth: 0 }}>
<strong style={{ display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{file.name}
</strong>
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.86rem' }}>{file.type || 'arquivo'}</span>
</div>
<button
type="button"
onClick={onRemove}
title="Remover anexo"
style={{
border: 'none',
borderRadius: 12,
width: 36,
height: 36,
background: 'rgba(214, 40, 40, 0.1)',
color: '#b42318',
fontWeight: 900,
}}
>
x
</button>
</div>
);
}
export function ChatWindow({
contact,
@ -7,6 +186,10 @@ export function ChatWindow({
setSelectedArea,
draft,
setDraft,
attachedFile,
onAttachFile,
onRemoveAttachedFile,
onLoadMedia,
onSend,
onToggleTransfer,
isReplying,
@ -141,13 +324,36 @@ export function ChatWindow({
background: isAgent ? 'var(--color-primary)' : '#edf1f5',
color: isAgent ? '#fff' : 'var(--color-text)',
boxShadow: 'var(--shadow-md)',
display: 'grid',
gap: '0.65rem',
}}
>
{message.text}
<MediaRenderer
message={message}
contactId={contact.id}
onLoadMedia={onLoadMedia}
isAgent={isAgent}
/>
{message.text ? <span>{message.text}</span> : null}
</div>
);
})}
{messages.length === 0 ? (
<div
style={{
justifySelf: 'center',
padding: '0.8rem 1rem',
borderRadius: 16,
background: 'rgba(0,49,80,0.06)',
color: 'var(--color-text-soft)',
fontWeight: 700,
}}
>
Nenhuma mensagem carregada.
</div>
) : null}
{isReplying ? (
<div
style={{
@ -169,42 +375,79 @@ export function ChatWindow({
padding: '1rem 1.25rem 1.25rem',
borderTop: '1px solid var(--color-border)',
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : '1fr auto',
gridTemplateColumns: '1fr',
gap: '0.75rem',
}}
>
<input
type="text"
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
onSend();
}
}}
placeholder="Escreva sua mensagem..."
<AttachmentPreview file={attachedFile} onRemove={onRemoveAttachedFile} />
<div
style={{
border: '1px solid var(--color-border)',
borderRadius: '18px',
padding: '0.95rem 1rem',
background: '#fff',
outline: 'none',
}}
/>
<button
type="button"
onClick={onSend}
style={{
border: 'none',
borderRadius: '18px',
padding: '0.95rem 1.2rem',
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
color: '#fff',
fontWeight: 700,
display: 'grid',
gridTemplateColumns: isMobile ? 'auto 1fr' : 'auto 1fr auto',
gap: '0.75rem',
alignItems: 'center',
}}
>
Enviar
</button>
<label
title="Anexar arquivo"
style={{
border: '1px solid var(--color-border)',
borderRadius: 16,
width: 48,
height: 48,
display: 'grid',
placeItems: 'center',
background: '#fff',
cursor: 'pointer',
fontWeight: 900,
}}
>
📎
<input
type="file"
accept="image/png,image/jpeg,image/jpg,image/webp,video/mp4,video/webm,audio/mp3,audio/mpeg,audio/ogg,audio/wav,application/pdf"
onChange={(event) => {
onAttachFile?.(event.target.files?.[0]);
event.target.value = '';
}}
style={{ display: 'none' }}
/>
</label>
<input
type="text"
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
onSend();
}
}}
placeholder="Escreva sua mensagem..."
style={{
border: '1px solid var(--color-border)',
borderRadius: '18px',
padding: '0.95rem 1rem',
background: '#fff',
outline: 'none',
minWidth: 0,
}}
/>
<button
type="button"
onClick={onSend}
style={{
border: 'none',
borderRadius: '18px',
padding: '0.95rem 1.2rem',
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
color: '#fff',
fontWeight: 700,
gridColumn: isMobile ? '1 / -1' : 'auto',
}}
>
Enviar
</button>
</div>
</footer>
</section>
);

View File

@ -1,11 +1,13 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useWhatsappSocket } from '../../../shared/hooks/useWhatsappSocket';
import {
attendantsByArea,
chatContacts,
getMockReply,
transferAreas,
} from '../services/chatMocks';
const API_BASE_URL = 'http://localhost:3001';
function buildInitialMessages() {
return chatContacts.reduce((acc, contact) => {
acc[contact.id] = contact.messages;
@ -13,18 +15,95 @@ function buildInitialMessages() {
}, {});
}
function getSerializedId(value) {
if (!value) return '';
if (typeof value === 'string') return value;
return value._serialized || `${value.user || ''}@${value.server || 'c.us'}`;
}
function formatTime(timestamp) {
if (!timestamp) return '';
const date = new Date(timestamp * 1000);
return date.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
}
function getContactName(chat) {
const serializedId = getSerializedId(chat.id);
return chat.name || chat.pushname || serializedId.split('@')[0] || 'Contato';
}
function getPreviewFromMessage(message) {
if (message?.body) return message.body;
if (message?.text) return message.text;
if (message?.hasMedia || message?.media) return '[Midia]';
return '';
}
function normalizeChat(chat) {
const id = getSerializedId(chat.id);
return {
id,
name: getContactName(chat),
channel: 'WhatsApp',
status: 'online',
area: chat.assignment?.area_id ? String(chat.assignment.area_id) : 'Suporte',
lastSeen: chat.timestamp ? `Visto as ${formatTime(chat.timestamp)}` : 'Online agora',
preview: chat.preview || chat.lastMessage?.body || '',
time: formatTime(chat.timestamp) || 'Agora',
unread: chat.unreadCount || 0,
assignment: chat.assignment || null,
};
}
function normalizeMessage(message) {
const id = getSerializedId(message.id) || message.id || `msg-${Date.now()}`;
const sender = message.sender || (message.fromMe ? 'agent' : 'customer');
return {
id,
chatId: message.from || message.to || message.chatId,
sender,
text: message.body ?? message.text ?? '',
timestamp: message.timestamp,
hasMedia: Boolean(message.hasMedia || message.media),
media: message.media || null,
mediaLoading: false,
mediaError: null,
};
}
function fileToBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = String(reader.result || '');
resolve(result.includes(',') ? result.split(',')[1] : result);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
function buildFallbackContacts() {
return chatContacts.map((contact) => ({ ...contact }));
}
export function useChat() {
const [contacts, setContacts] = useState(chatContacts);
const { incomingMessage, clearIncomingMessage } = useWhatsappSocket();
const [contacts, setContacts] = useState(buildFallbackContacts);
const [activeContactId, setActiveContactId] = useState(chatContacts[0].id);
const [messagesByContact, setMessagesByContact] = useState(buildInitialMessages);
const [draft, setDraft] = useState('');
const [attachedFile, setAttachedFile] = useState(null);
const [selectedArea, setSelectedArea] = useState(chatContacts[0].area);
const [isTransferOpen, setIsTransferOpen] = useState(false);
const [transferArea, setTransferArea] = useState('Suporte');
const [transferAttendant, setTransferAttendant] = useState(attendantsByArea.Suporte[0]);
const [transferNote, setTransferNote] = useState('');
const [isReplying, setIsReplying] = useState(false);
const replyTimeoutRef = useRef(null);
const [isReplying] = useState(false);
const [isLoadingChats, setIsLoadingChats] = useState(false);
const [isLoadingMessages, setIsLoadingMessages] = useState(false);
const [apiError, setApiError] = useState(null);
const activeContactRef = useRef(activeContactId);
const activeContact = useMemo(
() => contacts.find((contact) => contact.id === activeContactId) || contacts[0],
@ -43,61 +122,223 @@ export function useChat() {
}, [transferArea]);
useEffect(() => {
return () => {
if (replyTimeoutRef.current) {
window.clearTimeout(replyTimeoutRef.current);
activeContactRef.current = activeContactId;
}, [activeContactId]);
useEffect(() => {
let isMounted = true;
async function loadChats() {
setIsLoadingChats(true);
try {
const response = await fetch(`${API_BASE_URL}/whatsapp/chats`);
if (!response.ok) throw new Error('Falha ao carregar chats do WhatsApp.');
const data = await response.json();
if (!isMounted || !Array.isArray(data) || data.length === 0) return;
const nextContacts = data.map(normalizeChat);
setContacts(nextContacts);
setActiveContactId((current) =>
nextContacts.some((contact) => contact.id === current) ? current : nextContacts[0].id,
);
setApiError(null);
} catch (error) {
if (isMounted) setApiError(error.message);
} finally {
if (isMounted) setIsLoadingChats(false);
}
}
loadChats();
const intervalId = window.setInterval(loadChats, 30000);
return () => {
isMounted = false;
window.clearInterval(intervalId);
};
}, []);
function updateContactPreview(contactId, preview) {
useEffect(() => {
if (!activeContactId) return;
let isMounted = true;
async function loadMessages() {
if (!activeContactId.includes('@')) return;
setIsLoadingMessages(true);
try {
const response = await fetch(`${API_BASE_URL}/whatsapp/messages/${encodeURIComponent(activeContactId)}`);
if (!response.ok) throw new Error('Falha ao carregar mensagens do WhatsApp.');
const data = await response.json();
if (!isMounted || !Array.isArray(data)) return;
setMessagesByContact((current) => ({
...current,
[activeContactId]: data.map((message) => ({
...normalizeMessage(message),
chatId: activeContactId,
})),
}));
setApiError(null);
} catch (error) {
if (isMounted) setApiError(error.message);
} finally {
if (isMounted) setIsLoadingMessages(false);
}
}
loadMessages();
return () => {
isMounted = false;
};
}, [activeContactId]);
useEffect(() => {
if (!incomingMessage) return;
const contactId = incomingMessage.from || incomingMessage.to || incomingMessage.chatId;
if (!contactId) return;
const message = {
...normalizeMessage(incomingMessage),
chatId: contactId,
};
const preview = getPreviewFromMessage(message);
setMessagesByContact((current) => {
const currentMessages = current[contactId] || [];
if (currentMessages.some((item) => item.id === message.id)) return current;
return {
...current,
[contactId]: [...currentMessages, message],
};
});
setContacts((current) => {
const existing = current.find((contact) => contact.id === contactId);
const nextContact = {
...(existing || {
id: contactId,
name: incomingMessage.notifyName || contactId.split('@')[0],
channel: 'WhatsApp',
status: 'online',
area: 'Suporte',
lastSeen: 'Online agora',
unread: 0,
}),
preview,
time: 'Agora',
unread:
incomingMessage.fromMe || contactId === activeContactRef.current
? 0
: (existing?.unread || 0) + 1,
};
return [nextContact, ...current.filter((contact) => contact.id !== contactId)];
});
clearIncomingMessage();
}, [incomingMessage, clearIncomingMessage]);
function updateContactPreview(contactId, preview, media) {
setContacts((current) =>
current.map((contact) =>
contact.id === contactId ? { ...contact, preview, time: 'Agora', unread: 0 } : contact,
contact.id === contactId
? { ...contact, preview: media ? `[Midia: ${media.filename || 'Arquivo'}]` : preview, time: 'Agora', unread: 0 }
: contact,
),
);
}
function sendMessage() {
async function attachFile(file) {
if (!file) return;
const data = await fileToBase64(file);
setAttachedFile({
name: file.name,
type: file.type || 'application/octet-stream',
data,
});
}
function removeAttachedFile() {
setAttachedFile(null);
}
async function hydrateMessageMedia(contactId, messageId) {
if (!contactId || !messageId) return;
setMessagesByContact((current) => ({
...current,
[contactId]: (current[contactId] || []).map((message) =>
message.id === messageId ? { ...message, mediaLoading: true, mediaError: null } : message,
),
}));
try {
const response = await fetch(
`${API_BASE_URL}/whatsapp/media/${encodeURIComponent(contactId)}/${encodeURIComponent(messageId)}`,
);
if (!response.ok) throw new Error('Falha ao carregar midia.');
const media = await response.json();
setMessagesByContact((current) => ({
...current,
[contactId]: (current[contactId] || []).map((message) =>
message.id === messageId ? { ...message, media, mediaLoading: false } : message,
),
}));
} catch (error) {
setMessagesByContact((current) => ({
...current,
[contactId]: (current[contactId] || []).map((message) =>
message.id === messageId
? { ...message, mediaLoading: false, mediaError: error.message || 'Erro ao carregar midia.' }
: message,
),
}));
}
}
async function sendMessage() {
const trimmed = draft.trim();
if (!trimmed) {
if (!trimmed && !attachedFile) {
return;
}
const media = attachedFile
? {
data: attachedFile.data,
mimetype: attachedFile.type,
filename: attachedFile.name,
}
: null;
const newMessage = {
id: Date.now(),
id: `temp-${Date.now()}`,
chatId: activeContactId,
sender: 'agent',
text: trimmed,
hasMedia: Boolean(media),
media,
};
setMessagesByContact((current) => ({
...current,
[activeContactId]: [...(current[activeContactId] || []), newMessage],
}));
updateContactPreview(activeContactId, trimmed);
updateContactPreview(activeContactId, trimmed || '[Midia]', media);
setDraft('');
setIsReplying(true);
setAttachedFile(null);
replyTimeoutRef.current = window.setTimeout(() => {
const reply = {
id: Date.now() + 1,
sender: 'customer',
text: getMockReply(activeContact.name),
};
if (!activeContactId.includes('@')) return;
setMessagesByContact((current) => ({
...current,
[activeContactId]: [...(current[activeContactId] || []), reply],
}));
setContacts((current) =>
current.map((contact) =>
contact.id === activeContactId
? { ...contact, preview: reply.text, time: 'Agora', unread: contact.unread + 1 }
: contact,
),
);
setIsReplying(false);
}, 1400);
try {
await fetch(`${API_BASE_URL}/whatsapp/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
to: activeContactId,
message: trimmed,
media,
}),
});
setApiError(null);
} catch (error) {
setApiError(error.message);
}
}
function submitTransfer() {
@ -132,8 +373,15 @@ export function useChat() {
messages,
draft,
setDraft,
attachedFile,
attachFile,
removeAttachedFile,
sendMessage,
hydrateMessageMedia,
isReplying,
isLoadingChats,
isLoadingMessages,
apiError,
selectedArea,
setSelectedArea,
isTransferOpen,

View File

@ -17,7 +17,11 @@ export function ChatPage() {
messages,
draft,
setDraft,
attachedFile,
attachFile,
removeAttachedFile,
sendMessage,
hydrateMessageMedia,
isReplying,
selectedArea,
setSelectedArea,
@ -117,6 +121,10 @@ export function ChatPage() {
setSelectedArea={setSelectedArea}
draft={draft}
setDraft={setDraft}
attachedFile={attachedFile}
onAttachFile={attachFile}
onRemoveAttachedFile={removeAttachedFile}
onLoadMedia={hydrateMessageMedia}
onSend={sendMessage}
onToggleTransfer={() => setIsTransferOpen((current) => !current)}
isReplying={isReplying}

View File

@ -0,0 +1,120 @@
import { useState, useEffect } from 'react';
export function AttendantOpsPanel({ activeChatsCount }) {
const [isPaused, setIsPaused] = useState(false);
const [secondsOnline, setSecondsOnline] = useState(0);
useEffect(() => {
let interval;
if (!isPaused) {
interval = setInterval(() => {
setSecondsOnline((s) => s + 1);
}, 1000);
}
return () => clearInterval(interval);
}, [isPaused]);
const formatTime = (totalSeconds) => {
const h = Math.floor(totalSeconds / 3600);
const m = Math.floor((totalSeconds % 3600) / 60);
const s = totalSeconds % 60;
return [h, m, s]
.map(v => v.toString().padStart(2, '0'))
.filter((v, i) => v !== '00' || i > 0)
.join(':');
};
return (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: '1rem',
}}
>
<article
style={{
padding: '1.25rem',
borderRadius: '24px',
border: '1px solid var(--color-border)',
background: 'linear-gradient(145deg, #ffffff, #f8fafc)',
boxShadow: 'var(--shadow-sm)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<div>
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.9rem', fontWeight: 600 }}>
Tempo Online
</span>
<strong style={{ display: 'block', fontSize: '1.6rem', marginTop: '0.2rem', color: 'var(--color-text)' }}>
{formatTime(secondsOnline)}
</strong>
</div>
<div style={{
width: '12px',
height: '12px',
borderRadius: '50%',
background: isPaused ? '#ef4444' : '#10b981',
boxShadow: `0 0 10px ${isPaused ? '#ef4444' : '#10b981'}`,
animation: !isPaused ? 'pulse 2s infinite' : 'none'
}} />
</article>
<article
style={{
padding: '1.25rem',
borderRadius: '24px',
border: '1px solid var(--color-border)',
background: 'linear-gradient(145deg, #ffffff, #f8fafc)',
boxShadow: 'var(--shadow-sm)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<div>
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.9rem', fontWeight: 600 }}>
Atendimentos Abertos
</span>
<strong style={{ display: 'block', fontSize: '1.6rem', marginTop: '0.2rem', color: 'var(--color-text)' }}>
{activeChatsCount}
</strong>
</div>
</article>
<article
style={{
padding: '1.25rem',
borderRadius: '24px',
border: '1px solid var(--color-border)',
background: 'linear-gradient(145deg, #ffffff, #f8fafc)',
boxShadow: 'var(--shadow-sm)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<button
onClick={() => setIsPaused(!isPaused)}
style={{
width: '100%',
height: '100%',
padding: '1rem',
borderRadius: '16px',
border: 'none',
background: isPaused ? 'rgba(16, 185, 129, 0.1)' : 'rgba(239, 68, 68, 0.1)',
color: isPaused ? '#10b981' : '#ef4444',
fontSize: '1rem',
fontWeight: 700,
cursor: 'pointer',
transition: 'all 0.2s ease',
}}
>
{isPaused ? '▶ Retomar Atendimento' : '⏸ Pausar'}
</button>
</article>
</div>
);
}

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();
@ -28,7 +29,7 @@ export function HomeSidebar({ items, activeItem, isMobile = false }) {
textAlign: 'left',
}}
>
+ Novo Atendimento
Abrir atendimento
</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: 'Ligações' },
{ id: 'calls', label: 'Ligacoes' },
];
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 março
{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,5 +1,8 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
const WORKSPACE_HEIGHT = 660;
function ChannelBadge({ channel }) {
const colors = {
WhatsApp: '#2bb741',
@ -25,28 +28,145 @@ function ChannelBadge({ channel }) {
);
}
function buildSuggestedReplies(conversation) {
const lastMessage = conversation?.lastMessage || conversation?.messages?.at(-1)?.text || '';
const firstName = conversation?.name?.split(' ')?.[0] || 'voce';
const lowerContext = lastMessage.toLowerCase();
if (
lowerContext.includes('fatura') ||
lowerContext.includes('cobranca') ||
lowerContext.includes('pagamento')
) {
return [
`${firstName}, vou conferir os dados financeiros e ja te retorno com a posicao correta.`,
'Recebi sua mensagem sobre cobranca. Vou validar o historico antes de seguir com a orientacao.',
'Consigo te ajudar com isso. Pode me confirmar o CPF/CNPJ ou protocolo vinculado ao atendimento?',
];
}
if (
lowerContext.includes('endereco') ||
lowerContext.includes('cadastro') ||
lowerContext.includes('atualizar')
) {
return [
`${firstName}, vou validar seu cadastro e confirmar se a alteracao ja foi registrada.`,
'Para seguir com a atualizacao, me confirme por favor os dados que precisam ser ajustados.',
'Entendi. Vou verificar o cadastro atual e te retorno com o proximo passo.',
];
}
if (
lowerContext.includes('ligar') ||
lowerContext.includes('telefone') ||
lowerContext.includes('retorno')
) {
return [
`${firstName}, consigo organizar esse retorno. Qual o melhor horario para contato?`,
'Vou registrar sua solicitacao e direcionar o retorno para o time responsavel.',
'Obrigado pelo aviso. Vou confirmar disponibilidade e te retorno por aqui.',
];
}
return [
`${firstName}, recebi sua mensagem e vou verificar o contexto para te orientar corretamente.`,
'Entendi. Vou analisar as informacoes do atendimento e retorno com o melhor encaminhamento.',
'Posso acionar o time responsavel e te atualizar por aqui assim que tiver uma posicao.',
];
}
export function MessagesWorkspace({
conversations,
activeConversationId,
onSelectConversation,
actionItems,
isWideDesktop = false,
isDesktop = false,
isTablet = false,
isMobile = false,
}) {
const navigate = useNavigate();
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(() => {
try {
return JSON.parse(window.localStorage.getItem('agentNotes') || '[]');
} catch {
return [];
}
});
const selectedReply = suggestedReplies[selectedReplyIndex] || suggestedReplies[0];
const managerMessages = [
{
id: 'sla',
title: 'Comunicado do supervisor',
text: 'Priorizar atendimentos com SLA abaixo de 15 minutos antes de abrir novos casos.',
},
{
id: 'script',
title: 'Atualizacao de script',
text: 'Use o novo roteiro de confirmacao de dados em atendimentos financeiros.',
},
];
useEffect(() => {
setSelectedReplyIndex(0);
}, [safeActiveConversation.id]);
useEffect(() => {
window.localStorage.setItem('agentNotes', JSON.stringify(notes));
}, [notes]);
function selectPreviousReply() {
setSelectedReplyIndex((current) =>
current === 0 ? suggestedReplies.length - 1 : current - 1,
);
}
function selectNextReply() {
setSelectedReplyIndex((current) => (current + 1) % suggestedReplies.length);
}
function saveNote() {
const text = noteDraft.trim();
if (!text) return;
setNotes((current) => [
{
id: Date.now(),
text,
time: new Date().toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }),
},
...current,
]);
setNoteDraft('');
}
const gridTemplateColumns = isMobile
? '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 +174,7 @@ export function MessagesWorkspace({
display: 'grid',
gridTemplateColumns,
gap: '1rem',
alignItems: 'start',
alignItems: 'stretch',
}}
>
<section
@ -65,18 +185,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.
Ultimos 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
@ -123,6 +245,23 @@ export function MessagesWorkspace({
</button>
);
})}
{conversations.length > 3 ? (
<button
type="button"
onClick={() => navigate('/chat')}
style={{
border: '1px solid var(--color-border)',
borderRadius: '16px',
padding: '0.85rem 1rem',
background: '#fff',
color: 'var(--color-primary)',
fontWeight: 700,
}}
>
Ver todos no chat
</button>
) : null}
</section>
<section
@ -131,8 +270,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,9 +288,11 @@ 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.status === 'online' ? 'Online agora' : 'Offline'}
</span>
</div>
<div style={{ display: 'flex', gap: '0.6rem', flexWrap: 'wrap' }}>
@ -190,11 +332,12 @@ export function MessagesWorkspace({
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) => {
const isAgent = message.from === 'agent';
return (
@ -218,37 +361,72 @@ export function MessagesWorkspace({
<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"
style={{
border: '1px solid var(--color-border)',
borderRadius: '14px',
background: '#fff',
color: 'var(--color-primary)',
fontWeight: 900,
}}
>
</button>
<button
type="button"
onClick={() => navigate('/chat')}
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',
}}
>
{selectedReply}
</button>
<button
type="button"
onClick={selectNextReply}
title="Proxima resposta"
style={{
border: '1px solid var(--color-border)',
borderRadius: '14px',
background: '#fff',
color: 'var(--color-primary)',
fontWeight: 900,
}}
>
</button>
</div>
</footer>
</section>
@ -259,49 +437,102 @@ 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 ações</strong>
<p style={{ margin: '0.35rem 0 0', color: 'var(--color-text-soft)' }}>
Contexto rápido 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 }}>Anotacao rapida</span>
<textarea
value={noteDraft}
onChange={(event) => setNoteDraft(event.target.value)}
placeholder="Ex: cliente pediu retorno apos as 15h"
rows={4}
style={{
border: '1px solid var(--color-border)',
borderRadius: '14px',
padding: '0.85rem 0.9rem',
background: '#fff',
color: 'var(--color-text)',
resize: 'none',
outline: 'none',
lineHeight: 1.45,
}}
/>
</label>
<button
type="button"
onClick={saveNote}
style={{
border: 'none',
borderRadius: '18px',
padding: '0.95rem 1rem',
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
color: '#fff',
fontWeight: 800,
}}
>
Salvar anotacao
</button>
<div style={{ display: 'grid', gap: '0.55rem' }}>
{notes.length ? (
notes.map((note) => (
<article
key={note.id}
style={{
border: '1px solid var(--color-border)',
borderRadius: '16px',
padding: '0.8rem',
background: '#fff',
}}
>
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.82rem' }}>
{note.time}
</span>
<p style={{ margin: '0.35rem 0 0', lineHeight: 1.45 }}>{note.text}</p>
</article>
))
) : (
<span style={{ color: 'var(--color-text-soft)' }}>Nenhuma anotacao salva.</span>
)}
</div>
</div>
</aside>
</div>
);

View File

@ -4,14 +4,43 @@ import { HomeSidebar } from '../components/HomeSidebar';
import { HomeTopbar } from '../components/HomeTopbar';
import { MessagesWorkspace } from '../components/MessagesWorkspace';
import { CallsWorkspace } from '../components/CallsWorkspace';
import { actionItems, conversations, recentCalls, sidebarItems } from '../services/homeMocks';
import { AttendantOpsPanel } from '../components/AttendantOpsPanel';
import { recentCalls, sidebarItems } from '../services/homeMocks';
import { useViewport } from '../../../shared/hooks/useViewport';
import { useChat } from '../../chat/hooks/useChat';
function toHomeConversation(contact, messages = []) {
return {
id: contact.id,
name: contact.name,
channel: contact.channel || 'WhatsApp',
status: contact.status || 'online',
lastMessage: contact.preview || messages[messages.length - 1]?.text || '',
unread: contact.unread || 0,
time: contact.time || 'Agora',
messages: messages.map((message) => ({
id: message.id,
from: message.sender === 'agent' ? 'agent' : 'customer',
text: message.text || (message.hasMedia ? '[Midia]' : ''),
})),
};
}
export function HomePage() {
const { isWideDesktop, isDesktop, isTablet, isMobile } = useViewport();
const {
contacts,
activeContactId,
setActiveContactId,
messages,
isLoadingChats,
} = useChat();
const [activeTab, setActiveTab] = useState('messages');
const [searchValue, setSearchValue] = useState('');
const [activeConversationId, setActiveConversationId] = useState(conversations[0].id);
const conversations = contacts.map((contact) =>
toHomeConversation(contact, contact.id === activeContactId ? messages : []),
);
const search = searchValue.trim().toLowerCase();
const filteredConversations = !search
@ -22,9 +51,9 @@ export function HomePage() {
});
const safeConversationId =
filteredConversations.find((conversation) => conversation.id === activeConversationId)?.id ||
filteredConversations.find((conversation) => conversation.id === activeContactId)?.id ||
filteredConversations[0]?.id ||
conversations[0].id;
conversations[0]?.id;
return (
<main
@ -90,46 +119,28 @@ export function HomePage() {
gap: '1rem',
}}
>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
gap: '1rem',
}}
>
{[
{ label: 'Atendimentos ativos', value: '18', detail: '7 aguardando retorno' },
{ label: 'Primeira resposta', value: '2m 14s', detail: 'Dentro do SLA' },
{ label: 'Fila de voz', value: '4 chamadas', detail: '1 prioridade alta' },
].map((item) => (
<article
key={item.label}
style={{
padding: '1.15rem',
borderRadius: '22px',
border: '1px solid var(--color-border)',
background: '#fff',
}}
>
<span style={{ color: 'var(--color-text-soft)', display: 'block' }}>
{item.label}
</span>
<strong style={{ display: 'block', fontSize: '1.4rem', marginTop: '0.45rem' }}>
{item.value}
</strong>
<span style={{ color: 'var(--color-text-soft)', display: 'block', marginTop: '0.45rem' }}>
{item.detail}
</span>
</article>
))}
</div>
<AttendantOpsPanel activeChatsCount={filteredConversations.length} />
{isLoadingChats ? (
<div
style={{
border: '1px solid var(--color-border)',
borderRadius: 18,
padding: '0.85rem 1rem',
background: '#fff',
color: 'var(--color-text-soft)',
fontWeight: 700,
}}
>
Atualizando conversas do WhatsApp...
</div>
) : null}
{activeTab === 'messages' ? (
<MessagesWorkspace
conversations={filteredConversations}
activeConversationId={safeConversationId}
onSelectConversation={setActiveConversationId}
actionItems={actionItems}
onSelectConversation={setActiveContactId}
isWideDesktop={isWideDesktop}
isDesktop={isDesktop}
isTablet={isTablet}

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 configuracao
</span>
<h1 style={{ margin: 0, fontSize: '2rem' }}>Seu usuario ainda nao tem atribuicoes</h1>
<p style={{ margin: 0, color: 'var(--color-text-soft)', lineHeight: 1.7 }}>
O login foi realizado, mas um administrador ainda precisa vincular seu usuario a um
perfil de acesso e a uma area operacional antes de liberar a plataforma.
</p>
</div>
<div
style={{
borderRadius: '24px',
background: 'rgba(0, 49, 80, 0.04)',
padding: '1.25rem',
display: 'grid',
gap: '0.65rem',
}}
>
<span style={{ color: 'var(--color-text-soft)' }}>Usuario autenticado</span>
<strong>{user?.name || user?.username || 'Usuario'}</strong>
<span style={{ color: 'var(--color-text-soft)' }}>
{user?.email || user?.username || 'Sem email informado'}
</span>
</div>
<button
type="button"
onClick={handleLogout}
style={{
border: 'none',
borderRadius: '18px',
padding: '0.95rem 1.15rem',
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
color: '#fff',
fontWeight: 800,
width: 'fit-content',
}}
>
Sair
</button>
</section>
</main>
);
}

View File

@ -1,9 +1,8 @@
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 },
{ id: 'scripts', label: 'Scripts e respostas prontas' },
{ id: 'personal-reports', label: 'Relatorios pessoais' },
{ id: 'mass-message', label: 'Disparo em massa' },
{ id: 'knowledge-base', label: 'Base de conhecimento' },
];
export const conversations = [

View File

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

View File

@ -0,0 +1,241 @@
import { useNavigate } from 'react-router-dom';
import { BrandMark } from '../../../shared/components/BrandMark';
import { clearSession } from '../../auth/services/sessionService';
const navigationBySection = {
supervisor: [
{ id: 'dashboard', label: 'Dashboard', count: null },
{ id: 'queues', label: 'Filas em tempo real', count: 42 },
{ id: 'areas', label: 'Areas supervisionadas', count: 3 },
{ id: 'agents', label: 'Agentes online', count: 18 },
{ id: 'reports', label: 'Relatorios', count: null },
],
admin: [
{ id: 'dashboard', label: 'Dashboard', count: null },
{ id: 'users', label: 'Usuarios e acessos', count: 64 },
{ id: 'areas', label: 'Areas', count: 3 },
{ id: 'knowledge', label: 'Conteudo para IA', count: 28 },
{ id: 'channels', label: 'Canais', count: 1 },
{ id: 'audit', label: 'Auditoria', count: null },
],
};
const actionLabelBySection = {
supervisor: '+ Redistribuir atendimento',
admin: '+ Nova configuracao',
};
export function ManagementLayout({
title,
subtitle,
activeSection,
profileLabel,
initials,
children,
isDesktop,
isMobile,
}) {
const navigate = useNavigate();
const navItems = navigationBySection[activeSection] || navigationBySection.supervisor;
const actionLabel = actionLabelBySection[activeSection] || '+ Nova acao';
function handleLogout() {
clearSession();
navigate('/login', { replace: true });
}
return (
<main style={{ minHeight: '100vh', padding: '1.5rem' }}>
<section
style={{
width: 'min(1680px, calc(100vw - 3rem))',
margin: '0 auto',
background: 'var(--color-surface-strong)',
borderRadius: '32px',
boxShadow: 'var(--shadow-lg)',
padding: '1.5rem',
display: 'grid',
gap: '1.5rem',
}}
>
<div
style={{
display: 'grid',
gridTemplateColumns: isDesktop ? 'minmax(300px, 360px) minmax(0, 1fr)' : '1fr',
gap: '1.5rem',
alignItems: 'start',
}}
>
<div style={{ display: 'grid', gap: '1.25rem' }}>
<div
style={{
background: '#fff',
border: '1px solid var(--color-border)',
borderRadius: '28px',
padding: '1.5rem',
}}
>
<BrandMark size="lg" />
</div>
<aside
style={{
background: 'linear-gradient(180deg, rgba(0, 49, 80, 0.98), rgba(7, 64, 98, 0.96))',
color: '#fff',
borderRadius: '28px',
padding: '1.5rem',
display: 'grid',
gap: '1.25rem',
alignContent: 'start',
}}
>
<button
type="button"
onClick={() => navigate('/home')}
style={{
border: 'none',
borderRadius: '20px',
padding: '1rem 1.15rem',
background: 'linear-gradient(135deg, var(--color-highlight), #f3b94d)',
color: '#132534',
fontWeight: 800,
textAlign: 'left',
}}
>
{actionLabel}
</button>
<nav
style={{
display: 'grid',
gap: '0.5rem',
gridTemplateColumns: isMobile ? 'repeat(auto-fit, minmax(180px, 1fr))' : '1fr',
}}
>
{navItems.map((item, index) => {
const isActive = index === 0;
return (
<button
key={item.id}
type="button"
style={{
border: 'none',
borderRadius: '18px',
padding: '0.9rem 1rem',
background: isActive ? 'rgba(255, 255, 255, 0.14)' : 'transparent',
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
fontWeight: isActive ? 700 : 500,
width: '100%',
textAlign: 'left',
}}
>
<span>{item.label}</span>
{item.count ? (
<span
style={{
minWidth: 30,
borderRadius: 999,
padding: '0.2rem 0.5rem',
background: 'rgba(255, 255, 255, 0.12)',
fontSize: '0.82rem',
textAlign: 'center',
}}
>
{item.count}
</span>
) : null}
</button>
);
})}
</nav>
<button
type="button"
onClick={handleLogout}
style={{
border: '1px solid rgba(255, 255, 255, 0.18)',
borderRadius: '18px',
padding: '0.9rem 1rem',
background: 'transparent',
color: '#fff',
fontWeight: 700,
textAlign: 'left',
}}
>
Sair
</button>
</aside>
</div>
<div style={{ display: 'grid', gap: '1.25rem', minWidth: 0 }}>
<header
style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 1fr) auto',
gap: '1rem',
alignItems: 'center',
}}
>
<div
style={{
padding: '1.1rem 1.25rem',
borderRadius: '22px',
background: '#fff',
border: '1px solid var(--color-border)',
minWidth: 0,
}}
>
<h1 style={{ margin: 0, fontSize: '1.65rem' }}>{title}</h1>
<p style={{ margin: '0.45rem 0 0', color: 'var(--color-text-soft)' }}>
{subtitle}
</p>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.9rem',
justifySelf: isMobile ? 'stretch' : 'end',
justifyContent: isMobile ? 'space-between' : 'flex-end',
padding: '0.85rem 1rem',
borderRadius: '22px',
background: '#fff',
border: '1px solid var(--color-border)',
}}
>
<div style={{ textAlign: 'right' }}>
<strong style={{ display: 'block' }}>{profileLabel}</strong>
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.92rem' }}>
Ambiente de gestao
</span>
</div>
<div
aria-hidden="true"
style={{
width: 48,
height: 48,
borderRadius: '16px',
display: 'grid',
placeItems: 'center',
background: 'linear-gradient(135deg, var(--color-accent), var(--color-primary))',
color: '#fff',
fontWeight: 800,
}}
>
{initials}
</div>
</div>
</header>
{children}
</div>
</div>
</section>
</main>
);
}

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

View File

@ -0,0 +1,272 @@
import { useEffect, useMemo, useState } from 'react';
import { DataPanel } from '../components/DataPanel';
import { ManagementLayout } from '../components/ManagementLayout';
import { ManagementTable } from '../components/ManagementTable';
import { MetricGrid } from '../components/MetricGrid';
import { adminMetrics, aiContentRows, areaRows, userRows } from '../services/managementMocks';
import { getAccessOptions, getAccessUsers, updateUserAccess } from '../services/adminAccessService';
import { useViewport } from '../../../shared/hooks/useViewport';
import { getCurrentUserDisplay } from '../../auth/services/sessionService';
const areaColumns = [
{ key: 'name', label: 'Area' },
{ key: 'owner', label: 'Responsavel' },
{ key: 'members', label: 'Usuarios' },
{ key: 'status', label: 'Status' },
];
const contentColumns = [
{ key: 'title', label: 'Conteudo' },
{ key: 'area', label: 'Area' },
{ key: 'status', label: 'Status' },
{ key: 'updatedAt', label: 'Atualizado' },
];
const selectStyle = {
width: '100%',
border: '1px solid var(--color-border)',
borderRadius: '14px',
padding: '0.75rem 0.85rem',
background: '#fff',
color: 'var(--color-text)',
fontWeight: 600,
};
function mapMockUsers() {
return userRows.map((user) => ({
id: user.id,
nome: user.name,
email: user.email,
perfilPrincipal: { id: user.role, nome: user.role },
areaPrincipal: { id: user.area, nome: user.area },
accessStatus: 'assigned',
}));
}
export function AdminPage() {
const { isDesktop, isMobile } = useViewport();
const userDisplay = getCurrentUserDisplay();
const [users, setUsers] = useState(mapMockUsers);
const [profiles, setProfiles] = useState([]);
const [areas, setAreas] = useState([]);
const [isLoadingAccess, setIsLoadingAccess] = useState(true);
const [accessError, setAccessError] = useState('');
useEffect(() => {
let isMounted = true;
async function loadAccessData() {
try {
const [options, accessUsers] = await Promise.all([getAccessOptions(), getAccessUsers()]);
if (!isMounted) {
return;
}
setProfiles(options.profiles || []);
setAreas(options.areas || []);
setUsers(accessUsers || []);
setAccessError('');
} catch {
if (isMounted) {
setAccessError('Backend indisponivel. Exibindo dados demonstrativos.');
}
} finally {
if (isMounted) {
setIsLoadingAccess(false);
}
}
}
loadAccessData();
return () => {
isMounted = false;
};
}, []);
async function handleAccessChange(user, field, value) {
const currentPerfilId = user.perfilPrincipal?.id || null;
const currentAreaId = user.areaPrincipal?.id || null;
const nextAccess = {
perfilId: field === 'perfil' ? Number(value) || null : currentPerfilId,
areaId: field === 'area' ? Number(value) || null : currentAreaId,
};
setUsers((current) =>
current.map((item) =>
item.id === user.id
? {
...item,
perfilPrincipal:
profiles.find((profile) => profile.id === nextAccess.perfilId) || null,
areaPrincipal: areas.find((area) => area.id === nextAccess.areaId) || null,
accessStatus: nextAccess.perfilId && nextAccess.areaId ? 'assigned' : 'unassigned',
}
: item,
),
);
try {
const updatedUser = await updateUserAccess(user.id, nextAccess);
if (updatedUser) {
setUsers((current) =>
current.map((item) => (item.id === updatedUser.id ? updatedUser : item)),
);
}
setAccessError('');
} catch {
setAccessError('Nao foi possivel salvar a atribuicao. Confira o backend.');
}
}
const userColumns = useMemo(
() => [
{
key: 'nome',
label: 'Usuario',
render: (row) => (
<div>
<strong style={{ display: 'block' }}>{row.nome}</strong>
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.9rem' }}>
{row.email || 'Sem email'}
</span>
</div>
),
},
{
key: 'perfil',
label: 'Perfil',
render: (row) =>
profiles.length ? (
<select
value={row.perfilPrincipal?.id || ''}
onChange={(event) => handleAccessChange(row, 'perfil', event.target.value)}
style={selectStyle}
>
<option value="">Sem perfil</option>
{profiles.map((profile) => (
<option key={profile.id} value={profile.id}>
{profile.nome}
</option>
))}
</select>
) : (
<span>{row.perfilPrincipal?.nome || 'Sem perfil'}</span>
),
},
{
key: 'area',
label: 'Area',
render: (row) =>
areas.length ? (
<select
value={row.areaPrincipal?.id || ''}
onChange={(event) => handleAccessChange(row, 'area', event.target.value)}
style={selectStyle}
>
<option value="">Sem area</option>
{areas.map((area) => (
<option key={area.id} value={area.id}>
{area.nome}
</option>
))}
</select>
) : (
<span>{row.areaPrincipal?.nome || 'Sem area'}</span>
),
},
{
key: 'status',
label: 'Status',
render: (row) => {
const isAssigned = row.accessStatus === 'assigned';
return (
<span
style={{
width: 'fit-content',
borderRadius: 999,
padding: '0.25rem 0.6rem',
background: isAssigned ? 'rgba(0, 164, 183, 0.1)' : 'rgba(229, 162, 42, 0.16)',
color: isAssigned ? 'var(--color-primary)' : '#8a5a00',
fontWeight: 700,
}}
>
{isAssigned ? 'Atribuido' : 'Pendente'}
</span>
);
},
},
],
[areas, profiles],
);
return (
<ManagementLayout
title="Painel administrativo"
subtitle="Controle de usuarios, perfis, areas e base de conteudo para IA."
activeSection="admin"
profileLabel={userDisplay.name}
initials={userDisplay.initials}
isDesktop={isDesktop}
isMobile={isMobile}
>
<MetricGrid metrics={adminMetrics} />
<div
style={{
display: 'grid',
gridTemplateColumns: isDesktop ? 'minmax(0, 1.2fr) minmax(320px, 0.8fr)' : '1fr',
gap: '1rem',
alignItems: 'start',
}}
>
<DataPanel
title="Usuarios e niveis de acesso"
description={
isLoadingAccess
? 'Carregando usuarios do banco...'
: accessError || 'Gerencie perfil e area principal dos usuarios autenticados.'
}
actionLabel="Adicionar usuario"
>
<ManagementTable
columns={userColumns}
rows={users}
getRowId={(row) => row.id}
isMobile={isMobile}
/>
</DataPanel>
<DataPanel
title="Areas"
description="Areas operacionais e seus responsaveis."
actionLabel="Nova area"
>
<ManagementTable
columns={areaColumns}
rows={areaRows}
getRowId={(row) => row.id}
isMobile={isMobile}
/>
</DataPanel>
</div>
<DataPanel
title="Conteudo para IA"
description="Entradas mockadas para alimentar a base de conhecimento."
actionLabel="Adicionar conteudo"
>
<ManagementTable
columns={contentColumns}
rows={aiContentRows}
getRowId={(row) => row.id}
isMobile={isMobile}
/>
</DataPanel>
</ManagementLayout>
);
}

View File

@ -0,0 +1,322 @@
import { useEffect, useState } from 'react';
import { DataPanel } from '../components/DataPanel';
import { ManagementLayout } from '../components/ManagementLayout';
import { ManagementTable } from '../components/ManagementTable';
import { MetricGrid } from '../components/MetricGrid';
import { areaRows, queueRows, supervisorMetrics } from '../services/managementMocks';
import { useViewport } from '../../../shared/hooks/useViewport';
import { getCurrentUserDisplay } from '../../auth/services/sessionService';
const queueColumns = [
{ key: 'customer', label: 'Cliente' },
{ key: 'channel', label: 'Canal' },
{ key: 'area', label: 'Area' },
{ key: 'wait', label: 'Espera' },
{
key: 'priority',
label: 'Prioridade',
render: (row) => (
<span
style={{
width: 'fit-content',
borderRadius: 999,
padding: '0.25rem 0.6rem',
background: row.priority === 'Alta' ? 'rgba(181, 31, 31, 0.1)' : 'rgba(0, 49, 80, 0.08)',
color: row.priority === 'Alta' ? 'var(--color-secondary)' : 'var(--color-primary)',
fontWeight: 700,
}}
>
{row.priority}
</span>
),
},
];
const areaColumns = [
{ key: 'name', label: 'Area' },
{ key: 'owner', label: 'Responsavel' },
{ key: 'members', label: 'Usuarios' },
{ key: 'openTickets', label: 'Abertos' },
{ key: 'status', label: 'Status' },
];
export function SupervisorPage() {
const { isDesktop, isMobile } = useViewport();
const userDisplay = getCurrentUserDisplay();
const [templates, setTemplates] = useState([]);
const [editingTemplate, setEditingTemplate] = useState(null);
const [editName, setEditName] = useState('');
const [editContent, setEditContent] = useState('');
const [saveStatus, setSaveStatus] = useState('');
const fetchTemplates = async () => {
try {
const res = await fetch('http://localhost:3001/whatsapp/templates');
if (res.ok) {
const data = await res.json();
setTemplates(data);
}
} catch (err) {
console.error(err);
}
};
useEffect(() => {
fetchTemplates();
}, []);
const handleEdit = (tpl) => {
setEditingTemplate(tpl);
setEditName(tpl.name);
setEditContent(tpl.content);
setSaveStatus('');
};
const handleSave = async (e) => {
e.preventDefault();
if (!editName || !editContent) return;
try {
const url = editingTemplate
? `http://localhost:3001/whatsapp/templates/update/${editingTemplate.id}`
: 'http://localhost:3001/whatsapp/templates';
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: editName, content: editContent }),
});
if (res.ok) {
setSaveStatus('Salvo com sucesso!');
setEditingTemplate(null);
setEditName('');
setEditContent('');
fetchTemplates();
setTimeout(() => setSaveStatus(''), 3000);
} else {
setSaveStatus('Erro ao salvar template.');
}
} catch (err) {
console.error(err);
setSaveStatus('Erro ao salvar template.');
}
};
return (
<ManagementLayout
title="Painel do supervisor"
subtitle="Acompanhamento operacional das filas, areas e distribuicao de atendimento."
activeSection="supervisor"
profileLabel={userDisplay.name}
initials={userDisplay.initials}
isDesktop={isDesktop}
isMobile={isMobile}
>
<MetricGrid metrics={supervisorMetrics} />
<div
style={{
display: 'grid',
gridTemplateColumns: isDesktop ? 'minmax(0, 1.35fr) minmax(320px, 0.85fr)' : '1fr',
gap: '1rem',
alignItems: 'start',
}}
>
<DataPanel
title="Fila em tempo real"
description="Mock da visao que depois sera alimentada pelos atendimentos reais."
actionLabel="Redistribuir fila"
>
<ManagementTable
columns={queueColumns}
rows={queueRows}
getRowId={(row) => row.id}
isMobile={isMobile}
/>
</DataPanel>
<DataPanel
title="Areas supervisionadas"
description="Resumo operacional por area."
actionLabel="Ver detalhes"
>
<ManagementTable
columns={areaColumns}
rows={areaRows}
getRowId={(row) => row.id}
isMobile={isMobile}
/>
</DataPanel>
</div>
<div style={{ marginTop: '1.5rem' }}>
<DataPanel
title="Homologador de Templates WhatsApp (Meta)"
description="Gerencie os modelos de primeiro contato pré-aprovados pela Meta para uso dos atendentes."
>
<div style={{ display: 'grid', gridTemplateColumns: isDesktop ? '1.2fr 0.8fr' : '1fr', gap: '1.5rem', padding: '0.5rem 0' }}>
<div style={{ display: 'grid', gap: '0.75rem' }}>
{templates.map((tpl) => (
<div
key={tpl.id}
style={{
border: '1px solid var(--color-border)',
borderRadius: '16px',
padding: '1rem',
background: '#fff',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
gap: '0.6rem',
boxShadow: 'var(--shadow-sm)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<strong style={{ fontSize: '0.96rem', color: 'var(--color-primary)', textTransform: 'uppercase', letterSpacing: '0.02em' }}>
{tpl.name}
</strong>
<span style={{ fontSize: '0.75rem', fontWeight: 700, padding: '0.2rem 0.5rem', borderRadius: '999px', background: 'rgba(34, 197, 94, 0.1)', color: '#16a34a' }}>
Homologado Meta
</span>
</div>
<p style={{ margin: 0, color: 'var(--color-text-soft)', fontSize: '0.88rem', lineHeight: 1.5 }}>
{tpl.content}
</p>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '0.25rem' }}>
<button
type="button"
onClick={() => handleEdit(tpl)}
style={{
border: 'none',
background: 'rgba(0, 49, 80, 0.08)',
color: 'var(--color-primary)',
padding: '0.45rem 0.85rem',
borderRadius: '10px',
fontSize: '0.82rem',
fontWeight: 700,
cursor: 'pointer',
transition: 'background 0.2s',
}}
>
Editar Modelo
</button>
</div>
</div>
))}
</div>
<form
onSubmit={handleSave}
style={{
border: '1px solid var(--color-border)',
borderRadius: '20px',
padding: '1.25rem',
background: '#f8fafc',
display: 'grid',
gap: '1rem',
height: 'fit-content',
}}
>
<strong style={{ fontSize: '1.05rem', display: 'block', borderBottom: '1px solid var(--color-border)', paddingBottom: '0.5rem', color: 'var(--color-text)' }}>
{editingTemplate ? `Editar Template #${editingTemplate.id}` : 'Criar Novo Template'}
</strong>
{saveStatus && (
<div style={{
padding: '0.65rem 0.85rem',
borderRadius: '12px',
background: saveStatus.includes('sucesso') ? 'rgba(34, 197, 94, 0.12)' : 'rgba(239, 68, 68, 0.12)',
color: saveStatus.includes('sucesso') ? '#16a34a' : '#ef4444',
fontWeight: 700,
fontSize: '0.85rem',
}}>
{saveStatus}
</div>
)}
<label style={{ display: 'grid', gap: '0.35rem', color: 'var(--color-text)' }}>
<span style={{ fontSize: '0.84rem', fontWeight: 600 }}>Identificador Único (Nome)</span>
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
placeholder="ex: aviso_fatura"
style={{
border: '1px solid var(--color-border)',
borderRadius: '12px',
padding: '0.75rem 0.85rem',
background: '#fff',
outline: 'none',
fontSize: '0.88rem',
color: 'var(--color-text)',
}}
required
/>
</label>
<label style={{ display: 'grid', gap: '0.35rem', color: 'var(--color-text)' }}>
<span style={{ fontSize: '0.84rem', fontWeight: 600 }}>Mensagem do Template</span>
<textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
placeholder="Use placeholders como {nome}, {data} ou {protocolo}..."
rows={4}
style={{
border: '1px solid var(--color-border)',
borderRadius: '12px',
padding: '0.75rem 0.85rem',
background: '#fff',
outline: 'none',
fontSize: '0.88rem',
resize: 'none',
lineHeight: 1.5,
color: 'var(--color-text)',
}}
required
/>
</label>
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end', marginTop: '0.5rem' }}>
{editingTemplate && (
<button
type="button"
onClick={() => {
setEditingTemplate(null);
setEditName('');
setEditContent('');
}}
style={{
border: 'none',
background: 'rgba(239, 68, 68, 0.08)',
color: '#ef4444',
padding: '0.65rem 1rem',
borderRadius: '12px',
fontSize: '0.85rem',
fontWeight: 700,
cursor: 'pointer',
}}
>
Cancelar
</button>
)}
<button
type="submit"
style={{
border: 'none',
background: 'var(--color-primary)',
color: '#fff',
padding: '0.65rem 1rem',
borderRadius: '12px',
fontSize: '0.85rem',
fontWeight: 700,
cursor: 'pointer',
}}
>
{editingTemplate ? 'Atualizar Modelo' : 'Criar Modelo'}
</button>
</div>
</form>
</div>
</DataPanel>
</div>
</ManagementLayout>
);
}

View File

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

View File

@ -0,0 +1,33 @@
const API_BASE_URL =
import.meta.env.VITE_API_BASE_URL || import.meta.env.VITE_API_URL || 'http://localhost:3001';
async function request(path, options = {}) {
const response = await fetch(`${API_BASE_URL}${path}`, {
headers: {
'Content-Type': 'application/json',
...options.headers,
},
...options,
});
if (!response.ok) {
throw new Error('Falha ao consultar acessos');
}
return response.json();
}
export async function getAccessOptions() {
return request('/admin/access/options');
}
export async function getAccessUsers() {
return request('/admin/access/users');
}
export async function updateUserAccess(userId, access) {
return request(`/admin/access/users/${userId}`, {
method: 'PUT',
body: JSON.stringify(access),
});
}

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: 'Transferencias hoje', value: '23', detail: 'Tempo medio 4m 20s' },
];
export const adminMetrics = [
{ label: 'Usuarios ativos', value: '64', detail: '8 supervisores configurados' },
{ label: 'Areas cadastradas', value: '3', detail: 'Suporte, Financeiro e Comercial' },
{ label: 'Conteudos IA', value: '28', detail: '6 aguardando revisao' },
{ label: 'Canais conectados', value: '1', detail: 'WhatsApp em homologacao' },
];
export const areaRows = [
{
id: 'suporte',
name: 'Suporte',
owner: 'Marina Alves',
members: 22,
openTickets: 18,
status: 'Ativa',
},
{
id: 'financeiro',
name: 'Financeiro',
owner: 'Rafael Nunes',
members: 11,
openTickets: 9,
status: 'Ativa',
},
{
id: 'comercial',
name: 'Comercial',
owner: 'Camila Rocha',
members: 14,
openTickets: 15,
status: 'Ativa',
},
];
export const userRows = [
{
id: 'ana-camolesi',
name: 'Ana Camolesi',
email: 'ana.camolesi@sothis.com.br',
role: 'Agente',
area: 'Suporte',
status: 'Ativo',
},
{
id: 'marina-alves',
name: 'Marina Alves',
email: 'marina.alves@sothis.com.br',
role: 'Supervisor',
area: 'Suporte',
status: 'Ativo',
},
{
id: 'rafael-nunes',
name: 'Rafael Nunes',
email: 'rafael.nunes@sothis.com.br',
role: 'Supervisor',
area: 'Financeiro',
status: 'Ativo',
},
{
id: 'lucas-admin',
name: 'Lucas Admin',
email: 'lucas.admin@sothis.com.br',
role: 'Admin',
area: 'Todas',
status: 'Ativo',
},
];
export const queueRows = [
{ id: 'q1', customer: 'Maria Souza', channel: 'WhatsApp', area: 'Suporte', wait: '8 min', priority: 'Alta' },
{ id: 'q2', customer: 'Empresa Alpha', channel: 'Email', area: 'Comercial', wait: '14 min', priority: 'Media' },
{ id: 'q3', customer: 'Joao Pedro', channel: 'WhatsApp', area: 'Financeiro', wait: '5 min', priority: 'Alta' },
{ id: 'q4', customer: 'Grupo Solaris', channel: 'Voz', area: 'Comercial', wait: '2 min', priority: 'Normal' },
];
export const aiContentRows = [
{
id: 'c1',
title: 'Politica de segunda via de boleto',
area: 'Financeiro',
status: 'Publicado',
updatedAt: 'Hoje',
},
{
id: 'c2',
title: 'Passo a passo para troca de senha',
area: 'Suporte',
status: 'Rascunho',
updatedAt: 'Ontem',
},
{
id: 'c3',
title: 'Argumentario de proposta comercial',
area: 'Comercial',
status: 'Revisao',
updatedAt: 'Segunda',
},
];

View File

@ -1,9 +1,10 @@
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 { ChatPage } from '../modules/chat/pages/ChatPage';
import { CallPage } from '../modules/call/pages/CallPage';
import { NewAttendancePage } from '../modules/attendance/pages/NewAttendancePage';
import { WhatsappAdminPage } from '../modules/management/pages/WhatsappAdminPage';
export const router = createBrowserRouter([
{
@ -16,7 +17,7 @@ export const router = createBrowserRouter([
},
{
path: '/home',
element: <HomePage />,
element: <ProfileHomePage />,
},
{
path: '/chat',
@ -30,4 +31,8 @@ export const router = createBrowserRouter([
path: '/new-attendance',
element: <NewAttendancePage />,
},
{
path: '/admin/whatsapp',
element: <WhatsappAdminPage />,
},
]);

View File

@ -0,0 +1,66 @@
import { useEffect, useState, useRef } from 'react';
import io from 'socket.io-client';
export function useWhatsappSocket() {
const [socket, setSocket] = useState(null);
const [qrCode, setQrCode] = useState(null);
const [status, setStatus] = useState('DISCONNECTED');
const [incomingMessage, setIncomingMessage] = useState(null);
const [presenceUpdate, setPresenceUpdate] = useState(null);
const socketRef = useRef(null);
useEffect(() => {
if (socketRef.current) return;
// Conectar ao namespace /whatsapp
const newSocket = io('http://localhost:3001/whatsapp', {
reconnectionAttempts: 5,
});
socketRef.current = newSocket;
setSocket(newSocket);
newSocket.on('connect', () => {
console.log('Conectado ao WebSocket do WhatsApp');
// Fetch status atual
fetch('http://localhost:3001/whatsapp/status')
.then(res => res.json())
.then(data => setStatus(data.status))
.catch(console.error);
});
newSocket.on('qr', (qr) => {
setQrCode(qr);
setStatus('AWAITING_QR');
});
newSocket.on('status', (newStatus) => {
setStatus(newStatus);
});
newSocket.on('message', (message) => {
console.log('Nova mensagem recebida:', message);
setIncomingMessage(message);
});
newSocket.on('presence', (presence) => {
console.log('Atualização de presença:', presence);
setPresenceUpdate(presence);
});
return () => {
newSocket.disconnect();
socketRef.current = null;
};
}, []);
return {
socket,
qrCode,
status,
incomingMessage,
presenceUpdate,
clearIncomingMessage: () => setIncomingMessage(null),
clearPresenceUpdate: () => setPresenceUpdate(null)
};
}