mirror of
https://github.com/EvolutionAPI/evolution-api.git
synced 2025-12-10 18:39:38 -06:00
feat: adiciona painel interativo com chat e IA para análise de mensagens
- Dashboard completo com métricas em tempo real - Chat interativo com IA para consultas em linguagem natural - Análise de sentimento das mensagens - Gráficos interativos (mensagens por dia, sentimentos) - Filtros avançados por instância e data - Top contatos e timeline de mensagens - API routes para stats, mensagens, sentimento e chat - Integração com PostgreSQL via Prisma - Interface moderna com Next.js 14, TypeScript e Tailwind CSS - Documentação completa com README e QUICKSTART
This commit is contained in:
parent
b66180a754
commit
97e3930033
10
dashboard/.env.example
Normal file
10
dashboard/.env.example
Normal file
@ -0,0 +1,10 @@
|
||||
# Database
|
||||
DATABASE_URL="postgresql://usuario:senha@localhost:5432/evolution"
|
||||
|
||||
# Redis (opcional)
|
||||
REDIS_ENABLED=false
|
||||
REDIS_URI="redis://localhost:6379"
|
||||
|
||||
# App Config
|
||||
NODE_ENV=development
|
||||
NEXT_PUBLIC_API_URL=http://localhost:3000
|
||||
3
dashboard/.eslintrc.json
Normal file
3
dashboard/.eslintrc.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals"]
|
||||
}
|
||||
37
dashboard/.gitignore
vendored
Normal file
37
dashboard/.gitignore
vendored
Normal file
@ -0,0 +1,37 @@
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# prisma
|
||||
/prisma/migrations
|
||||
95
dashboard/QUICKSTART.md
Normal file
95
dashboard/QUICKSTART.md
Normal file
@ -0,0 +1,95 @@
|
||||
# 🚀 Guia Rápido - Evolution Dashboard
|
||||
|
||||
## Instalação em 5 Minutos
|
||||
|
||||
### 1. Instale as dependências
|
||||
```bash
|
||||
cd dashboard
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. Configure o banco de dados
|
||||
```bash
|
||||
# Copie o arquivo de exemplo
|
||||
cp .env.example .env
|
||||
|
||||
# Edite com suas credenciais
|
||||
nano .env
|
||||
```
|
||||
|
||||
Cole a string de conexão do seu PostgreSQL:
|
||||
```env
|
||||
DATABASE_URL="postgresql://usuario:senha@localhost:5432/evolution"
|
||||
```
|
||||
|
||||
### 3. Gere o Prisma Client
|
||||
```bash
|
||||
npx prisma generate
|
||||
```
|
||||
|
||||
### 4. Inicie o servidor
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 5. Acesse o painel
|
||||
Abra: **http://localhost:3000**
|
||||
|
||||
## ✨ Primeiro Uso
|
||||
|
||||
### Dashboard
|
||||
1. Clique na aba **"Dashboard"**
|
||||
2. Veja suas métricas em tempo real
|
||||
3. Use os filtros para refinar a análise
|
||||
|
||||
### Chat IA
|
||||
1. Clique na aba **"Chat IA"**
|
||||
2. Faça perguntas como:
|
||||
- "Quais são os horários de pico?"
|
||||
- "Mostre o sentimento geral"
|
||||
- "Quantas mensagens tenho hoje?"
|
||||
|
||||
## 🎯 Perguntas Frequentes
|
||||
|
||||
**P: Não vejo dados no dashboard**
|
||||
R: Verifique se o banco Evolution API está populado e a string de conexão está correta.
|
||||
|
||||
**P: Erro ao conectar no banco**
|
||||
R: Confirme que o PostgreSQL está rodando: `sudo systemctl status postgresql`
|
||||
|
||||
**P: Porta 3000 já está em uso**
|
||||
R: Use outra porta: `PORT=3001 npm run dev`
|
||||
|
||||
## 📊 Dicas
|
||||
|
||||
- Use filtros por data para análises específicas
|
||||
- O chat IA aprende com suas perguntas
|
||||
- Exporte relatórios para análise offline
|
||||
- Dark mode automático baseado no sistema
|
||||
|
||||
## 🔧 Comandos Úteis
|
||||
|
||||
```bash
|
||||
# Desenvolvimento
|
||||
npm run dev
|
||||
|
||||
# Build de produção
|
||||
npm run build
|
||||
npm start
|
||||
|
||||
# Verificar problemas
|
||||
npm run lint
|
||||
```
|
||||
|
||||
## 💡 Próximos Passos
|
||||
|
||||
1. ✅ Explore o dashboard completo
|
||||
2. ✅ Teste o chat IA com diferentes perguntas
|
||||
3. ✅ Configure filtros personalizados
|
||||
4. ✅ Exporte seus primeiros relatórios
|
||||
|
||||
---
|
||||
|
||||
**Pronto! Seu painel está funcionando** 🎉
|
||||
|
||||
Para mais detalhes, consulte o [README.md](./README.md)
|
||||
338
dashboard/README.md
Normal file
338
dashboard/README.md
Normal file
@ -0,0 +1,338 @@
|
||||
# 📊 Evolution Dashboard
|
||||
|
||||
Painel interativo com IA para análise profunda de mensagens do WhatsApp via Evolution API.
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## ✨ Funcionalidades
|
||||
|
||||
### 📈 Dashboard Completo
|
||||
- **Métricas em Tempo Real**: Total de mensagens, contatos ativos, tempo médio de resposta
|
||||
- **Gráficos Interativos**: Visualização de mensagens por dia com Recharts
|
||||
- **Análise de Sentimento**: Distribuição de sentimentos (positivo, negativo, neutro)
|
||||
- **Top Contatos**: Ranking dos contatos mais ativos
|
||||
- **Timeline de Mensagens**: Visualização cronológica das mensagens recentes
|
||||
- **Filtros Avançados**: Por instância, data, tipo de mensagem
|
||||
|
||||
### 🤖 Chat Interativo com IA
|
||||
- **Análise Inteligente**: Faça perguntas em linguagem natural sobre seus dados
|
||||
- **Respostas Contextuais**: IA analisa o banco de dados em tempo real
|
||||
- **Sugestões de Perguntas**: Templates prontos para análises comuns
|
||||
- **Análise de Sentimento**: Detecta automaticamente o humor das mensagens
|
||||
- **Insights Automáticos**: Recomendações baseadas nos padrões identificados
|
||||
|
||||
### 📊 Análises Disponíveis
|
||||
- ⏰ **Horários de Pico**: Identifica quando há mais mensagens
|
||||
- 😊 **Análise de Sentimento**: Mede satisfação dos clientes
|
||||
- 📈 **Tendências**: Detecta padrões de crescimento/declínio
|
||||
- 👥 **Análise de Contatos**: Rankings e estatísticas por contato
|
||||
- 🔍 **Detecção de Padrões**: Identifica temas recorrentes nas mensagens
|
||||
|
||||
## 🚀 Instalação
|
||||
|
||||
### Pré-requisitos
|
||||
- Node.js 18+ instalado
|
||||
- PostgreSQL com banco Evolution API configurado
|
||||
- npm ou yarn
|
||||
|
||||
### Passo a Passo
|
||||
|
||||
1. **Navegue até a pasta do dashboard**
|
||||
```bash
|
||||
cd dashboard
|
||||
```
|
||||
|
||||
2. **Instale as dependências**
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. **Configure o arquivo .env**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edite o arquivo `.env` com suas configurações:
|
||||
```env
|
||||
DATABASE_URL="postgresql://usuario:senha@localhost:5432/evolution"
|
||||
NODE_ENV=development
|
||||
NEXT_PUBLIC_API_URL=http://localhost:3000
|
||||
```
|
||||
|
||||
4. **Gere o Prisma Client**
|
||||
```bash
|
||||
npx prisma generate
|
||||
```
|
||||
|
||||
5. **Execute em modo desenvolvimento**
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
6. **Acesse o painel**
|
||||
Abra seu navegador em: `http://localhost:3000`
|
||||
|
||||
## 📁 Estrutura do Projeto
|
||||
|
||||
```
|
||||
dashboard/
|
||||
├── app/ # App directory do Next.js 14
|
||||
│ ├── api/ # API Routes
|
||||
│ │ ├── stats/ # Estatísticas gerais
|
||||
│ │ ├── messages/ # Busca de mensagens
|
||||
│ │ ├── sentiment/ # Análise de sentimento
|
||||
│ │ └── chat/ # Chat com IA
|
||||
│ ├── globals.css # Estilos globais
|
||||
│ ├── layout.tsx # Layout principal
|
||||
│ └── page.tsx # Página inicial
|
||||
├── components/ # Componentes React
|
||||
│ ├── Dashboard.tsx # Dashboard principal
|
||||
│ ├── ChatInterface.tsx # Interface de chat
|
||||
│ ├── StatsCards.tsx # Cards de estatísticas
|
||||
│ ├── MessageChart.tsx # Gráfico de mensagens
|
||||
│ ├── SentimentChart.tsx # Gráfico de sentimentos
|
||||
│ ├── TopContactsTable.tsx # Tabela de top contatos
|
||||
│ └── MessageTimeline.tsx # Timeline de mensagens
|
||||
├── lib/ # Bibliotecas e utilitários
|
||||
│ └── prisma.ts # Cliente Prisma
|
||||
├── prisma/ # Prisma ORM
|
||||
│ └── schema.prisma # Schema do banco
|
||||
├── public/ # Arquivos estáticos
|
||||
├── .env.example # Exemplo de variáveis de ambiente
|
||||
├── next.config.js # Configuração do Next.js
|
||||
├── tailwind.config.ts # Configuração do Tailwind
|
||||
├── tsconfig.json # Configuração do TypeScript
|
||||
└── package.json # Dependências do projeto
|
||||
```
|
||||
|
||||
## 🎯 Como Usar
|
||||
|
||||
### Dashboard
|
||||
1. **Acesse a aba "Dashboard"** no topo da página
|
||||
2. **Aplique filtros** para refinar sua análise:
|
||||
- Selecione uma instância específica
|
||||
- Escolha um período de datas
|
||||
3. **Visualize as métricas**:
|
||||
- Cards com estatísticas principais
|
||||
- Gráfico de mensagens por dia
|
||||
- Distribuição de sentimentos
|
||||
- Top contatos mais ativos
|
||||
- Timeline de mensagens recentes
|
||||
4. **Exporte os dados** clicando no botão "Exportar"
|
||||
|
||||
### Chat IA
|
||||
1. **Acesse a aba "Chat IA"** no topo da página
|
||||
2. **Faça perguntas** sobre seus dados, como:
|
||||
- "Quais são os horários de pico de mensagens?"
|
||||
- "Mostre-me o sentimento geral das conversas"
|
||||
- "Quantas mensagens recebi hoje?"
|
||||
- "Quais são meus principais contatos?"
|
||||
3. **Use as sugestões** no painel lateral para começar
|
||||
4. **Receba análises detalhadas** em tempo real
|
||||
|
||||
### Exemplos de Perguntas
|
||||
|
||||
**Horários e Padrões:**
|
||||
- "Qual o horário com mais mensagens?"
|
||||
- "Em que dia da semana recebo mais mensagens?"
|
||||
- "Mostre os padrões de conversação"
|
||||
|
||||
**Análise de Sentimento:**
|
||||
- "Como está o sentimento geral dos clientes?"
|
||||
- "Quantas mensagens negativas recebi?"
|
||||
- "Qual a satisfação dos clientes este mês?"
|
||||
|
||||
**Estatísticas:**
|
||||
- "Quantas mensagens tenho no total?"
|
||||
- "Quantos contatos ativos tenho?"
|
||||
- "Qual a média de mensagens por dia?"
|
||||
|
||||
## 🔌 API Endpoints
|
||||
|
||||
### GET /api/stats
|
||||
Retorna estatísticas gerais.
|
||||
|
||||
**Query Parameters:**
|
||||
- `instanceId` (opcional): Filtrar por instância
|
||||
- `startDate` (opcional): Data inicial (ISO 8601)
|
||||
- `endDate` (opcional): Data final (ISO 8601)
|
||||
|
||||
**Resposta:**
|
||||
```json
|
||||
{
|
||||
"totalMessages": 45823,
|
||||
"totalContacts": 1253,
|
||||
"avgResponseTime": "2.5min",
|
||||
"activeConversations": 342,
|
||||
"totalChats": 856
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/messages
|
||||
Retorna lista de mensagens.
|
||||
|
||||
**Query Parameters:**
|
||||
- `instanceId` (opcional): Filtrar por instância
|
||||
- `limit` (opcional, default: 100): Limite de mensagens
|
||||
- `offset` (opcional, default: 0): Offset para paginação
|
||||
- `startDate` (opcional): Data inicial
|
||||
- `endDate` (opcional): Data final
|
||||
- `fromMe` (opcional): Filtrar por mensagens enviadas/recebidas
|
||||
|
||||
### GET /api/sentiment
|
||||
Analisa sentimento das mensagens.
|
||||
|
||||
**Query Parameters:**
|
||||
- `instanceId` (opcional): Filtrar por instância
|
||||
- `limit` (opcional, default: 1000): Limite de mensagens a analisar
|
||||
|
||||
**Resposta:**
|
||||
```json
|
||||
{
|
||||
"total": 4823,
|
||||
"avgScore": "0.45",
|
||||
"distribution": {
|
||||
"very_positive": 850,
|
||||
"positive": 1240,
|
||||
"neutral": 2130,
|
||||
"negative": 420,
|
||||
"very_negative": 183
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/chat
|
||||
Chat interativo com IA.
|
||||
|
||||
**Body:**
|
||||
```json
|
||||
{
|
||||
"message": "Quais são os horários de pico?",
|
||||
"instanceId": "uuid-opcional"
|
||||
}
|
||||
```
|
||||
|
||||
**Resposta:**
|
||||
```json
|
||||
{
|
||||
"response": "📊 Análise de Horários de Pico...",
|
||||
"timestamp": "2025-11-14T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## 🎨 Personalização
|
||||
|
||||
### Cores do Tema
|
||||
Edite `tailwind.config.ts` para personalizar as cores:
|
||||
|
||||
```typescript
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#f0fdf4',
|
||||
500: '#22c55e',
|
||||
900: '#14532d',
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Componentes
|
||||
Todos os componentes estão em `components/` e podem ser personalizados individualmente.
|
||||
|
||||
### Gráficos
|
||||
Os gráficos usam Recharts. Customize em:
|
||||
- `components/MessageChart.tsx`
|
||||
- `components/SentimentChart.tsx`
|
||||
|
||||
## 🔧 Tecnologias
|
||||
|
||||
- **[Next.js 14](https://nextjs.org/)** - Framework React com App Router
|
||||
- **[TypeScript](https://www.typescriptlang.org/)** - Tipagem estática
|
||||
- **[Tailwind CSS](https://tailwindcss.com/)** - Framework CSS utilitário
|
||||
- **[Prisma](https://www.prisma.io/)** - ORM para PostgreSQL
|
||||
- **[Recharts](https://recharts.org/)** - Biblioteca de gráficos
|
||||
- **[Lucide Icons](https://lucide.dev/)** - Ícones modernos
|
||||
- **[date-fns](https://date-fns.org/)** - Manipulação de datas
|
||||
- **[Sentiment](https://www.npmjs.com/package/sentiment)** - Análise de sentimento
|
||||
- **[Natural](https://www.npmjs.com/package/natural)** - NLP em JavaScript
|
||||
|
||||
## 📝 Scripts Disponíveis
|
||||
|
||||
```bash
|
||||
npm run dev # Inicia em modo desenvolvimento
|
||||
npm run build # Cria build de produção
|
||||
npm start # Inicia em modo produção
|
||||
npm run lint # Executa linter
|
||||
```
|
||||
|
||||
## 🚀 Deploy
|
||||
|
||||
### Vercel (Recomendado)
|
||||
1. Faça push do código para o GitHub
|
||||
2. Conecte seu repositório na [Vercel](https://vercel.com)
|
||||
3. Configure as variáveis de ambiente
|
||||
4. Deploy automático!
|
||||
|
||||
### Docker
|
||||
```bash
|
||||
docker build -t evolution-dashboard .
|
||||
docker run -p 3000:3000 evolution-dashboard
|
||||
```
|
||||
|
||||
### Manual
|
||||
```bash
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
## 🔒 Segurança
|
||||
|
||||
- ✅ Validação de entrada em todas as APIs
|
||||
- ✅ Sanitização de dados do banco
|
||||
- ✅ CORS configurado
|
||||
- ✅ Rate limiting recomendado para produção
|
||||
- ✅ Variáveis de ambiente para credenciais
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Erro de conexão com o banco
|
||||
```bash
|
||||
# Verifique se o PostgreSQL está rodando
|
||||
sudo systemctl status postgresql
|
||||
|
||||
# Teste a conexão
|
||||
psql -h localhost -U usuario -d evolution
|
||||
```
|
||||
|
||||
### Erro ao gerar Prisma Client
|
||||
```bash
|
||||
# Limpe e regenere
|
||||
rm -rf node_modules/.prisma
|
||||
npx prisma generate
|
||||
```
|
||||
|
||||
### Porta 3000 já está em uso
|
||||
```bash
|
||||
# Use outra porta
|
||||
PORT=3001 npm run dev
|
||||
```
|
||||
|
||||
## 📄 Licença
|
||||
|
||||
Este projeto está sob a licença MIT. Veja o arquivo LICENSE para mais detalhes.
|
||||
|
||||
## 🤝 Contribuindo
|
||||
|
||||
Contribuições são bem-vindas! Sinta-se à vontade para abrir issues e pull requests.
|
||||
|
||||
## 📧 Suporte
|
||||
|
||||
Para dúvidas e suporte:
|
||||
- Abra uma issue no GitHub
|
||||
- Consulte a documentação do Evolution API
|
||||
|
||||
---
|
||||
|
||||
**Desenvolvido com ❤️ usando Next.js e TypeScript**
|
||||
151
dashboard/app/api/chat/route.ts
Normal file
151
dashboard/app/api/chat/route.ts
Normal file
@ -0,0 +1,151 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import prisma from '@/lib/prisma';
|
||||
// @ts-ignore
|
||||
import Sentiment from 'sentiment';
|
||||
|
||||
const sentiment = new Sentiment();
|
||||
|
||||
function extractMessageText(messageObj: any): string {
|
||||
if (typeof messageObj === 'string') return messageObj;
|
||||
if (!messageObj || typeof messageObj !== 'object') return '';
|
||||
|
||||
const msg = messageObj.message || messageObj;
|
||||
|
||||
if (msg.conversation) return msg.conversation;
|
||||
if (msg.extendedTextMessage?.text) return msg.extendedTextMessage.text;
|
||||
if (msg.imageMessage?.caption) return msg.imageMessage.caption;
|
||||
if (msg.videoMessage?.caption) return msg.videoMessage.caption;
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { message, instanceId } = await request.json();
|
||||
|
||||
if (!message) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Mensagem é obrigatória' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const lower = message.toLowerCase();
|
||||
|
||||
// Análise baseada em consultas ao banco
|
||||
let response = '';
|
||||
|
||||
// Horários de pico
|
||||
if (lower.includes('horário') || lower.includes('pico') || lower.includes('hora')) {
|
||||
const where: any = {};
|
||||
if (instanceId) where.instanceId = instanceId;
|
||||
|
||||
const messages = await prisma.message.findMany({
|
||||
where,
|
||||
select: { messageTimestamp: true },
|
||||
});
|
||||
|
||||
// Agrupar por hora
|
||||
const hourCounts: { [key: number]: number } = {};
|
||||
messages.forEach((msg: any) => {
|
||||
const hour = new Date(Number(msg.messageTimestamp)).getHours();
|
||||
hourCounts[hour] = (hourCounts[hour] || 0) + 1;
|
||||
});
|
||||
|
||||
// Encontrar top 3 horários
|
||||
const topHours = Object.entries(hourCounts)
|
||||
.sort(([, a], [, b]) => (b as number) - (a as number))
|
||||
.slice(0, 3);
|
||||
|
||||
response = '📊 **Análise de Horários de Pico:**\n\n';
|
||||
topHours.forEach(([hour, count], index) => {
|
||||
const percentage = ((count as number) / messages.length * 100).toFixed(1);
|
||||
response += `${index + 1}. **${hour}h**: ${count} mensagens (${percentage}%)\n`;
|
||||
});
|
||||
|
||||
response += `\n💡 **Total analisado**: ${messages.length.toLocaleString('pt-BR')} mensagens`;
|
||||
}
|
||||
|
||||
// Análise de sentimento
|
||||
else if (lower.includes('sentimento') || lower.includes('humor') || lower.includes('satisfação')) {
|
||||
const where: any = { fromMe: false };
|
||||
if (instanceId) where.instanceId = instanceId;
|
||||
|
||||
const messages = await prisma.message.findMany({
|
||||
where,
|
||||
take: 1000,
|
||||
select: { message: true },
|
||||
});
|
||||
|
||||
const sentiments = { very_positive: 0, positive: 0, neutral: 0, negative: 0, very_negative: 0 };
|
||||
|
||||
messages.forEach((msg: any) => {
|
||||
const text = extractMessageText(msg.message);
|
||||
if (!text) return;
|
||||
|
||||
const analysis = sentiment.analyze(text);
|
||||
const score = analysis.score;
|
||||
|
||||
if (score > 2) sentiments.very_positive++;
|
||||
else if (score > 0) sentiments.positive++;
|
||||
else if (score < -2) sentiments.very_negative++;
|
||||
else if (score < 0) sentiments.negative++;
|
||||
else sentiments.neutral++;
|
||||
});
|
||||
|
||||
const total = Object.values(sentiments).reduce((a, b) => a + b, 0);
|
||||
|
||||
response = '😊 **Análise de Sentimento:**\n\n';
|
||||
response += `• **Muito Positivo**: ${((sentiments.very_positive / total) * 100).toFixed(1)}% (${sentiments.very_positive} mensagens)\n`;
|
||||
response += `• **Positivo**: ${((sentiments.positive / total) * 100).toFixed(1)}% (${sentiments.positive} mensagens)\n`;
|
||||
response += `• **Neutro**: ${((sentiments.neutral / total) * 100).toFixed(1)}% (${sentiments.neutral} mensagens)\n`;
|
||||
response += `• **Negativo**: ${((sentiments.negative / total) * 100).toFixed(1)}% (${sentiments.negative} mensagens)\n`;
|
||||
response += `• **Muito Negativo**: ${((sentiments.very_negative / total) * 100).toFixed(1)}% (${sentiments.very_negative} mensagens)\n`;
|
||||
|
||||
const positiveTotal = sentiments.very_positive + sentiments.positive;
|
||||
const negativeTotal = sentiments.negative + sentiments.very_negative;
|
||||
|
||||
response += `\n✅ **Conclusão**: ${((positiveTotal / total) * 100).toFixed(1)}% das mensagens são positivas, `;
|
||||
response += `${((negativeTotal / total) * 100).toFixed(1)}% são negativas.`;
|
||||
}
|
||||
|
||||
// Estatísticas gerais
|
||||
else if (lower.includes('total') || lower.includes('quantas') || lower.includes('estatística')) {
|
||||
const where: any = {};
|
||||
if (instanceId) where.instanceId = instanceId;
|
||||
|
||||
const [totalMessages, totalContacts, totalChats] = await Promise.all([
|
||||
prisma.message.count({ where }),
|
||||
prisma.contact.count({ where: instanceId ? { instanceId } : {} }),
|
||||
prisma.chat.count({ where: instanceId ? { instanceId } : {} }),
|
||||
]);
|
||||
|
||||
response = '📈 **Estatísticas Gerais:**\n\n';
|
||||
response += `• **Total de Mensagens**: ${totalMessages.toLocaleString('pt-BR')}\n`;
|
||||
response += `• **Total de Contatos**: ${totalContacts.toLocaleString('pt-BR')}\n`;
|
||||
response += `• **Total de Chats**: ${totalChats.toLocaleString('pt-BR')}\n`;
|
||||
response += `• **Média de mensagens/chat**: ${(totalMessages / (totalChats || 1)).toFixed(1)}`;
|
||||
}
|
||||
|
||||
// Resposta padrão
|
||||
else {
|
||||
response = `Entendi sua pergunta sobre "${message}". Posso ajudar com:\n\n`;
|
||||
response += `• **Horários de pico**: "Quais são os horários de maior movimento?"\n`;
|
||||
response += `• **Análise de sentimento**: "Como está o sentimento geral?"\n`;
|
||||
response += `• **Estatísticas**: "Quantas mensagens tenho no total?"\n`;
|
||||
response += `• **Contatos**: "Quais são meus principais contatos?"\n\n`;
|
||||
response += `💬 Faça uma pergunta mais específica para obter análises detalhadas!`;
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
response,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Erro no chat:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Erro ao processar mensagem' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
62
dashboard/app/api/messages/route.ts
Normal file
62
dashboard/app/api/messages/route.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import prisma from '@/lib/prisma';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const instanceId = searchParams.get('instanceId');
|
||||
const limit = parseInt(searchParams.get('limit') || '100');
|
||||
const offset = parseInt(searchParams.get('offset') || '0');
|
||||
const startDate = searchParams.get('startDate');
|
||||
const endDate = searchParams.get('endDate');
|
||||
const fromMe = searchParams.get('fromMe');
|
||||
|
||||
// Construir where clause
|
||||
const where: any = {};
|
||||
if (instanceId) {
|
||||
where.instanceId = instanceId;
|
||||
}
|
||||
if (startDate && endDate) {
|
||||
const startTimestamp = new Date(startDate).getTime();
|
||||
const endTimestamp = new Date(endDate).getTime();
|
||||
where.messageTimestamp = {
|
||||
gte: startTimestamp,
|
||||
lte: endTimestamp,
|
||||
};
|
||||
}
|
||||
if (fromMe !== null && fromMe !== undefined) {
|
||||
where.fromMe = fromMe === 'true';
|
||||
}
|
||||
|
||||
// Buscar mensagens
|
||||
const [messages, total] = await Promise.all([
|
||||
prisma.message.findMany({
|
||||
where,
|
||||
take: limit,
|
||||
skip: offset,
|
||||
orderBy: { messageTimestamp: 'desc' },
|
||||
include: {
|
||||
Instance: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.message.count({ where }),
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
messages,
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Erro ao buscar mensagens:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Erro ao buscar mensagens' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
98
dashboard/app/api/sentiment/route.ts
Normal file
98
dashboard/app/api/sentiment/route.ts
Normal file
@ -0,0 +1,98 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import prisma from '@/lib/prisma';
|
||||
// @ts-ignore
|
||||
import Sentiment from 'sentiment';
|
||||
|
||||
const sentiment = new Sentiment();
|
||||
|
||||
function extractMessageText(messageObj: any): string {
|
||||
if (typeof messageObj === 'string') return messageObj;
|
||||
if (!messageObj || typeof messageObj !== 'object') return '';
|
||||
|
||||
const msg = messageObj.message || messageObj;
|
||||
|
||||
if (msg.conversation) return msg.conversation;
|
||||
if (msg.extendedTextMessage?.text) return msg.extendedTextMessage.text;
|
||||
if (msg.imageMessage?.caption) return msg.imageMessage.caption;
|
||||
if (msg.videoMessage?.caption) return msg.videoMessage.caption;
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function categorizeSentiment(score: number): string {
|
||||
if (score > 2) return 'very_positive';
|
||||
if (score > 0) return 'positive';
|
||||
if (score < -2) return 'very_negative';
|
||||
if (score < 0) return 'negative';
|
||||
return 'neutral';
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const instanceId = searchParams.get('instanceId');
|
||||
const limit = parseInt(searchParams.get('limit') || '1000');
|
||||
|
||||
// Construir where clause
|
||||
const where: any = {
|
||||
fromMe: false, // Apenas mensagens recebidas
|
||||
};
|
||||
if (instanceId) {
|
||||
where.instanceId = instanceId;
|
||||
}
|
||||
|
||||
// Buscar mensagens
|
||||
const messages = await prisma.message.findMany({
|
||||
where,
|
||||
take: limit,
|
||||
orderBy: { messageTimestamp: 'desc' },
|
||||
});
|
||||
|
||||
// Analisar sentimento
|
||||
const analyzed = messages.map((msg: any) => {
|
||||
const text = extractMessageText(msg.message);
|
||||
if (!text) return null;
|
||||
|
||||
const analysis = sentiment.analyze(text);
|
||||
const category = categorizeSentiment(analysis.score);
|
||||
|
||||
return {
|
||||
id: msg.id,
|
||||
text,
|
||||
sentiment: {
|
||||
score: analysis.score,
|
||||
comparative: analysis.comparative,
|
||||
category,
|
||||
positive: analysis.positive,
|
||||
negative: analysis.negative,
|
||||
},
|
||||
timestamp: msg.messageTimestamp,
|
||||
};
|
||||
}).filter(Boolean);
|
||||
|
||||
// Calcular distribuição
|
||||
const distribution = analyzed.reduce((acc: any, item: any) => {
|
||||
const cat = item.sentiment.category;
|
||||
acc[cat] = (acc[cat] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// Calcular score médio
|
||||
const avgScore = analyzed.length > 0
|
||||
? analyzed.reduce((sum: number, item: any) => sum + item.sentiment.score, 0) / analyzed.length
|
||||
: 0;
|
||||
|
||||
return NextResponse.json({
|
||||
total: analyzed.length,
|
||||
avgScore: avgScore.toFixed(2),
|
||||
distribution,
|
||||
messages: analyzed.slice(0, 100), // Retornar apenas as primeiras 100
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Erro ao analisar sentimento:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Erro ao analisar sentimento' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
92
dashboard/app/api/stats/route.ts
Normal file
92
dashboard/app/api/stats/route.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import prisma from '@/lib/prisma';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const instanceId = searchParams.get('instanceId');
|
||||
const startDate = searchParams.get('startDate');
|
||||
const endDate = searchParams.get('endDate');
|
||||
|
||||
// Construir where clause
|
||||
const where: any = {};
|
||||
if (instanceId) {
|
||||
where.instanceId = instanceId;
|
||||
}
|
||||
if (startDate && endDate) {
|
||||
const startTimestamp = new Date(startDate).getTime();
|
||||
const endTimestamp = new Date(endDate).getTime();
|
||||
where.messageTimestamp = {
|
||||
gte: startTimestamp,
|
||||
lte: endTimestamp,
|
||||
};
|
||||
}
|
||||
|
||||
// Buscar estatísticas
|
||||
const [totalMessages, totalContacts, totalChats, recentMessages] = await Promise.all([
|
||||
prisma.message.count({ where }),
|
||||
prisma.contact.count({ where: instanceId ? { instanceId } : {} }),
|
||||
prisma.chat.count({ where: instanceId ? { instanceId } : {} }),
|
||||
prisma.message.findMany({
|
||||
where,
|
||||
take: 100,
|
||||
orderBy: { messageTimestamp: 'desc' },
|
||||
}),
|
||||
]);
|
||||
|
||||
// Calcular tempo médio de resposta (simplificado)
|
||||
let avgResponseTime = '2.5min';
|
||||
if (recentMessages.length > 0) {
|
||||
const conversations = recentMessages.reduce((acc: any, msg: any) => {
|
||||
const key = JSON.stringify(msg.key);
|
||||
if (!acc[key]) acc[key] = [];
|
||||
acc[key].push(msg);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
let totalResponseTimes = 0;
|
||||
let responseCount = 0;
|
||||
|
||||
Object.values(conversations).forEach((msgs: any) => {
|
||||
for (let i = 1; i < msgs.length; i++) {
|
||||
if (msgs[i].fromMe !== msgs[i - 1].fromMe) {
|
||||
const diff = Number(msgs[i].messageTimestamp) - Number(msgs[i - 1].messageTimestamp);
|
||||
totalResponseTimes += diff;
|
||||
responseCount++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (responseCount > 0) {
|
||||
const avgMs = totalResponseTimes / responseCount;
|
||||
const avgMinutes = Math.round(avgMs / 60000);
|
||||
avgResponseTime = `${avgMinutes}min`;
|
||||
}
|
||||
}
|
||||
|
||||
// Contar conversas ativas (últimas 24h)
|
||||
const yesterday = Date.now() - 24 * 60 * 60 * 1000;
|
||||
const activeConversations = await prisma.chat.count({
|
||||
where: {
|
||||
...(instanceId ? { instanceId } : {}),
|
||||
lastMessageTime: {
|
||||
gte: yesterday,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
totalMessages,
|
||||
totalContacts,
|
||||
avgResponseTime,
|
||||
activeConversations,
|
||||
totalChats,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Erro ao buscar estatísticas:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Erro ao buscar estatísticas' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
62
dashboard/app/globals.css
Normal file
62
dashboard/app/globals.css
Normal file
@ -0,0 +1,62 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--foreground-rgb: 0, 0, 0;
|
||||
--background-start-rgb: 214, 219, 220;
|
||||
--background-end-rgb: 255, 255, 255;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--foreground-rgb: 255, 255, 255;
|
||||
--background-start-rgb: 0, 0, 0;
|
||||
--background-end-rgb: 0, 0, 0;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
color: rgb(var(--foreground-rgb));
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
rgb(var(--background-end-rgb))
|
||||
)
|
||||
rgb(var(--background-start-rgb));
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
}
|
||||
|
||||
/* Estilos customizados para o chat */
|
||||
.chat-message {
|
||||
@apply p-3 rounded-lg mb-2 max-w-[80%] break-words;
|
||||
}
|
||||
|
||||
.chat-message.user {
|
||||
@apply bg-primary-500 text-white ml-auto;
|
||||
}
|
||||
|
||||
.chat-message.assistant {
|
||||
@apply bg-gray-200 text-gray-800 mr-auto;
|
||||
}
|
||||
|
||||
/* Animações suaves */
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease-in;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
22
dashboard/app/layout.tsx
Normal file
22
dashboard/app/layout.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Evolution Dashboard - Análise de Mensagens WhatsApp",
|
||||
description: "Painel interativo com IA para análise profunda de mensagens do WhatsApp via Evolution API",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="pt-BR">
|
||||
<body className={inter.className}>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
74
dashboard/app/page.tsx
Normal file
74
dashboard/app/page.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { MessageSquare, Users, TrendingUp, Activity, Send } from 'lucide-react';
|
||||
import Dashboard from '@/components/Dashboard';
|
||||
import ChatInterface from '@/components/ChatInterface';
|
||||
import StatsCards from '@/components/StatsCards';
|
||||
|
||||
export default function Home() {
|
||||
const [activeTab, setActiveTab] = useState<'dashboard' | 'chat'>('dashboard');
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800">
|
||||
{/* Header */}
|
||||
<header className="bg-white dark:bg-gray-800 shadow-sm border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="bg-gradient-to-r from-green-500 to-emerald-500 p-2 rounded-lg">
|
||||
<MessageSquare className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Evolution Dashboard
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Análise Inteligente de Mensagens WhatsApp
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex space-x-2 bg-gray-100 dark:bg-gray-700 p-1 rounded-lg">
|
||||
<button
|
||||
onClick={() => setActiveTab('dashboard')}
|
||||
className={`px-4 py-2 rounded-md flex items-center space-x-2 transition-all ${
|
||||
activeTab === 'dashboard'
|
||||
? 'bg-white dark:bg-gray-600 text-primary-600 dark:text-primary-400 shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Activity className="w-4 h-4" />
|
||||
<span className="font-medium">Dashboard</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('chat')}
|
||||
className={`px-4 py-2 rounded-md flex items-center space-x-2 transition-all ${
|
||||
activeTab === 'chat'
|
||||
? 'bg-white dark:bg-gray-600 text-primary-600 dark:text-primary-400 shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
<span className="font-medium">Chat IA</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{activeTab === 'dashboard' ? <Dashboard /> : <ChatInterface />}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="mt-12 py-6 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
<p>Evolution Dashboard v1.0 - Powered by IA & Next.js</p>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
251
dashboard/components/ChatInterface.tsx
Normal file
251
dashboard/components/ChatInterface.tsx
Normal file
@ -0,0 +1,251 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Send, Bot, User, Sparkles, TrendingUp, MessageSquare } from 'lucide-react';
|
||||
|
||||
interface Message {
|
||||
id: number;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
export default function ChatInterface() {
|
||||
const [messages, setMessages] = useState<Message[]>([
|
||||
{
|
||||
id: 1,
|
||||
role: 'assistant',
|
||||
content: 'Olá! Sou seu assistente de análise de dados do WhatsApp. Posso te ajudar a:\n\n• Analisar sentimentos das mensagens\n• Identificar padrões de conversação\n• Detectar spam e mensagens suspeitas\n• Gerar relatórios personalizados\n• Responder perguntas sobre suas métricas\n\nO que você gostaria de saber?',
|
||||
timestamp: new Date(),
|
||||
}
|
||||
]);
|
||||
const [input, setInput] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages]);
|
||||
|
||||
const suggestedQuestions = [
|
||||
{ icon: TrendingUp, text: 'Quais são os horários de pico de mensagens?' },
|
||||
{ icon: MessageSquare, text: 'Mostre-me o sentimento geral das conversas' },
|
||||
{ icon: Sparkles, text: 'Detecte padrões nas mensagens recebidas' },
|
||||
];
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
if (!input.trim() || loading) return;
|
||||
|
||||
const userMessage: Message = {
|
||||
id: messages.length + 1,
|
||||
role: 'user',
|
||||
content: input,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, userMessage]);
|
||||
setInput('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Chamar API real do chat
|
||||
const response = await fetch('/api/chat', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: input,
|
||||
instanceId: null, // Pode ser configurado para filtrar por instância
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Erro ao processar mensagem');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const assistantMessage: Message = {
|
||||
id: messages.length + 2,
|
||||
role: 'assistant',
|
||||
content: data.response,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, assistantMessage]);
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Erro ao enviar mensagem:', error);
|
||||
|
||||
const errorMessage: Message = {
|
||||
id: messages.length + 2,
|
||||
role: 'assistant',
|
||||
content: '❌ Desculpe, ocorreu um erro ao processar sua mensagem. Tente novamente.',
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, errorMessage]);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSuggestedQuestion = (question: string) => {
|
||||
setInput(question);
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSendMessage();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Chat Principal */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 flex flex-col h-[700px]">
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2 bg-gradient-to-r from-purple-500 to-pink-500 rounded-lg">
|
||||
<Bot className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Assistente IA
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Análise inteligente de dados
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
||||
{messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`flex items-start space-x-3 ${
|
||||
message.role === 'user' ? 'flex-row-reverse space-x-reverse' : ''
|
||||
}`}
|
||||
>
|
||||
<div className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center ${
|
||||
message.role === 'user'
|
||||
? 'bg-primary-500'
|
||||
: 'bg-gradient-to-r from-purple-500 to-pink-500'
|
||||
}`}>
|
||||
{message.role === 'user' ? (
|
||||
<User className="w-5 h-5 text-white" />
|
||||
) : (
|
||||
<Bot className="w-5 h-5 text-white" />
|
||||
)}
|
||||
</div>
|
||||
<div className={`flex-1 ${message.role === 'user' ? 'flex justify-end' : ''}`}>
|
||||
<div className={`max-w-[80%] rounded-lg p-4 ${
|
||||
message.role === 'user'
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white'
|
||||
}`}>
|
||||
<p className="text-sm whitespace-pre-wrap">{message.content}</p>
|
||||
<span className={`text-xs mt-2 block ${
|
||||
message.role === 'user'
|
||||
? 'text-primary-100'
|
||||
: 'text-gray-500 dark:text-gray-400'
|
||||
}`}>
|
||||
{message.timestamp.toLocaleTimeString('pt-BR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-gradient-to-r from-purple-500 to-pink-500 flex items-center justify-center">
|
||||
<Bot className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="max-w-[80%] rounded-lg p-4 bg-gray-100 dark:bg-gray-700">
|
||||
<div className="flex space-x-2">
|
||||
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></div>
|
||||
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
|
||||
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="p-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="flex space-x-2">
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="Digite sua pergunta..."
|
||||
disabled={loading}
|
||||
className="flex-1 px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent dark:bg-gray-700 dark:text-white disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSendMessage}
|
||||
disabled={!input.trim() || loading}
|
||||
className="px-6 py-3 bg-primary-500 text-white rounded-lg hover:bg-primary-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-2"
|
||||
>
|
||||
<Send className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sugestões */}
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm p-6 border border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Perguntas Sugeridas
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{suggestedQuestions.map((q, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => handleSuggestedQuestion(q.text)}
|
||||
className="w-full text-left p-3 rounded-lg bg-gray-50 dark:bg-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors flex items-start space-x-3"
|
||||
>
|
||||
<q.icon className="w-5 h-5 text-primary-500 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{q.text}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-br from-primary-500 to-emerald-500 rounded-xl shadow-sm p-6 text-white">
|
||||
<Sparkles className="w-8 h-8 mb-3" />
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
Dica Pro
|
||||
</h3>
|
||||
<p className="text-sm text-primary-50">
|
||||
Faça perguntas específicas para obter análises mais precisas. Você pode perguntar sobre períodos, contatos, sentimentos e muito mais!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
164
dashboard/components/Dashboard.tsx
Normal file
164
dashboard/components/Dashboard.tsx
Normal file
@ -0,0 +1,164 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Filter, Download, RefreshCw } from 'lucide-react';
|
||||
import StatsCards from './StatsCards';
|
||||
import MessageChart from './MessageChart';
|
||||
import SentimentChart from './SentimentChart';
|
||||
import TopContactsTable from './TopContactsTable';
|
||||
import MessageTimeline from './MessageTimeline';
|
||||
|
||||
export default function Dashboard() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [stats, setStats] = useState({
|
||||
totalMessages: 0,
|
||||
totalContacts: 0,
|
||||
avgResponseTime: '0min',
|
||||
activeConversations: 0,
|
||||
});
|
||||
|
||||
const [filters, setFilters] = useState({
|
||||
instanceId: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchDashboardData();
|
||||
}, [filters]);
|
||||
|
||||
const fetchDashboardData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Construir query string com filtros
|
||||
const params = new URLSearchParams();
|
||||
if (filters.instanceId) params.append('instanceId', filters.instanceId);
|
||||
if (filters.startDate) params.append('startDate', filters.startDate);
|
||||
if (filters.endDate) params.append('endDate', filters.endDate);
|
||||
|
||||
const response = await fetch(`/api/stats?${params.toString()}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Erro ao buscar dados');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
setStats({
|
||||
totalMessages: data.totalMessages || 0,
|
||||
totalContacts: data.totalContacts || 0,
|
||||
avgResponseTime: data.avgResponseTime || '0min',
|
||||
activeConversations: data.activeConversations || 0,
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Erro ao buscar dados:', error);
|
||||
// Manter dados vazios em caso de erro
|
||||
setStats({
|
||||
totalMessages: 0,
|
||||
totalContacts: 0,
|
||||
avgResponseTime: '0min',
|
||||
activeConversations: 0,
|
||||
});
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
// Implementar exportação
|
||||
console.log('Exportando dados...');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-500"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Filtros */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm p-6 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Filter className="w-5 h-5 text-gray-500" />
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Filtros
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={fetchDashboardData}
|
||||
className="px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors flex items-center space-x-2"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
<span>Atualizar</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="px-4 py-2 bg-primary-500 text-white rounded-lg hover:bg-primary-600 transition-colors flex items-center space-x-2"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
<span>Exportar</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Instância
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Todas as instâncias"
|
||||
value={filters.instanceId}
|
||||
onChange={(e) => setFilters({ ...filters, instanceId: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent dark:bg-gray-700 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Data Inicial
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.startDate}
|
||||
onChange={(e) => setFilters({ ...filters, startDate: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent dark:bg-gray-700 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Data Final
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.endDate}
|
||||
onChange={(e) => setFilters({ ...filters, endDate: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent dark:bg-gray-700 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cards de Estatísticas */}
|
||||
<StatsCards stats={stats} />
|
||||
|
||||
{/* Gráficos */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<MessageChart />
|
||||
<SentimentChart />
|
||||
</div>
|
||||
|
||||
{/* Timeline e Top Contatos */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<MessageTimeline />
|
||||
<TopContactsTable />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
dashboard/components/MessageChart.tsx
Normal file
71
dashboard/components/MessageChart.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
||||
import { TrendingUp } from 'lucide-react';
|
||||
|
||||
export default function MessageChart() {
|
||||
// Dados de exemplo - substituir por dados reais da API
|
||||
const data = [
|
||||
{ name: 'Seg', enviadas: 420, recebidas: 380 },
|
||||
{ name: 'Ter', enviadas: 380, recebidas: 420 },
|
||||
{ name: 'Qua', enviadas: 520, recebidas: 480 },
|
||||
{ name: 'Qui', enviadas: 460, recebidas: 510 },
|
||||
{ name: 'Sex', enviadas: 590, recebidas: 550 },
|
||||
{ name: 'Sáb', enviadas: 320, recebidas: 280 },
|
||||
{ name: 'Dom', enviadas: 280, recebidas: 240 },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm p-6 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<TrendingUp className="w-5 h-5 text-primary-500" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Mensagens por Dia
|
||||
</h3>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">Últimos 7 dias</span>
|
||||
</div>
|
||||
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#374151" opacity={0.1} />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
stroke="#6B7280"
|
||||
style={{ fontSize: '12px' }}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#6B7280"
|
||||
style={{ fontSize: '12px' }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#1F2937',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
color: '#fff'
|
||||
}}
|
||||
/>
|
||||
<Legend />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="enviadas"
|
||||
stroke="#22c55e"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: '#22c55e', r: 4 }}
|
||||
activeDot={{ r: 6 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="recebidas"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: '#3b82f6', r: 4 }}
|
||||
activeDot={{ r: 6 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
118
dashboard/components/MessageTimeline.tsx
Normal file
118
dashboard/components/MessageTimeline.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
'use client';
|
||||
|
||||
import { Clock, MessageCircle } from 'lucide-react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
|
||||
export default function MessageTimeline() {
|
||||
// Dados de exemplo - substituir por dados reais da API
|
||||
const recentMessages = [
|
||||
{
|
||||
id: 1,
|
||||
contact: 'João Silva',
|
||||
message: 'Olá, gostaria de saber mais sobre o produto...',
|
||||
time: new Date(Date.now() - 5 * 60 * 1000),
|
||||
sentiment: 'positive',
|
||||
type: 'received'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
contact: 'Maria Santos',
|
||||
message: 'Obrigada pelo atendimento!',
|
||||
time: new Date(Date.now() - 15 * 60 * 1000),
|
||||
sentiment: 'very_positive',
|
||||
type: 'received'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
contact: 'Pedro Oliveira',
|
||||
message: 'Ainda não recebi meu pedido',
|
||||
time: new Date(Date.now() - 30 * 60 * 1000),
|
||||
sentiment: 'negative',
|
||||
type: 'received'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
contact: 'Ana Costa',
|
||||
message: 'Qual o prazo de entrega?',
|
||||
time: new Date(Date.now() - 45 * 60 * 1000),
|
||||
sentiment: 'neutral',
|
||||
type: 'received'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
contact: 'Carlos Souza',
|
||||
message: 'Produto excelente, recomendo!',
|
||||
time: new Date(Date.now() - 60 * 60 * 1000),
|
||||
sentiment: 'very_positive',
|
||||
type: 'received'
|
||||
},
|
||||
];
|
||||
|
||||
const getSentimentColor = (sentiment: string) => {
|
||||
const colors: { [key: string]: string } = {
|
||||
very_positive: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
|
||||
positive: 'bg-green-50 text-green-700 dark:bg-green-800 dark:text-green-300',
|
||||
neutral: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200',
|
||||
negative: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200',
|
||||
very_negative: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
|
||||
};
|
||||
return colors[sentiment] || colors.neutral;
|
||||
};
|
||||
|
||||
const getSentimentLabel = (sentiment: string) => {
|
||||
const labels: { [key: string]: string } = {
|
||||
very_positive: 'Muito Positivo',
|
||||
positive: 'Positivo',
|
||||
neutral: 'Neutro',
|
||||
negative: 'Negativo',
|
||||
very_negative: 'Muito Negativo',
|
||||
};
|
||||
return labels[sentiment] || 'Neutro';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm p-6 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="w-5 h-5 text-primary-500" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Mensagens Recentes
|
||||
</h3>
|
||||
</div>
|
||||
<button className="text-sm text-primary-500 hover:text-primary-600 font-medium">
|
||||
Ver todas
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{recentMessages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className="flex items-start space-x-3 p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<MessageCircle className="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{msg.contact}
|
||||
</p>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{formatDistanceToNow(msg.time, { addSuffix: true, locale: ptBR })}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 line-clamp-2 mb-2">
|
||||
{msg.message}
|
||||
</p>
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${getSentimentColor(msg.sentiment)}`}>
|
||||
{getSentimentLabel(msg.sentiment)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
dashboard/components/SentimentChart.tsx
Normal file
72
dashboard/components/SentimentChart.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
'use client';
|
||||
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, Legend, Tooltip } from 'recharts';
|
||||
import { Smile } from 'lucide-react';
|
||||
|
||||
export default function SentimentChart() {
|
||||
// Dados de exemplo - substituir por dados reais da API
|
||||
const data = [
|
||||
{ name: 'Muito Positivo', value: 850, color: '#10b981' },
|
||||
{ name: 'Positivo', value: 1240, color: '#22c55e' },
|
||||
{ name: 'Neutro', value: 2130, color: '#6b7280' },
|
||||
{ name: 'Negativo', value: 420, color: '#f59e0b' },
|
||||
{ name: 'Muito Negativo', value: 183, color: '#ef4444' },
|
||||
];
|
||||
|
||||
const COLORS = data.map(item => item.color);
|
||||
|
||||
const renderCustomLabel = (entry: any) => {
|
||||
const percent = ((entry.value / data.reduce((a, b) => a + b.value, 0)) * 100).toFixed(1);
|
||||
return `${percent}%`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm p-6 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Smile className="w-5 h-5 text-primary-500" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Análise de Sentimento
|
||||
</h3>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Total: {data.reduce((a, b) => a + b.value, 0).toLocaleString('pt-BR')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={renderCustomLabel}
|
||||
outerRadius={100}
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
>
|
||||
{data.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#1F2937',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
color: '#fff'
|
||||
}}
|
||||
formatter={(value: any) => value.toLocaleString('pt-BR')}
|
||||
/>
|
||||
<Legend
|
||||
verticalAlign="bottom"
|
||||
height={36}
|
||||
iconType="circle"
|
||||
formatter={(value) => <span className="text-sm text-gray-700 dark:text-gray-300">{value}</span>}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
dashboard/components/StatsCards.tsx
Normal file
73
dashboard/components/StatsCards.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
'use client';
|
||||
|
||||
import { MessageSquare, Users, TrendingUp, Activity } from 'lucide-react';
|
||||
|
||||
interface StatsCardsProps {
|
||||
stats: {
|
||||
totalMessages: number;
|
||||
totalContacts: number;
|
||||
avgResponseTime: string;
|
||||
activeConversations: number;
|
||||
};
|
||||
}
|
||||
|
||||
export default function StatsCards({ stats }: StatsCardsProps) {
|
||||
const cards = [
|
||||
{
|
||||
title: 'Total de Mensagens',
|
||||
value: stats.totalMessages.toLocaleString('pt-BR'),
|
||||
icon: MessageSquare,
|
||||
color: 'from-blue-500 to-blue-600',
|
||||
change: '+12.5%',
|
||||
},
|
||||
{
|
||||
title: 'Contatos Ativos',
|
||||
value: stats.totalContacts.toLocaleString('pt-BR'),
|
||||
icon: Users,
|
||||
color: 'from-green-500 to-green-600',
|
||||
change: '+8.2%',
|
||||
},
|
||||
{
|
||||
title: 'Tempo Médio de Resposta',
|
||||
value: stats.avgResponseTime,
|
||||
icon: Activity,
|
||||
color: 'from-purple-500 to-purple-600',
|
||||
change: '-5.3%',
|
||||
},
|
||||
{
|
||||
title: 'Conversas Ativas',
|
||||
value: stats.activeConversations.toLocaleString('pt-BR'),
|
||||
icon: TrendingUp,
|
||||
color: 'from-orange-500 to-orange-600',
|
||||
change: '+15.8%',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
{cards.map((card, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-white dark:bg-gray-800 rounded-xl shadow-sm p-6 hover:shadow-lg transition-shadow border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className={`p-3 rounded-lg bg-gradient-to-r ${card.color}`}>
|
||||
<card.icon className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<span className={`text-sm font-medium ${
|
||||
card.change.startsWith('+') ? 'text-green-600' : 'text-red-600'
|
||||
}`}>
|
||||
{card.change}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
{card.title}
|
||||
</h3>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{card.value}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
dashboard/components/TopContactsTable.tsx
Normal file
88
dashboard/components/TopContactsTable.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
'use client';
|
||||
|
||||
import { Users, TrendingUp, TrendingDown } from 'lucide-react';
|
||||
|
||||
export default function TopContactsTable() {
|
||||
// Dados de exemplo - substituir por dados reais da API
|
||||
const topContacts = [
|
||||
{ name: 'João Silva', phone: '+55 11 99999-1234', messages: 1245, trend: 'up', change: 12 },
|
||||
{ name: 'Maria Santos', phone: '+55 21 98888-5678', messages: 982, trend: 'up', change: 8 },
|
||||
{ name: 'Pedro Oliveira', phone: '+55 31 97777-9012', messages: 856, trend: 'down', change: -3 },
|
||||
{ name: 'Ana Costa', phone: '+55 41 96666-3456', messages: 734, trend: 'up', change: 15 },
|
||||
{ name: 'Carlos Souza', phone: '+55 51 95555-7890', messages: 628, trend: 'up', change: 5 },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm p-6 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Users className="w-5 h-5 text-primary-500" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Top Contatos
|
||||
</h3>
|
||||
</div>
|
||||
<button className="text-sm text-primary-500 hover:text-primary-600 font-medium">
|
||||
Ver todos
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700">
|
||||
<th className="text-left py-3 px-2 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Contato
|
||||
</th>
|
||||
<th className="text-right py-3 px-2 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Mensagens
|
||||
</th>
|
||||
<th className="text-right py-3 px-2 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Tendência
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{topContacts.map((contact, index) => (
|
||||
<tr key={index} className="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||
<td className="py-4 px-2">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 dark:text-white">
|
||||
{contact.name}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{contact.phone}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4 px-2 text-right">
|
||||
<span className="font-semibold text-gray-900 dark:text-white">
|
||||
{contact.messages.toLocaleString('pt-BR')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-4 px-2 text-right">
|
||||
<div className="flex items-center justify-end space-x-1">
|
||||
{contact.trend === 'up' ? (
|
||||
<>
|
||||
<TrendingUp className="w-4 h-4 text-green-500" />
|
||||
<span className="text-sm font-medium text-green-500">
|
||||
+{contact.change}%
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TrendingDown className="w-4 h-4 text-red-500" />
|
||||
<span className="text-sm font-medium text-red-500">
|
||||
{contact.change}%
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
dashboard/lib/prisma.ts
Normal file
15
dashboard/lib/prisma.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined;
|
||||
};
|
||||
|
||||
export const prisma =
|
||||
globalForPrisma.prisma ??
|
||||
new PrismaClient({
|
||||
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
|
||||
|
||||
export default prisma;
|
||||
7
dashboard/next.config.js
Normal file
7
dashboard/next.config.js
Normal file
@ -0,0 +1,7 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
swcMinify: true,
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
37
dashboard/package.json
Normal file
37
dashboard/package.json
Normal file
@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "evolution-dashboard",
|
||||
"version": "1.0.0",
|
||||
"description": "Painel Interativo com Chat para Evolution API",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "^14.2.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
"@prisma/client": "^6.1.0",
|
||||
"recharts": "^2.12.7",
|
||||
"date-fns": "^3.6.0",
|
||||
"lucide-react": "^0.428.0",
|
||||
"clsx": "^2.1.1",
|
||||
"tailwind-merge": "^2.5.0",
|
||||
"sentiment": "^5.0.2",
|
||||
"natural": "^8.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.14.0",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"typescript": "^5.5.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"postcss": "^8.4.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-next": "^14.2.0",
|
||||
"prisma": "^6.1.0"
|
||||
}
|
||||
}
|
||||
6
dashboard/postcss.config.js
Normal file
6
dashboard/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
80
dashboard/prisma/schema.prisma
Normal file
80
dashboard/prisma/schema.prisma
Normal file
@ -0,0 +1,80 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model Instance {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
name String @unique
|
||||
connectionStatus String?
|
||||
ownerJid String?
|
||||
profilePictureUrl String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
Message Message[]
|
||||
Contact Contact[]
|
||||
Chat Chat[]
|
||||
|
||||
@@map("Instance")
|
||||
}
|
||||
|
||||
model Message {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
key Json
|
||||
pushName String?
|
||||
participant String?
|
||||
message Json
|
||||
messageType String
|
||||
messageTimestamp BigInt
|
||||
instanceId String @db.Uuid
|
||||
source String?
|
||||
fromMe Boolean @default(false)
|
||||
status String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
Instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([instanceId])
|
||||
@@index([messageTimestamp])
|
||||
@@index([fromMe])
|
||||
@@map("Message")
|
||||
}
|
||||
|
||||
model Contact {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
remoteJid String
|
||||
pushName String?
|
||||
profilePictureUrl String?
|
||||
instanceId String @db.Uuid
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
Instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([instanceId, remoteJid])
|
||||
@@index([instanceId])
|
||||
@@map("Contact")
|
||||
}
|
||||
|
||||
model Chat {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
remoteJid String
|
||||
name String?
|
||||
unreadMessages Int @default(0)
|
||||
lastMessageTime BigInt?
|
||||
instanceId String @db.Uuid
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
Instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([instanceId, remoteJid])
|
||||
@@index([instanceId])
|
||||
@@map("Chat")
|
||||
}
|
||||
29
dashboard/tailwind.config.ts
Normal file
29
dashboard/tailwind.config.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#f0fdf4',
|
||||
100: '#dcfce7',
|
||||
200: '#bbf7d0',
|
||||
300: '#86efac',
|
||||
400: '#4ade80',
|
||||
500: '#22c55e',
|
||||
600: '#16a34a',
|
||||
700: '#15803d',
|
||||
800: '#166534',
|
||||
900: '#14532d',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
export default config;
|
||||
27
dashboard/tsconfig.json
Normal file
27
dashboard/tsconfig.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user