Initial commit
- Telas iniciais do projeto criadas - Estrutura de pastas e arquivos definida - Componentes instalados e linguagem definida - Vite configurado para React e build de dev rapida - Mockups de dados criados para desenvolvimento dos módulos - Documentação inicial criada para guiar o desenvolvimento e uso do projeto
This commit is contained in:
commit
8e29dde2a1
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
node_modules
|
||||||
12
Dockerfile
Normal file
12
Dockerfile
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
FROM node:lts
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "3000"]
|
||||||
BIN
dist/assets/favicon_blue-CzkOczz3.png
vendored
Normal file
BIN
dist/assets/favicon_blue-CzkOczz3.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.8 KiB |
68
dist/assets/index-1xjqdjIG.js
vendored
Normal file
68
dist/assets/index-1xjqdjIG.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/assets/index-BsY34Fgu.css
vendored
Normal file
1
dist/assets/index-BsY34Fgu.css
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
:root{font-family:Segoe UI,Helvetica Neue,sans-serif;color:#122230;background:radial-gradient(circle at top left,rgba(0,164,183,.12),transparent 28%),radial-gradient(circle at bottom right,rgba(229,162,42,.14),transparent 24%),#f5f8fb;color-scheme:light;--color-primary: #003150;--color-secondary: #b51f1f;--color-accent: #00a4b7;--color-highlight: #e5a22a;--color-surface: rgba(255, 255, 255, .9);--color-surface-strong: #ffffff;--color-text: #122230;--color-text-soft: #5e6d7b;--color-border: rgba(0, 49, 80, .12);--shadow-lg: 0 24px 60px rgba(0, 49, 80, .12);--shadow-md: 0 12px 28px rgba(0, 49, 80, .08)}*{box-sizing:border-box}html,body,#root{min-height:100%;margin:0}body{min-height:100vh}body,button,input{font:inherit}button{cursor:pointer}a{color:inherit;text-decoration:none}
|
||||||
BIN
dist/assets/logo_white_dark_mode-BKcVSu03.png
vendored
Normal file
BIN
dist/assets/logo_white_dark_mode-BKcVSu03.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
BIN
dist/assets/logo_white_mode-BIHgqUPv.png
vendored
Normal file
BIN
dist/assets/logo_white_mode-BIHgqUPv.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
14
dist/index.html
vendored
Normal file
14
dist/index.html
vendored
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="pt-BR">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" type="image/png" href="/assets/favicon_blue-CzkOczz3.png" />
|
||||||
|
<title>Omnichannel Sothis</title>
|
||||||
|
<script type="module" crossorigin src="/assets/index-1xjqdjIG.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-BsY34Fgu.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
13
docs/README.md
Normal file
13
docs/README.md
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
# Documentação do Frontend
|
||||||
|
|
||||||
|
Esta pasta reúne a documentação funcional e conceitual do frontend MVP Omnichannel.
|
||||||
|
|
||||||
|
## Índice
|
||||||
|
|
||||||
|
- [Visão Geral](./visao-geral.md)
|
||||||
|
- [Módulo Auth / Login](./modulo-auth.md)
|
||||||
|
- [Módulo Home / Dashboard](./modulo-home.md)
|
||||||
|
- [Módulo Chat](./modulo-chat.md)
|
||||||
|
- [Módulo Call](./modulo-call.md)
|
||||||
|
- [Módulo Attendance / Novo Atendimento](./modulo-attendance.md)
|
||||||
|
- [Casos de Uso em Formato RPG](./casos-de-uso-rpg.md)
|
||||||
105
docs/casos-de-uso-rpg.md
Normal file
105
docs/casos-de-uso-rpg.md
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
# Casos de Uso em Formato RPG
|
||||||
|
|
||||||
|
## Introdução
|
||||||
|
|
||||||
|
Imagine o reino de **Sharvus**, onde toda vila, fortaleza e guilda depende de mensagens rápidas para manter ordem, comércio e confiança com seus cidadãos.
|
||||||
|
|
||||||
|
No centro desse reino existe a fortaleza da **Ordem de Sothis**, onde trabalham os guerreiros do suporte, os mensageiros do comercial e os guardiões do financeiro.
|
||||||
|
|
||||||
|
Cada atendimento é uma missão.
|
||||||
|
|
||||||
|
Cada cliente é um personagem importante.
|
||||||
|
|
||||||
|
Cada tela do sistema é uma parte da jornada.
|
||||||
|
|
||||||
|
## O Herói
|
||||||
|
|
||||||
|
Nosso herói é **Aren**, um guerreiro de suporte da Ordem de Sothis.
|
||||||
|
|
||||||
|
Sua missão não é derrotar monstros, mas resolver problemas antes que eles virem caos no reino.
|
||||||
|
|
||||||
|
Para isso, ele usa o grande portal chamado **Omnichannel**.
|
||||||
|
|
||||||
|
## Capítulo 1: O Portal de Entrada
|
||||||
|
|
||||||
|
Aren chega ao salão principal e encontra o **Portal de Login**.
|
||||||
|
|
||||||
|
Ali ele:
|
||||||
|
|
||||||
|
- informa suas credenciais
|
||||||
|
- entra no sistema
|
||||||
|
- acessa o centro de comando
|
||||||
|
|
||||||
|
Na prática, este é o caso de uso de autenticação visual do módulo `auth`.
|
||||||
|
|
||||||
|
## Capítulo 2: O Mapa da Operação
|
||||||
|
|
||||||
|
Ao entrar, Aren vê o grande mapa do reino: a **Home / Dashboard**.
|
||||||
|
|
||||||
|
Nesse mapa ele consegue:
|
||||||
|
|
||||||
|
- ver conversas ativas
|
||||||
|
- trocar entre mensagens e ligações
|
||||||
|
- buscar contatos
|
||||||
|
- iniciar novas missões
|
||||||
|
|
||||||
|
Na prática, este é o caso de uso central do módulo `home`.
|
||||||
|
|
||||||
|
## Capítulo 3: A Mensagem do Cidadão
|
||||||
|
|
||||||
|
Uma cidadã chamada **Maria Souza** envia um pedido urgente por WhatsApp.
|
||||||
|
|
||||||
|
Aren abre a conversa no módulo `chat` e pode:
|
||||||
|
|
||||||
|
- ler o histórico
|
||||||
|
- responder rapidamente
|
||||||
|
- acompanhar novas mensagens
|
||||||
|
- transferir o caso para outra guilda, como Financeiro ou Comercial
|
||||||
|
|
||||||
|
Na prática, este módulo representa o caso de uso de atendimento textual em tempo real.
|
||||||
|
|
||||||
|
## Capítulo 4: O Chamado por Voz
|
||||||
|
|
||||||
|
Nem toda missão pode ser resolvida por pergaminhos e mensagens.
|
||||||
|
|
||||||
|
Às vezes, o cidadão precisa ouvir a voz de alguém da Ordem.
|
||||||
|
|
||||||
|
Então Aren inicia uma ligação no módulo `call`, onde ele:
|
||||||
|
|
||||||
|
- visualiza quem está na chamada
|
||||||
|
- acompanha o tempo da conversa
|
||||||
|
- usa controles de chamada
|
||||||
|
- encerra o contato quando a missão termina
|
||||||
|
|
||||||
|
Na prática, este módulo representa o caso de uso de atendimento por voz.
|
||||||
|
|
||||||
|
## Capítulo 5: A Missão Começa Aqui
|
||||||
|
|
||||||
|
Antes de qualquer conversa, Aren pode abrir o módulo `attendance` para iniciar uma nova missão.
|
||||||
|
|
||||||
|
Ele escolhe:
|
||||||
|
|
||||||
|
- quem será atendido
|
||||||
|
- qual canal usar
|
||||||
|
- para qual área o caso deve ir
|
||||||
|
|
||||||
|
Depois disso:
|
||||||
|
|
||||||
|
- se for mensagem, ele segue para o chat
|
||||||
|
- se for voz, ele segue para a chamada
|
||||||
|
|
||||||
|
Na prática, este módulo representa o caso de uso de abertura rápida de atendimento.
|
||||||
|
|
||||||
|
## Moral da História
|
||||||
|
|
||||||
|
O Omnichannel é a mesa de guerra de Sharvus.
|
||||||
|
|
||||||
|
Ele permite que um único guerreiro:
|
||||||
|
|
||||||
|
- veja o cenário
|
||||||
|
- escolha o canal
|
||||||
|
- converse com o cidadão
|
||||||
|
- transfira a missão
|
||||||
|
- resolva o problema com agilidade
|
||||||
|
|
||||||
|
Em linguagem de produto, o sistema mostra como centralizar operação, comunicação e contexto em uma experiência única.
|
||||||
35
docs/modulo-attendance.md
Normal file
35
docs/modulo-attendance.md
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
# Módulo Attendance / Novo Atendimento
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
|
||||||
|
Permitir que um operador inicie rapidamente um novo atendimento.
|
||||||
|
|
||||||
|
## Tela principal
|
||||||
|
|
||||||
|
- `NewAttendancePage.jsx`
|
||||||
|
|
||||||
|
## Componentes e lógica
|
||||||
|
|
||||||
|
- `RecentContactsList.jsx`: contatos recentes e seleção rápida
|
||||||
|
- `attendanceMocks.js`: canais, áreas e contatos mockados
|
||||||
|
|
||||||
|
## Funcionalidades simuladas
|
||||||
|
|
||||||
|
- buscar contato
|
||||||
|
- escolher um contato recente
|
||||||
|
- informar novo número
|
||||||
|
- escolher canal:
|
||||||
|
- WhatsApp
|
||||||
|
- SMS
|
||||||
|
- Ligação
|
||||||
|
- selecionar área opcional
|
||||||
|
- iniciar atendimento
|
||||||
|
|
||||||
|
## Regras de navegação
|
||||||
|
|
||||||
|
- se o canal escolhido for `WhatsApp` ou `SMS`, a navegação vai para `/chat`
|
||||||
|
- se o canal escolhido for `Ligação`, a navegação vai para `/call`
|
||||||
|
|
||||||
|
## Papel na apresentação
|
||||||
|
|
||||||
|
Esse módulo deixa muito claro o ganho operacional do produto: o atendente consegue iniciar fluxos rapidamente sem sair da mesma plataforma.
|
||||||
33
docs/modulo-auth.md
Normal file
33
docs/modulo-auth.md
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
# Módulo Auth / Login
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
|
||||||
|
Apresentar uma entrada elegante e moderna para o produto, simulando autenticação corporativa sem backend real.
|
||||||
|
|
||||||
|
## Tela principal
|
||||||
|
|
||||||
|
- `LoginPage.jsx`
|
||||||
|
|
||||||
|
## Componentes e lógica
|
||||||
|
|
||||||
|
- `LoginForm.jsx`: formulário visual de acesso
|
||||||
|
- `useLogin.js`: controla o envio e redirecionamento
|
||||||
|
- `authService.js`: mock de autenticação
|
||||||
|
|
||||||
|
## Fluxo
|
||||||
|
|
||||||
|
1. O usuário informa usuário e senha
|
||||||
|
2. Clica em `Entrar`
|
||||||
|
3. O login mock é executado
|
||||||
|
4. O usuário é redirecionado para `/home`
|
||||||
|
|
||||||
|
## Elementos importantes
|
||||||
|
|
||||||
|
- branding Sothis
|
||||||
|
- botão `Entrar com Microsoft`
|
||||||
|
- link `Esqueci minha senha`
|
||||||
|
- visual de produto SaaS
|
||||||
|
|
||||||
|
## Papel na apresentação
|
||||||
|
|
||||||
|
A tela de login serve como entrada institucional do MVP e ajuda a criar percepção de maturidade do produto logo no primeiro contato.
|
||||||
32
docs/modulo-call.md
Normal file
32
docs/modulo-call.md
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
# Módulo Call
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
|
||||||
|
Simular uma ligação ativa em modo softphone, com visual de operação em tempo real.
|
||||||
|
|
||||||
|
## Tela principal
|
||||||
|
|
||||||
|
- `CallPage.jsx`
|
||||||
|
|
||||||
|
## Componentes e lógica
|
||||||
|
|
||||||
|
- `CallHeader.jsx`: contexto superior e retorno para home
|
||||||
|
- `CallControls.jsx`: controles visuais da chamada
|
||||||
|
- `useCallTimer.js`: timer automático
|
||||||
|
- `callMocks.js`: dados do cliente e controles
|
||||||
|
|
||||||
|
## Funcionalidades simuladas
|
||||||
|
|
||||||
|
- contagem automática do tempo de chamada
|
||||||
|
- exibição do cliente ativo
|
||||||
|
- avatar e fila de atendimento
|
||||||
|
- controles:
|
||||||
|
- mudo
|
||||||
|
- teclado
|
||||||
|
- alto-falante
|
||||||
|
- transferir
|
||||||
|
- encerrar chamada
|
||||||
|
|
||||||
|
## Papel na apresentação
|
||||||
|
|
||||||
|
Este módulo mostra que o produto não é apenas mensageria, mas também cobre voz dentro da mesma proposta omnichannel.
|
||||||
37
docs/modulo-chat.md
Normal file
37
docs/modulo-chat.md
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
# Módulo Chat
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
|
||||||
|
Simular um atendimento em tempo real com aparência próxima de um produto de operação real.
|
||||||
|
|
||||||
|
## Tela principal
|
||||||
|
|
||||||
|
- `ChatPage.jsx`
|
||||||
|
|
||||||
|
## Componentes e lógica
|
||||||
|
|
||||||
|
- `ChatConversationList.jsx`: lista de contatos e canais
|
||||||
|
- `ChatWindow.jsx`: header, mensagens e input
|
||||||
|
- `ChatTransferPanel.jsx`: fluxo visual de transferência
|
||||||
|
- `useChat.js`: estado do chat, envio e resposta simulada
|
||||||
|
- `chatMocks.js`: contatos, áreas, atendentes e mensagens iniciais
|
||||||
|
|
||||||
|
## Funcionalidades simuladas
|
||||||
|
|
||||||
|
- alternar entre conversas
|
||||||
|
- enviar mensagem
|
||||||
|
- receber resposta mock automática
|
||||||
|
- rolagem automática
|
||||||
|
- transferência para outra área
|
||||||
|
- escolha de atendente de destino
|
||||||
|
- observação opcional na transferência
|
||||||
|
|
||||||
|
## Canais representados
|
||||||
|
|
||||||
|
- WhatsApp
|
||||||
|
- SMS
|
||||||
|
- Email
|
||||||
|
|
||||||
|
## Papel na apresentação
|
||||||
|
|
||||||
|
O módulo de chat demonstra como a plataforma concentra diferentes canais em uma única experiência operacional.
|
||||||
40
docs/modulo-home.md
Normal file
40
docs/modulo-home.md
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
# Módulo Home / Dashboard
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
|
||||||
|
Ser o hub central do atendente após o login.
|
||||||
|
|
||||||
|
## Tela principal
|
||||||
|
|
||||||
|
- `HomePage.jsx`
|
||||||
|
|
||||||
|
## Componentes e lógica
|
||||||
|
|
||||||
|
- `HomeTopbar.jsx`: busca, toggle de abas, avatar e contexto superior
|
||||||
|
- `HomeSidebar.jsx`: navegação lateral e botão de novo atendimento
|
||||||
|
- `MessagesWorkspace.jsx`: lista de conversas, chat resumido e painel de ações
|
||||||
|
- `CallsWorkspace.jsx`: visão resumida das ligações
|
||||||
|
- `homeMocks.js`: dados mockados do dashboard
|
||||||
|
|
||||||
|
## Funcionalidades simuladas
|
||||||
|
|
||||||
|
- trocar entre `Mensagens` e `Ligações`
|
||||||
|
- buscar conversas mockadas
|
||||||
|
- abrir rota de chat
|
||||||
|
- abrir rota de call
|
||||||
|
- abrir rota de novo atendimento
|
||||||
|
|
||||||
|
## Papel na apresentação
|
||||||
|
|
||||||
|
É a tela que melhor comunica o conceito do produto:
|
||||||
|
|
||||||
|
- múltiplos canais
|
||||||
|
- visão operacional única
|
||||||
|
- agilidade no atendimento
|
||||||
|
- sensação de plataforma pronta para uso
|
||||||
|
|
||||||
|
## Pontos de UX
|
||||||
|
|
||||||
|
- responsividade adaptada para mobile, tablet, desktop e desktop largo
|
||||||
|
- sidebar separada do conteúdo para leitura mais clara
|
||||||
|
- cards de contexto e indicadores operacionais
|
||||||
83
docs/visao-geral.md
Normal file
83
docs/visao-geral.md
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
# Visão Geral do Projeto
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
|
||||||
|
O projeto representa o frontend MVP do Omnichannel Sothis.
|
||||||
|
|
||||||
|
O objetivo principal é demonstrar, de forma visual e convincente, como um atendente pode:
|
||||||
|
|
||||||
|
- entrar na plataforma
|
||||||
|
- visualizar atendimentos e conversas
|
||||||
|
- iniciar um novo atendimento
|
||||||
|
- conversar com clientes em canais diferentes
|
||||||
|
- simular uma ligação ativa
|
||||||
|
|
||||||
|
## Diretriz do MVP
|
||||||
|
|
||||||
|
Este MVP prioriza percepção de produto acabado.
|
||||||
|
|
||||||
|
Ou seja:
|
||||||
|
|
||||||
|
- os fluxos parecem reais
|
||||||
|
- os dados são mockados
|
||||||
|
- a navegação existe
|
||||||
|
- a experiência é pensada para demonstração, validação e apresentação
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- React
|
||||||
|
- Vite
|
||||||
|
- JavaScript
|
||||||
|
- React Router
|
||||||
|
- CSS via estilos modernos em componentes
|
||||||
|
|
||||||
|
## Estrutura
|
||||||
|
|
||||||
|
O frontend foi organizado por módulos, seguindo uma abordagem feature-based:
|
||||||
|
|
||||||
|
- `auth`
|
||||||
|
- `home`
|
||||||
|
- `chat`
|
||||||
|
- `call`
|
||||||
|
- `attendance`
|
||||||
|
|
||||||
|
Cada módulo concentra suas páginas, componentes, hooks e services mockados.
|
||||||
|
|
||||||
|
## Rotas atuais
|
||||||
|
|
||||||
|
- `/login`
|
||||||
|
- `/home`
|
||||||
|
- `/chat`
|
||||||
|
- `/call`
|
||||||
|
- `/new-attendance`
|
||||||
|
|
||||||
|
## Módulos
|
||||||
|
|
||||||
|
### Auth
|
||||||
|
|
||||||
|
Simula autenticação e entrada no sistema.
|
||||||
|
|
||||||
|
### Home
|
||||||
|
|
||||||
|
É a central do operador, com dashboard, conversas, atalhos e navegação fake para os fluxos principais.
|
||||||
|
|
||||||
|
### Chat
|
||||||
|
|
||||||
|
Simula atendimento em tempo real com mensagens, transferência e respostas automáticas mockadas.
|
||||||
|
|
||||||
|
### Call
|
||||||
|
|
||||||
|
Simula uma ligação ativa com timer automático e controles visuais de softphone.
|
||||||
|
|
||||||
|
### Attendance
|
||||||
|
|
||||||
|
Permite iniciar rapidamente um novo atendimento, escolhendo contato, canal e área.
|
||||||
|
|
||||||
|
## Público da documentação
|
||||||
|
|
||||||
|
Esta documentação serve para:
|
||||||
|
|
||||||
|
- apresentação de produto
|
||||||
|
- onboarding técnico
|
||||||
|
- alinhamento entre frontend, backend e deploy
|
||||||
|
- futura evolução para integração real
|
||||||
13
index.html
Normal file
13
index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!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="/src/shared/assets/favicon_blue.png" />
|
||||||
|
<title>Omnichannel Sothis</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1719
package-lock.json
generated
Normal file
1719
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
package.json
Normal file
20
package.json
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "omnichannel-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview --host 0.0.0.0 --port 3000"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.30.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.3.3",
|
||||||
|
"vite": "^5.4.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/main.jsx
Normal file
11
src/main.jsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { RouterProvider } from 'react-router-dom';
|
||||||
|
import { router } from './routes/router';
|
||||||
|
import './shared/styles/global.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
80
src/modules/attendance/components/RecentContactsList.jsx
Normal file
80
src/modules/attendance/components/RecentContactsList.jsx
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
export function RecentContactsList({
|
||||||
|
contacts,
|
||||||
|
activeContactId,
|
||||||
|
onSelectContact,
|
||||||
|
selectedChannel,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
style={{
|
||||||
|
background: '#fff',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: '28px',
|
||||||
|
padding: '1.25rem',
|
||||||
|
display: 'grid',
|
||||||
|
gap: '0.9rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<strong style={{ display: 'block', fontSize: '1.08rem' }}>Contatos recentes</strong>
|
||||||
|
<span style={{ color: 'var(--color-text-soft)' }}>
|
||||||
|
Reaproveite ultimos atendimentos para ganhar velocidade.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gap: '0.75rem' }}>
|
||||||
|
{contacts.map((contact) => {
|
||||||
|
const isActive = contact.id === activeContactId;
|
||||||
|
const isPreferred = selectedChannel === 'call'
|
||||||
|
? contact.channel === 'Ligacao'
|
||||||
|
: selectedChannel === 'sms'
|
||||||
|
? contact.channel === 'SMS'
|
||||||
|
: contact.channel === 'WhatsApp';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={contact.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelectContact(contact.id)}
|
||||||
|
style={{
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: isActive ? 'rgba(0, 164, 183, 0.26)' : 'var(--color-border)',
|
||||||
|
background: isActive ? 'rgba(0, 164, 183, 0.08)' : '#fff',
|
||||||
|
borderRadius: '20px',
|
||||||
|
padding: '1rem',
|
||||||
|
textAlign: 'left',
|
||||||
|
display: 'grid',
|
||||||
|
gap: '0.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
|
||||||
|
<strong>{contact.name}</strong>
|
||||||
|
{isPreferred ? (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
padding: '0.2rem 0.5rem',
|
||||||
|
borderRadius: 999,
|
||||||
|
background: 'rgba(0, 164, 183, 0.12)',
|
||||||
|
color: 'var(--color-primary)',
|
||||||
|
fontSize: '0.76rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sugerido
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<span style={{ color: 'var(--color-text-soft)' }}>{contact.phone}</span>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
|
||||||
|
<span style={{ color: 'var(--color-primary)', fontWeight: 700 }}>{contact.channel}</span>
|
||||||
|
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.85rem' }}>
|
||||||
|
{contact.lastContact}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
340
src/modules/attendance/pages/NewAttendancePage.jsx
Normal file
340
src/modules/attendance/pages/NewAttendancePage.jsx
Normal file
@ -0,0 +1,340 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { BrandMark } from '../../../shared/components/BrandMark';
|
||||||
|
import { useViewport } from '../../../shared/hooks/useViewport';
|
||||||
|
import { RecentContactsList } from '../components/RecentContactsList';
|
||||||
|
import {
|
||||||
|
attendanceAreas,
|
||||||
|
attendanceChannels,
|
||||||
|
recentContacts,
|
||||||
|
} from '../services/attendanceMocks';
|
||||||
|
|
||||||
|
export function NewAttendancePage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { isWideDesktop, isDesktop, isTablet, isMobile } = useViewport();
|
||||||
|
const [searchValue, setSearchValue] = useState('');
|
||||||
|
const [selectedChannelId, setSelectedChannelId] = useState('whatsapp');
|
||||||
|
const [selectedArea, setSelectedArea] = useState('');
|
||||||
|
const [selectedContactId, setSelectedContactId] = useState(recentContacts[0].id);
|
||||||
|
const [customNumber, setCustomNumber] = useState('');
|
||||||
|
|
||||||
|
const search = searchValue.trim().toLowerCase();
|
||||||
|
const filteredContacts = useMemo(() => {
|
||||||
|
if (!search) {
|
||||||
|
return recentContacts;
|
||||||
|
}
|
||||||
|
|
||||||
|
return recentContacts.filter((contact) => {
|
||||||
|
const haystack = `${contact.name} ${contact.phone} ${contact.channel}`.toLowerCase();
|
||||||
|
return haystack.includes(search);
|
||||||
|
});
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
const selectedContact =
|
||||||
|
filteredContacts.find((contact) => contact.id === selectedContactId) ||
|
||||||
|
recentContacts.find((contact) => contact.id === selectedContactId) ||
|
||||||
|
recentContacts[0];
|
||||||
|
|
||||||
|
const selectedChannel =
|
||||||
|
attendanceChannels.find((channel) => channel.id === selectedChannelId) || attendanceChannels[0];
|
||||||
|
|
||||||
|
const gridTemplateColumns = isMobile
|
||||||
|
? '1fr'
|
||||||
|
: isWideDesktop
|
||||||
|
? 'minmax(300px, 360px) minmax(0, 1fr)'
|
||||||
|
: isDesktop || isTablet
|
||||||
|
? 'minmax(280px, 340px) minmax(0, 1fr)'
|
||||||
|
: '1fr';
|
||||||
|
|
||||||
|
function handleStartAttendance() {
|
||||||
|
navigate(selectedChannel.route);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main style={{ minHeight: '100vh', padding: '1.5rem' }}>
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
width: 'min(1680px, calc(100vw - 3rem))',
|
||||||
|
margin: '0 auto',
|
||||||
|
background: 'var(--color-surface-strong)',
|
||||||
|
borderRadius: '32px',
|
||||||
|
boxShadow: 'var(--shadow-lg)',
|
||||||
|
padding: '1.5rem',
|
||||||
|
display: 'grid',
|
||||||
|
gap: '1.25rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<header
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: isMobile ? '1fr' : 'auto 1fr auto',
|
||||||
|
gap: '1rem',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BrandMark />
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
justifySelf: isMobile ? 'stretch' : 'center',
|
||||||
|
padding: '0.85rem 1rem',
|
||||||
|
borderRadius: '18px',
|
||||||
|
background: 'rgba(0, 49, 80, 0.06)',
|
||||||
|
color: 'var(--color-primary)',
|
||||||
|
fontWeight: 700,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Criacao rapida de atendimento
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to="/home"
|
||||||
|
style={{
|
||||||
|
justifySelf: isMobile ? 'stretch' : 'end',
|
||||||
|
borderRadius: '16px',
|
||||||
|
padding: '0.85rem 1rem',
|
||||||
|
background: 'var(--color-primary)',
|
||||||
|
color: '#fff',
|
||||||
|
fontWeight: 700,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Voltar para home
|
||||||
|
</Link>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns,
|
||||||
|
gap: '1rem',
|
||||||
|
alignItems: 'start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RecentContactsList
|
||||||
|
contacts={filteredContacts}
|
||||||
|
activeContactId={selectedContact.id}
|
||||||
|
onSelectContact={setSelectedContactId}
|
||||||
|
selectedChannel={selectedChannelId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
background: '#fff',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: '28px',
|
||||||
|
padding: '1.5rem',
|
||||||
|
display: 'grid',
|
||||||
|
gap: '1.25rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<strong style={{ display: 'block', fontSize: '1.18rem' }}>Novo atendimento</strong>
|
||||||
|
<p style={{ margin: '0.45rem 0 0', color: 'var(--color-text-soft)', lineHeight: 1.6 }}>
|
||||||
|
Escolha o contato, o canal e a area opcional antes de iniciar. O fluxo e mockado
|
||||||
|
e leva voce direto para chat ou ligacao.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 1fr) auto',
|
||||||
|
gap: '0.85rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={searchValue}
|
||||||
|
onChange={(event) => setSearchValue(event.target.value)}
|
||||||
|
placeholder="Buscar contato por nome ou numero"
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: '18px',
|
||||||
|
padding: '0.95rem 1rem',
|
||||||
|
background: '#fff',
|
||||||
|
outline: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCustomNumber(selectedContact.phone)}
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: '18px',
|
||||||
|
padding: '0.95rem 1rem',
|
||||||
|
background: '#fff',
|
||||||
|
color: 'var(--color-primary)',
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Novo numero
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: isMobile ? '1fr' : 'repeat(3, minmax(0, 1fr))',
|
||||||
|
gap: '0.85rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{attendanceChannels.map((channel) => {
|
||||||
|
const isActive = channel.id === selectedChannelId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={channel.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedChannelId(channel.id)}
|
||||||
|
style={{
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: isActive ? `${channel.accent}44` : 'var(--color-border)',
|
||||||
|
borderRadius: '22px',
|
||||||
|
padding: '1rem',
|
||||||
|
background: isActive ? `${channel.accent}12` : '#fff',
|
||||||
|
textAlign: 'left',
|
||||||
|
display: 'grid',
|
||||||
|
gap: '0.45rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong style={{ color: isActive ? channel.accent : 'var(--color-text)' }}>
|
||||||
|
{channel.label}
|
||||||
|
</strong>
|
||||||
|
<span style={{ color: 'var(--color-text-soft)' }}>
|
||||||
|
{channel.id === 'call'
|
||||||
|
? 'Inicia uma ligacao mock em tela cheia.'
|
||||||
|
: 'Abre o fluxo de conversa em tempo real.'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: isMobile ? '1fr' : 'repeat(2, minmax(0, 1fr))',
|
||||||
|
gap: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label style={{ display: 'grid', gap: '0.45rem' }}>
|
||||||
|
<span style={{ fontWeight: 600 }}>Area (opcional)</span>
|
||||||
|
<select
|
||||||
|
value={selectedArea}
|
||||||
|
onChange={(event) => setSelectedArea(event.target.value)}
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: '18px',
|
||||||
|
padding: '0.95rem 1rem',
|
||||||
|
background: '#fff',
|
||||||
|
outline: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Selecionar depois</option>
|
||||||
|
{attendanceAreas.map((area) => (
|
||||||
|
<option key={area} value={area}>
|
||||||
|
{area}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={{ display: 'grid', gap: '0.45rem' }}>
|
||||||
|
<span style={{ fontWeight: 600 }}>Numero selecionado</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={customNumber || selectedContact.phone}
|
||||||
|
onChange={(event) => setCustomNumber(event.target.value)}
|
||||||
|
placeholder="+55 11 99999-9999"
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: '18px',
|
||||||
|
padding: '0.95rem 1rem',
|
||||||
|
background: '#fff',
|
||||||
|
outline: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 1.1fr) minmax(280px, 0.9fr)',
|
||||||
|
gap: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<article
|
||||||
|
style={{
|
||||||
|
borderRadius: '24px',
|
||||||
|
padding: '1.25rem',
|
||||||
|
background:
|
||||||
|
'linear-gradient(135deg, rgba(0, 49, 80, 0.98), rgba(11, 90, 134, 0.94))',
|
||||||
|
color: '#fff',
|
||||||
|
display: 'grid',
|
||||||
|
gap: '0.7rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 'fit-content',
|
||||||
|
padding: '0.35rem 0.75rem',
|
||||||
|
borderRadius: '999px',
|
||||||
|
background: 'rgba(255, 255, 255, 0.12)',
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: '0.84rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Resumo do fluxo
|
||||||
|
</span>
|
||||||
|
<strong style={{ fontSize: '1.25rem' }}>{selectedContact.name}</strong>
|
||||||
|
<span style={{ color: 'rgba(255, 255, 255, 0.74)' }}>
|
||||||
|
Canal escolhido: {selectedChannel.label}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: 'rgba(255, 255, 255, 0.74)' }}>
|
||||||
|
Numero: {customNumber || selectedContact.phone}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: 'rgba(255, 255, 255, 0.74)' }}>
|
||||||
|
Area: {selectedArea || 'Definir depois'}
|
||||||
|
</span>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article
|
||||||
|
style={{
|
||||||
|
borderRadius: '24px',
|
||||||
|
padding: '1.25rem',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
background: '#fff',
|
||||||
|
display: 'grid',
|
||||||
|
gap: '0.7rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>Proxima rota</strong>
|
||||||
|
<span style={{ color: 'var(--color-text-soft)' }}>
|
||||||
|
{selectedChannel.route === '/call'
|
||||||
|
? 'Ao iniciar, voce vai para a tela de ligacao.'
|
||||||
|
: 'Ao iniciar, voce vai para a tela de chat.'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleStartAttendance}
|
||||||
|
style={{
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '18px',
|
||||||
|
padding: '1rem 1.1rem',
|
||||||
|
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
|
||||||
|
color: '#fff',
|
||||||
|
fontWeight: 800,
|
||||||
|
marginTop: '0.4rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Iniciar atendimento
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
src/modules/attendance/services/attendanceMocks.js
Normal file
38
src/modules/attendance/services/attendanceMocks.js
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
export const attendanceChannels = [
|
||||||
|
{ id: 'whatsapp', label: 'WhatsApp', route: '/chat', accent: '#2bb741' },
|
||||||
|
{ id: 'sms', label: 'SMS', route: '/chat', accent: '#00a4b7' },
|
||||||
|
{ id: 'call', label: 'Ligacao', route: '/call', accent: '#e5a22a' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const attendanceAreas = ['Suporte', 'Financeiro', 'Comercial'];
|
||||||
|
|
||||||
|
export const recentContacts = [
|
||||||
|
{
|
||||||
|
id: 'maria-souza',
|
||||||
|
name: 'Maria Souza',
|
||||||
|
phone: '+55 11 99888-7766',
|
||||||
|
channel: 'WhatsApp',
|
||||||
|
lastContact: 'Hoje, 09:42',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'empresa-alpha',
|
||||||
|
name: 'Empresa Alpha',
|
||||||
|
phone: '+55 11 4002-2020',
|
||||||
|
channel: 'Email',
|
||||||
|
lastContact: 'Ontem, 16:18',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'joao-pedro',
|
||||||
|
name: 'Joao Pedro',
|
||||||
|
phone: '+55 31 98877-1102',
|
||||||
|
channel: 'SMS',
|
||||||
|
lastContact: 'Hoje, 08:15',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'beatriz-lima',
|
||||||
|
name: 'Beatriz Lima',
|
||||||
|
phone: '+55 21 99701-4455',
|
||||||
|
channel: 'Ligacao',
|
||||||
|
lastContact: 'Hoje, 07:51',
|
||||||
|
},
|
||||||
|
];
|
||||||
97
src/modules/auth/components/LoginForm.jsx
Normal file
97
src/modules/auth/components/LoginForm.jsx
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useLogin } from '../hooks/useLogin';
|
||||||
|
|
||||||
|
const fieldStyle = {
|
||||||
|
width: '100%',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: '16px',
|
||||||
|
padding: '0.95rem 1rem',
|
||||||
|
background: '#fff',
|
||||||
|
color: 'var(--color-text)',
|
||||||
|
outline: 'none',
|
||||||
|
};
|
||||||
|
|
||||||
|
const primaryButtonStyle = {
|
||||||
|
width: '100%',
|
||||||
|
padding: '0.95rem 1rem',
|
||||||
|
borderRadius: '16px',
|
||||||
|
border: 'none',
|
||||||
|
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
|
||||||
|
color: '#fff',
|
||||||
|
fontWeight: 700,
|
||||||
|
boxShadow: 'var(--shadow-md)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const secondaryButtonStyle = {
|
||||||
|
width: '100%',
|
||||||
|
padding: '0.95rem 1rem',
|
||||||
|
borderRadius: '16px',
|
||||||
|
border: '1px solid rgba(0, 49, 80, 0.16)',
|
||||||
|
background: '#fff',
|
||||||
|
color: 'var(--color-primary)',
|
||||||
|
fontWeight: 700,
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialFormData = {
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LoginForm() {
|
||||||
|
const [formData, setFormData] = useState(initialFormData);
|
||||||
|
const { login, isSubmitting } = useLogin();
|
||||||
|
|
||||||
|
async function handleSubmit(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
await login(formData);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} style={{ display: 'grid', gap: '1rem' }}>
|
||||||
|
<label style={{ display: 'grid', gap: '0.5rem' }}>
|
||||||
|
<span style={{ fontWeight: 600 }}>Usuario AD</span>
|
||||||
|
<input
|
||||||
|
style={fieldStyle}
|
||||||
|
type="text"
|
||||||
|
placeholder="seu.usuario"
|
||||||
|
value={formData.username}
|
||||||
|
onChange={(event) =>
|
||||||
|
setFormData((current) => ({ ...current, username: event.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<button style={primaryButtonStyle} type="submit" disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? 'Entrando...' : 'Entrar'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button style={secondaryButtonStyle} type="button">
|
||||||
|
Entrar com Microsoft
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="#forgot-password"
|
||||||
|
style={{
|
||||||
|
justifySelf: 'center',
|
||||||
|
color: 'var(--color-secondary)',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Esqueci minha senha
|
||||||
|
</a>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
src/modules/auth/hooks/useLogin.js
Normal file
24
src/modules/auth/hooks/useLogin.js
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { mockLogin } from '../services/authService';
|
||||||
|
|
||||||
|
export function useLogin() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
async function login() {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mockLogin();
|
||||||
|
navigate('/home');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isSubmitting,
|
||||||
|
login,
|
||||||
|
};
|
||||||
|
}
|
||||||
161
src/modules/auth/pages/LoginPage.jsx
Normal file
161
src/modules/auth/pages/LoginPage.jsx
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
import { LoginForm } from '../components/LoginForm';
|
||||||
|
import { BrandMark } from '../../../shared/components/BrandMark';
|
||||||
|
|
||||||
|
export function LoginPage() {
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
style={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
display: 'grid',
|
||||||
|
placeItems: 'center',
|
||||||
|
padding: '2rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
width: 'min(100%, 1080px)',
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))',
|
||||||
|
gap: '1.5rem',
|
||||||
|
alignItems: 'stretch',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<article
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
'linear-gradient(180deg, rgba(0, 49, 80, 0.98) 0%, rgba(11, 90, 134, 0.95) 100%)',
|
||||||
|
color: '#fff',
|
||||||
|
borderRadius: '32px',
|
||||||
|
padding: '2.5rem',
|
||||||
|
boxShadow: 'var(--shadow-lg)',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: 'grid',
|
||||||
|
gap: '1.5rem',
|
||||||
|
alignContent: 'space-between',
|
||||||
|
minHeight: '540px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
background:
|
||||||
|
'radial-gradient(circle at top right, rgba(229, 162, 42, 0.3), transparent 30%), radial-gradient(circle at bottom left, rgba(0, 164, 183, 0.22), transparent 35%)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ position: 'relative', display: 'grid', gap: '1.5rem' }}>
|
||||||
|
<BrandMark theme="dark" />
|
||||||
|
<div>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.12em',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'rgba(255, 255, 255, 0.7)',
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
MVP de atendimento
|
||||||
|
</p>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
margin: '0.75rem 0',
|
||||||
|
fontSize: 'clamp(2.2rem, 4vw, 3.4rem)',
|
||||||
|
lineHeight: 1.05,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Conecte-se com seu cliente em uma unica tela.
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
maxWidth: '34rem',
|
||||||
|
color: 'rgba(255, 255, 255, 0.78)',
|
||||||
|
fontSize: '1.05rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Acesse a exeriência all in one e acompanhe o produto pensado para facilitar o contato no dia a dia.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
|
||||||
|
gap: '0.75rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
{ label: 'Canais', value: 'WhatsApp, SMS e Voz' },
|
||||||
|
{ label: 'Fila', value: 'Distribuicao rapida' },
|
||||||
|
{ label: 'UX', value: 'Padrao SaaS responsivo' },
|
||||||
|
].map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.label}
|
||||||
|
style={{
|
||||||
|
padding: '1rem',
|
||||||
|
borderRadius: '20px',
|
||||||
|
background: 'rgba(255, 255, 255, 0.1)',
|
||||||
|
backdropFilter: 'blur(10px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: '0.8rem', opacity: 0.7 }}>{item.label}</div>
|
||||||
|
<strong style={{ display: 'block', marginTop: '0.35rem' }}>{item.value}</strong>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article
|
||||||
|
style={{
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
backdropFilter: 'blur(12px)',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.65)',
|
||||||
|
borderRadius: '32px',
|
||||||
|
padding: '2.5rem',
|
||||||
|
boxShadow: 'var(--shadow-lg)',
|
||||||
|
display: 'grid',
|
||||||
|
gap: '1.75rem',
|
||||||
|
alignContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'grid', gap: '0.75rem' }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 'fit-content',
|
||||||
|
padding: '0.4rem 0.8rem',
|
||||||
|
borderRadius: 999,
|
||||||
|
background: 'rgba(0, 164, 183, 0.12)',
|
||||||
|
color: 'var(--color-primary)',
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Login seguro
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ margin: 0, fontSize: '2rem' }}>Entrar na plataforma</h2>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: '0.75rem 0 0',
|
||||||
|
color: 'var(--color-text-soft)',
|
||||||
|
lineHeight: 1.6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Use seu usuario corporativo para acessar o MVP. A autenticacao e mockada
|
||||||
|
nesta etapa e leva voce diretamente para a dashboard principal.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LoginForm />
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
src/modules/auth/services/authService.js
Normal file
11
src/modules/auth/services/authService.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
const networkDelay = 450;
|
||||||
|
|
||||||
|
export async function mockLogin() {
|
||||||
|
await new Promise((resolve) => window.setTimeout(resolve, networkDelay));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: 'agent-001',
|
||||||
|
name: 'Ana Camolesi',
|
||||||
|
email: 'ana.camolesi@sothis.local',
|
||||||
|
};
|
||||||
|
}
|
||||||
114
src/modules/call/components/CallControls.jsx
Normal file
114
src/modules/call/components/CallControls.jsx
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
function ControlIcon({ id }) {
|
||||||
|
const commonProps = {
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
viewBox: '0 0 24 24',
|
||||||
|
fill: 'none',
|
||||||
|
stroke: 'currentColor',
|
||||||
|
strokeWidth: 1.8,
|
||||||
|
strokeLinecap: 'round',
|
||||||
|
strokeLinejoin: 'round',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (id === 'mute') {
|
||||||
|
return (
|
||||||
|
<svg {...commonProps}>
|
||||||
|
<path d="M11 5a2 2 0 0 1 4 0v5a2 2 0 0 1-4 0z" />
|
||||||
|
<path d="M19 10a7 7 0 0 1-.6 2.8" />
|
||||||
|
<path d="M5.6 5.6 18.4 18.4" />
|
||||||
|
<path d="M9 10a3 3 0 0 0 4.7 2.5" />
|
||||||
|
<path d="M12 19v3" />
|
||||||
|
<path d="M8 22h8" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id === 'keypad') {
|
||||||
|
return (
|
||||||
|
<svg {...commonProps}>
|
||||||
|
<rect x="4" y="4" width="4" height="4" rx="1" />
|
||||||
|
<rect x="10" y="4" width="4" height="4" rx="1" />
|
||||||
|
<rect x="16" y="4" width="4" height="4" rx="1" />
|
||||||
|
<rect x="4" y="10" width="4" height="4" rx="1" />
|
||||||
|
<rect x="10" y="10" width="4" height="4" rx="1" />
|
||||||
|
<rect x="16" y="10" width="4" height="4" rx="1" />
|
||||||
|
<rect x="4" y="16" width="4" height="4" rx="1" />
|
||||||
|
<rect x="10" y="16" width="4" height="4" rx="1" />
|
||||||
|
<rect x="16" y="16" width="4" height="4" rx="1" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id === 'speaker') {
|
||||||
|
return (
|
||||||
|
<svg {...commonProps}>
|
||||||
|
<path d="M5 9v6h4l5 4V5l-5 4z" />
|
||||||
|
<path d="M18 9a4 4 0 0 1 0 6" />
|
||||||
|
<path d="M20.5 6.5a7.5 7.5 0 0 1 0 11" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg {...commonProps}>
|
||||||
|
<path d="M16 3h5v5" />
|
||||||
|
<path d="M21 3l-7 7" />
|
||||||
|
<path d="M8 7H3v5" />
|
||||||
|
<path d="M3 12l7-7" />
|
||||||
|
<path d="M16 21h5v-5" />
|
||||||
|
<path d="M21 21l-7-7" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CallControls({ controls, isMobile = false }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: isMobile
|
||||||
|
? 'repeat(2, minmax(0, 1fr))'
|
||||||
|
: 'repeat(4, minmax(120px, 1fr))',
|
||||||
|
gap: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{controls.map((control) => (
|
||||||
|
<button
|
||||||
|
key={control.id}
|
||||||
|
type="button"
|
||||||
|
style={{
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.12)',
|
||||||
|
borderRadius: '24px',
|
||||||
|
padding: '1rem',
|
||||||
|
background: 'rgba(255, 255, 255, 0.06)',
|
||||||
|
color: '#fff',
|
||||||
|
display: 'grid',
|
||||||
|
gap: '0.35rem',
|
||||||
|
justifyItems: 'start',
|
||||||
|
textAlign: 'left',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: '16px',
|
||||||
|
background: 'rgba(255, 255, 255, 0.1)',
|
||||||
|
display: 'grid',
|
||||||
|
placeItems: 'center',
|
||||||
|
fontWeight: 800,
|
||||||
|
color: '#fff',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ControlIcon id={control.id} />
|
||||||
|
</span>
|
||||||
|
<strong>{control.label}</strong>
|
||||||
|
<span style={{ color: 'rgba(255, 255, 255, 0.66)', fontSize: '0.9rem' }}>
|
||||||
|
{control.hint}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
src/modules/call/components/CallHeader.jsx
Normal file
56
src/modules/call/components/CallHeader.jsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
export function CallHeader({ isMobile = false }) {
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: isMobile ? '1fr' : 'auto 1fr auto',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
justifySelf: isMobile ? 'stretch' : 'start',
|
||||||
|
padding: '0.65rem 0.95rem',
|
||||||
|
borderRadius: '999px',
|
||||||
|
background: 'rgba(255, 255, 255, 0.08)',
|
||||||
|
color: 'rgba(255, 255, 255, 0.84)',
|
||||||
|
fontWeight: 700,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Ligacao ativa
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
color: 'rgba(255, 255, 255, 0.68)',
|
||||||
|
letterSpacing: '0.12em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Softphone MVP Omnichannel
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to="/home"
|
||||||
|
style={{
|
||||||
|
justifySelf: isMobile ? 'stretch' : 'end',
|
||||||
|
borderRadius: '16px',
|
||||||
|
padding: '0.85rem 1rem',
|
||||||
|
background: 'rgba(255, 255, 255, 0.08)',
|
||||||
|
color: '#fff',
|
||||||
|
fontWeight: 700,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Voltar para home
|
||||||
|
</Link>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
src/modules/call/hooks/useCallTimer.js
Normal file
26
src/modules/call/hooks/useCallTimer.js
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
function formatTime(totalSeconds) {
|
||||||
|
const hours = String(Math.floor(totalSeconds / 3600)).padStart(2, '0');
|
||||||
|
const minutes = String(Math.floor((totalSeconds % 3600) / 60)).padStart(2, '0');
|
||||||
|
const seconds = String(totalSeconds % 60).padStart(2, '0');
|
||||||
|
|
||||||
|
return `${hours}:${minutes}:${seconds}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCallTimer(initialSeconds = 0) {
|
||||||
|
const [seconds, setSeconds] = useState(initialSeconds);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const intervalId = window.setInterval(() => {
|
||||||
|
setSeconds((current) => current + 1);
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => window.clearInterval(intervalId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
seconds,
|
||||||
|
formattedTime: formatTime(seconds),
|
||||||
|
};
|
||||||
|
}
|
||||||
222
src/modules/call/pages/CallPage.jsx
Normal file
222
src/modules/call/pages/CallPage.jsx
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
import { CallControls } from '../components/CallControls';
|
||||||
|
import { CallHeader } from '../components/CallHeader';
|
||||||
|
import { useCallTimer } from '../hooks/useCallTimer';
|
||||||
|
import { activeCall, callControls } from '../services/callMocks';
|
||||||
|
import { useViewport } from '../../../shared/hooks/useViewport';
|
||||||
|
|
||||||
|
export function CallPage() {
|
||||||
|
const { isMobile } = useViewport();
|
||||||
|
const { formattedTime } = useCallTimer(312);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
style={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
padding: '1.5rem',
|
||||||
|
background:
|
||||||
|
'radial-gradient(circle at top, rgba(0, 164, 183, 0.22), transparent 22%), radial-gradient(circle at bottom, rgba(181, 31, 31, 0.2), transparent 26%), linear-gradient(180deg, #061521 0%, #0a2233 100%)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
width: 'min(1480px, calc(100vw - 3rem))',
|
||||||
|
minHeight: 'calc(100vh - 3rem)',
|
||||||
|
margin: '0 auto',
|
||||||
|
borderRadius: '36px',
|
||||||
|
padding: '1.5rem',
|
||||||
|
display: 'grid',
|
||||||
|
gap: '1.5rem',
|
||||||
|
background:
|
||||||
|
'linear-gradient(180deg, rgba(8, 22, 34, 0.94), rgba(10, 34, 51, 0.92))',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.08)',
|
||||||
|
boxShadow: '0 30px 70px rgba(0, 0, 0, 0.35)',
|
||||||
|
color: '#fff',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CallHeader isMobile={isMobile} />
|
||||||
|
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: isMobile ? '1fr' : 'minmax(280px, 360px) minmax(0, 1fr)',
|
||||||
|
gap: '1.5rem',
|
||||||
|
alignItems: 'stretch',
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<aside
|
||||||
|
style={{
|
||||||
|
borderRadius: '30px',
|
||||||
|
padding: '1.5rem',
|
||||||
|
background: 'rgba(255, 255, 255, 0.05)',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.08)',
|
||||||
|
display: 'grid',
|
||||||
|
gap: '1rem',
|
||||||
|
alignContent: 'start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 'fit-content',
|
||||||
|
padding: '0.45rem 0.8rem',
|
||||||
|
borderRadius: '999px',
|
||||||
|
background: 'rgba(229, 162, 42, 0.16)',
|
||||||
|
color: '#ffd37f',
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: '0.84rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activeCall.queue}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
width: isMobile ? 110 : 132,
|
||||||
|
height: isMobile ? 110 : 132,
|
||||||
|
borderRadius: '32px',
|
||||||
|
background: 'linear-gradient(135deg, #0d3d5d, #00a4b7)',
|
||||||
|
display: 'grid',
|
||||||
|
placeItems: 'center',
|
||||||
|
fontSize: isMobile ? '2rem' : '2.4rem',
|
||||||
|
fontWeight: 800,
|
||||||
|
boxShadow: '0 20px 40px rgba(0, 0, 0, 0.28)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activeCall.initials}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<strong style={{ display: 'block', fontSize: '1.5rem' }}>{activeCall.customerName}</strong>
|
||||||
|
<span style={{ color: 'rgba(255, 255, 255, 0.7)' }}>{activeCall.role}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gap: '0.85rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
{ label: 'Numero', value: activeCall.number },
|
||||||
|
{ label: 'Canal original', value: 'Atendimento omnichannel' },
|
||||||
|
{ label: 'Responsavel atual', value: 'Ana Camolesi' },
|
||||||
|
].map((item) => (
|
||||||
|
<article
|
||||||
|
key={item.label}
|
||||||
|
style={{
|
||||||
|
borderRadius: '22px',
|
||||||
|
padding: '1rem',
|
||||||
|
background: 'rgba(255, 255, 255, 0.05)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: 'rgba(255, 255, 255, 0.62)', display: 'block' }}>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
<strong style={{ display: 'block', marginTop: '0.35rem' }}>{item.value}</strong>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gap: '1.5rem',
|
||||||
|
alignContent: 'space-between',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: '32px',
|
||||||
|
padding: isMobile ? '1.5rem' : '2.25rem',
|
||||||
|
background:
|
||||||
|
'radial-gradient(circle at top right, rgba(229, 162, 42, 0.2), transparent 28%), rgba(255, 255, 255, 0.04)',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.08)',
|
||||||
|
display: 'grid',
|
||||||
|
gap: '1rem',
|
||||||
|
justifyItems: 'center',
|
||||||
|
textAlign: 'center',
|
||||||
|
minHeight: isMobile ? 'auto' : 360,
|
||||||
|
alignContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 'fit-content',
|
||||||
|
padding: '0.5rem 0.9rem',
|
||||||
|
borderRadius: '999px',
|
||||||
|
background: 'rgba(0, 164, 183, 0.16)',
|
||||||
|
color: '#8ce8f2',
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Chamada em andamento
|
||||||
|
</span>
|
||||||
|
<strong style={{ fontSize: isMobile ? '2.7rem' : '4rem', letterSpacing: '0.08em' }}>
|
||||||
|
{formattedTime}
|
||||||
|
</strong>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
maxWidth: '34rem',
|
||||||
|
color: 'rgba(255, 255, 255, 0.74)',
|
||||||
|
lineHeight: 1.6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Voce esta em uma ligacao ativa com a cliente. Os controles abaixo sao visuais
|
||||||
|
neste MVP e ajudam a demonstrar a experiencia de voz do produto.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CallControls controls={callControls} isMobile={isMobile} />
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: isMobile ? '1fr' : 'repeat(2, minmax(0, 1fr)) auto',
|
||||||
|
gap: '1rem',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: '24px',
|
||||||
|
padding: '1rem 1.15rem',
|
||||||
|
background: 'rgba(255, 255, 255, 0.05)',
|
||||||
|
color: 'rgba(255, 255, 255, 0.72)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Qualidade da chamada: Estavel
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: '24px',
|
||||||
|
padding: '1rem 1.15rem',
|
||||||
|
background: 'rgba(255, 255, 255, 0.05)',
|
||||||
|
color: 'rgba(255, 255, 255, 0.72)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Gravacao mock: Habilitada
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={{
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '24px',
|
||||||
|
padding: '1rem 1.4rem',
|
||||||
|
background: 'linear-gradient(135deg, var(--color-secondary), #d43a3a)',
|
||||||
|
color: '#fff',
|
||||||
|
fontWeight: 800,
|
||||||
|
minHeight: 58,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Encerrar chamada
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
src/modules/call/services/callMocks.js
Normal file
15
src/modules/call/services/callMocks.js
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
export const activeCall = {
|
||||||
|
id: 'call-1',
|
||||||
|
customerName: 'Maria Souza',
|
||||||
|
role: 'Cliente VIP • Ligação via PABX',
|
||||||
|
number: '+55 11 99888-7766',
|
||||||
|
queue: 'Fila Suporte Premium',
|
||||||
|
initials: 'MS',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const callControls = [
|
||||||
|
{ id: 'mute', label: 'Mudo', hint: 'Microfone' },
|
||||||
|
{ id: 'keypad', label: 'Teclado', hint: 'DTMF' },
|
||||||
|
{ id: 'speaker', label: 'Alto-falante', hint: 'Saida de audio' },
|
||||||
|
{ id: 'transfer', label: 'Transferir', hint: 'Encaminhar' },
|
||||||
|
];
|
||||||
108
src/modules/chat/components/ChatConversationList.jsx
Normal file
108
src/modules/chat/components/ChatConversationList.jsx
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
function ChannelBadge({ channel }) {
|
||||||
|
const colors = {
|
||||||
|
WhatsApp: '#2bb741',
|
||||||
|
Email: '#e5a22a',
|
||||||
|
SMS: '#00a4b7',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderRadius: 999,
|
||||||
|
padding: '0.22rem 0.6rem',
|
||||||
|
background: `${colors[channel] || '#003150'}16`,
|
||||||
|
color: colors[channel] || '#003150',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{channel}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatConversationList({
|
||||||
|
contacts,
|
||||||
|
activeContactId,
|
||||||
|
onSelectContact,
|
||||||
|
isMobile = false,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
style={{
|
||||||
|
background: '#fff',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: '28px',
|
||||||
|
padding: '1rem',
|
||||||
|
display: 'grid',
|
||||||
|
gap: '0.85rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<strong style={{ display: 'block', fontSize: '1.08rem' }}>Conversas ativas</strong>
|
||||||
|
<span style={{ color: 'var(--color-text-soft)' }}>
|
||||||
|
WhatsApp, SMS e email em uma fila visual.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gap: '0.75rem',
|
||||||
|
gridTemplateColumns: isMobile ? '1fr' : '1fr',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{contacts.map((contact) => {
|
||||||
|
const isActive = contact.id === activeContactId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={contact.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelectContact(contact.id)}
|
||||||
|
style={{
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: isActive ? 'rgba(0, 164, 183, 0.26)' : 'var(--color-border)',
|
||||||
|
background: isActive ? 'rgba(0, 164, 183, 0.08)' : '#fff',
|
||||||
|
borderRadius: '20px',
|
||||||
|
padding: '1rem',
|
||||||
|
textAlign: 'left',
|
||||||
|
display: 'grid',
|
||||||
|
gap: '0.6rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
|
||||||
|
<strong>{contact.name}</strong>
|
||||||
|
<span style={{ fontSize: '0.82rem', color: 'var(--color-text-soft)' }}>
|
||||||
|
{contact.time}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.75rem' }}>
|
||||||
|
<ChannelBadge channel={contact.channel} />
|
||||||
|
{contact.unread ? (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
minWidth: 24,
|
||||||
|
borderRadius: 999,
|
||||||
|
padding: '0.15rem 0.45rem',
|
||||||
|
background: 'var(--color-secondary)',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: '0.78rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{contact.unread}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<span style={{ color: 'var(--color-text-soft)' }}>{contact.preview}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
src/modules/chat/components/ChatTransferPanel.jsx
Normal file
112
src/modules/chat/components/ChatTransferPanel.jsx
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
export function ChatTransferPanel({
|
||||||
|
isOpen,
|
||||||
|
transferArea,
|
||||||
|
setTransferArea,
|
||||||
|
transferAreas,
|
||||||
|
attendants,
|
||||||
|
transferAttendant,
|
||||||
|
setTransferAttendant,
|
||||||
|
transferNote,
|
||||||
|
setTransferNote,
|
||||||
|
onSubmit,
|
||||||
|
onClose,
|
||||||
|
}) {
|
||||||
|
if (!isOpen) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldStyle = {
|
||||||
|
width: '100%',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: '16px',
|
||||||
|
padding: '0.9rem 1rem',
|
||||||
|
background: '#fff',
|
||||||
|
outline: 'none',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
style={{
|
||||||
|
background: '#fff',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: '28px',
|
||||||
|
padding: '1.25rem',
|
||||||
|
display: 'grid',
|
||||||
|
gap: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
|
||||||
|
<div>
|
||||||
|
<strong style={{ display: 'block', fontSize: '1.06rem' }}>Transferir atendimento</strong>
|
||||||
|
<span style={{ color: 'var(--color-text-soft)' }}>
|
||||||
|
Reencaminhe a conversa para a area ideal.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
border: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'var(--color-text-soft)',
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Fechar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label style={{ display: 'grid', gap: '0.45rem' }}>
|
||||||
|
<span style={{ fontWeight: 600 }}>Area</span>
|
||||||
|
<select value={transferArea} onChange={(event) => setTransferArea(event.target.value)} style={fieldStyle}>
|
||||||
|
{transferAreas.map((area) => (
|
||||||
|
<option key={area} value={area}>
|
||||||
|
{area}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={{ display: 'grid', gap: '0.45rem' }}>
|
||||||
|
<span style={{ fontWeight: 600 }}>Atendente</span>
|
||||||
|
<select
|
||||||
|
value={transferAttendant}
|
||||||
|
onChange={(event) => setTransferAttendant(event.target.value)}
|
||||||
|
style={fieldStyle}
|
||||||
|
>
|
||||||
|
{attendants.map((attendant) => (
|
||||||
|
<option key={attendant} value={attendant}>
|
||||||
|
{attendant}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={{ display: 'grid', gap: '0.45rem' }}>
|
||||||
|
<span style={{ fontWeight: 600 }}>Observacao</span>
|
||||||
|
<textarea
|
||||||
|
rows={5}
|
||||||
|
value={transferNote}
|
||||||
|
onChange={(event) => setTransferNote(event.target.value)}
|
||||||
|
placeholder="Contexto opcional para ajudar o proximo atendente."
|
||||||
|
style={{ ...fieldStyle, resize: 'vertical' }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSubmit}
|
||||||
|
style={{
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '16px',
|
||||||
|
padding: '0.95rem 1rem',
|
||||||
|
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
|
||||||
|
color: '#fff',
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Confirmar transferencia
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
211
src/modules/chat/components/ChatWindow.jsx
Normal file
211
src/modules/chat/components/ChatWindow.jsx
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
export function ChatWindow({
|
||||||
|
contact,
|
||||||
|
messages,
|
||||||
|
selectedArea,
|
||||||
|
setSelectedArea,
|
||||||
|
draft,
|
||||||
|
setDraft,
|
||||||
|
onSend,
|
||||||
|
onToggleTransfer,
|
||||||
|
isReplying,
|
||||||
|
isMobile = false,
|
||||||
|
}) {
|
||||||
|
const messagesRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = messagesRef.current;
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.scrollTo({
|
||||||
|
top: container.scrollHeight,
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
}, [messages, isReplying]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
background: '#fff',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: '28px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateRows: 'auto 1fr auto',
|
||||||
|
minHeight: 680,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<header
|
||||||
|
style={{
|
||||||
|
padding: '1.25rem 1.5rem',
|
||||||
|
borderBottom: '1px solid var(--color-border)',
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 1fr) auto',
|
||||||
|
gap: '1rem',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<strong style={{ display: 'block', fontSize: '1.15rem' }}>{contact.name}</strong>
|
||||||
|
<span style={{ color: 'var(--color-text-soft)' }}>
|
||||||
|
{contact.status === 'online' ? 'Online' : 'Offline'} • {contact.lastSeen}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '0.7rem',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
justifyContent: isMobile ? 'stretch' : 'flex-end',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
value={selectedArea}
|
||||||
|
onChange={(event) => setSelectedArea(event.target.value)}
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: '14px',
|
||||||
|
padding: '0.8rem 0.95rem',
|
||||||
|
background: '#fff',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option>Suporte</option>
|
||||||
|
<option>Financeiro</option>
|
||||||
|
<option>Comercial</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggleTransfer}
|
||||||
|
style={{
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '14px',
|
||||||
|
padding: '0.8rem 1rem',
|
||||||
|
background: 'rgba(0, 49, 80, 0.08)',
|
||||||
|
color: 'var(--color-primary)',
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Transferir
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={messagesRef}
|
||||||
|
style={{
|
||||||
|
padding: '1.5rem',
|
||||||
|
display: 'grid',
|
||||||
|
gap: '0.9rem',
|
||||||
|
alignContent: 'start',
|
||||||
|
overflowY: 'auto',
|
||||||
|
background:
|
||||||
|
'radial-gradient(circle at top left, rgba(0, 164, 183, 0.06), transparent 22%), linear-gradient(180deg, rgba(245, 248, 251, 0.8), rgba(255, 255, 255, 0.95))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{messages.map((message) => {
|
||||||
|
const isAgent = message.sender === 'agent';
|
||||||
|
const isSystem = message.sender === 'system';
|
||||||
|
|
||||||
|
if (isSystem) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={message.id}
|
||||||
|
style={{
|
||||||
|
justifySelf: 'center',
|
||||||
|
padding: '0.7rem 1rem',
|
||||||
|
borderRadius: '999px',
|
||||||
|
background: 'rgba(0, 49, 80, 0.08)',
|
||||||
|
color: 'var(--color-primary)',
|
||||||
|
fontSize: '0.88rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{message.text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={message.id}
|
||||||
|
style={{
|
||||||
|
justifySelf: isAgent ? 'end' : 'start',
|
||||||
|
maxWidth: isMobile ? '88%' : '72%',
|
||||||
|
padding: '0.95rem 1rem',
|
||||||
|
borderRadius: isAgent ? '18px 18px 6px 18px' : '18px 18px 18px 6px',
|
||||||
|
background: isAgent ? 'var(--color-primary)' : '#edf1f5',
|
||||||
|
color: isAgent ? '#fff' : 'var(--color-text)',
|
||||||
|
boxShadow: 'var(--shadow-md)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{message.text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{isReplying ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
justifySelf: 'start',
|
||||||
|
padding: '0.8rem 0.95rem',
|
||||||
|
borderRadius: '18px 18px 18px 6px',
|
||||||
|
background: '#edf1f5',
|
||||||
|
color: 'var(--color-text-soft)',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Digitando...
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer
|
||||||
|
style={{
|
||||||
|
padding: '1rem 1.25rem 1.25rem',
|
||||||
|
borderTop: '1px solid var(--color-border)',
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: isMobile ? '1fr' : '1fr auto',
|
||||||
|
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..."
|
||||||
|
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,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Enviar
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
151
src/modules/chat/hooks/useChat.js
Normal file
151
src/modules/chat/hooks/useChat.js
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
attendantsByArea,
|
||||||
|
chatContacts,
|
||||||
|
getMockReply,
|
||||||
|
transferAreas,
|
||||||
|
} from '../services/chatMocks';
|
||||||
|
|
||||||
|
function buildInitialMessages() {
|
||||||
|
return chatContacts.reduce((acc, contact) => {
|
||||||
|
acc[contact.id] = contact.messages;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChat() {
|
||||||
|
const [contacts, setContacts] = useState(chatContacts);
|
||||||
|
const [activeContactId, setActiveContactId] = useState(chatContacts[0].id);
|
||||||
|
const [messagesByContact, setMessagesByContact] = useState(buildInitialMessages);
|
||||||
|
const [draft, setDraft] = useState('');
|
||||||
|
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 activeContact = useMemo(
|
||||||
|
() => contacts.find((contact) => contact.id === activeContactId) || contacts[0],
|
||||||
|
[contacts, activeContactId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const messages = messagesByContact[activeContactId] || [];
|
||||||
|
const attendants = attendantsByArea[transferArea] || [];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedArea(activeContact.area);
|
||||||
|
}, [activeContact]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTransferAttendant(attendants[0] || '');
|
||||||
|
}, [transferArea]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (replyTimeoutRef.current) {
|
||||||
|
window.clearTimeout(replyTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function updateContactPreview(contactId, preview) {
|
||||||
|
setContacts((current) =>
|
||||||
|
current.map((contact) =>
|
||||||
|
contact.id === contactId ? { ...contact, preview, time: 'Agora', unread: 0 } : contact,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendMessage() {
|
||||||
|
const trimmed = draft.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newMessage = {
|
||||||
|
id: Date.now(),
|
||||||
|
sender: 'agent',
|
||||||
|
text: trimmed,
|
||||||
|
};
|
||||||
|
|
||||||
|
setMessagesByContact((current) => ({
|
||||||
|
...current,
|
||||||
|
[activeContactId]: [...(current[activeContactId] || []), newMessage],
|
||||||
|
}));
|
||||||
|
updateContactPreview(activeContactId, trimmed);
|
||||||
|
setDraft('');
|
||||||
|
setIsReplying(true);
|
||||||
|
|
||||||
|
replyTimeoutRef.current = window.setTimeout(() => {
|
||||||
|
const reply = {
|
||||||
|
id: Date.now() + 1,
|
||||||
|
sender: 'customer',
|
||||||
|
text: getMockReply(activeContact.name),
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitTransfer() {
|
||||||
|
const note = transferNote.trim();
|
||||||
|
const transferMessage = note
|
||||||
|
? `Transferencia solicitada para ${transferArea} com ${transferAttendant}. Obs: ${note}`
|
||||||
|
: `Transferencia solicitada para ${transferArea} com ${transferAttendant}.`;
|
||||||
|
|
||||||
|
setMessagesByContact((current) => ({
|
||||||
|
...current,
|
||||||
|
[activeContactId]: [
|
||||||
|
...(current[activeContactId] || []),
|
||||||
|
{ id: Date.now() + 2, sender: 'system', text: transferMessage },
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
setContacts((current) =>
|
||||||
|
current.map((contact) =>
|
||||||
|
contact.id === activeContactId ? { ...contact, area: transferArea } : contact,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setSelectedArea(transferArea);
|
||||||
|
setIsTransferOpen(false);
|
||||||
|
setTransferNote('');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
contacts,
|
||||||
|
activeContact,
|
||||||
|
activeContactId,
|
||||||
|
setActiveContactId,
|
||||||
|
messages,
|
||||||
|
draft,
|
||||||
|
setDraft,
|
||||||
|
sendMessage,
|
||||||
|
isReplying,
|
||||||
|
selectedArea,
|
||||||
|
setSelectedArea,
|
||||||
|
isTransferOpen,
|
||||||
|
setIsTransferOpen,
|
||||||
|
transferArea,
|
||||||
|
setTransferArea,
|
||||||
|
transferAreas,
|
||||||
|
attendants,
|
||||||
|
transferAttendant,
|
||||||
|
setTransferAttendant,
|
||||||
|
transferNote,
|
||||||
|
setTransferNote,
|
||||||
|
submitTransfer,
|
||||||
|
};
|
||||||
|
}
|
||||||
189
src/modules/chat/pages/ChatPage.jsx
Normal file
189
src/modules/chat/pages/ChatPage.jsx
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { BrandMark } from '../../../shared/components/BrandMark';
|
||||||
|
import { useViewport } from '../../../shared/hooks/useViewport';
|
||||||
|
import { ChatConversationList } from '../components/ChatConversationList';
|
||||||
|
import { ChatTransferPanel } from '../components/ChatTransferPanel';
|
||||||
|
import { ChatWindow } from '../components/ChatWindow';
|
||||||
|
import { useChat } from '../hooks/useChat';
|
||||||
|
import { quickReplies } from '../services/chatMocks';
|
||||||
|
|
||||||
|
export function ChatPage() {
|
||||||
|
const { isWideDesktop, isDesktop, isTablet, isMobile } = useViewport();
|
||||||
|
const {
|
||||||
|
contacts,
|
||||||
|
activeContact,
|
||||||
|
activeContactId,
|
||||||
|
setActiveContactId,
|
||||||
|
messages,
|
||||||
|
draft,
|
||||||
|
setDraft,
|
||||||
|
sendMessage,
|
||||||
|
isReplying,
|
||||||
|
selectedArea,
|
||||||
|
setSelectedArea,
|
||||||
|
isTransferOpen,
|
||||||
|
setIsTransferOpen,
|
||||||
|
transferArea,
|
||||||
|
setTransferArea,
|
||||||
|
transferAreas,
|
||||||
|
attendants,
|
||||||
|
transferAttendant,
|
||||||
|
setTransferAttendant,
|
||||||
|
transferNote,
|
||||||
|
setTransferNote,
|
||||||
|
submitTransfer,
|
||||||
|
} = useChat();
|
||||||
|
|
||||||
|
const gridTemplateColumns = isMobile
|
||||||
|
? '1fr'
|
||||||
|
: isWideDesktop
|
||||||
|
? 'minmax(260px, 320px) minmax(0, 1.6fr) minmax(280px, 320px)'
|
||||||
|
: isDesktop || isTablet
|
||||||
|
? 'minmax(260px, 320px) minmax(0, 1fr)'
|
||||||
|
: '1fr';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main style={{ minHeight: '100vh', padding: '1.5rem' }}>
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
width: 'min(1680px, calc(100vw - 3rem))',
|
||||||
|
margin: '0 auto',
|
||||||
|
background: 'var(--color-surface-strong)',
|
||||||
|
borderRadius: '32px',
|
||||||
|
boxShadow: 'var(--shadow-lg)',
|
||||||
|
padding: '1.5rem',
|
||||||
|
display: 'grid',
|
||||||
|
gap: '1.25rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<header
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: isMobile ? '1fr' : 'auto 1fr auto',
|
||||||
|
gap: '1rem',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BrandMark />
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
justifySelf: isMobile ? 'stretch' : 'center',
|
||||||
|
padding: '0.85rem 1rem',
|
||||||
|
borderRadius: '18px',
|
||||||
|
background: 'rgba(0, 49, 80, 0.06)',
|
||||||
|
color: 'var(--color-primary)',
|
||||||
|
fontWeight: 700,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Atendimento em tempo real
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to="/home"
|
||||||
|
style={{
|
||||||
|
justifySelf: isMobile ? 'stretch' : 'end',
|
||||||
|
borderRadius: '16px',
|
||||||
|
padding: '0.85rem 1rem',
|
||||||
|
background: 'var(--color-primary)',
|
||||||
|
color: '#fff',
|
||||||
|
fontWeight: 700,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Voltar para home
|
||||||
|
</Link>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns,
|
||||||
|
gap: '1rem',
|
||||||
|
alignItems: 'start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChatConversationList
|
||||||
|
contacts={contacts}
|
||||||
|
activeContactId={activeContactId}
|
||||||
|
onSelectContact={setActiveContactId}
|
||||||
|
isMobile={isMobile}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gap: '1rem', minWidth: 0 }}>
|
||||||
|
<ChatWindow
|
||||||
|
contact={activeContact}
|
||||||
|
messages={messages}
|
||||||
|
selectedArea={selectedArea}
|
||||||
|
setSelectedArea={setSelectedArea}
|
||||||
|
draft={draft}
|
||||||
|
setDraft={setDraft}
|
||||||
|
onSend={sendMessage}
|
||||||
|
onToggleTransfer={() => setIsTransferOpen((current) => !current)}
|
||||||
|
isReplying={isReplying}
|
||||||
|
isMobile={isMobile}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))',
|
||||||
|
gap: '0.75rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{quickReplies.map((reply) => (
|
||||||
|
<button
|
||||||
|
key={reply}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDraft(reply)}
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: '18px',
|
||||||
|
padding: '0.85rem 1rem',
|
||||||
|
background: '#fff',
|
||||||
|
color: 'var(--color-primary)',
|
||||||
|
fontWeight: 600,
|
||||||
|
textAlign: 'left',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{reply}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isWideDesktop ? (
|
||||||
|
<ChatTransferPanel
|
||||||
|
isOpen={isTransferOpen}
|
||||||
|
transferArea={transferArea}
|
||||||
|
setTransferArea={setTransferArea}
|
||||||
|
transferAreas={transferAreas}
|
||||||
|
attendants={attendants}
|
||||||
|
transferAttendant={transferAttendant}
|
||||||
|
setTransferAttendant={setTransferAttendant}
|
||||||
|
transferNote={transferNote}
|
||||||
|
setTransferNote={setTransferNote}
|
||||||
|
onSubmit={submitTransfer}
|
||||||
|
onClose={() => setIsTransferOpen(false)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{!isWideDesktop ? (
|
||||||
|
<ChatTransferPanel
|
||||||
|
isOpen={isTransferOpen}
|
||||||
|
transferArea={transferArea}
|
||||||
|
setTransferArea={setTransferArea}
|
||||||
|
transferAreas={transferAreas}
|
||||||
|
attendants={attendants}
|
||||||
|
transferAttendant={transferAttendant}
|
||||||
|
setTransferAttendant={setTransferAttendant}
|
||||||
|
transferNote={transferNote}
|
||||||
|
setTransferNote={setTransferNote}
|
||||||
|
onSubmit={submitTransfer}
|
||||||
|
onClose={() => setIsTransferOpen(false)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
src/modules/chat/services/chatMocks.js
Normal file
74
src/modules/chat/services/chatMocks.js
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
export const chatContacts = [
|
||||||
|
{
|
||||||
|
id: 'maria-souza',
|
||||||
|
name: 'Maria Souza',
|
||||||
|
channel: 'WhatsApp',
|
||||||
|
status: 'online',
|
||||||
|
area: 'Suporte',
|
||||||
|
lastSeen: 'Online agora',
|
||||||
|
preview: 'Preciso atualizar o cadastro do meu pedido.',
|
||||||
|
time: '09:42',
|
||||||
|
unread: 2,
|
||||||
|
messages: [
|
||||||
|
{ id: 1, sender: 'customer', text: 'Oi, bom dia! Preciso de ajuda com meu pedido.' },
|
||||||
|
{ id: 2, sender: 'agent', text: 'Bom dia, Maria! Claro, me conta o que aconteceu.' },
|
||||||
|
{ id: 3, sender: 'customer', text: 'Quero confirmar se o endereco foi alterado.' },
|
||||||
|
{ id: 4, sender: 'agent', text: 'Estou verificando aqui e te atualizo em instantes.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'joao-pedro',
|
||||||
|
name: 'Joao Pedro',
|
||||||
|
channel: 'SMS',
|
||||||
|
status: 'offline',
|
||||||
|
area: 'Financeiro',
|
||||||
|
lastSeen: 'Visto ha 12 min',
|
||||||
|
preview: 'Pode me ligar em 10 minutos?',
|
||||||
|
time: '08:15',
|
||||||
|
unread: 1,
|
||||||
|
messages: [
|
||||||
|
{ id: 1, sender: 'customer', text: 'Recebi a cobranca em duplicidade.' },
|
||||||
|
{ id: 2, sender: 'agent', text: 'Vou analisar isso agora para voce.' },
|
||||||
|
{ id: 3, sender: 'customer', text: 'Pode me ligar em 10 minutos?' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'empresa-alpha',
|
||||||
|
name: 'Empresa Alpha',
|
||||||
|
channel: 'Email',
|
||||||
|
status: 'offline',
|
||||||
|
area: 'Comercial',
|
||||||
|
lastSeen: 'Visto ontem',
|
||||||
|
preview: 'Aguardando retorno sobre a proposta comercial.',
|
||||||
|
time: 'Ontem',
|
||||||
|
unread: 0,
|
||||||
|
messages: [
|
||||||
|
{ id: 1, sender: 'customer', text: 'Precisamos rever os valores da ultima proposta.' },
|
||||||
|
{ id: 2, sender: 'agent', text: 'Perfeito, vou encaminhar para o time comercial.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const transferAreas = ['Suporte', 'Financeiro', 'Comercial'];
|
||||||
|
|
||||||
|
export const attendantsByArea = {
|
||||||
|
Suporte: ['Ana Camolesi', 'Rafael Lopes', 'Romero Britto'],
|
||||||
|
Financeiro: ['Roberto Pêra', 'Monica Limoeira', 'Edson Arantes'],
|
||||||
|
Comercial: ['Natasha Homanoff', 'Helena Pêra', 'Pedro Parque'],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const quickReplies = [
|
||||||
|
'Recebi sua mensagem e ja vou verificar.',
|
||||||
|
'Consegue me confirmar o numero do protocolo?',
|
||||||
|
'Posso seguir com essa atualizacao por aqui.',
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getMockReply(contactName) {
|
||||||
|
const replies = [
|
||||||
|
`Perfeito, obrigado pelo retorno, ${contactName.split(' ')[0]}.`,
|
||||||
|
'Tudo bem, fico no aguardo dessa confirmacao.',
|
||||||
|
'Entendi. Se precisar, posso encaminhar para a area responsavel.',
|
||||||
|
];
|
||||||
|
|
||||||
|
return replies[Math.floor(Math.random() * replies.length)];
|
||||||
|
}
|
||||||
95
src/modules/home/components/CallsWorkspace.jsx
Normal file
95
src/modules/home/components/CallsWorkspace.jsx
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
export function CallsWorkspace({ calls, isWideDesktop = false, isDesktop = false, isMobile = false }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
background: '#fff',
|
||||||
|
borderRadius: '26px',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
padding: '1.5rem',
|
||||||
|
display: 'grid',
|
||||||
|
gap: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '1rem',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<strong style={{ display: 'block', fontSize: '1.1rem' }}>Ligacoes recentes</strong>
|
||||||
|
<span style={{ color: 'var(--color-text-soft)' }}>
|
||||||
|
Visualizacao rapida do fluxo de voz do time.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/call')}
|
||||||
|
style={{
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '18px',
|
||||||
|
padding: '0.95rem 1.1rem',
|
||||||
|
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
|
||||||
|
color: '#fff',
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Nova ligacao
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gap: '0.85rem' }}>
|
||||||
|
{calls.map((call) => (
|
||||||
|
<article
|
||||||
|
key={call.id}
|
||||||
|
style={{
|
||||||
|
borderRadius: '22px',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
padding: '1rem 1.1rem',
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: isMobile
|
||||||
|
? '1fr'
|
||||||
|
: isWideDesktop
|
||||||
|
? 'minmax(0, 1.4fr) repeat(3, minmax(100px, 1fr)) auto'
|
||||||
|
: isDesktop
|
||||||
|
? 'minmax(0, 1.2fr) repeat(2, minmax(100px, 1fr)) auto'
|
||||||
|
: 'repeat(2, minmax(0, 1fr))',
|
||||||
|
gap: '1rem',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<strong style={{ display: 'block' }}>{call.name}</strong>
|
||||||
|
<span style={{ color: 'var(--color-text-soft)' }}>{call.area}</span>
|
||||||
|
</div>
|
||||||
|
<span>{call.duration}</span>
|
||||||
|
<span>{call.status}</span>
|
||||||
|
{!isMobile ? <span style={{ color: 'var(--color-text-soft)' }}>Hoje</span> : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/call')}
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: '14px',
|
||||||
|
padding: '0.7rem 0.95rem',
|
||||||
|
background: '#fff',
|
||||||
|
color: 'var(--color-primary)',
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Ver
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
src/modules/home/components/HomeSidebar.jsx
Normal file
82
src/modules/home/components/HomeSidebar.jsx
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
export function HomeSidebar({ items, activeItem, isMobile = false }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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('/new-attendance')}
|
||||||
|
style={{
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '20px',
|
||||||
|
padding: '1rem 1.15rem',
|
||||||
|
background: 'linear-gradient(135deg, var(--color-highlight), #f3b94d)',
|
||||||
|
color: '#132534',
|
||||||
|
fontWeight: 800,
|
||||||
|
textAlign: 'left',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ Novo Atendimento
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<nav
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gap: '0.5rem',
|
||||||
|
gridTemplateColumns: isMobile ? 'repeat(auto-fit, minmax(180px, 1fr))' : '1fr',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{items.map((item) => {
|
||||||
|
const isActive = item.id === activeItem;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => item.route && navigate(item.route)}
|
||||||
|
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%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.count}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
src/modules/home/components/HomeTopbar.jsx
Normal file
134
src/modules/home/components/HomeTopbar.jsx
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
export function HomeTopbar({
|
||||||
|
activeTab,
|
||||||
|
onTabChange,
|
||||||
|
searchValue,
|
||||||
|
onSearchChange,
|
||||||
|
isWideDesktop = false,
|
||||||
|
isDesktop = false,
|
||||||
|
isTablet = false,
|
||||||
|
isMobile = false,
|
||||||
|
}) {
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'messages', label: 'Mensagens' },
|
||||||
|
{ id: 'calls', label: 'Ligacoes' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const gridTemplateColumns = isMobile
|
||||||
|
? '1fr'
|
||||||
|
: isWideDesktop
|
||||||
|
? 'max-content minmax(180px, 220px) minmax(280px, 1fr) max-content'
|
||||||
|
: isDesktop || isTablet
|
||||||
|
? 'repeat(2, minmax(0, 1fr))'
|
||||||
|
: '1fr';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns,
|
||||||
|
gap: '1rem',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
padding: '0.35rem',
|
||||||
|
borderRadius: '18px',
|
||||||
|
background: 'rgba(0, 49, 80, 0.06)',
|
||||||
|
gap: '0.35rem',
|
||||||
|
width: isMobile ? '100%' : 'fit-content',
|
||||||
|
justifyContent: isMobile ? 'space-between' : 'flex-start',
|
||||||
|
minWidth: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const isActive = tab.id === activeTab;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onTabChange(tab.id)}
|
||||||
|
style={{
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '14px',
|
||||||
|
padding: '0.8rem 1rem',
|
||||||
|
background: isActive ? 'var(--color-primary)' : 'transparent',
|
||||||
|
color: isActive ? '#fff' : 'var(--color-primary)',
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '0.9rem 1.1rem',
|
||||||
|
borderRadius: '18px',
|
||||||
|
background: '#fff',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
color: 'var(--color-text-soft)',
|
||||||
|
fontWeight: 600,
|
||||||
|
width: isMobile ? '100%' : 'auto',
|
||||||
|
minWidth: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sexta, 19 de marco
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
placeholder="Buscar conversas, contatos ou protocolos"
|
||||||
|
value={searchValue}
|
||||||
|
onChange={(event) => onSearchChange(event.target.value)}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: '18px',
|
||||||
|
padding: '0.95rem 1rem',
|
||||||
|
background: '#fff',
|
||||||
|
outline: 'none',
|
||||||
|
minWidth: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.9rem',
|
||||||
|
justifySelf: isMobile ? 'stretch' : 'end',
|
||||||
|
justifyContent: isMobile ? 'space-between' : 'flex-end',
|
||||||
|
width: isMobile ? '100%' : 'auto',
|
||||||
|
minWidth: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ textAlign: 'right', minWidth: 0 }}>
|
||||||
|
<strong style={{ display: 'block' }}>Ana Camolesi</strong>
|
||||||
|
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.92rem' }}>
|
||||||
|
Atendimento omnichannel
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: '16px',
|
||||||
|
display: 'grid',
|
||||||
|
placeItems: 'center',
|
||||||
|
background: 'linear-gradient(135deg, var(--color-accent), var(--color-primary))',
|
||||||
|
color: '#fff',
|
||||||
|
fontWeight: 800,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
AM
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
308
src/modules/home/components/MessagesWorkspace.jsx
Normal file
308
src/modules/home/components/MessagesWorkspace.jsx
Normal file
@ -0,0 +1,308 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
function ChannelBadge({ channel }) {
|
||||||
|
const colors = {
|
||||||
|
WhatsApp: '#2bb741',
|
||||||
|
Email: '#e5a22a',
|
||||||
|
SMS: '#00a4b7',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderRadius: 999,
|
||||||
|
padding: '0.22rem 0.6rem',
|
||||||
|
background: `${colors[channel] || '#003150'}16`,
|
||||||
|
color: colors[channel] || '#003150',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{channel}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessagesWorkspace({
|
||||||
|
conversations,
|
||||||
|
activeConversationId,
|
||||||
|
onSelectConversation,
|
||||||
|
actionItems,
|
||||||
|
isWideDesktop = false,
|
||||||
|
isDesktop = false,
|
||||||
|
isTablet = false,
|
||||||
|
isMobile = false,
|
||||||
|
}) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const activeConversation =
|
||||||
|
conversations.find((conversation) => conversation.id === activeConversationId) ||
|
||||||
|
conversations[0];
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns,
|
||||||
|
gap: '1rem',
|
||||||
|
alignItems: 'start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
background: '#fff',
|
||||||
|
borderRadius: '26px',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
padding: '1rem',
|
||||||
|
display: 'grid',
|
||||||
|
gap: '0.75rem',
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{conversations.map((conversation) => {
|
||||||
|
const isActive = conversation.id === activeConversation.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={conversation.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelectConversation(conversation.id)}
|
||||||
|
style={{
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: isActive ? 'rgba(0, 164, 183, 0.26)' : 'var(--color-border)',
|
||||||
|
borderRadius: '20px',
|
||||||
|
padding: '1rem',
|
||||||
|
background: isActive ? 'rgba(0, 164, 183, 0.08)' : '#fff',
|
||||||
|
textAlign: 'left',
|
||||||
|
display: 'grid',
|
||||||
|
gap: '0.6rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
|
||||||
|
<strong>{conversation.name}</strong>
|
||||||
|
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.86rem' }}>
|
||||||
|
{conversation.time}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.75rem' }}>
|
||||||
|
<ChannelBadge channel={conversation.channel} />
|
||||||
|
{conversation.unread ? (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
minWidth: 24,
|
||||||
|
borderRadius: 999,
|
||||||
|
padding: '0.15rem 0.45rem',
|
||||||
|
background: 'var(--color-secondary)',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: '0.78rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{conversation.unread}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<span style={{ color: 'var(--color-text-soft)' }}>{conversation.lastMessage}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
background: '#fff',
|
||||||
|
borderRadius: '26px',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateRows: 'auto 1fr auto',
|
||||||
|
minHeight: 580,
|
||||||
|
overflow: 'hidden',
|
||||||
|
minWidth: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<header
|
||||||
|
style={{
|
||||||
|
padding: '1.15rem 1.25rem',
|
||||||
|
borderBottom: '1px solid var(--color-border)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<strong style={{ display: 'block', fontSize: '1.08rem' }}>{activeConversation.name}</strong>
|
||||||
|
<span style={{ color: 'var(--color-text-soft)' }}>
|
||||||
|
{activeConversation.status === 'online' ? 'Online agora' : 'Offline'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '0.6rem', flexWrap: 'wrap' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/chat')}
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: '14px',
|
||||||
|
padding: '0.7rem 0.9rem',
|
||||||
|
background: '#fff',
|
||||||
|
color: 'var(--color-primary)',
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Abrir chat
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={{
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '14px',
|
||||||
|
padding: '0.7rem 0.9rem',
|
||||||
|
background: 'rgba(0, 49, 80, 0.08)',
|
||||||
|
color: 'var(--color-primary)',
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Transferir
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '1.25rem',
|
||||||
|
display: 'grid',
|
||||||
|
gap: '0.9rem',
|
||||||
|
alignContent: 'start',
|
||||||
|
background:
|
||||||
|
'linear-gradient(180deg, rgba(245, 248, 251, 0.45), rgba(255, 255, 255, 0.9))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activeConversation.messages.map((message) => {
|
||||||
|
const isAgent = message.from === 'agent';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={message.id}
|
||||||
|
style={{
|
||||||
|
justifySelf: isAgent ? 'end' : 'start',
|
||||||
|
maxWidth: '72%',
|
||||||
|
padding: '0.95rem 1rem',
|
||||||
|
borderRadius: isAgent ? '18px 18px 6px 18px' : '18px 18px 18px 6px',
|
||||||
|
background: isAgent ? 'var(--color-primary)' : '#edf1f5',
|
||||||
|
color: isAgent ? '#fff' : 'var(--color-text)',
|
||||||
|
boxShadow: 'var(--shadow-md)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{message.text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer
|
||||||
|
style={{
|
||||||
|
padding: '1rem 1.25rem 1.25rem',
|
||||||
|
borderTop: '1px solid var(--color-border)',
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '1fr auto',
|
||||||
|
gap: '0.75rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value="Posso acionar o time responsavel e te retorno em seguida."
|
||||||
|
readOnly
|
||||||
|
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,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Enviar
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside
|
||||||
|
style={{
|
||||||
|
background: '#fff',
|
||||||
|
borderRadius: '26px',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
padding: '1.2rem',
|
||||||
|
display: 'grid',
|
||||||
|
gap: '1rem',
|
||||||
|
alignContent: 'start',
|
||||||
|
gridColumn: isWideDesktop ? 'auto' : '1 / -1',
|
||||||
|
minWidth: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<strong style={{ fontSize: '1.05rem' }}>Painel de acoes</strong>
|
||||||
|
<p style={{ margin: '0.35rem 0 0', color: 'var(--color-text-soft)' }}>
|
||||||
|
Contexto rapido do atendimento selecionado.
|
||||||
|
</p>
|
||||||
|
</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')}
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: '18px',
|
||||||
|
padding: '0.95rem 1rem',
|
||||||
|
background: '#fff',
|
||||||
|
color: 'var(--color-primary)',
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Criar novo fluxo
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
152
src/modules/home/pages/HomePage.jsx
Normal file
152
src/modules/home/pages/HomePage.jsx
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { BrandMark } from '../../../shared/components/BrandMark';
|
||||||
|
import { HomeSidebar } from '../components/HomeSidebar';
|
||||||
|
import { HomeTopbar } from '../components/HomeTopbar';
|
||||||
|
import { MessagesWorkspace } from '../components/MessagesWorkspace';
|
||||||
|
import { CallsWorkspace } from '../components/CallsWorkspace';
|
||||||
|
import { actionItems, conversations, recentCalls, sidebarItems } from '../services/homeMocks';
|
||||||
|
import { useViewport } from '../../../shared/hooks/useViewport';
|
||||||
|
|
||||||
|
export function HomePage() {
|
||||||
|
const { isWideDesktop, isDesktop, isTablet, isMobile } = useViewport();
|
||||||
|
const [activeTab, setActiveTab] = useState('messages');
|
||||||
|
const [searchValue, setSearchValue] = useState('');
|
||||||
|
const [activeConversationId, setActiveConversationId] = useState(conversations[0].id);
|
||||||
|
|
||||||
|
const search = searchValue.trim().toLowerCase();
|
||||||
|
const filteredConversations = !search
|
||||||
|
? conversations
|
||||||
|
: conversations.filter((conversation) => {
|
||||||
|
const haystack = `${conversation.name} ${conversation.channel} ${conversation.lastMessage}`;
|
||||||
|
return haystack.toLowerCase().includes(search);
|
||||||
|
});
|
||||||
|
|
||||||
|
const safeConversationId =
|
||||||
|
filteredConversations.find((conversation) => conversation.id === activeConversationId)?.id ||
|
||||||
|
filteredConversations[0]?.id ||
|
||||||
|
conversations[0].id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
style={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
padding: '1.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
width: 'min(1680px, calc(100vw - 3rem))',
|
||||||
|
margin: '0 auto',
|
||||||
|
background: 'var(--color-surface-strong)',
|
||||||
|
borderRadius: '32px',
|
||||||
|
boxShadow: 'var(--shadow-lg)',
|
||||||
|
padding: '1.5rem',
|
||||||
|
display: 'grid',
|
||||||
|
gap: '1.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: isDesktop ? 'minmax(340px, 380px) minmax(0, 1fr)' : '1fr',
|
||||||
|
gap: '1.5rem',
|
||||||
|
alignItems: 'start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gap: '1.25rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: '#fff',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: '28px',
|
||||||
|
padding: '1.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BrandMark size="lg" />
|
||||||
|
</div>
|
||||||
|
<HomeSidebar items={sidebarItems} activeItem="dashboard" isMobile={!isDesktop} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gap: '1.25rem', minWidth: 0 }}>
|
||||||
|
<HomeTopbar
|
||||||
|
activeTab={activeTab}
|
||||||
|
onTabChange={setActiveTab}
|
||||||
|
searchValue={searchValue}
|
||||||
|
onSearchChange={setSearchValue}
|
||||||
|
isWideDesktop={isWideDesktop}
|
||||||
|
isDesktop={isDesktop}
|
||||||
|
isTablet={isTablet}
|
||||||
|
isMobile={isMobile}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
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>
|
||||||
|
|
||||||
|
{activeTab === 'messages' ? (
|
||||||
|
<MessagesWorkspace
|
||||||
|
conversations={filteredConversations}
|
||||||
|
activeConversationId={safeConversationId}
|
||||||
|
onSelectConversation={setActiveConversationId}
|
||||||
|
actionItems={actionItems}
|
||||||
|
isWideDesktop={isWideDesktop}
|
||||||
|
isDesktop={isDesktop}
|
||||||
|
isTablet={isTablet}
|
||||||
|
isMobile={isMobile}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CallsWorkspace
|
||||||
|
calls={recentCalls}
|
||||||
|
isWideDesktop={isWideDesktop}
|
||||||
|
isDesktop={isDesktop}
|
||||||
|
isMobile={isMobile}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
src/modules/home/services/homeMocks.js
Normal file
63
src/modules/home/services/homeMocks.js
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
export const sidebarItems = [
|
||||||
|
{ id: 'dashboard', label: 'Dashboard' },
|
||||||
|
{ id: 'new-attendance', label: 'Novos Atendimentos', route: '/new-attendance' },
|
||||||
|
{ id: 'in-progress', label: 'Em andamento', count: 8 },
|
||||||
|
{ id: 'completed', label: 'Finalizados', count: 24 },
|
||||||
|
{ id: 'contacts', label: 'Contatos', count: 128 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const conversations = [
|
||||||
|
{
|
||||||
|
id: 'maria-souza',
|
||||||
|
name: 'Maria Souza',
|
||||||
|
channel: 'WhatsApp',
|
||||||
|
status: 'online',
|
||||||
|
lastMessage: 'Preciso atualizar o cadastro do meu pedido.',
|
||||||
|
unread: 2,
|
||||||
|
time: '09:42',
|
||||||
|
messages: [
|
||||||
|
{ id: 1, from: 'customer', text: 'Oi, bom dia! Preciso de ajuda com meu pedido.' },
|
||||||
|
{ id: 2, from: 'agent', text: 'Bom dia, Maria! Claro, me conta o que aconteceu.' },
|
||||||
|
{ id: 3, from: 'customer', text: 'Quero confirmar se o endereco foi alterado.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'empresa-alpha',
|
||||||
|
name: 'Empresa Alpha',
|
||||||
|
channel: 'Email',
|
||||||
|
status: 'offline',
|
||||||
|
lastMessage: 'Aguardando retorno sobre a proposta comercial.',
|
||||||
|
unread: 0,
|
||||||
|
time: 'Ontem',
|
||||||
|
messages: [
|
||||||
|
{ id: 1, from: 'customer', text: 'Precisamos rever os valores da ultima proposta.' },
|
||||||
|
{ id: 2, from: 'agent', text: 'Perfeito, vou encaminhar para o time comercial.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'joao-pedro',
|
||||||
|
name: 'Joao Pedro',
|
||||||
|
channel: 'SMS',
|
||||||
|
status: 'online',
|
||||||
|
lastMessage: 'Pode me ligar em 10 minutos?',
|
||||||
|
unread: 1,
|
||||||
|
time: '08:15',
|
||||||
|
messages: [
|
||||||
|
{ id: 1, from: 'customer', text: 'Recebi a cobranca em duplicidade.' },
|
||||||
|
{ id: 2, from: 'agent', text: 'Vou analisar isso agora para voce.' },
|
||||||
|
{ id: 3, from: 'customer', text: 'Pode me ligar em 10 minutos?' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const actionItems = [
|
||||||
|
{ title: 'Area atual', value: 'Suporte' },
|
||||||
|
{ title: 'SLA restante', value: '18 min' },
|
||||||
|
{ title: 'Prioridade', value: 'Alta' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const recentCalls = [
|
||||||
|
{ id: 'call-1', name: 'Beatriz Lima', area: 'Comercial', duration: '12:48', status: 'Encerrada' },
|
||||||
|
{ id: 'call-2', name: 'Carlos Nunes', area: 'Suporte', duration: '03:12', status: 'Perdida' },
|
||||||
|
{ id: 'call-3', name: 'Grupo Solaris', area: 'Financeiro', duration: '08:55', status: 'Em andamento' },
|
||||||
|
];
|
||||||
33
src/routes/router.jsx
Normal file
33
src/routes/router.jsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { createBrowserRouter, Navigate } from 'react-router-dom';
|
||||||
|
import { LoginPage } from '../modules/auth/pages/LoginPage';
|
||||||
|
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';
|
||||||
|
|
||||||
|
export const router = createBrowserRouter([
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
element: <Navigate to="/login" replace />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
element: <LoginPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/home',
|
||||||
|
element: <HomePage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/chat',
|
||||||
|
element: <ChatPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/call',
|
||||||
|
element: <CallPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/new-attendance',
|
||||||
|
element: <NewAttendancePage />,
|
||||||
|
},
|
||||||
|
]);
|
||||||
BIN
src/shared/assets/favicon_blue.png
Normal file
BIN
src/shared/assets/favicon_blue.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.8 KiB |
BIN
src/shared/assets/logo_white_dark_mode.png
Normal file
BIN
src/shared/assets/logo_white_dark_mode.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
BIN
src/shared/assets/logo_white_mode.png
Normal file
BIN
src/shared/assets/logo_white_mode.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
57
src/shared/components/BrandMark.jsx
Normal file
57
src/shared/components/BrandMark.jsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import logoLight from '../assets/logo_white_mode.png';
|
||||||
|
import logoDark from '../assets/logo_white_dark_mode.png';
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
sm: {
|
||||||
|
gap: '0.75rem',
|
||||||
|
logoHeight: 42,
|
||||||
|
titleSize: '1rem',
|
||||||
|
subtitleSize: '0.85rem',
|
||||||
|
},
|
||||||
|
md: {
|
||||||
|
gap: '1rem',
|
||||||
|
logoHeight: 54,
|
||||||
|
titleSize: '1.2rem',
|
||||||
|
subtitleSize: '0.95rem',
|
||||||
|
},
|
||||||
|
lg: {
|
||||||
|
gap: '1.25rem',
|
||||||
|
logoHeight: 78,
|
||||||
|
titleSize: '1.5rem',
|
||||||
|
subtitleSize: '1.05rem',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BrandMark({ compact = false, theme = 'light', size }) {
|
||||||
|
const logoSrc = theme === 'dark' ? logoDark : logoLight;
|
||||||
|
const variant = size || (compact ? 'sm' : 'md');
|
||||||
|
const currentSize = sizes[variant] || sizes.md;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: currentSize.gap,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={logoSrc}
|
||||||
|
alt="Sothis"
|
||||||
|
style={{
|
||||||
|
height: currentSize.logoHeight,
|
||||||
|
width: 'auto',
|
||||||
|
objectFit: 'contain',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: currentSize.titleSize, fontWeight: 800 }}>
|
||||||
|
Sothis Omnichannel
|
||||||
|
</div>
|
||||||
|
<div style={{ color: 'var(--color-text-soft)', fontSize: currentSize.subtitleSize }}>
|
||||||
|
Central de atendimento unificada
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
src/shared/hooks/useViewport.js
Normal file
30
src/shared/hooks/useViewport.js
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
function getViewportWidth() {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return 1440;
|
||||||
|
}
|
||||||
|
|
||||||
|
return window.innerWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useViewport() {
|
||||||
|
const [width, setWidth] = useState(getViewportWidth);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleResize() {
|
||||||
|
setWidth(window.innerWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
width,
|
||||||
|
isWideDesktop: width >= 1500,
|
||||||
|
isDesktop: width >= 1180,
|
||||||
|
isTablet: width < 1180 && width >= 760,
|
||||||
|
isMobile: width < 760,
|
||||||
|
};
|
||||||
|
}
|
||||||
50
src/shared/styles/global.css
Normal file
50
src/shared/styles/global.css
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
:root {
|
||||||
|
font-family: "Segoe UI", "Helvetica Neue", sans-serif;
|
||||||
|
color: #122230;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(0, 164, 183, 0.12), transparent 28%),
|
||||||
|
radial-gradient(circle at bottom right, rgba(229, 162, 42, 0.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, 0.9);
|
||||||
|
--color-surface-strong: #ffffff;
|
||||||
|
--color-text: #122230;
|
||||||
|
--color-text-soft: #5e6d7b;
|
||||||
|
--color-border: rgba(0, 49, 80, 0.12);
|
||||||
|
--shadow-lg: 0 24px 60px rgba(0, 49, 80, 0.12);
|
||||||
|
--shadow-md: 0 12px 28px rgba(0, 49, 80, 0.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;
|
||||||
|
}
|
||||||
14
vite.config.js
Normal file
14
vite.config.js
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 3000,
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 3000,
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user