WIP: Reconstrução e refatoração do frontend

This commit is contained in:
Rafael Alves Lopes 2026-05-18 17:34:23 -03:00
parent 3f0ca83430
commit de1e4f518b
24 changed files with 2436 additions and 109 deletions

3
.gitignore vendored
View File

@ -1 +1,4 @@
node_modules
dist
.env.development
.env.production

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

@ -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,10 +375,44 @@ export function ChatWindow({
padding: '1rem 1.25rem 1.25rem',
borderTop: '1px solid var(--color-border)',
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : '1fr auto',
gridTemplateColumns: '1fr',
gap: '0.75rem',
}}
>
<AttachmentPreview file={attachedFile} onRemove={onRemoveAttachedFile} />
<div
style={{
display: 'grid',
gridTemplateColumns: isMobile ? 'auto 1fr' : 'auto 1fr auto',
gap: '0.75rem',
alignItems: 'center',
}}
>
<label
title="Anexar arquivo"
style={{
border: '1px solid var(--color-border)',
borderRadius: 16,
width: 48,
height: 48,
display: 'grid',
placeItems: 'center',
background: '#fff',
cursor: 'pointer',
fontWeight: 900,
}}
>
📎
<input
type="file"
accept="image/png,image/jpeg,image/jpg,image/webp,video/mp4,video/webm,audio/mp3,audio/mpeg,audio/ogg,audio/wav,application/pdf"
onChange={(event) => {
onAttachFile?.(event.target.files?.[0]);
event.target.value = '';
}}
style={{ display: 'none' }}
/>
</label>
<input
type="text"
value={draft}
@ -189,6 +429,7 @@ export function ChatWindow({
padding: '0.95rem 1rem',
background: '#fff',
outline: 'none',
minWidth: 0,
}}
/>
<button
@ -201,10 +442,12 @@ export function ChatWindow({
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, getCurrentUserProfile } from '../../auth/services/sessionService';
export function HomeSidebar({ items, activeItem, isMobile = false }) {
const navigate = useNavigate();
@ -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

@ -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 { actionItems, 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,45 +119,28 @@ export function HomePage() {
gap: '1rem',
}}
>
<AttendantOpsPanel activeChatsCount={filteredConversations.length} />
{isLoadingChats ? (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
gap: '1rem',
}}
>
{[
{ label: 'Atendimentos ativos', value: '18', detail: '7 aguardando retorno' },
{ label: 'Primeira resposta', value: '2m 14s', detail: 'Dentro do SLA' },
{ label: 'Fila de voz', value: '4 chamadas', detail: '1 prioridade alta' },
].map((item) => (
<article
key={item.label}
style={{
padding: '1.15rem',
borderRadius: '22px',
border: '1px solid var(--color-border)',
borderRadius: 18,
padding: '0.85rem 1rem',
background: '#fff',
color: 'var(--color-text-soft)',
fontWeight: 700,
}}
>
<span style={{ color: 'var(--color-text-soft)', display: 'block' }}>
{item.label}
</span>
<strong style={{ display: 'block', fontSize: '1.4rem', marginTop: '0.45rem' }}>
{item.value}
</strong>
<span style={{ color: 'var(--color-text-soft)', display: 'block', marginTop: '0.45rem' }}>
{item.detail}
</span>
</article>
))}
Atualizando conversas do WhatsApp...
</div>
) : null}
{activeTab === 'messages' ? (
<MessagesWorkspace
conversations={filteredConversations}
activeConversationId={safeConversationId}
onSelectConversation={setActiveConversationId}
onSelectConversation={setActiveContactId}
actionItems={actionItems}
isWideDesktop={isWideDesktop}
isDesktop={isDesktop}

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

@ -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(activeSection === 'admin' ? '/admin' : '/supervisor')}
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,270 @@
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';
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 [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="Lucas Admin"
initials="LA"
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,320 @@
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';
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 [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="Marina Alves"
initials="MA"
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,61 @@
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');
});
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

@ -4,6 +4,20 @@ import { HomePage } from '../modules/home/pages/HomePage';
import { ChatPage } from '../modules/chat/pages/ChatPage';
import { CallPage } from '../modules/call/pages/CallPage';
import { NewAttendancePage } from '../modules/attendance/pages/NewAttendancePage';
import { AdminPage } from '../modules/management/pages/AdminPage';
import { SupervisorPage } from '../modules/management/pages/SupervisorPage';
import { getCurrentUserProfile } from '../modules/auth/services/sessionService';
function HomeRouter() {
const profile = getCurrentUserProfile();
if (profile === 'admin') {
return <AdminPage />;
}
if (profile === 'supervisor') {
return <SupervisorPage />;
}
return <HomePage />;
}
export const router = createBrowserRouter([
{
@ -16,7 +30,7 @@ export const router = createBrowserRouter([
},
{
path: '/home',
element: <HomePage />,
element: <HomeRouter />,
},
{
path: '/chat',

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)
};
}