Migração para PostgreSQL multi-driver + correções de segurança
- Camada de banco unificada (src/database.js): drivers Postgres/Firebird, tradutor de SQL, suporte a schema e pool de conexões - Conexões: novo_local (Postgres externo) e firebird_local (legado) - Tela de rotas da API redesenhada (auth, params, exemplos de body) - Correções de segurança (críticos/altos/médios/baixos): XSS no chat, escalonamento de privilégio, mídia autenticada, SQL restrito a gerente, JWT sem fallback + issuer, IDOR em conversas, CORS por allowlist, rate-limit no login, limites de corpo por rota - Deploy alinhado: install.sh grava .env com PG_*, migracoes.js driver-aware Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+16
@@ -0,0 +1,16 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
.env.bak.*
|
||||||
|
src/databases_custom.json
|
||||||
|
|
||||||
|
# Bancos / dumps
|
||||||
|
*.FDB
|
||||||
|
*.fdb
|
||||||
|
*.rar
|
||||||
|
relatorio_migracao_*.json
|
||||||
|
|
||||||
|
# Logs / temporários
|
||||||
|
*.log
|
||||||
|
_inspect*.js
|
||||||
|
whisper/
|
||||||
|
.claude
|
||||||
+176
@@ -0,0 +1,176 @@
|
|||||||
|
a # Chatc2 - Contexto do Projeto
|
||||||
|
|
||||||
|
_Arquivo gerado em 05/06/2026 para retomada em qualquer computador._
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Missão Atual
|
||||||
|
|
||||||
|
Plataforma multi-empresa de atendimento com WhatsApp (Evolution API) + Firebase Firebird, incluindo:
|
||||||
|
- Chat com atendimento humano e triagem automatizada
|
||||||
|
- Geração de boletos em PDF via API externa
|
||||||
|
- Transcrição de áudio com Whisper.cpp (local)
|
||||||
|
- Fluxo de atendimento personalizado por empresa (submenus, etiquetas)
|
||||||
|
- Breve: integração com Asterisk para ligações telefônicas
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗂️ Estrutura de Arquivos (49 arquivos)
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── app.js # Express app setup
|
||||||
|
├── server.js # Entry point (porta 3000)
|
||||||
|
├── database.js # Conexão Firebird (query, execute, readBlob)
|
||||||
|
├── config/
|
||||||
|
│ ├── databases.js # Conexões estáticas + custom (JSON)
|
||||||
|
│ ├── databases_custom.json # Conexões adicionadas via API
|
||||||
|
│ ├── database.js # Config padrão do banco
|
||||||
|
│ └── auth.js # JWT secret/expires
|
||||||
|
├── middlewares/
|
||||||
|
│ └── auth.js # JWT + USU_TOKEN dual auth
|
||||||
|
├── controllers/
|
||||||
|
│ ├── authController.js # Login, /me, dashboard
|
||||||
|
│ ├── chatController.js # Conversas, mensagens, mídia, Evolution send
|
||||||
|
│ ├── clientController.js # CRUD clientes, carnês, dependentes, convalescentes, listcarne
|
||||||
|
│ ├── configController.js # Config empresa, equipes, etiquetas, instâncias
|
||||||
|
│ ├── evolutionController.js # Webhook Evolution, QR Code, instâncias
|
||||||
|
│ ├── genericController.js # Health, tables, query, aliases
|
||||||
|
│ ├── menuController.js # CRUD CHATC2_MENUS_EMPRESA (submenus do fluxo)
|
||||||
|
│ ├── triageController.js # Fluxo de triagem (sendMenu, processResponse, boleto)
|
||||||
|
│ ├── databaseController.js # CRUD de conexões de banco (custom JSON)
|
||||||
|
│ ├── routesController.js # Descoberta automática de rotas
|
||||||
|
│ ├── empresaController.js # Logo da empresa
|
||||||
|
│ └── transcriber.js # Whisper.cpp para transcrição de áudio
|
||||||
|
├── routes/
|
||||||
|
│ ├── index.js # Agregador de rotas
|
||||||
|
│ ├── authRoutes.js, chatRoutes.js, configRoutes.js
|
||||||
|
│ ├── genericRoutes.js, menuRoutes.js, databaseRoutes.js
|
||||||
|
└── public/
|
||||||
|
├── login.html, dashboard.html, chat.html, client-list.html
|
||||||
|
├── client-detail.html, settings.html, routes.html
|
||||||
|
├── admin-conversations.html, csat.html
|
||||||
|
├── css/dark-mode.css, css/main.css
|
||||||
|
└── js/dark-mode.js
|
||||||
|
scripts/
|
||||||
|
├── migracoes.js # 19 migrações de banco (controle via CHATC2_CONTROLE_MIGRACOES)
|
||||||
|
├── criar_tabelas_chat.js # Script original de criação de tabelas
|
||||||
|
├── gerar-token-jwt.js, gerar-token-usuario.js
|
||||||
|
├── habilitar-acesso-web.js, definir-senha-web.js
|
||||||
|
└── listar-tokens.js
|
||||||
|
deploy/
|
||||||
|
├── cloud-init.yml # Deploy automático Ubuntu 22.04
|
||||||
|
├── setup.sh # Instalação manual
|
||||||
|
└── README_INSTALACAO.md # Guia completo
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔥 Principais Funcionalidades Implementadas
|
||||||
|
|
||||||
|
### 1. Autenticação
|
||||||
|
- JWT + USU_TOKEN (dual auth)
|
||||||
|
- Login com USU_LOGIN + USU_SENHA (ou USU_SENHA_WEB)
|
||||||
|
- Roles: Agente (A) e Gerente/Admin (G) via USU_TIPO_CHAT
|
||||||
|
- `/app/:alias/me` retorna dados do usuário
|
||||||
|
|
||||||
|
### 2. Webhook Evolution API (v2.3.7)
|
||||||
|
- URL: `POST /api/webhook/evolution` (sem alias na URL)
|
||||||
|
- Detecta alias automaticamente pelo nome da instância
|
||||||
|
- Cria conversas novas ou reusa existentes (últimos 8 dígitos)
|
||||||
|
- Busca cliente por telefone (CLI_CELULAR, CLI_FONE1, CLI_FONE2, DEPENDENTES_CLI)
|
||||||
|
- Sempre corrige CON_CLIENTE_ID pelo telefone (novas + existentes)
|
||||||
|
- Mídia: tenta download via URL, depois /chat/getMedia/, depois base64 embutido
|
||||||
|
|
||||||
|
### 3. Chat (Tempo Real)
|
||||||
|
- Sidebar: Minhas / Sem Atendimento / Equipe / Todas (com contagens)
|
||||||
|
- Painel central com mensagens
|
||||||
|
- Painel direito: atendente, equipe, etiquetas
|
||||||
|
- Envio de texto, imagem, áudio (MediaRecorder → base64), documento
|
||||||
|
- Prévia do áudio antes de enviar
|
||||||
|
- Transcrição de áudio via Whisper.cpp (exibida abaixo do player)
|
||||||
|
|
||||||
|
### 4. Triagem / Fluxo Automático
|
||||||
|
- Menu principal = Equipes (ordenadas por EQU_ORDEM) + Boleto (fixo)
|
||||||
|
- **Ao escolher equipe: CON_EQUIPE_ID atribuído IMEDIATAMENTE**
|
||||||
|
- Submenus via CHATC2_MENUS_EMPRESA (M=submenu, T=texto, R=rota)
|
||||||
|
- Navegação multi-nível com "0 - Voltar"
|
||||||
|
- Etiquetas associadas a cada submenu (adicionadas automaticamente)
|
||||||
|
- Boleto: lista títulos → seleciona → gera PDF via API externa → envia via Evolution → pergunta "continuar?"
|
||||||
|
|
||||||
|
### 5. Boleto
|
||||||
|
- Geração: `POST https://cobpagweb.com.br/boleto/cliente/index.php?base64`
|
||||||
|
- Payload: `{ empresa: {EMP_NOME, EMP_CNPJ, EMP_FOTO}, Cliente: {...}, Carnes: [...] }`
|
||||||
|
- Envio Evolution: `/message/sendMedia/` com `mediatype: 'document'`
|
||||||
|
- Fallback: texto com dados do boleto quando PDF não gerado
|
||||||
|
- Nome do arquivo: `<MATRICULA> - <VENCIMENTO>.pdf`
|
||||||
|
|
||||||
|
### 6. Whisper.cpp (Transcrição de Áudio)
|
||||||
|
- Instalado em `whisper/` (main.exe/main + modelo tiny)
|
||||||
|
- Transcreve áudios recebidos via webhook
|
||||||
|
- Salva transcrição em `MAT_TRANSCRICAO` (BLOB SUB_TYPE TEXT)
|
||||||
|
- Exibe no chat: player + transcrição abaixo
|
||||||
|
- **Limitação: Evolution API v2.3.7 não fornece áudio válido para download**
|
||||||
|
- Funcionará quando atualizar a Evolution API (> v2.5.0)
|
||||||
|
|
||||||
|
### 7. Settings (Configurações)
|
||||||
|
- Abas: Equipe, Fluxo, Empresa, Etiquetas, Conexão
|
||||||
|
- Fluxo: CRUD de submenus por equipe (árvore), com tipos e etiquetas
|
||||||
|
- Empresa: saudação, triagem, CSAT, foto, nome do usuário
|
||||||
|
- Etiquetas: nome + cor
|
||||||
|
- Conexão: instâncias WhatsApp (criar, editar, QR Code)
|
||||||
|
|
||||||
|
### 8. Banco de Dados (Migrações)
|
||||||
|
- Script: `scripts/migracoes.js` — 19 migrações
|
||||||
|
- Tabelas: CHATC2_INSTANCIAS, CHATC2_EQUIPES, CHATC2_USU_EQUIPES, CHATC2_ETIQUETAS
|
||||||
|
- CHATC2_CONVERSAS, CHATC2_CONVERSAS_MENSAGENS, CHATC2_MENSAGENS_ATENDIMENTOS
|
||||||
|
- CHATC2_CONVERSAS_ETIQUETAS, CHATC2_CSAT_AVALIACOES, CHATC2_CONFIGURACOES_EMPRESA
|
||||||
|
- CHATC2_MENUS_EMPRESA (com MNE_ACAO_ROTA, MNE_ETIQUETA_IDS)
|
||||||
|
- Controle via `CHATC2_CONTROLE_MIGRACOES` (cada migração roda uma vez)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Próximos Passos (quando retomar)
|
||||||
|
|
||||||
|
1. **Aguardar**: Usuário vai contratar SIP Trunk e instalar Asterisk
|
||||||
|
2. **Implementar**: Módulo Voice (AMI) para fazer ligações + transcrição
|
||||||
|
3. **Atualizar**: Evolution API (> v2.5.0) para download de mídia funcionar
|
||||||
|
4. **Corrigir pendências da conversa**:
|
||||||
|
- Etiquetas em submenus (não estava persistindo - adicionar logs `[Menu]` no console)
|
||||||
|
- Transcrição só funciona para áudio do agente (aguardando Evolution atualizar)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 URLs Importantes
|
||||||
|
|
||||||
|
| Recurso | URL |
|
||||||
|
|---------|-----|
|
||||||
|
| Login | `/app/:alias/login` |
|
||||||
|
| Chat | `/app/:alias/company/:empresaId/conversation/:conversaId` |
|
||||||
|
| Clientes | `/app/:alias/clients` |
|
||||||
|
| Settings | `/app/:alias/settings` |
|
||||||
|
| Rotas | `/app/:alias/routes` |
|
||||||
|
| CSAT | `/app/:alias/csat` |
|
||||||
|
| Webhook | `POST /api/webhook/evolution` |
|
||||||
|
| Rotas API | `GET /api/routes` |
|
||||||
|
| Bancos | `GET /api/:alias/databases` |
|
||||||
|
| Carnê | `GET /api/:alias/clients/:id/listcarne` |
|
||||||
|
| Convalescentes | `GET /api/:alias/clients/:id/convalescentes` |
|
||||||
|
|
||||||
|
## 🔑 Testes
|
||||||
|
|
||||||
|
| Usuário | Senha | Base | Tipo |
|
||||||
|
|---------|-------|------|------|
|
||||||
|
| SUPORTE | 123456 | lajedo | Admin (G) |
|
||||||
|
| SUPORTE | 123456 | novo_local | Admin (G) |
|
||||||
|
|
||||||
|
## ☁️ Deploy
|
||||||
|
|
||||||
|
Arquivos em `deploy/`:
|
||||||
|
- `cloud-init.yml` — cloud-init (DigitalOcean, Vultr, etc.)
|
||||||
|
- `setup.sh` — instalação manual
|
||||||
|
- `README_INSTALACAO.md` — guia completo
|
||||||
|
|
||||||
|
## 📦 Backup
|
||||||
|
|
||||||
|
`ponto_1/` contém snapshot completo do código fonte.
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# =============================================================
|
||||||
|
# Chatc2 - Script de deploy Windows -> Linux
|
||||||
|
# Uso: .\deploy.ps1 -IP 192.168.1.100
|
||||||
|
# =============================================================
|
||||||
|
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory=$true)]
|
||||||
|
[string]$IP,
|
||||||
|
[string]$User = "root",
|
||||||
|
[string]$ProjectPath = (Split-Path -Parent $PSScriptRoot),
|
||||||
|
[string]$RemotePath = "/home/chatc2/chatc2"
|
||||||
|
)
|
||||||
|
|
||||||
|
Write-Host "╔════════════════════════════════════════════╗" -ForegroundColor Cyan
|
||||||
|
Write-Host "║ Chatc2 - Deploy Windows -> Linux ║" -ForegroundColor Cyan
|
||||||
|
Write-Host "╚════════════════════════════════════════════╝" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# 1. Testar conexao SSH
|
||||||
|
Write-Host "[1/5] Testando conexao SSH..." -ForegroundColor Yellow
|
||||||
|
try {
|
||||||
|
$result = ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 "$User@$IP" "echo OK" 2>&1
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "SSH falhou" }
|
||||||
|
Write-Host " ✓ Conectado a $IP" -ForegroundColor Green
|
||||||
|
} catch {
|
||||||
|
Write-Host " ✘ Erro: $_" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. Criar diretorios no servidor
|
||||||
|
Write-Host "[2/5] Criando diretorios no servidor..." -ForegroundColor Yellow
|
||||||
|
ssh "$User@$IP" "mkdir -p $RemotePath/{db,logs,tmp,uploads/audio,public,whisper/models} 2>/dev/null; chown -R chatc2:chatc2 $RemotePath 2>/dev/null; echo 'OK'" 2>$null
|
||||||
|
Write-Host " ✓ Diretorios criados" -ForegroundColor Green
|
||||||
|
|
||||||
|
# 3. Enviar arquivos via SCP
|
||||||
|
Write-Host "[3/5] Enviando arquivos do projeto..." -ForegroundColor Yellow
|
||||||
|
Write-Host " (isso pode levar alguns minutos...)" -ForegroundColor Gray
|
||||||
|
|
||||||
|
$exclude = @('node_modules', '.git', 'db/*.FDB', 'logs', 'tmp', 'Nova pasta', 'chatc2-py', 'ponto_1', 'whisper')
|
||||||
|
$excludeArgs = ($exclude | ForEach-Object { "--exclude=$_" }) -join ' '
|
||||||
|
|
||||||
|
$scpCommand = "scp -r $excludeArgs `"$ProjectPath\*`" $User@`"$IP`":$RemotePath/"
|
||||||
|
Write-Host " Executando: scp -r (arquivos do projeto) para $IP..." -ForegroundColor Gray
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = scp -r `
|
||||||
|
--exclude='node_modules' `
|
||||||
|
--exclude='.git' `
|
||||||
|
--exclude='db/*.FDB' `
|
||||||
|
--exclude='logs' `
|
||||||
|
--exclude='tmp' `
|
||||||
|
--exclude='Nova pasta' `
|
||||||
|
--exclude='chatc2-py' `
|
||||||
|
--exclude='ponto_1' `
|
||||||
|
--exclude='whisper' `
|
||||||
|
"$ProjectPath\*" "$User@$IP`:$RemotePath/" 2>&1
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "SCP falhou" }
|
||||||
|
Write-Host " ✓ Arquivos enviados" -ForegroundColor Green
|
||||||
|
} catch {
|
||||||
|
Write-Host " ✘ Erro no SCP: $_" -ForegroundColor Red
|
||||||
|
Write-Host " Tente enviar manualmente via FileZilla ou WinSCP" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
# 4. Ajustar permissoes
|
||||||
|
Write-Host "[4/5] Ajustando permissoes..." -ForegroundColor Yellow
|
||||||
|
ssh "$User@$IP" "chown -R chatc2:chatc2 $RemotePath 2>/dev/null; chmod -R 755 $RemotePath 2>/dev/null; echo OK" 2>$null
|
||||||
|
Write-Host " ✓ Permissoes ajustadas" -ForegroundColor Green
|
||||||
|
|
||||||
|
# 5. Instalar dependencias e rodar script de instalacao
|
||||||
|
Write-Host "[5/5] Instalando dependencias e configurando servico..." -ForegroundColor Yellow
|
||||||
|
ssh "$User@$IP" "cd $RemotePath/deploy-linux && sudo bash install.sh --continue" 2>&1 | Write-Host
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "╔════════════════════════════════════════════╗" -ForegroundColor Cyan
|
||||||
|
Write-Host "║ Deploy Concluido! ║" -ForegroundColor Cyan
|
||||||
|
Write-Host "╚════════════════════════════════════════════╝" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " Acesse: http://$IP" -ForegroundColor Green
|
||||||
|
Write-Host " SSH: ssh $User@$IP" -ForegroundColor Gray
|
||||||
|
Write-Host " Logs: ssh $User@$IP 'pm2 logs chatc2'" -ForegroundColor Gray
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# =============================================================
|
||||||
|
# Chatc2 - Instalacao automatizada para Ubuntu 22.04 LTS
|
||||||
|
# Uso: curl -sL https://bit.ly/chatc2-install | bash
|
||||||
|
# ou: wget -qO- https://bit.ly/chatc2-install | bash
|
||||||
|
# =============================================================
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ████████████████████████████████████████████████████
|
||||||
|
# VARIAVEIS
|
||||||
|
# ████████████████████████████████████████████████████
|
||||||
|
CHATC2_USER="chatc2"
|
||||||
|
CHATC2_DIR="/home/$CHATC2_USER/chatc2"
|
||||||
|
CHATC2_REPO="https://github.com/seu-usuario/chatc2/archive/refs/heads/main.tar.gz"
|
||||||
|
NODE_VERSION="22"
|
||||||
|
FIREBIRD_VERSION="3.0"
|
||||||
|
WHISPER_VERSION="1.5.4"
|
||||||
|
WHISPER_MODEL="ggml-tiny.bin" # 75MB - troque para ggml-base.bin (142MB) para mais precisao
|
||||||
|
SERVER_DOMAIN="${SERVER_DOMAIN:-}" # Opcional: seu-dominio.com.br
|
||||||
|
JWT_SECRET=""
|
||||||
|
NGROK_URL=""
|
||||||
|
|
||||||
|
# PostgreSQL EXTERNO (banco principal "novo_local").
|
||||||
|
# Informe via variaveis de ambiente antes de rodar, ou edite o .env depois.
|
||||||
|
PG_HOST="${PG_HOST:-}"
|
||||||
|
PG_PORT="${PG_PORT:-5432}"
|
||||||
|
PG_USER="${PG_USER:-postgres}"
|
||||||
|
PG_PASSWORD="${PG_PASSWORD:-}"
|
||||||
|
PG_DATABASE="${PG_DATABASE:-postgres}"
|
||||||
|
PG_SCHEMA="${PG_SCHEMA:-public}"
|
||||||
|
|
||||||
|
# Cores
|
||||||
|
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
|
||||||
|
log() { echo -e "${GREEN}[✓]${NC} $1"; }
|
||||||
|
warn() { echo -e "${YELLOW}[!]${NC} $1"; }
|
||||||
|
err() { echo -e "${RED}[✘]${NC} $1"; exit 1; }
|
||||||
|
info() { echo -e "${CYAN}[i]${NC} $1"; }
|
||||||
|
|
||||||
|
# ████████████████████████████████████████████████████
|
||||||
|
# VERIFICACAO INICIAL
|
||||||
|
# ████████████████████████████████████████████████████
|
||||||
|
if [ "$EUID" -ne 0 ]; then
|
||||||
|
err "Execute como root: sudo bash install.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "╔══════════════════════════════════════════════════╗"
|
||||||
|
echo "║ Chatc2 - Instalacao Automatica ║"
|
||||||
|
echo "║ Ubuntu 22.04 LTS ║"
|
||||||
|
echo "╚══════════════════════════════════════════════════╝"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ████████████████████████████████████████████████████
|
||||||
|
# PASSO 1: SYSTEMA BASE
|
||||||
|
# ████████████████████████████████████████████████████
|
||||||
|
info "Passo 1/12: Atualizando sistema..."
|
||||||
|
apt-get update -y
|
||||||
|
apt-get upgrade -y
|
||||||
|
apt-get install -y curl wget git unzip tar gzip \
|
||||||
|
build-essential python3 python3-pip \
|
||||||
|
ffmpeg nginx ufw certbot python3-certbot-nginx \
|
||||||
|
firebird3.0-server firebird3.0-utils
|
||||||
|
|
||||||
|
# ████████████████████████████████████████████████████
|
||||||
|
# PASSO 2: NODE.JS
|
||||||
|
# ████████████████████████████████████████████████████
|
||||||
|
info "Passo 2/12: Instalando Node.js $NODE_VERSION..."
|
||||||
|
curl -fsSL "https://deb.nodesource.com/setup_${NODE_VERSION}.x" | bash -
|
||||||
|
apt-get install -y nodejs
|
||||||
|
npm install -g pm2
|
||||||
|
log "Node.js $(node -v) instalado"
|
||||||
|
|
||||||
|
# ████████████████████████████████████████████████████
|
||||||
|
# PASSO 3: FIREBIRD
|
||||||
|
# ████████████████████████████████████████████████████
|
||||||
|
info "Passo 3/12: Configurando Firebird..."
|
||||||
|
systemctl enable firebird3.0
|
||||||
|
systemctl start firebird3.0 2>/dev/null || true
|
||||||
|
sleep 2
|
||||||
|
sed -i 's/^isc_password =.*/isc_password = masterkey/' /etc/firebird/3.0/firebird.conf 2>/dev/null || true
|
||||||
|
sed -i 's/^RemoteBindAddress = localhost/RemoteBindAddress = 0.0.0.0/' /etc/firebird/3.0/firebird.conf 2>/dev/null || true
|
||||||
|
|
||||||
|
# ── Correção de compatibilidade node-firebird + Firebird 3.0 ──
|
||||||
|
# Usa Legacy_Auth (mais compatível) em vez de Srp256 (não suportado pelo node-firebird)
|
||||||
|
sed -i 's/^#AuthServer = Srp$/AuthServer = Legacy_Auth/' /etc/firebird/3.0/firebird.conf 2>/dev/null || true
|
||||||
|
sed -i 's/^#AuthClient = Srp, Srp256, Legacy_Auth #Non Windows clients$/AuthClient = Legacy_Auth/' /etc/firebird/3.0/firebird.conf 2>/dev/null || true
|
||||||
|
# WireCrypt = Enabled (compatível com Legacy_Auth — não exige criptografia)
|
||||||
|
sed -i 's/^#WireCrypt = Enabled (for client) \/ Required (for server)$/WireCrypt = Enabled/' /etc/firebird/3.0/firebird.conf 2>/dev/null || true
|
||||||
|
|
||||||
|
systemctl restart firebird3.0
|
||||||
|
log "Firebird 3.0 configurado (senha: masterkey, auth: Legacy_Auth, WireCrypt: Enabled)"
|
||||||
|
# Garante que o Firebird esta rodando antes de continuar
|
||||||
|
systemctl restart firebird3.0 2>/dev/null || systemctl start firebird3.0 2>/dev/null || true
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
# ████████████████████████████████████████████████████
|
||||||
|
# PASSO 4: USUARIO E DIRETORIOS
|
||||||
|
# ████████████████████████████████████████████████████
|
||||||
|
info "Passo 4/12: Criando usuario e diretorios..."
|
||||||
|
id -u "$CHATC2_USER" &>/dev/null || useradd -m -s /bin/bash "$CHATC2_USER"
|
||||||
|
mkdir -p "$CHATC2_DIR"/{db,logs,tmp,uploads/audio,public,whisper/models,scripts,app/config,app/controllers,app/routes,app/middlewares}
|
||||||
|
chown -R "$CHATC2_USER:$CHATC2_USER" "$CHATC2_DIR"
|
||||||
|
|
||||||
|
# ████████████████████████████████████████████████████
|
||||||
|
# PASSO 5: WHISPER.CPP (Transcricao de Audio)
|
||||||
|
# ████████████████████████████████████████████████████
|
||||||
|
info "Passo 5/12: Instalando Whisper.cpp (compilacao nativa Linux)..."
|
||||||
|
mkdir -p "$CHATC2_DIR/whisper/models"
|
||||||
|
|
||||||
|
# Compila whisper.cpp do source (Linux) - binario nativo
|
||||||
|
if [ ! -f "$CHATC2_DIR/whisper/main" ]; then
|
||||||
|
info "Compilando whisper.cpp v${WHISPER_VERSION}..."
|
||||||
|
cd /tmp
|
||||||
|
rm -rf whisper-build 2>/dev/null || true
|
||||||
|
git clone --depth 1 --branch v${WHISPER_VERSION} https://github.com/ggerganov/whisper.cpp.git whisper-build 2>&1 | tail -1
|
||||||
|
cd whisper-build
|
||||||
|
make -j$(nproc) main 2>&1 | tail -3
|
||||||
|
cp -f main "$CHATC2_DIR/whisper/main"
|
||||||
|
chmod +x "$CHATC2_DIR/whisper/main"
|
||||||
|
cd /tmp && rm -rf whisper-build
|
||||||
|
log "Whisper.cpp compilado e instalado em $CHATC2_DIR/whisper/main"
|
||||||
|
else
|
||||||
|
log "Whisper.cpp binario Linux ja existe, pulando compilacao"
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "Baixando modelo $WHISPER_MODEL (pode levar alguns minutos)..."
|
||||||
|
if [ ! -f "$CHATC2_DIR/whisper/models/$WHISPER_MODEL" ]; then
|
||||||
|
su - "$CHATC2_USER" -c "cd $CHATC2_DIR/whisper && \
|
||||||
|
curl -sL -o models/$WHISPER_MODEL \
|
||||||
|
https://huggingface.co/ggerganov/whisper.cpp/resolve/main/$WHISPER_MODEL"
|
||||||
|
log "Modelo $WHISPER_MODEL baixado"
|
||||||
|
else
|
||||||
|
log "Modelo $WHISPER_MODEL ja existe"
|
||||||
|
fi
|
||||||
|
log "Whisper.cpp instalado em $CHATC2_DIR/whisper/"
|
||||||
|
|
||||||
|
# ████████████████████████████████████████████████████
|
||||||
|
# PASSO 6: ARQUIVOS DO PROJETO
|
||||||
|
# ████████████████████████████████████████████████████
|
||||||
|
info "Passo 6/12: Preparando para receber arquivos..."
|
||||||
|
info "Transfira os arquivos do projeto para $CHATC2_DIR via SCP/SFTP"
|
||||||
|
info "Depois execute manualmente: cd $CHATC2_DIR && npm install"
|
||||||
|
echo ""
|
||||||
|
echo " ╔══════════════════════════════════════════════════════════╗"
|
||||||
|
echo " ║ Comando para enviar os arquivos (do seu computador): ║"
|
||||||
|
echo " ║ ║"
|
||||||
|
echo " ║ cd C:/projects/Chatc2 ║"
|
||||||
|
echo " ║ scp -r src/ scripts/ package.json package-lock.json ║"
|
||||||
|
echo " ║ .env.example deploy-linux/ ║"
|
||||||
|
echo " ║ root@<IP>:$CHATC2_DIR/ ║"
|
||||||
|
echo " ║ ║"
|
||||||
|
echo " ║ E depois rode novamente este script: ║"
|
||||||
|
echo " ║ bash $0 --continue ║"
|
||||||
|
echo " ╚══════════════════════════════════════════════════════════╝"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Se o script foi chamado com --continue, pula a espera
|
||||||
|
if [ "${1:-}" != "--continue" ]; then
|
||||||
|
info "Aguardando transferencia dos arquivos..."
|
||||||
|
info "Apos transferir, execute: bash $0 --continue"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ████████████████████████████████████████████████████
|
||||||
|
# PASSO 7: NPM INSTALL
|
||||||
|
# ████████████████████████████████████████████████████
|
||||||
|
if [ ! -f "$CHATC2_DIR/package.json" ]; then
|
||||||
|
err "Arquivos do projeto nao encontrados em $CHATC2_DIR. Transfira via SCP primeiro."
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "Passo 7/12: Instalando dependencias Node..."
|
||||||
|
chown -R "$CHATC2_USER:$CHATC2_USER" "$CHATC2_DIR"
|
||||||
|
|
||||||
|
# Remove node_modules anterior (se existir) para garantir instalação limpa
|
||||||
|
if [ -d "$CHATC2_DIR/node_modules" ]; then
|
||||||
|
info "Removendo node_modules anterior para instalacao limpa..."
|
||||||
|
rm -rf "$CHATC2_DIR/node_modules"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Executa npm install com saida completa (sem suprimir erros)
|
||||||
|
if ! su - "$CHATC2_USER" -c "cd $CHATC2_DIR && npm install"; then
|
||||||
|
err "Falha ao instalar dependencias Node. Verifique a conexao e o package.json"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verifica se modulos criticos foram instalados
|
||||||
|
if [ ! -d "$CHATC2_DIR/node_modules/express" ]; then
|
||||||
|
err "Modulo 'express' nao encontrado apos npm install. Verifique o package.json"
|
||||||
|
fi
|
||||||
|
if [ ! -d "$CHATC2_DIR/node_modules/dotenv" ]; then
|
||||||
|
info "Instalando dotenv (carregamento de .env)..."
|
||||||
|
su - "$CHATC2_USER" -c "cd $CHATC2_DIR && npm install dotenv" || warn "Falha ao instalar dotenv"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Garante que o server.js carrega dotenv (variaveis do .env)
|
||||||
|
SERVER_JS="$CHATC2_DIR/src/server.js"
|
||||||
|
if [ -f "$SERVER_JS" ] && ! grep -q "require('dotenv').config()" "$SERVER_JS" 2>/dev/null; then
|
||||||
|
sed -i "1s/^/require('dotenv').config();\n/" "$SERVER_JS"
|
||||||
|
log "dotenv configurado no server.js"
|
||||||
|
fi
|
||||||
|
log "Dependencias instaladas e verificadas"
|
||||||
|
|
||||||
|
# ── Patch node-firebird: compatibilidade com Firebird 3.0 + sintaxe Node 22 ──
|
||||||
|
info "Passo 7b/12: Aplicando patches node-firebird..."
|
||||||
|
if [ -f "$(dirname "$0")/patch_node_firebird.py" ]; then
|
||||||
|
python3 "$(dirname "$0")/patch_node_firebird.py" "$CHATC2_DIR" 2>&1
|
||||||
|
log "Patches node-firebird aplicados"
|
||||||
|
else
|
||||||
|
warn "patch_node_firebird.py nao encontrado"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── wireCrypt (Firebird 3.0) ──
|
||||||
|
# A partir da v2.0 a camada de banco foi unificada em src/database.js e o
|
||||||
|
# wireCrypt já vem embutido nos defaults do driver Firebird — nada a patchar.
|
||||||
|
info "Passo 7c/12: wireCrypt (Firebird) já embutido em src/database.js"
|
||||||
|
log "Nenhum patch de wireCrypt necessário"
|
||||||
|
|
||||||
|
# ████████████████████████████████████████████████████
|
||||||
|
# PASSO 8: ARQUIVO .ENV
|
||||||
|
# ████████████████████████████████████████████████████
|
||||||
|
info "Passo 8/12: Configurando .env..."
|
||||||
|
JWT_SECRET="CHATc2_$(date +%s)_$(openssl rand -hex 16)"
|
||||||
|
|
||||||
|
# Detecta IPs da maquina
|
||||||
|
LOCAL_IP=$(ip -4 addr show scope global | grep -oP 'inet \K[\d.]+' | head -1)
|
||||||
|
[ -z "$LOCAL_IP" ] && LOCAL_IP="127.0.0.1"
|
||||||
|
PUBLIC_IP=$(curl -s ifconfig.me 2>/dev/null || echo "")
|
||||||
|
|
||||||
|
# Monta URLs
|
||||||
|
LOCAL_URL="http://${LOCAL_IP}:3000"
|
||||||
|
if [ -n "$SERVER_DOMAIN" ]; then
|
||||||
|
EXTERNAL_URL="https://${SERVER_DOMAIN}"
|
||||||
|
elif [ -n "$PUBLIC_IP" ]; then
|
||||||
|
EXTERNAL_URL="http://${PUBLIC_IP}"
|
||||||
|
else
|
||||||
|
EXTERNAL_URL="http://${LOCAL_IP}:3000"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Sempre gera um .env limpo (remove paths Windows, comentarios invalidos, etc.)
|
||||||
|
if [ -f "$CHATC2_DIR/.env" ]; then
|
||||||
|
info ".env existente encontrado — fazendo backup e recriando..."
|
||||||
|
cp "$CHATC2_DIR/.env" "$CHATC2_DIR/.env.bak.$(date +%Y%m%d%H%M%S)" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat > "$CHATC2_DIR/.env" << EOF
|
||||||
|
# ============================================================
|
||||||
|
# Configuracao do Chatc2
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# Driver padrao do banco principal
|
||||||
|
DB_DRIVER=postgres
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# PostgreSQL (EXTERNO) — banco principal "novo_local"
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
PG_HOST=${PG_HOST}
|
||||||
|
PG_PORT=${PG_PORT}
|
||||||
|
PG_USER=${PG_USER}
|
||||||
|
PG_PASSWORD=${PG_PASSWORD}
|
||||||
|
PG_DATABASE=${PG_DATABASE}
|
||||||
|
PG_SCHEMA=${PG_SCHEMA}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# Firebird (legado / alias "firebird_local")
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=3050
|
||||||
|
DB_USER=SYSDBA
|
||||||
|
DB_PASSWORD=masterkey
|
||||||
|
DB_ENCODING=UTF-8
|
||||||
|
# Caminho do .FDB (vazio = usa ../NOVO.FDB na raiz do projeto)
|
||||||
|
DB_DATABASE=
|
||||||
|
|
||||||
|
# Servidor
|
||||||
|
PORT=3000
|
||||||
|
JWT_SECRET=$JWT_SECRET
|
||||||
|
JWT_EXPIRES_IN=1h
|
||||||
|
|
||||||
|
# URLs de acesso
|
||||||
|
LOCAL_URL=${LOCAL_URL}
|
||||||
|
EXTERNAL_URL=${EXTERNAL_URL}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# Seguranca (opcionais)
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# Origens permitidas no CORS (vazio = usa LOCAL_URL + EXTERNAL_URL)
|
||||||
|
# CORS_ORIGINS=${EXTERNAL_URL}
|
||||||
|
# Token de verificacao do webhook Evolution (header apikey)
|
||||||
|
# WEBHOOK_TOKEN=
|
||||||
|
EOF
|
||||||
|
chown "$CHATC2_USER:$CHATC2_USER" "$CHATC2_DIR/.env"
|
||||||
|
log "Arquivo .env criado/atualizado com JWT_SECRET seguro"
|
||||||
|
log " PG_HOST/DB = ${PG_HOST:-(vazio)} / ${PG_DATABASE} (schema: ${PG_SCHEMA})"
|
||||||
|
log " LOCAL_URL = ${LOCAL_URL}"
|
||||||
|
log " EXTERNAL_URL = ${EXTERNAL_URL}"
|
||||||
|
if [ -z "$PG_HOST" ] || [ -z "$PG_PASSWORD" ]; then
|
||||||
|
warn "PostgreSQL externo nao configurado: edite $CHATC2_DIR/.env (PG_HOST, PG_PASSWORD, PG_DATABASE, PG_SCHEMA) antes de iniciar."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ████████████████████████████████████████████████████
|
||||||
|
# PASSO 9: MIGRACOES DO BANCO
|
||||||
|
# ████████████████████████████████████████████████████
|
||||||
|
# ── Permissões dos arquivos .FDB ──
|
||||||
|
info "Passo 8b/12: Ajustando permissoes dos arquivos .FDB..."
|
||||||
|
# O Firebird roda como usuario 'firebird'. Os arquivos .FDB precisam
|
||||||
|
# pertencer ao grupo firebird com permissao de leitura+escrita (660).
|
||||||
|
if ls "$CHATC2_DIR/db/"*.FDB 2>/dev/null; then
|
||||||
|
chown "$CHATC2_USER:firebird" "$CHATC2_DIR/db/"*.FDB 2>/dev/null || true
|
||||||
|
chmod 660 "$CHATC2_DIR/db/"*.FDB 2>/dev/null || true
|
||||||
|
# Garante que o diretório db/ também seja acessível
|
||||||
|
chmod 750 "$CHATC2_DIR/db/" 2>/dev/null || true
|
||||||
|
log "Permissoes ajustadas: $CHATC2_USER:firebird 660"
|
||||||
|
else
|
||||||
|
warn "Nenhum arquivo .FDB encontrado em $CHATC2_DIR/db/ — copie os bancos manualmente"
|
||||||
|
warn "Apos copiar, execute: chown $CHATC2_USER:firebird $CHATC2_DIR/db/*.FDB && chmod 660 $CHATC2_DIR/db/*.FDB"
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "Passo 9/12: Migracoes (Firebird legado)..."
|
||||||
|
# O schema do PostgreSQL e gerenciado no banco EXTERNO — nao migramos aqui.
|
||||||
|
# Migracoes Firebird so rodam se houver um .FDB local (alias firebird_local).
|
||||||
|
if ls "$CHATC2_DIR/db/"*.FDB "$CHATC2_DIR/"*.FDB >/dev/null 2>&1; then
|
||||||
|
if [ -f "$CHATC2_DIR/scripts/migracoes.js" ]; then
|
||||||
|
su - "$CHATC2_USER" -c "cd $CHATC2_DIR && node scripts/migracoes.js firebird_local 2>&1 | tail -10" || warn "Migracoes Firebird podem ter falhado"
|
||||||
|
log "Migracoes Firebird executadas"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
info "Sem .FDB local — pulando migracoes Firebird (schema do Postgres e externo)."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ████████████████████████████████████████████████████
|
||||||
|
# PASSO 9b: HABILITAR USUARIOS ADMIN PARA WEB
|
||||||
|
# ████████████████████████████████████████████████████
|
||||||
|
info "Passo 9b/12: Habilitando usuarios admin para acesso web..."
|
||||||
|
# Habilita acesso web para admins (Postgres principal e Firebird se presente)
|
||||||
|
cat > /tmp/chatc2-habilitar-web.js << 'SCRIPTJS'
|
||||||
|
const db = require('DATABASE_PATH');
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const alias = process.argv[2];
|
||||||
|
if (!alias) { console.log('Uso: node script <alias>'); process.exit(1); }
|
||||||
|
try {
|
||||||
|
// Habilita USU_ACESSO_WEB = 1 para usuarios ativos com perfil admin (USU_TIPO = A)
|
||||||
|
const result = await db.query(alias,
|
||||||
|
"SELECT USU_CODIGO_ID, USU_NOME, USU_LOGIN FROM USUARIOS WHERE USU_STATUS = 'A' AND USU_TIPO = 'A' AND COALESCE(USU_ACESSO_WEB, 0) = 0");
|
||||||
|
for (const u of result) {
|
||||||
|
await db.execute(alias, 'UPDATE USUARIOS SET USU_ACESSO_WEB = 1 WHERE USU_CODIGO_ID = ?', [u.USU_CODIGO_ID]);
|
||||||
|
console.log(' ✅ ' + u.USU_NOME.trim() + ' (' + u.USU_LOGIN.trim() + ') — acesso web habilitado');
|
||||||
|
}
|
||||||
|
if (result.length === 0) console.log(' Nenhum usuario admin pendente.');
|
||||||
|
process.exit(0);
|
||||||
|
} catch(e) { console.error('Erro:', e.message); process.exit(1); }
|
||||||
|
})();
|
||||||
|
SCRIPTJS
|
||||||
|
sed -i "s|DATABASE_PATH|$CHATC2_DIR/src/database|" /tmp/chatc2-habilitar-web.js
|
||||||
|
|
||||||
|
# Postgres externo (alias principal)
|
||||||
|
su - "$CHATC2_USER" -c "cd $CHATC2_DIR && node /tmp/chatc2-habilitar-web.js novo_local" 2>&1 || true
|
||||||
|
|
||||||
|
# Firebird local (apenas se houver .FDB)
|
||||||
|
if ls "$CHATC2_DIR/db/"*.FDB "$CHATC2_DIR/"*.FDB >/dev/null 2>&1; then
|
||||||
|
su - "$CHATC2_USER" -c "cd $CHATC2_DIR && node /tmp/chatc2-habilitar-web.js firebird_local" 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f /tmp/chatc2-habilitar-web.js
|
||||||
|
log "Usuarios admin verificados (Postgres + Firebird se presente)"
|
||||||
|
|
||||||
|
# ████████████████████████████████████████████████████
|
||||||
|
# PASSO 10: NGINX
|
||||||
|
# ████████████████████████████████████████████████████
|
||||||
|
info "Passo 10/12: Configurando Nginx..."
|
||||||
|
cat > /etc/nginx/sites-available/chatc2 << 'NGINX'
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
client_max_body_size 100M;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
proxy_read_timeout 86400s;
|
||||||
|
proxy_send_timeout 86400s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NGINX
|
||||||
|
|
||||||
|
# Remove sites padrao que podem conflitar
|
||||||
|
rm -f /etc/nginx/sites-enabled/default 2>/dev/null || true
|
||||||
|
rm -f /etc/nginx/sites-enabled/example.com 2>/dev/null || true
|
||||||
|
|
||||||
|
# Habilita o site do chatc2
|
||||||
|
if [ ! -L /etc/nginx/sites-enabled/chatc2 ]; then
|
||||||
|
ln -sf /etc/nginx/sites-available/chatc2 /etc/nginx/sites-enabled/
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Testa configuracao antes de aplicar
|
||||||
|
if nginx -t 2>&1; then
|
||||||
|
systemctl enable nginx 2>/dev/null || true
|
||||||
|
systemctl restart nginx 2>/dev/null || systemctl start nginx 2>/dev/null || true
|
||||||
|
log "Nginx configurado e iniciado"
|
||||||
|
else
|
||||||
|
warn "Erro na configuracao do nginx. Verifique manualmente: nginx -t"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# SSL com Let's Encrypt (se dominio foi informado)
|
||||||
|
if [ -n "${SERVER_DOMAIN:-}" ]; then
|
||||||
|
info "Configurando SSL para $SERVER_DOMAIN..."
|
||||||
|
certbot --nginx -d "$SERVER_DOMAIN" --non-interactive --agree-tos \
|
||||||
|
--email "admin@${SERVER_DOMAIN}" || warn "SSL falhou (verifique DNS)"
|
||||||
|
log "SSL configurado para $SERVER_DOMAIN"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ████████████████████████████████████████████████████
|
||||||
|
# PASSO 11: PM2 (INICIO AUTOMATICO)
|
||||||
|
# ████████████████████████████████████████████████████
|
||||||
|
info "Passo 11/12: Configurando PM2..."
|
||||||
|
|
||||||
|
# Para processos anteriores (se existirem)
|
||||||
|
pm2 delete chatc2 2>/dev/null || true
|
||||||
|
|
||||||
|
# Inicia a aplicacao com PM2 como root (script ja exige root)
|
||||||
|
cd "$CHATC2_DIR"
|
||||||
|
if ! pm2 start src/server.js --name chatc2 --max-memory-restart 512M --time 2>&1; then
|
||||||
|
err "Falha ao iniciar a aplicacao com PM2. Verifique os logs."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Aguarda o servidor iniciar e verifica se esta ouvindo na porta 3000
|
||||||
|
sleep 2
|
||||||
|
if ss -tlnp | grep -q ':3000'; then
|
||||||
|
log "Servidor Node.js ouvindo na porta 3000"
|
||||||
|
else
|
||||||
|
warn "Servidor pode nao estar ouvindo na porta 3000. Verifique: pm2 logs chatc2"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Salva a lista de processos e configura inicio automatico no boot
|
||||||
|
pm2 save 2>/dev/null || true
|
||||||
|
pm2 startup systemd -u root --hp /root 2>/dev/null || true
|
||||||
|
|
||||||
|
log "PM2 configurado - aplicacao iniciara automaticamente no boot"
|
||||||
|
|
||||||
|
# ████████████████████████████████████████████████████
|
||||||
|
# PASSO 12: FIREWALL
|
||||||
|
# ████████████████████████████████████████████████████
|
||||||
|
info "Passo 12/12: Configurando firewall..."
|
||||||
|
ufw allow 22/tcp
|
||||||
|
ufw allow 80/tcp
|
||||||
|
ufw allow 443/tcp
|
||||||
|
ufw allow 3000/tcp
|
||||||
|
ufw --force enable
|
||||||
|
log "Firewall configurado (portas: 22, 80, 443, 3000)"
|
||||||
|
|
||||||
|
# ████████████████████████████████████████████████████
|
||||||
|
# FINAL
|
||||||
|
# ████████████████████████████████████████████████████
|
||||||
|
PUBLIC_IP=$(curl -s ifconfig.me 2>/dev/null || echo "<IP>")
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "╔══════════════════════════════════════════════════╗"
|
||||||
|
echo "║ Chatc2 - Instalacao Concluida! ║"
|
||||||
|
echo "╚══════════════════════════════════════════════════╝"
|
||||||
|
echo ""
|
||||||
|
echo " 📍 Acessar: http://$PUBLIC_IP"
|
||||||
|
echo " 📁 Diretorio: $CHATC2_DIR"
|
||||||
|
echo " 👤 Usuario: $CHATC2_USER"
|
||||||
|
echo " 🗄️ Firebird: senha: masterkey"
|
||||||
|
echo " 🎤 Whisper: modelo $WHISPER_MODEL"
|
||||||
|
echo ""
|
||||||
|
echo " 📋 Proximos passos:"
|
||||||
|
echo " 1. Copie seus bancos .FDB para $CHATC2_DIR/db/"
|
||||||
|
echo " 2. Edite $CHATC2_DIR/.env se necessario"
|
||||||
|
echo " 3. Crie usuario admin: node $CHATC2_DIR/scripts/gerar-token-usuario.js"
|
||||||
|
echo " 4. Configure o webhook na Evolution API:"
|
||||||
|
echo " POST http://$PUBLIC_IP/api/webhook/evolution"
|
||||||
|
echo " 5. Logs: pm2 logs chatc2"
|
||||||
|
echo " 6. Parar: pm2 stop chatc2"
|
||||||
|
echo " 7. Reset: pm2 restart chatc2"
|
||||||
|
echo ""
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
patch_node_firebird.py - Aplica patches de compatibilidade node-firebird
|
||||||
|
|
||||||
|
Patches aplicados:
|
||||||
|
1. SRP empty buffer (Firebird 3.0 Legacy_Auth)
|
||||||
|
2. Correcao de sintaxe para Node.js v22+ (colchete faltante)
|
||||||
|
|
||||||
|
Uso: python3 patch_node_firebird.py <caminho_do_projeto>
|
||||||
|
Ex: python3 patch_node_firebird.py /home/chatc2/chatc2
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
def patch_srp(project_dir):
|
||||||
|
"""Patch 1: compatibilidade SRP com Firebird 3.0 Legacy_Auth"""
|
||||||
|
filepath = os.path.join(project_dir, 'node_modules',
|
||||||
|
'node-firebird', 'lib', 'wire', 'connection.js')
|
||||||
|
if not os.path.isfile(filepath):
|
||||||
|
print(" [ERRO] Arquivo connection.js nao encontrado")
|
||||||
|
return False
|
||||||
|
|
||||||
|
with open(filepath, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
if "No auth data from server" in content:
|
||||||
|
print(" [OK] Patch 1 (SRP) ja aplicado")
|
||||||
|
return True
|
||||||
|
|
||||||
|
old = (
|
||||||
|
' // TODO : Fallback Srp256 to Srp ?\n'
|
||||||
|
' /*if (!d.buffer) {\n'
|
||||||
|
' cnx.sendOpContAuth(\n'
|
||||||
|
' cnx.clientKeys.public.toString(16),\n'
|
||||||
|
' DEFAULT_ENCODING,\n'
|
||||||
|
' accept.pluginName\n'
|
||||||
|
' );\n'
|
||||||
|
'\n'
|
||||||
|
' return cb(new Error(\'login\'));\n'
|
||||||
|
' }*/\n'
|
||||||
|
'\n'
|
||||||
|
' // Check buffer contains salt\n'
|
||||||
|
' var saltLen = d.buffer.readUInt16LE(0);'
|
||||||
|
)
|
||||||
|
|
||||||
|
new = (
|
||||||
|
' // No auth data from server - server accepted the connection\n'
|
||||||
|
' // without requiring SRP. This happens with Firebird 3.0 when\n'
|
||||||
|
' // the server already validated the client.\n'
|
||||||
|
' if (!d || !d.buffer) {\n'
|
||||||
|
" accept.authData = '';\n"
|
||||||
|
" accept.sessionKey = '';\n"
|
||||||
|
' } else {\n'
|
||||||
|
' // Check buffer contains salt\n'
|
||||||
|
' var saltLen = d.buffer.readUInt16LE(0);'
|
||||||
|
)
|
||||||
|
|
||||||
|
if old in content:
|
||||||
|
content = content.replace(old, new, 1)
|
||||||
|
with open(filepath, 'w') as f:
|
||||||
|
f.write(content)
|
||||||
|
print(" [OK] Patch 1 (SRP) aplicado")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(" [AVISO] Patch 1 (SRP) nao encontrado - codigo ja modificado")
|
||||||
|
return True # nao e erro
|
||||||
|
|
||||||
|
|
||||||
|
def patch_syntax(project_dir):
|
||||||
|
"""Patch 2: colchete faltante (Node.js v22 nao tolera essa sintaxe)"""
|
||||||
|
filepath = os.path.join(project_dir, 'node_modules',
|
||||||
|
'node-firebird', 'lib', 'wire', 'connection.js')
|
||||||
|
if not os.path.isfile(filepath):
|
||||||
|
return False
|
||||||
|
|
||||||
|
with open(filepath, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Verifica se ja foi corrigido
|
||||||
|
if "fecha o else do if" in content:
|
||||||
|
print(" [OK] Patch 2 (sintaxe) ja aplicado")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# O erro: else block do if(!d||!d.buffer) nao tem chave de fechamento
|
||||||
|
# Linha original termina com: accept.sessionKey = proof.clientSessionKey;
|
||||||
|
# Linha seguinte: } else if (accept.pluginName === Const.AUTH_PLUGIN_LEGACY)
|
||||||
|
# Precisa de um } extra entre elas para fechar o else block
|
||||||
|
old = (
|
||||||
|
' accept.authData = proof.authData.toString(16);\n'
|
||||||
|
' accept.sessionKey = proof.clientSessionKey;\n'
|
||||||
|
' } else if (accept.pluginName === Const.AUTH_PLUGIN_LEGACY) {'
|
||||||
|
)
|
||||||
|
new = (
|
||||||
|
' accept.authData = proof.authData.toString(16);\n'
|
||||||
|
' accept.sessionKey = proof.clientSessionKey;\n'
|
||||||
|
' } // fecha o else do if (!d || !d.buffer)\n'
|
||||||
|
' } else if (accept.pluginName === Const.AUTH_PLUGIN_LEGACY) {'
|
||||||
|
)
|
||||||
|
|
||||||
|
if old in content:
|
||||||
|
content = content.replace(old, new, 1)
|
||||||
|
with open(filepath, 'w') as f:
|
||||||
|
f.write(content)
|
||||||
|
print(" [OK] Patch 2 (sintaxe Node22) aplicado")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(" [AVISO] Patch 2 (sintaxe) - padrao nao encontrado")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("Uso: python3 patch_node_firebird.py <caminho_do_projeto>")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
project_dir = sys.argv[1]
|
||||||
|
print(f" Aplicando patches em: {project_dir}")
|
||||||
|
|
||||||
|
r1 = patch_srp(project_dir)
|
||||||
|
r2 = patch_syntax(project_dir)
|
||||||
|
|
||||||
|
if r1 and r2:
|
||||||
|
print(" Patches aplicados com sucesso")
|
||||||
|
sys.exit(0)
|
||||||
|
else:
|
||||||
|
print(" Alguns patches falharam")
|
||||||
|
sys.exit(1)
|
||||||
Generated
+1328
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "chatc2",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"description": "API REST multi-banco (PostgreSQL + Firebird) para a plataforma de atendimento Chatc2",
|
||||||
|
"main": "src/server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/server.js",
|
||||||
|
"dev": "node --watch src/server.js"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"postgres",
|
||||||
|
"firebird",
|
||||||
|
"api",
|
||||||
|
"express"
|
||||||
|
],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"type": "commonjs",
|
||||||
|
"dependencies": {
|
||||||
|
"cors": "^2.8.6",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"ffmpeg-static": "^5.3.0",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"node-firebird": "^2.3.1",
|
||||||
|
"pg": "^8.21.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Script para definir/alterar a senha web de um usuário.
|
||||||
|
* Uso: node scripts/definir-senha-web.js <LOGIN> <SENHA> [alias]
|
||||||
|
*
|
||||||
|
* @param {string} LOGIN - Login do usuário
|
||||||
|
* @param {string} SENHA - Nova senha (máx. 30 caracteres)
|
||||||
|
* @param {string} [alias=lajedo] - Alias do banco de dados
|
||||||
|
*/
|
||||||
|
const db = require('../src/database');
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const login = process.argv[2];
|
||||||
|
const senha = process.argv[3];
|
||||||
|
const alias = process.argv[4] || 'lajedo';
|
||||||
|
|
||||||
|
if (!login || !senha) {
|
||||||
|
console.log('Uso: node scripts/definir-senha-web.js <LOGIN> <SENHA> [alias]');
|
||||||
|
console.log('Ex: node scripts/definir-senha-web.js SUPORTE 123456 lajedo');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (senha.length > 30) {
|
||||||
|
console.log('❌ A senha deve ter no máximo 30 caracteres.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verifica se o usuário existe
|
||||||
|
const users = await db.query(alias,
|
||||||
|
'SELECT USU_CODIGO_ID, USU_NOME, USU_LOGIN, USU_ACESSO_WEB FROM USUARIOS WHERE USU_LOGIN = ?',
|
||||||
|
[login]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (users.length === 0) {
|
||||||
|
console.log(`❌ Usuário "${login}" não encontrado no alias "${alias}".`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = users[0];
|
||||||
|
|
||||||
|
// Atualiza a senha web
|
||||||
|
await db.execute(alias,
|
||||||
|
'UPDATE USUARIOS SET USU_SENHA_WEB = ? WHERE USU_CODIGO_ID = ?',
|
||||||
|
[senha, user.USU_CODIGO_ID]
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`✅ Senha web do usuário "${login}" definida com sucesso! (alias: ${alias})`);
|
||||||
|
|
||||||
|
if (user.USU_ACESSO_WEB !== 1) {
|
||||||
|
console.log('⚠️ O usuário ainda não tem acesso WEB habilitado.');
|
||||||
|
console.log(` Execute: node scripts/habilitar-acesso-web.js ${login} ${alias}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err => {
|
||||||
|
console.error('Erro:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* Gera um token JWT para um usuário em uma empresa específica.
|
||||||
|
* O token é armazenado no campo USU_TOKEN da tabela USUARIOS.
|
||||||
|
*
|
||||||
|
* Uso: node scripts/gerar-token-jwt.js <LOGIN> <ID_EMPRESA> [alias]
|
||||||
|
*
|
||||||
|
* Exemplos:
|
||||||
|
* node scripts/gerar-token-jwt.js SUPORTE 1
|
||||||
|
* node scripts/gerar-token-jwt.js SUPORTE 1 lajedo
|
||||||
|
*/
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const db = require('../src/database');
|
||||||
|
const authConfig = require('../src/config/auth');
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const login = process.argv[2];
|
||||||
|
const empresaId = parseInt(process.argv[3], 10);
|
||||||
|
const alias = process.argv[4] || 'lajedo';
|
||||||
|
|
||||||
|
if (!login || !empresaId) {
|
||||||
|
console.log('Uso: node scripts/gerar-token-jwt.js <LOGIN> <ID_EMPRESA> [alias]');
|
||||||
|
console.log('Ex: node scripts/gerar-token-jwt.js SUPORTE 1 lajedo');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Busca o usuário pelo login
|
||||||
|
const users = await db.query(alias,
|
||||||
|
`SELECT USU_CODIGO_ID, USU_NOME, USU_LOGIN, USU_EMAIL, USU_STATUS,
|
||||||
|
USU_ACESSO_WEB, USU_TIPO, USU_TOKEN
|
||||||
|
FROM USUARIOS WHERE USU_LOGIN = ?`,
|
||||||
|
[login]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (users.length === 0) {
|
||||||
|
console.log(`❌ Usuário "${login}" não encontrado no alias "${alias}".`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = users[0];
|
||||||
|
|
||||||
|
// 2. Verifica se o usuário tem acesso à empresa
|
||||||
|
const empresas = await db.query(alias,
|
||||||
|
`SELECT USE_EMPRESA_ID FROM USUARIOS_EMPRESA
|
||||||
|
WHERE USE_USUARIO_ID = ? AND USE_EMPRESA_ID = ?`,
|
||||||
|
[user.USU_CODIGO_ID, empresaId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (empresas.length === 0) {
|
||||||
|
// Lista as empresas que o usuário tem acesso
|
||||||
|
const allEmpresas = await db.query(alias,
|
||||||
|
`SELECT USE_EMPRESA_ID FROM USUARIOS_EMPRESA
|
||||||
|
WHERE USE_USUARIO_ID = ?`,
|
||||||
|
[user.USU_CODIGO_ID]
|
||||||
|
);
|
||||||
|
const empresasList = allEmpresas.map(e => e.USE_EMPRESA_ID).join(', ');
|
||||||
|
|
||||||
|
console.log(`❌ Usuário "${login}" não tem acesso à empresa ${empresaId}.`);
|
||||||
|
console.log(` Empresas disponíveis para este usuário: ${empresasList || 'nenhuma'}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Verifica status e permissão web
|
||||||
|
const statusOk = user.USU_STATUS === 'A';
|
||||||
|
const webOk = user.USU_ACESSO_WEB === 1;
|
||||||
|
|
||||||
|
if (!statusOk) {
|
||||||
|
console.log(`❌ Usuário "${login}" não está ativo (status: ${user.USU_STATUS}).`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!webOk) {
|
||||||
|
console.log(`⚠️ Usuário "${login}" não tem acesso WEB habilitado.`);
|
||||||
|
console.log(` Execute: node scripts/habilitar-acesso-web.js ${login} ${alias}`);
|
||||||
|
// Não impede de gerar o token, mas avisa
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Gera o payload do token
|
||||||
|
const payload = {
|
||||||
|
id: user.USU_CODIGO_ID,
|
||||||
|
empresaId: empresaId,
|
||||||
|
nome: user.USU_NOME.trim(),
|
||||||
|
login: user.USU_LOGIN.trim(),
|
||||||
|
email: user.USU_EMAIL ? user.USU_EMAIL.trim() : null,
|
||||||
|
tipo: user.USU_TIPO ? user.USU_TIPO.trim() : null,
|
||||||
|
alias: alias,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 5. Gera o token JWT
|
||||||
|
const token = jwt.sign(payload, authConfig.secret, {
|
||||||
|
expiresIn: authConfig.expiresIn,
|
||||||
|
issuer: authConfig.issuer,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 6. Armazena no campo USU_TOKEN
|
||||||
|
await db.execute(alias,
|
||||||
|
'UPDATE USUARIOS SET USU_TOKEN = ? WHERE USU_CODIGO_ID = ?',
|
||||||
|
[token, user.USU_CODIGO_ID]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 7. Exibe o resultado
|
||||||
|
console.log('');
|
||||||
|
console.log('='.repeat(70));
|
||||||
|
console.log(' ✅ TOKEN JWT GERADO COM SUCESSO');
|
||||||
|
console.log('='.repeat(70));
|
||||||
|
console.log('');
|
||||||
|
console.log(` Alias: ${alias}`);
|
||||||
|
console.log(` Usuário: ${user.USU_NOME.trim()} (${user.USU_LOGIN.trim()})`);
|
||||||
|
console.log(` ID Usuário: ${user.USU_CODIGO_ID}`);
|
||||||
|
console.log(` Empresa: ${empresaId}`);
|
||||||
|
console.log(` Status: ${statusOk ? '✅ Ativo' : '❌ Inativo'}`);
|
||||||
|
console.log(` Acesso Web: ${webOk ? '✅ Sim' : '❌ Não'}`);
|
||||||
|
console.log('');
|
||||||
|
console.log(' ┌─ TOKEN ─────────────────────────────────────────────────┐');
|
||||||
|
console.log(` │ ${token}`);
|
||||||
|
console.log(' └─────────────────────────────────────────────────────────┘');
|
||||||
|
console.log('');
|
||||||
|
console.log(' Expira em: ' + authConfig.expiresIn);
|
||||||
|
console.log('');
|
||||||
|
console.log(' 📌 Headers para usar nas requisições:');
|
||||||
|
console.log(' Authorization: Bearer ' + token.substring(0, 50) + '...');
|
||||||
|
console.log(' X-Usu-Token: ' + token.substring(0, 50) + '...');
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err => {
|
||||||
|
console.error('Erro:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* Script para gerar/renovar o USU_TOKEN de um usuário.
|
||||||
|
* Uso: node scripts/gerar-token-usuario.js <LOGIN> [alias]
|
||||||
|
*
|
||||||
|
* Cada usuário recebe um token único (48 caracteres hex) armazenado
|
||||||
|
* no campo USU_TOKEN, que pode ser usado para autenticação na API
|
||||||
|
* via header X-Usu-Token.
|
||||||
|
*
|
||||||
|
* @param {string} LOGIN - Login do usuário
|
||||||
|
* @param {string} [alias=lajedo] - Alias do banco de dados
|
||||||
|
*/
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const db = require('../src/database');
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const login = process.argv[2];
|
||||||
|
const alias = process.argv[3] || 'lajedo';
|
||||||
|
|
||||||
|
if (!login) {
|
||||||
|
console.log('Uso: node scripts/gerar-token-usuario.js <LOGIN> [alias]');
|
||||||
|
console.log('Ex: node scripts/gerar-token-usuario.js SUPORTE lajedo');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verifica se o usuário existe
|
||||||
|
const users = await db.query(alias,
|
||||||
|
`SELECT USU_CODIGO_ID, USU_NOME, USU_LOGIN, USU_STATUS, USU_ACESSO_WEB
|
||||||
|
FROM USUARIOS WHERE USU_LOGIN = ?`,
|
||||||
|
[login]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (users.length === 0) {
|
||||||
|
console.log(`❌ Usuário "${login}" não encontrado no alias "${alias}".`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = users[0];
|
||||||
|
|
||||||
|
// Gera um token único (48 caracteres hex)
|
||||||
|
const token = crypto.randomBytes(24).toString('hex');
|
||||||
|
|
||||||
|
// Armazena no banco
|
||||||
|
await db.execute(alias,
|
||||||
|
'UPDATE USUARIOS SET USU_TOKEN = ? WHERE USU_CODIGO_ID = ?',
|
||||||
|
[token, user.USU_CODIGO_ID]
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('=== Token gerado com sucesso! ===');
|
||||||
|
console.log(`Alias: ${alias}`);
|
||||||
|
console.log(`Usuário: ${user.USU_NOME.trim()} (${user.USU_LOGIN.trim()})`);
|
||||||
|
console.log(`ID: ${user.USU_CODIGO_ID}`);
|
||||||
|
console.log(`Status: ${user.USU_STATUS === 'A' ? '✅ Ativo' : '❌ Inativo'}`);
|
||||||
|
console.log(`Acesso Web: ${user.USU_ACESSO_WEB === 1 ? '✅ Sim' : '❌ Não'}`);
|
||||||
|
console.log(`\n🔑 USU_TOKEN:`);
|
||||||
|
console.log(`${token}`);
|
||||||
|
console.log(`\n📌 Use no header das requisições:`);
|
||||||
|
console.log(`X-Usu-Token: ${token}`);
|
||||||
|
console.log(`ou`);
|
||||||
|
console.log(`Authorization: Bearer ${token}`);
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err => {
|
||||||
|
console.error('Erro:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* Script para habilitar acesso WEB a um usuário existente.
|
||||||
|
* Uso: node scripts/habilitar-acesso-web.js <LOGIN> [alias]
|
||||||
|
*
|
||||||
|
* @param {string} LOGIN - Login do usuário
|
||||||
|
* @param {string} [alias=lajedo] - Alias do banco de dados
|
||||||
|
*/
|
||||||
|
const db = require('../src/database');
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const login = process.argv[2];
|
||||||
|
const alias = process.argv[3] || 'lajedo';
|
||||||
|
|
||||||
|
if (!login) {
|
||||||
|
console.log('Uso: node scripts/habilitar-acesso-web.js <LOGIN> [alias]');
|
||||||
|
console.log('Ex: node scripts/habilitar-acesso-web.js SUPORTE lajedo');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verifica se o usuário existe
|
||||||
|
const users = await db.query(alias,
|
||||||
|
'SELECT USU_CODIGO_ID, USU_NOME, USU_LOGIN, USU_ACESSO_WEB, USU_STATUS, USU_SENHA_WEB FROM USUARIOS WHERE USU_LOGIN = ?',
|
||||||
|
[login]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (users.length === 0) {
|
||||||
|
console.log(`❌ Usuário "${login}" não encontrado no alias "${alias}".`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = users[0];
|
||||||
|
|
||||||
|
console.log('=== Usuário encontrado ===');
|
||||||
|
console.log(`Alias: ${alias}`);
|
||||||
|
console.log(`ID: ${user.USU_CODIGO_ID}`);
|
||||||
|
console.log(`Nome: ${user.USU_NOME.trim()}`);
|
||||||
|
console.log(`Login: ${user.USU_LOGIN.trim()}`);
|
||||||
|
console.log(`Acesso Web: ${user.USU_ACESSO_WEB === 1 ? '✅ Sim' : '❌ Não'}`);
|
||||||
|
console.log(`Status: ${user.USU_STATUS === 'A' ? '✅ Ativo' : '❌ Inativo'}`);
|
||||||
|
console.log(`Senha Web: ${user.USU_SENHA_WEB || '(vazia)'}`);
|
||||||
|
|
||||||
|
// Habilita acesso web
|
||||||
|
await db.execute(alias,
|
||||||
|
'UPDATE USUARIOS SET USU_ACESSO_WEB = 1 WHERE USU_CODIGO_ID = ?',
|
||||||
|
[user.USU_CODIGO_ID]
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('\n✅ Acesso WEB habilitado com sucesso!');
|
||||||
|
console.log(` Agora o usuário "${login}" pode usar o app.`);
|
||||||
|
|
||||||
|
if (!user.USU_SENHA_WEB) {
|
||||||
|
console.log('\n⚠️ ATENÇÃO: O usuário não possui senha web (USU_SENHA_WEB).');
|
||||||
|
console.log(' Defina uma senha para que o login funcione.');
|
||||||
|
console.log(' Ex: node scripts/definir-senha-web.js SUPORTE 123456 ' + alias);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err => {
|
||||||
|
console.error('Erro:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* Lista todos os usuários com seus respectivos USU_TOKEN.
|
||||||
|
* Uso: node scripts/listar-tokens.js [LOGIN] [alias]
|
||||||
|
*
|
||||||
|
* @param {string} [LOGIN] - Filtrar por login (opcional)
|
||||||
|
* @param {string} [alias=lajedo] - Alias do banco de dados
|
||||||
|
*/
|
||||||
|
const db = require('../src/database');
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const filtro = process.argv[2];
|
||||||
|
const alias = process.argv[3] || 'lajedo';
|
||||||
|
|
||||||
|
const sql = filtro
|
||||||
|
? `SELECT USU_CODIGO_ID, USU_NOME, USU_LOGIN, USU_STATUS,
|
||||||
|
USU_ACESSO_WEB, USU_TOKEN
|
||||||
|
FROM USUARIOS WHERE USU_LOGIN = ?`
|
||||||
|
: `SELECT USU_CODIGO_ID, USU_NOME, USU_LOGIN, USU_STATUS,
|
||||||
|
USU_ACESSO_WEB, USU_TOKEN
|
||||||
|
FROM USUARIOS`;
|
||||||
|
|
||||||
|
const users = await db.query(alias, sql, filtro ? [filtro] : []);
|
||||||
|
|
||||||
|
if (users.length === 0) {
|
||||||
|
console.log(`Nenhum usuário encontrado no alias "${alias}".`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('='.repeat(80));
|
||||||
|
console.log(` USUÁRIOS E TOKENS (alias: ${alias})`);
|
||||||
|
console.log('='.repeat(80));
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
users.forEach((u, i) => {
|
||||||
|
const status = u.USU_STATUS?.trim() === 'A' ? '✅' : '❌';
|
||||||
|
const web = u.USU_ACESSO_WEB === 1 ? '✅' : '❌';
|
||||||
|
const token = u.USU_TOKEN?.trim();
|
||||||
|
|
||||||
|
console.log(`${i + 1}. ${u.USU_NOME.trim()} (${u.USU_LOGIN.trim()})`);
|
||||||
|
console.log(` ID: ${u.USU_CODIGO_ID} | Status: ${status} | Acesso Web: ${web}`);
|
||||||
|
console.log(` USU_TOKEN: ${token || '(vazio)'}`);
|
||||||
|
console.log('');
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('='.repeat(80));
|
||||||
|
console.log(`Total: ${users.length} usuário(s)`);
|
||||||
|
console.log('='.repeat(80));
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err => {
|
||||||
|
console.error('Erro:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,490 @@
|
|||||||
|
/**
|
||||||
|
* ARQUIVO DE MIGRAÇÕES DO BANCO DE DADOS
|
||||||
|
* ========================================
|
||||||
|
*
|
||||||
|
* Este script contém TODAS as alterações estruturais feitas no banco de dados
|
||||||
|
* desde a criação inicial do projeto.
|
||||||
|
*
|
||||||
|
* USO:
|
||||||
|
* node scripts/migracoes.js [alias]
|
||||||
|
*
|
||||||
|
* Exemplo:
|
||||||
|
* node scripts/migracoes.js lajedo
|
||||||
|
* node scripts/migracoes.js novo
|
||||||
|
*
|
||||||
|
* IMPORTANTE:
|
||||||
|
* - Cada migração tem um ID único e só executa uma vez
|
||||||
|
* - O controle é feito via tabela CHATC2_CONTROLE_MIGRACOES
|
||||||
|
* - Para adicionar NOVA migração, adicione um novo objeto no array MIGRACOES
|
||||||
|
* - Nunca remova ou altere migrações já existentes (apenas adicione novas)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const db = require('../src/database');
|
||||||
|
|
||||||
|
// Este script aplica DDL no dialeto Firebird (BLOB SUB_TYPE, etc.).
|
||||||
|
// O schema do PostgreSQL é gerenciado externamente (banco externo) — use este
|
||||||
|
// script apenas para bancos Firebird. Alias padrão: firebird_local.
|
||||||
|
const alias = process.argv[2] || 'firebird_local';
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// CONTROLE DE MIGRAÇÕES
|
||||||
|
// ============================================================
|
||||||
|
async function garantirControle() {
|
||||||
|
try {
|
||||||
|
await db.execute(alias, `
|
||||||
|
CREATE TABLE CHATC2_CONTROLE_MIGRACOES (
|
||||||
|
MIG_ID INTEGER NOT NULL PRIMARY KEY,
|
||||||
|
MIG_DESCRICAO VARCHAR(200),
|
||||||
|
MIG_DATA_EXECUCAO TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
MIG_STATUS CHAR(1) DEFAULT 'A'
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
console.log('✅ Tabela CHATC2_CONTROLE_MIGRACOES criada');
|
||||||
|
} catch(e) {
|
||||||
|
if (!e.message.includes('already exists')) {
|
||||||
|
console.log('⚠️ CHATC2_CONTROLE_MIGRACOES:', e.message.substring(0, 100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function migracaoJaExecutada(id) {
|
||||||
|
try {
|
||||||
|
var r = await db.query(alias,
|
||||||
|
'SELECT COUNT(*) AS CT FROM CHATC2_CONTROLE_MIGRACOES WHERE MIG_ID = ?', [id]);
|
||||||
|
return r[0]?.CT > 0;
|
||||||
|
} catch(e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registrarMigracao(id, descricao) {
|
||||||
|
try {
|
||||||
|
await db.execute(alias,
|
||||||
|
'INSERT INTO CHATC2_CONTROLE_MIGRACOES (MIG_ID, MIG_DESCRICAO) VALUES (?, ?)',
|
||||||
|
[id, descricao]);
|
||||||
|
} catch(e) {
|
||||||
|
console.log('⚠️ Erro ao registrar migração:', e.message.substring(0, 80));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// LISTA DE MIGRAÇÕES
|
||||||
|
// ============================================================
|
||||||
|
// SEMPRE adicione NOVAS migrações no FINAL do array, com ID sequencial.
|
||||||
|
// NUNCA remova ou modifique migrações já existentes.
|
||||||
|
|
||||||
|
const MIGRACOES = [
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// MIGRAÇÃO 1: Campo USU_TIPO_CHAT em USUARIOS
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
descricao: 'Adicionar USU_TIPO_CHAT em USUARIOS (A=Atendente, G=Gerente)',
|
||||||
|
sql: [
|
||||||
|
`ALTER TABLE USUARIOS ADD USU_TIPO_CHAT CHAR(1) DEFAULT 'A'`,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// MIGRAÇÃO 2: Tabela CHATC2_INSTANCIAS (Evolution API)
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
descricao: 'Criar tabela CHATC2_INSTANCIAS para conexões WhatsApp',
|
||||||
|
sql: [
|
||||||
|
`CREATE TABLE CHATC2_INSTANCIAS (
|
||||||
|
INS_CODIGO_ID INTEGER NOT NULL PRIMARY KEY,
|
||||||
|
INS_EMPRESA_ID INTEGER NOT NULL,
|
||||||
|
INS_NOME VARCHAR(100),
|
||||||
|
INS_URL VARCHAR(500),
|
||||||
|
INS_API_KEY VARCHAR(500),
|
||||||
|
INS_INSTANCE_NAME VARCHAR(100),
|
||||||
|
INS_STATUS CHAR(1) DEFAULT 'D',
|
||||||
|
INS_QR_CODE BLOB SUB_TYPE 0,
|
||||||
|
INS_DT_CONEXAO TIMESTAMP,
|
||||||
|
INS_DT_CADASTRO TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INS_SITUACAO CHAR(1) DEFAULT 'A'
|
||||||
|
)`,
|
||||||
|
`CREATE SEQUENCE GEN_INSTANCIAS`,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// MIGRAÇÃO 3: Tabela CHATC2_EQUIPES
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
descricao: 'Criar tabela CHATC2_EQUIPES',
|
||||||
|
sql: [
|
||||||
|
`CREATE TABLE CHATC2_EQUIPES (
|
||||||
|
EQU_CODIGO_ID INTEGER NOT NULL PRIMARY KEY,
|
||||||
|
EQU_EMPRESA_ID INTEGER NOT NULL,
|
||||||
|
EQU_NOME VARCHAR(100),
|
||||||
|
EQU_ORDEM INTEGER DEFAULT 0,
|
||||||
|
EQU_SITUACAO CHAR(1) DEFAULT 'A'
|
||||||
|
)`,
|
||||||
|
`CREATE SEQUENCE GEN_EQUIPES`,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// MIGRAÇÃO 4: Tabela CHATC2_USU_EQUIPES
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
descricao: 'Criar tabela CHATC2_USU_EQUIPES (relação N:N)',
|
||||||
|
sql: [
|
||||||
|
`CREATE TABLE CHATC2_USU_EQUIPES (
|
||||||
|
EQU_EQUIPE_ID INTEGER NOT NULL,
|
||||||
|
EQU_USUARIO_ID INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (EQU_EQUIPE_ID, EQU_USUARIO_ID)
|
||||||
|
)`,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// MIGRAÇÃO 5: Tabela CHATC2_ETIQUETAS
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
descricao: 'Criar tabela CHATC2_ETIQUETAS',
|
||||||
|
sql: [
|
||||||
|
`CREATE TABLE CHATC2_ETIQUETAS (
|
||||||
|
ETI_CODIGO_ID INTEGER NOT NULL PRIMARY KEY,
|
||||||
|
ETI_EMPRESA_ID INTEGER NOT NULL,
|
||||||
|
ETI_NOME VARCHAR(50),
|
||||||
|
ETI_COR VARCHAR(7) DEFAULT '#667eea',
|
||||||
|
ETI_SITUACAO CHAR(1) DEFAULT 'A'
|
||||||
|
)`,
|
||||||
|
`CREATE SEQUENCE GEN_ETIQUETAS`,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// MIGRAÇÃO 6: Tabela CHATC2_CONVERSAS
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
descricao: 'Criar tabela CHATC2_CONVERSAS',
|
||||||
|
sql: [
|
||||||
|
`CREATE TABLE CHATC2_CONVERSAS (
|
||||||
|
CON_CODIGO_ID INTEGER NOT NULL PRIMARY KEY,
|
||||||
|
CON_EMPRESA_ID INTEGER NOT NULL,
|
||||||
|
CON_INSTANCIA_ID INTEGER,
|
||||||
|
CON_CLIENTE_ID INTEGER,
|
||||||
|
CON_NUMERO VARCHAR(20),
|
||||||
|
CON_NOME_CONTATO VARCHAR(100),
|
||||||
|
CON_STATUS CHAR(1) DEFAULT 'E',
|
||||||
|
CON_USUARIO_ID INTEGER,
|
||||||
|
CON_EQUIPE_ID INTEGER,
|
||||||
|
CON_DT_INICIO TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CON_DT_FINAL TIMESTAMP,
|
||||||
|
CON_DT_ULTIMA_MSG TIMESTAMP,
|
||||||
|
CON_SAUDACAO_ENVIADA CHAR(1) DEFAULT 'N',
|
||||||
|
CON_CSAT_ENVIADO CHAR(1) DEFAULT 'N',
|
||||||
|
CON_PRIMEIRA_MSG TIMESTAMP,
|
||||||
|
CON_ORIGEM VARCHAR(20) DEFAULT 'whatsapp',
|
||||||
|
CON_SITUACAO CHAR(1) DEFAULT 'A'
|
||||||
|
)`,
|
||||||
|
`CREATE SEQUENCE GEN_CONVERSAS`,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// MIGRAÇÃO 7: Tabela CHATC2_CONVERSAS_MENSAGENS
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
descricao: 'Criar tabela CHATC2_CONVERSAS_MENSAGENS',
|
||||||
|
sql: [
|
||||||
|
`CREATE TABLE CHATC2_CONVERSAS_MENSAGENS (
|
||||||
|
CME_CODIGO_ID INTEGER NOT NULL PRIMARY KEY,
|
||||||
|
CME_CONVERSA_ID INTEGER NOT NULL,
|
||||||
|
CME_REMETENTE CHAR(1) DEFAULT 'C',
|
||||||
|
CME_USUARIO_ID INTEGER,
|
||||||
|
CME_MENSAGEM BLOB SUB_TYPE 1,
|
||||||
|
CME_TEXTO VARCHAR(4000),
|
||||||
|
CME_TIPO VARCHAR(20) DEFAULT 'text',
|
||||||
|
CME_DT_ENVIO TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CME_PRIVADA CHAR(1) DEFAULT 'N',
|
||||||
|
CME_LIDA CHAR(1) DEFAULT 'N',
|
||||||
|
CME_MIDIA_ID INTEGER,
|
||||||
|
CME_SITUACAO CHAR(1) DEFAULT 'A'
|
||||||
|
)`,
|
||||||
|
`CREATE SEQUENCE GEN_CONVERSAS_MENSAGENS`,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// MIGRAÇÃO 8: Tabela CHATC2_MENSAGENS_ATENDIMENTOS (blob mídias)
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
{
|
||||||
|
id: 8,
|
||||||
|
descricao: 'Criar tabela CHATC2_MENSAGENS_ATENDIMENTOS para mídias',
|
||||||
|
sql: [
|
||||||
|
`CREATE TABLE CHATC2_MENSAGENS_ATENDIMENTOS (
|
||||||
|
MAT_CODIGO_ID INTEGER NOT NULL PRIMARY KEY,
|
||||||
|
MAT_CONVERSA_ID INTEGER,
|
||||||
|
MAT_MENSAGEM_ID INTEGER,
|
||||||
|
MAT_NOME_ARQUIVO VARCHAR(255),
|
||||||
|
MAT_TIPO_ARQUIVO VARCHAR(50),
|
||||||
|
MAT_ARQUIVO BLOB SUB_TYPE 0,
|
||||||
|
MAT_DT_CADASTRO TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
MAT_SITUACAO CHAR(1) DEFAULT 'A'
|
||||||
|
)`,
|
||||||
|
`CREATE SEQUENCE GEN_MENSAGENS_ATENDIMENTO`,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// MIGRAÇÃO 9: Tabela CHATC2_CONVERSAS_ETIQUETAS
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
{
|
||||||
|
id: 9,
|
||||||
|
descricao: 'Criar tabela CHATC2_CONVERSAS_ETIQUETAS',
|
||||||
|
sql: [
|
||||||
|
`CREATE TABLE CHATC2_CONVERSAS_ETIQUETAS (
|
||||||
|
CET_CONVERSA_ID INTEGER NOT NULL,
|
||||||
|
CET_ETIQUETA_ID INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (CET_CONVERSA_ID, CET_ETIQUETA_ID)
|
||||||
|
)`,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// MIGRAÇÃO 10: Tabela CHATC2_CSAT_AVALIACOES
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
descricao: 'Criar tabela CHATC2_CSAT_AVALIACOES',
|
||||||
|
sql: [
|
||||||
|
`CREATE TABLE CHATC2_CSAT_AVALIACOES (
|
||||||
|
CSA_CODIGO_ID INTEGER NOT NULL PRIMARY KEY,
|
||||||
|
CSA_CONVERSA_ID INTEGER,
|
||||||
|
CSA_EMPRESA_ID INTEGER,
|
||||||
|
CSA_CLIENTE_ID INTEGER,
|
||||||
|
CSA_NOTA INTEGER,
|
||||||
|
CSA_COMENTARIO VARCHAR(500),
|
||||||
|
CSA_DT_AVALIACAO TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)`,
|
||||||
|
`CREATE SEQUENCE GEN_CSAT_AVALIACOES`,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// MIGRAÇÃO 11: Tabela CHATC2_CONFIGURACOES_EMPRESA (base)
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
{
|
||||||
|
id: 11,
|
||||||
|
descricao: 'Criar tabela CHATC2_CONFIGURACOES_EMPRESA (base)',
|
||||||
|
sql: [
|
||||||
|
`CREATE TABLE CHATC2_CONFIGURACOES_EMPRESA (
|
||||||
|
CFE_EMPRESA_ID INTEGER NOT NULL PRIMARY KEY,
|
||||||
|
CFE_INSTANCIA_PADRAO_ID INTEGER,
|
||||||
|
CFE_FOTO_CELULAR CHAR(1) DEFAULT 'N',
|
||||||
|
CFE_SAUDACAO_ATIVA CHAR(1) DEFAULT 'S',
|
||||||
|
CFE_SAUDACAO_MENSAGEM VARCHAR(500),
|
||||||
|
CFE_CSAT_ATIVO CHAR(1) DEFAULT 'N',
|
||||||
|
CFE_CSAT_MENSAGEM VARCHAR(500) DEFAULT 'Avalie seu atendimento de 1 a 5 estrelas'
|
||||||
|
)`,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// MIGRAÇÃO 12: EQU_ORDEM em CHATC2_EQUIPES
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
{
|
||||||
|
id: 12,
|
||||||
|
descricao: 'Adicionar EQU_ORDEM em CHATC2_EQUIPES (ordenação)',
|
||||||
|
sql: [
|
||||||
|
`ALTER TABLE CHATC2_EQUIPES ADD EQU_ORDEM INTEGER DEFAULT 0`,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// MIGRAÇÃO 13: Campos de triagem em CHATC2_CONFIGURACOES_EMPRESA
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
{
|
||||||
|
id: 13,
|
||||||
|
descricao: 'Adicionar campos de triagem em CHATC2_CONFIGURACOES_EMPRESA',
|
||||||
|
sql: [
|
||||||
|
`ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_ENVIAR_NOME_USUARIO CHAR(1) DEFAULT 'N'`,
|
||||||
|
`ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_TRIAGEM_ATIVA CHAR(1) DEFAULT 'N'`,
|
||||||
|
`ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_TRIAGEM_MSG_WELCOME VARCHAR(500)`,
|
||||||
|
`ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_TRIAGEM_MSG_AFTER VARCHAR(500)`,
|
||||||
|
`ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_TRIAGEM_BOLETO_NUMERO VARCHAR(10) DEFAULT '0'`,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// MIGRAÇÃO 14: CON_MENU_ESTADO em CHATC2_CONVERSAS
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
{
|
||||||
|
id: 14,
|
||||||
|
descricao: 'Adicionar CON_MENU_ESTADO em CHATC2_CONVERSAS (navegação triagem)',
|
||||||
|
sql: [
|
||||||
|
`ALTER TABLE CHATC2_CONVERSAS ADD CON_MENU_ESTADO VARCHAR(100)`,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// MIGRAÇÃO 15: CON_ETIQUETAS_DESC em CHATC2_CONVERSAS
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
{
|
||||||
|
id: 15,
|
||||||
|
descricao: 'Adicionar CON_ETIQUETAS_DESC em CHATC2_CONVERSAS (estado legado)',
|
||||||
|
sql: [
|
||||||
|
`ALTER TABLE CHATC2_CONVERSAS ADD CON_ETIQUETAS_DESC VARCHAR(500)`,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// MIGRAÇÃO 16: Tabela CHATC2_MENUS_EMPRESA (fluxo personalizado)
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
{
|
||||||
|
id: 16,
|
||||||
|
descricao: 'Criar tabela CHATC2_MENUS_EMPRESA para fluxo de atendimento personalizado',
|
||||||
|
sql: [
|
||||||
|
`CREATE TABLE CHATC2_MENUS_EMPRESA (
|
||||||
|
MNE_CODIGO_ID INTEGER NOT NULL PRIMARY KEY,
|
||||||
|
MNE_EMPRESA_ID INTEGER,
|
||||||
|
MNE_EQUIPE_ID INTEGER,
|
||||||
|
MNE_MENU_PAI_ID INTEGER,
|
||||||
|
MNE_ORDEM INTEGER DEFAULT 0,
|
||||||
|
MNE_TITULO VARCHAR(100),
|
||||||
|
MNE_TIPO CHAR(1) DEFAULT 'M',
|
||||||
|
MNE_TEXTO BLOB SUB_TYPE TEXT,
|
||||||
|
MNE_ACAO_ROTA VARCHAR(100),
|
||||||
|
MNE_ACAO_METODO VARCHAR(10),
|
||||||
|
MNE_ACAO_PROMPT VARCHAR(300),
|
||||||
|
MNE_SITUACAO CHAR(1) DEFAULT 'A'
|
||||||
|
)`,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// MIGRAÇÃO 17: CON_USUARIO_NOME, CON_EQUIPE_NOME, CON_ETIQUETAS_DESC em CHATC2_CONVERSAS
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
{
|
||||||
|
id: 17,
|
||||||
|
descricao: 'Adicionar CON_USUARIO_NOME, CON_EQUIPE_NOME, CON_ETIQUETAS_DESC em CHATC2_CONVERSAS',
|
||||||
|
sql: [
|
||||||
|
`ALTER TABLE CHATC2_CONVERSAS ADD CON_USUARIO_NOME VARCHAR(100)`,
|
||||||
|
`ALTER TABLE CHATC2_CONVERSAS ADD CON_EQUIPE_NOME VARCHAR(100)`,
|
||||||
|
`ALTER TABLE CHATC2_CONVERSAS ADD CON_ETIQUETAS_DESC VARCHAR(500)`,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// MIGRAÇÃO 18: Etiquetas nos menus de fluxo
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
{
|
||||||
|
id: 18,
|
||||||
|
descricao: 'Adicionar MNE_ETIQUETA_IDS em CHATC2_MENUS_EMPRESA (etiquetas por etapa do fluxo)',
|
||||||
|
sql: [
|
||||||
|
`ALTER TABLE CHATC2_MENUS_EMPRESA ADD MNE_ETIQUETA_IDS VARCHAR(200)`,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// MIGRAÇÃO 19: Transcrição de áudio em CHATC2_MENSAGENS_ATENDIMENTOS
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
{
|
||||||
|
id: 19,
|
||||||
|
descricao: 'Adicionar MAT_TRANSCRICAO em CHATC2_MENSAGENS_ATENDIMENTOS (transcricao de audio)',
|
||||||
|
sql: [
|
||||||
|
`ALTER TABLE CHATC2_MENSAGENS_ATENDIMENTOS ADD MAT_TRANSCRICAO BLOB SUB_TYPE TEXT`,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// >>> ADICIONE NOVAS MIGRAÇÕES AQUI <<<
|
||||||
|
// ============================================================
|
||||||
|
//
|
||||||
|
// Exemplo:
|
||||||
|
// {
|
||||||
|
// id: 19,
|
||||||
|
// descricao: 'Descrição clara do que esta migração faz',
|
||||||
|
// sql: [
|
||||||
|
// `ALTER TABLE EXEMPLO ADD NOVO_CAMPO VARCHAR(100)`,
|
||||||
|
// `CREATE TABLE NOVA_TABELA ( ... )`,
|
||||||
|
// ],
|
||||||
|
// },
|
||||||
|
//
|
||||||
|
];
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// EXECUTOR DE MIGRAÇÕES
|
||||||
|
// ============================================================
|
||||||
|
async function main() {
|
||||||
|
console.log(`\n=== MIGRAÇÕES DE BANCO DE DADOS ===`);
|
||||||
|
console.log(`Alias: ${alias}\n`);
|
||||||
|
|
||||||
|
// Blindagem: a DDL aqui é Firebird. Se o alias for Postgres (banco externo,
|
||||||
|
// gerenciado fora), não há o que aplicar — sai sem erro.
|
||||||
|
let driver;
|
||||||
|
try { driver = db.driverOf(alias); }
|
||||||
|
catch (e) { console.error('❌', e.message); process.exit(1); }
|
||||||
|
if (driver !== 'firebird') {
|
||||||
|
console.log(`⚠️ O alias "${alias}" usa o driver "${driver}". Este script aplica DDL Firebird.`);
|
||||||
|
console.log(' O schema do PostgreSQL é gerenciado no banco externo — nada a fazer aqui.');
|
||||||
|
console.log(' Para migrar um banco Firebird: node scripts/migracoes.js firebird_local\n');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
await garantirControle();
|
||||||
|
|
||||||
|
var executadas = 0;
|
||||||
|
var pendentes = 0;
|
||||||
|
|
||||||
|
for (var mig of MIGRACOES) {
|
||||||
|
var jaExecutou = await migracaoJaExecutada(mig.id);
|
||||||
|
|
||||||
|
if (jaExecutou) {
|
||||||
|
console.log(`⏭️ [${mig.id}] ${mig.descricao} — já executada`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendentes++;
|
||||||
|
console.log(`▶️ [${mig.id}] ${mig.descricao}...`);
|
||||||
|
|
||||||
|
var erros = 0;
|
||||||
|
for (var cmd of mig.sql) {
|
||||||
|
try {
|
||||||
|
await db.execute(alias, cmd);
|
||||||
|
console.log(` ✅ ${cmd.substring(0, 80)}...`);
|
||||||
|
} catch(e) {
|
||||||
|
var msg = e.message;
|
||||||
|
// Firebird pode reportar "already exists" de várias formas
|
||||||
|
if (msg.includes('already exists') || msg.includes('Violation') || msg.includes('duplicate') || msg.includes('Unknow')) {
|
||||||
|
console.log(` ℹ️ Já existe: ${cmd.substring(0, 60)}...`);
|
||||||
|
} else {
|
||||||
|
console.log(` ❌ ${msg.substring(0, 120)}`);
|
||||||
|
erros++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (erros === 0) {
|
||||||
|
await registrarMigracao(mig.id, mig.descricao);
|
||||||
|
executadas++;
|
||||||
|
console.log(` ✅ Migração ${mig.id} concluída\n`);
|
||||||
|
} else {
|
||||||
|
console.log(` ⚠️ Migração ${mig.id} concluída com ${erros} erro(s)\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`=== RESUMO ===`);
|
||||||
|
console.log(`Total de migrações: ${MIGRACOES.length}`);
|
||||||
|
console.log(`Executadas agora: ${executadas}`);
|
||||||
|
console.log(`Já executadas antes: ${MIGRACOES.length - pendentes}`);
|
||||||
|
console.log(`Pendentes (com erro): ${pendentes - executadas}`);
|
||||||
|
console.log(`\n✅ Finalizado!`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(function(err) {
|
||||||
|
console.error('❌ Erro fatal:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
const path = require('path');
|
||||||
|
const routes = require('./routes');
|
||||||
|
const authenticateToken = require('./middlewares/auth');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
// Middlewares
|
||||||
|
// CORS: restringível via CORS_ORIGINS (lista separada por vírgula no .env).
|
||||||
|
// Requisições same-origin e server-to-server (sem header Origin) sempre passam.
|
||||||
|
const corsOrigins = (process.env.CORS_ORIGINS || [process.env.LOCAL_URL, process.env.EXTERNAL_URL].filter(Boolean).join(','))
|
||||||
|
.split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
if (corsOrigins.length) {
|
||||||
|
app.use(cors({
|
||||||
|
origin: function (origin, cb) {
|
||||||
|
if (!origin || corsOrigins.indexOf(origin) !== -1) return cb(null, true);
|
||||||
|
return cb(null, false);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
console.log('🔒 CORS restrito a:', corsOrigins.join(', '));
|
||||||
|
} else {
|
||||||
|
app.use(cors());
|
||||||
|
console.log('⚠️ CORS aberto — defina CORS_ORIGINS no .env para restringir.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limite de corpo por rota (anti-DoS):
|
||||||
|
// - rotas de mídia (envio de mensagem, webhook, evolution) → MAX_FILE_SIZE (grande)
|
||||||
|
// - demais rotas → JSON_LIMIT (pequeno), reduz superfície de ataque
|
||||||
|
const maxFileSize = process.env.MAX_FILE_SIZE || '50mb';
|
||||||
|
const jsonPadrao = process.env.JSON_LIMIT || '2mb';
|
||||||
|
console.log('📦 Limite de upload (mídia):', maxFileSize, '| padrão:', jsonPadrao);
|
||||||
|
|
||||||
|
const bigJson = express.json({ limit: maxFileSize });
|
||||||
|
const smallJson = express.json({ limit: jsonPadrao });
|
||||||
|
const bigUrl = express.urlencoded({ extended: true, limit: maxFileSize });
|
||||||
|
const smallUrl = express.urlencoded({ extended: true, limit: jsonPadrao });
|
||||||
|
|
||||||
|
function rotaComMidia(req) {
|
||||||
|
const p = req.path || '';
|
||||||
|
return p.endsWith('/messages') || p.indexOf('/webhook/') !== -1 || p.indexOf('/evolution/') !== -1;
|
||||||
|
}
|
||||||
|
app.use((req, res, next) => (rotaComMidia(req) ? bigJson : smallJson)(req, res, next));
|
||||||
|
app.use((req, res, next) => (rotaComMidia(req) ? bigUrl : smallUrl)(req, res, next));
|
||||||
|
|
||||||
|
// Static files
|
||||||
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
|
||||||
|
// Log de requisições
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
const start = Date.now();
|
||||||
|
res.on('finish', () => {
|
||||||
|
const duration = Date.now() - start;
|
||||||
|
console.log(`${req.method} ${req.originalUrl} - ${res.statusCode} (${duration}ms)`);
|
||||||
|
});
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rotas (a autenticação é aplicada internamente em cada grupo de rotas)
|
||||||
|
app.use(routes);
|
||||||
|
|
||||||
|
// Rota raiz
|
||||||
|
app.get('/', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
name: 'API Firebird - Chatc2',
|
||||||
|
version: '2.0.0',
|
||||||
|
description: 'API multi-banco com sistema de aliases',
|
||||||
|
auth: {
|
||||||
|
loginPage: 'GET /app/:alias/login',
|
||||||
|
login: 'POST /app/:alias/login',
|
||||||
|
myData: 'GET /app/:alias/me',
|
||||||
|
dashboard: 'GET /app/:alias/dashboard',
|
||||||
|
},
|
||||||
|
api: {
|
||||||
|
aliases: 'GET /api/aliases',
|
||||||
|
health: 'GET /api/:alias/health',
|
||||||
|
tables: 'GET /api/:alias/tables',
|
||||||
|
tableInfo: 'GET /api/:alias/tables/:tableName',
|
||||||
|
query: 'POST /api/:alias/query',
|
||||||
|
execute: 'POST /api/:alias/execute',
|
||||||
|
},
|
||||||
|
authentication: {
|
||||||
|
jwt: 'Authorization: Bearer <jwt_token>',
|
||||||
|
usu_token: 'X-Usu-Token: <usu_token>',
|
||||||
|
},
|
||||||
|
routes: 'GET /app/:alias/routes',
|
||||||
|
obs: 'Substitua :alias pelo nome do banco (ex: lajedo, alfenas, ...)',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Middleware de erro global — loga o detalhe internamente e responde genérico
|
||||||
|
// (evita vazar mensagens de erro do banco/SQL para o cliente)
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
console.error('Erro não tratado:', err);
|
||||||
|
res.status(err.status || 500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Erro interno do servidor.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Middleware para rotas não encontradas
|
||||||
|
app.use((req, res) => {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: `Rota ${req.method} ${req.originalUrl} não encontrada`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = app;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Configuração de autenticação JWT
|
||||||
|
*/
|
||||||
|
const secret = process.env.JWT_SECRET;
|
||||||
|
|
||||||
|
// Segurança: sem segredo padrão. Se a variável não estiver definida, o boot
|
||||||
|
// falha de forma explícita — evita assinar tokens com um segredo previsível.
|
||||||
|
if (!secret) {
|
||||||
|
throw new Error(
|
||||||
|
'JWT_SECRET não definido. Configure JWT_SECRET no .env antes de iniciar o servidor ' +
|
||||||
|
'(não há segredo padrão por motivos de segurança).'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
secret: secret,
|
||||||
|
expiresIn: process.env.JWT_EXPIRES_IN || '8h',
|
||||||
|
issuer: 'chatc2-api',
|
||||||
|
};
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const path = require('path');
|
||||||
|
const db = require('../database');
|
||||||
|
const authConfig = require('../config/auth');
|
||||||
|
|
||||||
|
// ── Rate limit de login (em memória) — proteção anti força bruta ──
|
||||||
|
const LOGIN_MAX = 10; // tentativas falhas permitidas
|
||||||
|
const LOGIN_JANELA_MS = 15 * 60 * 1000; // por janela de 15 minutos
|
||||||
|
const loginTentativas = new Map();
|
||||||
|
function chaveLogin(req, login) { return (req.ip || '') + '|' + (login || ''); }
|
||||||
|
function loginBloqueado(req, login) {
|
||||||
|
const e = loginTentativas.get(chaveLogin(req, login));
|
||||||
|
if (!e) return false;
|
||||||
|
if (Date.now() - e.inicio > LOGIN_JANELA_MS) { loginTentativas.delete(chaveLogin(req, login)); return false; }
|
||||||
|
return e.count >= LOGIN_MAX;
|
||||||
|
}
|
||||||
|
function registrarFalhaLogin(req, login) {
|
||||||
|
const k = chaveLogin(req, login);
|
||||||
|
const agora = Date.now();
|
||||||
|
const e = loginTentativas.get(k);
|
||||||
|
if (!e || agora - e.inicio > LOGIN_JANELA_MS) loginTentativas.set(k, { count: 1, inicio: agora });
|
||||||
|
else e.count++;
|
||||||
|
}
|
||||||
|
function limparTentativasLogin(req, login) { loginTentativas.delete(chaveLogin(req, login)); }
|
||||||
|
|
||||||
|
class AuthController {
|
||||||
|
/**
|
||||||
|
* Exibe a página de login
|
||||||
|
* Endpoint: GET /app/:alias/login
|
||||||
|
*/
|
||||||
|
static async loginPage(req, res) {
|
||||||
|
const loginPath = path.resolve(__dirname, '../public/login.html');
|
||||||
|
res.sendFile(loginPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login do usuário no aplicativo
|
||||||
|
* Endpoint: POST /app/:alias/login
|
||||||
|
* Body: { USU_LOGIN, USU_SENHA }
|
||||||
|
*/
|
||||||
|
static async login(req, res) {
|
||||||
|
try {
|
||||||
|
const { alias } = req.params;
|
||||||
|
const { USU_LOGIN, USU_SENHA } = req.body;
|
||||||
|
|
||||||
|
// Validação dos campos obrigatórios
|
||||||
|
if (!USU_LOGIN || !USU_SENHA) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Os campos USU_LOGIN e USU_SENHA são obrigatórios.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loginBloqueado(req, USU_LOGIN)) {
|
||||||
|
return res.status(429).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Muitas tentativas de login. Aguarde alguns minutos e tente novamente.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Busca o usuário pelo login
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
USU_CODIGO_ID,
|
||||||
|
USU_NIVEL_ID,
|
||||||
|
USU_NOME,
|
||||||
|
USU_LOGIN,
|
||||||
|
USU_EMAIL,
|
||||||
|
USU_STATUS,
|
||||||
|
USU_ACESSO_WEB,
|
||||||
|
USU_CAMINHO_FOTO,
|
||||||
|
USU_DT_ULTIMO_ACESSO,
|
||||||
|
USU_TIPO,
|
||||||
|
USU_TIPO_CHAT
|
||||||
|
FROM USUARIOS
|
||||||
|
WHERE USU_LOGIN = ?
|
||||||
|
AND (USU_SENHA = ? OR USU_SENHA_WEB = ?)
|
||||||
|
AND COALESCE(USU_ACESSO_WEB, 0) = 1
|
||||||
|
AND USU_STATUS = 'A'
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(alias, sql, [USU_LOGIN, USU_SENHA, USU_SENHA]);
|
||||||
|
|
||||||
|
if (result.length === 0) {
|
||||||
|
registrarFalhaLogin(req, USU_LOGIN);
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Credenciais inválidas ou usuário sem permissão de acesso.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login bem-sucedido: zera o contador de tentativas
|
||||||
|
limparTentativasLogin(req, USU_LOGIN);
|
||||||
|
|
||||||
|
const user = result[0];
|
||||||
|
|
||||||
|
// Busca as empresas que o usuário tem acesso
|
||||||
|
const empresas = await db.query(alias,
|
||||||
|
'SELECT USE_EMPRESA_ID FROM USUARIOS_EMPRESA WHERE USE_USUARIO_ID = ?',
|
||||||
|
[user.USU_CODIGO_ID]
|
||||||
|
);
|
||||||
|
const empresasIds = empresas.map(e => e.USE_EMPRESA_ID);
|
||||||
|
|
||||||
|
// Atualiza a data do último acesso
|
||||||
|
await db.execute(
|
||||||
|
alias,
|
||||||
|
`UPDATE USUARIOS SET USU_DT_ULTIMO_ACESSO = CURRENT_DATE WHERE USU_CODIGO_ID = ?`,
|
||||||
|
[user.USU_CODIGO_ID]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Dados do usuário para o token
|
||||||
|
const payload = {
|
||||||
|
id: user.USU_CODIGO_ID,
|
||||||
|
nivelId: user.USU_NIVEL_ID,
|
||||||
|
nome: user.USU_NOME.trim(),
|
||||||
|
login: user.USU_LOGIN.trim(),
|
||||||
|
email: user.USU_EMAIL ? user.USU_EMAIL.trim() : null,
|
||||||
|
tipo: user.USU_TIPO ? user.USU_TIPO.trim() : null,
|
||||||
|
tipoChat: user.USU_TIPO_CHAT ? user.USU_TIPO_CHAT.trim() : null,
|
||||||
|
alias,
|
||||||
|
empresas: empresasIds, // empresas que o usuário pode acessar
|
||||||
|
};
|
||||||
|
|
||||||
|
// Gera o token JWT
|
||||||
|
const token = jwt.sign(payload, authConfig.secret, {
|
||||||
|
expiresIn: authConfig.expiresIn,
|
||||||
|
issuer: authConfig.issuer,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Retorna o token e dados do usuário
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Login realizado com sucesso.',
|
||||||
|
alias,
|
||||||
|
token,
|
||||||
|
expiresIn: authConfig.expiresIn,
|
||||||
|
user: {
|
||||||
|
...payload,
|
||||||
|
foto: user.USU_CAMINHO_FOTO ? user.USU_CAMINHO_FOTO.trim() : null,
|
||||||
|
ultimoAcesso: user.USU_DT_ULTIMO_ACESSO,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erro no login:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: `Erro interno ao realizar login: ${err.message}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valida o token atual e retorna dados atualizados do usuário
|
||||||
|
* Endpoint: GET /app/:alias/me
|
||||||
|
*/
|
||||||
|
static async me(req, res) {
|
||||||
|
try {
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
alias: req.params.alias,
|
||||||
|
user: {
|
||||||
|
...req.user,
|
||||||
|
authType: req.authType || 'jwt',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erro ao buscar usuário:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: `Erro ao buscar dados do usuário: ${err.message}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dashboard do app
|
||||||
|
* Endpoint: GET /app/:alias/dashboard
|
||||||
|
*/
|
||||||
|
static async dashboard(req, res) {
|
||||||
|
const dashboardPath = path.resolve(__dirname, '../public/dashboard.html');
|
||||||
|
res.sendFile(dashboardPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = AuthController;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,987 @@
|
|||||||
|
const db = require('../database');
|
||||||
|
|
||||||
|
class ClientController {
|
||||||
|
/**
|
||||||
|
* Lista as empresas que o usuário logado tem acesso
|
||||||
|
* Endpoint: GET /api/:alias/empresas
|
||||||
|
*/
|
||||||
|
static async listUserEmpresas(req, res) {
|
||||||
|
try {
|
||||||
|
const { alias } = req.params;
|
||||||
|
const empresasIds = req.user?.empresas || [];
|
||||||
|
|
||||||
|
if (empresasIds.length === 0) {
|
||||||
|
return res.json({ success: true, alias, data: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const placeholders = empresasIds.map(() => '?').join(',');
|
||||||
|
const result = await db.query(alias,
|
||||||
|
`SELECT EMP_CODIGO_ID, EMP_NOME, EMP_NOME_FANTASIA
|
||||||
|
FROM EMPRESAS
|
||||||
|
WHERE EMP_CODIGO_ID IN (${placeholders})
|
||||||
|
ORDER BY EMP_NOME`,
|
||||||
|
empresasIds
|
||||||
|
);
|
||||||
|
|
||||||
|
const empresas = result.map(row => ({
|
||||||
|
id: row.EMP_CODIGO_ID,
|
||||||
|
nome: (row.EMP_NOME || '').trim(),
|
||||||
|
nomeFantasia: (row.EMP_NOME_FANTASIA || '').trim(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return res.json({ success: true, alias, data: empresas });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erro ao listar empresas:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: `Erro ao listar empresas: ${err.message}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retorna os dados completos de um cliente para o app
|
||||||
|
* Endpoint: GET /app/:alias/company/:id_empresa/client/:id_cliente
|
||||||
|
*/
|
||||||
|
static async clientDetails(req, res) {
|
||||||
|
try {
|
||||||
|
const { alias, id_empresa, id_cliente } = req.params;
|
||||||
|
|
||||||
|
if (!id_empresa || !id_cliente) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Os parâmetros id_empresa e id_cliente são obrigatórios.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
c.CLI_NOME,
|
||||||
|
c.CLI_MATRICULA,
|
||||||
|
c.CLI_SITUACAO,
|
||||||
|
c.CLI_EMAIL,
|
||||||
|
c.CLI_CELULAR,
|
||||||
|
c.CLI_ENDERECO_FAT,
|
||||||
|
c.CLI_NUMERO,
|
||||||
|
c.CLI_CIDADES_FAT_ID,
|
||||||
|
c.CLI_BAIRROS_FAT_ID,
|
||||||
|
c.CLI_COBRADOR_ID,
|
||||||
|
c.CLI_DIA_COBRANCA,
|
||||||
|
c.CLI_EMPRESA_ID,
|
||||||
|
c.CLI_COMPLEMENTO_FAT,
|
||||||
|
c.CLI_CEP_FAT,
|
||||||
|
c.CLI_FONE1,
|
||||||
|
c.CLI_ENDERECO,
|
||||||
|
c.CLI_NUMERO_FAT,
|
||||||
|
c.CLI_BAIRRO_FAT,
|
||||||
|
c.CLI_CIDADES_ID
|
||||||
|
FROM CLIENTES c
|
||||||
|
WHERE c.CLI_CODIGO_ID = ?
|
||||||
|
AND c.CLI_EMPRESA_ID = ?
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(alias, sql, [id_cliente, id_empresa]);
|
||||||
|
|
||||||
|
if (result.length === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Cliente não encontrado para esta empresa.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = result[0];
|
||||||
|
|
||||||
|
// Busca nome da cidade de faturamento
|
||||||
|
let cidadeNome = null;
|
||||||
|
if (client.CLI_CIDADES_FAT_ID) {
|
||||||
|
const cidade = await db.query(alias,
|
||||||
|
'SELECT CID_NOME FROM CIDADES WHERE CID_CODIGO_ID = ?',
|
||||||
|
[client.CLI_CIDADES_FAT_ID]
|
||||||
|
);
|
||||||
|
if (cidade.length > 0) {
|
||||||
|
cidadeNome = cidade[0].CID_NOME.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Busca nome do bairro de faturamento
|
||||||
|
let bairroNome = null;
|
||||||
|
if (client.CLI_BAIRROS_FAT_ID) {
|
||||||
|
const bairro = await db.query(alias,
|
||||||
|
'SELECT BAR_DESCRICAO FROM BAIRROS WHERE BAR_CODIGO_ID = ?',
|
||||||
|
[client.CLI_BAIRROS_FAT_ID]
|
||||||
|
);
|
||||||
|
if (bairro.length > 0) {
|
||||||
|
bairroNome = bairro[0].BAR_DESCRICAO.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Busca nome do cobrador
|
||||||
|
let cobradorNome = null;
|
||||||
|
if (client.CLI_COBRADOR_ID) {
|
||||||
|
const cobrador = await db.query(alias,
|
||||||
|
'SELECT COB_NOME FROM COBRADORES WHERE COB_CODIGO_ID = ?',
|
||||||
|
[client.CLI_COBRADOR_ID]
|
||||||
|
);
|
||||||
|
if (cobrador.length > 0) {
|
||||||
|
cobradorNome = cobrador[0].COB_NOME.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Formata o celular com máscara (XX) XXXXX-XXXX
|
||||||
|
const celularFormatado = ClientController.formatCelular(client.CLI_CELULAR);
|
||||||
|
|
||||||
|
// Interpreta a situação
|
||||||
|
const situacao = client.CLI_SITUACAO?.trim() === 'A' ? 'Ativo' : 'Inativo';
|
||||||
|
|
||||||
|
// Monta o endereço completo de faturamento
|
||||||
|
const enderecoFaturamento = [
|
||||||
|
client.CLI_ENDERECO_FAT?.trim(),
|
||||||
|
client.CLI_NUMERO_FAT?.trim(),
|
||||||
|
client.CLI_COMPLEMENTO_FAT?.trim(),
|
||||||
|
].filter(Boolean).join(', ');
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
alias,
|
||||||
|
empresaId: client.CLI_EMPRESA_ID,
|
||||||
|
clienteId: id_cliente,
|
||||||
|
data: {
|
||||||
|
nome: client.CLI_NOME?.trim(),
|
||||||
|
matricula: client.CLI_MATRICULA?.trim(),
|
||||||
|
situacao: {
|
||||||
|
codigo: client.CLI_SITUACAO?.trim(),
|
||||||
|
descricao: situacao,
|
||||||
|
},
|
||||||
|
email: client.CLI_EMAIL?.trim(),
|
||||||
|
celular: {
|
||||||
|
original: client.CLI_CELULAR?.trim(),
|
||||||
|
formatado: celularFormatado,
|
||||||
|
},
|
||||||
|
telefone: client.CLI_FONE1?.trim(),
|
||||||
|
enderecoFaturamento: {
|
||||||
|
completo: enderecoFaturamento || null,
|
||||||
|
logradouro: client.CLI_ENDERECO_FAT?.trim(),
|
||||||
|
numero: client.CLI_NUMERO_FAT?.trim(),
|
||||||
|
complemento: client.CLI_COMPLEMENTO_FAT?.trim(),
|
||||||
|
cep: client.CLI_CEP_FAT?.trim(),
|
||||||
|
},
|
||||||
|
cidade: {
|
||||||
|
id: client.CLI_CIDADES_FAT_ID,
|
||||||
|
nome: cidadeNome,
|
||||||
|
},
|
||||||
|
bairro: {
|
||||||
|
id: client.CLI_BAIRROS_FAT_ID,
|
||||||
|
nome: bairroNome,
|
||||||
|
},
|
||||||
|
cobrador: {
|
||||||
|
id: client.CLI_COBRADOR_ID,
|
||||||
|
nome: cobradorNome,
|
||||||
|
},
|
||||||
|
diaCobranca: client.CLI_DIA_COBRANCA,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erro ao buscar dados do cliente:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: `Erro ao buscar dados do cliente: ${err.message}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retorna os títulos (carnes) de um cliente
|
||||||
|
* Endpoint: GET /api/:alias/clients/:id_cliente/carnes?tipo[]=abertos&tipo[]=vencidos&page=1&limit=20
|
||||||
|
*
|
||||||
|
* Tipos de filtro:
|
||||||
|
* abertos → CAR_SITUACAO = 0
|
||||||
|
* baixados → CAR_SITUACAO = 1
|
||||||
|
* parcial → CAR_SITUACAO = 2
|
||||||
|
* vencidos → CAR_SITUACAO = 0 AND CAR_DT_VENCIMENTO < CURRENT_DATE
|
||||||
|
*/
|
||||||
|
static async clientCarnes(req, res) {
|
||||||
|
try {
|
||||||
|
const { alias, id_cliente } = req.params;
|
||||||
|
const page = parseInt(req.query.page, 10) || 1;
|
||||||
|
const limit = parseInt(req.query.limit, 10) || 20;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
// Filtros: pode vir como query string repetida (tipo[]=abertos&tipo[]=baixados)
|
||||||
|
// ou como string única separada por vírgula
|
||||||
|
let tiposFiltro = [];
|
||||||
|
if (req.query.tipo) {
|
||||||
|
if (Array.isArray(req.query.tipo)) {
|
||||||
|
tiposFiltro = req.query.tipo;
|
||||||
|
} else {
|
||||||
|
tiposFiltro = req.query.tipo.split(',');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verifica empresa do cliente (para respeitar permissão)
|
||||||
|
const empresaCheck = await db.query(alias,
|
||||||
|
'SELECT CLI_EMPRESA_ID FROM CLIENTES WHERE CLI_CODIGO_ID = ?',
|
||||||
|
[id_cliente]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (empresaCheck.length === 0) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Cliente não encontrado.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const empresaId = empresaCheck[0].CLI_EMPRESA_ID;
|
||||||
|
const userEmpresas = req.user?.empresas || [];
|
||||||
|
|
||||||
|
if (!userEmpresas.includes(empresaId)) {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Você não tem permissão para acessar este cliente.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monta o WHERE com OR entre cada filtro
|
||||||
|
const params = [id_cliente];
|
||||||
|
let where = 'WHERE c.CAR_CLIENTE_ID = ?';
|
||||||
|
|
||||||
|
if (tiposFiltro.length > 0) {
|
||||||
|
const conditions = [];
|
||||||
|
|
||||||
|
if (tiposFiltro.includes('abertos')) {
|
||||||
|
conditions.push('c.CAR_SITUACAO = 0');
|
||||||
|
}
|
||||||
|
if (tiposFiltro.includes('baixados')) {
|
||||||
|
conditions.push('c.CAR_SITUACAO = 1');
|
||||||
|
}
|
||||||
|
if (tiposFiltro.includes('parcial')) {
|
||||||
|
conditions.push('c.CAR_SITUACAO = 2');
|
||||||
|
}
|
||||||
|
if (tiposFiltro.includes('vencidos')) {
|
||||||
|
conditions.push('(c.CAR_SITUACAO = 0 AND c.CAR_DT_VENCIMENTO < CURRENT_DATE)');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conditions.length > 0) {
|
||||||
|
where += ' AND (' + conditions.join(' OR ') + ')';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contagens por situacao
|
||||||
|
const countAbertos = await db.query(alias,'SELECT COUNT(*) AS CT FROM CARNES WHERE CAR_CLIENTE_ID = ? AND CAR_SITUACAO = 0 AND (CAR_DT_VENCIMENTO >= CURRENT_DATE OR CAR_DT_VENCIMENTO IS NULL)',[id_cliente]);
|
||||||
|
const countBaixados = await db.query(alias,'SELECT COUNT(*) AS CT FROM CARNES WHERE CAR_CLIENTE_ID = ? AND CAR_SITUACAO = 1',[id_cliente]);
|
||||||
|
const countParcial = await db.query(alias,'SELECT COUNT(*) AS CT FROM CARNES WHERE CAR_CLIENTE_ID = ? AND CAR_SITUACAO = 2',[id_cliente]);
|
||||||
|
const countVencidos = await db.query(alias,'SELECT COUNT(*) AS CT FROM CARNES WHERE CAR_CLIENTE_ID = ? AND CAR_SITUACAO = 0 AND CAR_DT_VENCIMENTO < CURRENT_DATE',[id_cliente]);
|
||||||
|
|
||||||
|
// Total
|
||||||
|
const countSql = `SELECT COUNT(*) AS TOTAL FROM CARNES c ${where}`;
|
||||||
|
const countResult = await db.query(alias, countSql, params);
|
||||||
|
const total = countResult[0].TOTAL;
|
||||||
|
|
||||||
|
// Busca paginada
|
||||||
|
const dataSql = `
|
||||||
|
SELECT
|
||||||
|
c.CAR_CODIGO_ID,
|
||||||
|
c.CAR_SITUACAO,
|
||||||
|
c.CAR_DT_VENCIMENTO,
|
||||||
|
c.CAR_VALOR_PARCELA,
|
||||||
|
c.CAR_DT_PAGAMENTO,
|
||||||
|
c.CAR_NOSSO_NUMERO,
|
||||||
|
c.CAR_NUMERO_PARCELA,
|
||||||
|
c.CAR_NUMERO_TOTAL_PARCELAS,
|
||||||
|
c.CAR_VALOR_BAIXADO,
|
||||||
|
c.CAR_DT_CANCELAMENTO,
|
||||||
|
c.CAR_DT_AGENDAMENTO_COBRANCA
|
||||||
|
FROM CARNES c
|
||||||
|
${where}
|
||||||
|
ORDER BY c.CAR_DT_VENCIMENTO
|
||||||
|
OFFSET ${offset} ROWS FETCH NEXT ${limit} ROWS ONLY
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(alias, dataSql, params);
|
||||||
|
|
||||||
|
const carnes = result.map(row => {
|
||||||
|
const situacao = row.CAR_SITUACAO;
|
||||||
|
const situacaoLabel =
|
||||||
|
situacao === 0 ? 'Aberto' :
|
||||||
|
situacao === 1 ? 'Baixado' :
|
||||||
|
situacao === 2 ? 'Parcial' :
|
||||||
|
situacao === 3 ? 'Cancelado' : 'Desconhecido';
|
||||||
|
|
||||||
|
const situacaoColor =
|
||||||
|
situacao === 0 ? '#dbeafe' :
|
||||||
|
situacao === 1 ? '#d1fae5' :
|
||||||
|
situacao === 2 ? '#fef3c7' :
|
||||||
|
situacao === 3 ? '#f3f4f6' : '#f3f4f6';
|
||||||
|
|
||||||
|
const situacaoTextColor =
|
||||||
|
situacao === 0 ? '#1e40af' :
|
||||||
|
situacao === 1 ? '#065f46' :
|
||||||
|
situacao === 2 ? '#92400e' :
|
||||||
|
situacao === 3 ? '#6b7280' : '#6b7280';
|
||||||
|
|
||||||
|
// Verifica se está vencido (aberto e vencido)
|
||||||
|
const vencido = situacao === 0 && row.CAR_DT_VENCIMENTO &&
|
||||||
|
new Date(row.CAR_DT_VENCIMENTO) < new Date(new Date().toDateString());
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: row.CAR_CODIGO_ID,
|
||||||
|
situacao: {
|
||||||
|
codigo: situacao,
|
||||||
|
descricao: situacaoLabel,
|
||||||
|
color: situacaoColor,
|
||||||
|
textColor: situacaoTextColor,
|
||||||
|
},
|
||||||
|
vencimento: row.CAR_DT_VENCIMENTO,
|
||||||
|
valorParcela: row.CAR_VALOR_PARCELA,
|
||||||
|
dataPagamento: row.CAR_DT_PAGAMENTO,
|
||||||
|
nossoNumero: (row.CAR_NOSSO_NUMERO || '').trim(),
|
||||||
|
parcela: row.CAR_NUMERO_PARCELA,
|
||||||
|
totalParcelas: row.CAR_NUMERO_TOTAL_PARCELAS,
|
||||||
|
valorBaixado: row.CAR_VALOR_BAIXADO,
|
||||||
|
agendamentoCobranca: row.CAR_DT_AGENDAMENTO_COBRANCA,
|
||||||
|
vencido,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(total / limit);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
alias,
|
||||||
|
clienteId: id_cliente,
|
||||||
|
data: carnes,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
totalPages,
|
||||||
|
contagens: {
|
||||||
|
abertos: countAbertos[0]?.CT || 0,
|
||||||
|
baixados: countBaixados[0]?.CT || 0,
|
||||||
|
parcial: countParcial[0]?.CT || 0,
|
||||||
|
vencidos: countVencidos[0]?.CT || 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erro ao buscar títulos:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: `Erro ao buscar títulos: ${err.message}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formata número de celular no padrão (XX) XXXXX-XXXX
|
||||||
|
* @param {string} celular
|
||||||
|
* @returns {string|null}
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Busca clientes com paginação e filtro por nome, matrícula ou CPF.
|
||||||
|
* Endpoint: GET /api/:alias/clients/search?q=&page=&limit=
|
||||||
|
*/
|
||||||
|
static async searchClients(req, res) {
|
||||||
|
try {
|
||||||
|
const { alias } = req.params;
|
||||||
|
const q = (req.query.q || '').trim();
|
||||||
|
const page = parseInt(req.query.page, 10) || 1;
|
||||||
|
const limit = parseInt(req.query.limit, 10) || 20;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
const filterEmpresa = parseInt(req.query.empresaId, 10) || null;
|
||||||
|
|
||||||
|
// Empresas que o usuário pode acessar (vem do token JWT ou USU_TOKEN)
|
||||||
|
const userEmpresas = req.user?.empresas || [];
|
||||||
|
|
||||||
|
if (userEmpresas.length === 0) {
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
alias,
|
||||||
|
data: [],
|
||||||
|
page: 1,
|
||||||
|
limit,
|
||||||
|
total: 0,
|
||||||
|
totalPages: 0,
|
||||||
|
empresasPermitidas: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determina qual empresa filtra:
|
||||||
|
// - Se o usuário informou ?empresaId=, usa esse (desde que ele tenha permissão)
|
||||||
|
// - Se não, mostra de TODAS as empresas que ele tem acesso
|
||||||
|
let empresasFiltro;
|
||||||
|
if (filterEmpresa) {
|
||||||
|
if (!userEmpresas.includes(filterEmpresa)) {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error: `Você não tem permissão para acessar a empresa ${filterEmpresa}.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
empresasFiltro = [filterEmpresa];
|
||||||
|
} else {
|
||||||
|
empresasFiltro = userEmpresas;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monta a lista de placeholders para o IN
|
||||||
|
const placeholders = empresasFiltro.map(() => '?').join(',');
|
||||||
|
|
||||||
|
// Monta o filtro WHERE
|
||||||
|
let where = '';
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
// Filtro por empresa
|
||||||
|
where = `WHERE c.CLI_EMPRESA_ID IN (${placeholders})`;
|
||||||
|
params.push(...empresasFiltro);
|
||||||
|
|
||||||
|
// Filtro por busca (nome, matrícula, CPF)
|
||||||
|
if (q) {
|
||||||
|
where += ' AND (c.CLI_NOME CONTAINING ? OR c.CLI_MATRICULA CONTAINING ? OR c.CLI_CPF CONTAINING ?)';
|
||||||
|
params.push(q, q, q);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Total de registros
|
||||||
|
const countSql = `SELECT COUNT(*) AS TOTAL FROM CLIENTES c ${where}`;
|
||||||
|
const countResult = await db.query(alias, countSql, params);
|
||||||
|
const total = countResult[0].TOTAL;
|
||||||
|
|
||||||
|
// Busca paginada
|
||||||
|
const dataSql = `
|
||||||
|
SELECT
|
||||||
|
c.CLI_CODIGO_ID,
|
||||||
|
c.CLI_EMPRESA_ID,
|
||||||
|
c.CLI_NOME,
|
||||||
|
c.CLI_MATRICULA,
|
||||||
|
c.CLI_CPF,
|
||||||
|
c.CLI_SITUACAO,
|
||||||
|
c.CLI_CELULAR,
|
||||||
|
c.CLI_FONE1,
|
||||||
|
c.CLI_EMAIL
|
||||||
|
FROM CLIENTES c
|
||||||
|
${where}
|
||||||
|
ORDER BY c.CLI_NOME
|
||||||
|
OFFSET ${offset} ROWS FETCH NEXT ${limit} ROWS ONLY
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(alias, dataSql, params);
|
||||||
|
|
||||||
|
const clients = result.map(row => ({
|
||||||
|
id: row.CLI_CODIGO_ID,
|
||||||
|
empresaId: row.CLI_EMPRESA_ID,
|
||||||
|
nome: (row.CLI_NOME || '').trim(),
|
||||||
|
matricula: (row.CLI_MATRICULA || '').trim(),
|
||||||
|
cpf: (row.CLI_CPF || '').trim(),
|
||||||
|
situacao: (row.CLI_SITUACAO || '').trim(),
|
||||||
|
celular: ClientController.formatCelular(row.CLI_CELULAR),
|
||||||
|
telefone: (row.CLI_FONE1 || '').trim(),
|
||||||
|
email: (row.CLI_EMAIL || '').trim(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(total / limit);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
alias,
|
||||||
|
data: clients,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
totalPages,
|
||||||
|
empresasPermitidas: empresasFiltro,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erro ao buscar clientes:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: `Erro ao buscar clientes: ${err.message}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listar dependentes de um cliente
|
||||||
|
* GET /api/:alias/clients/:id/dependents
|
||||||
|
*/
|
||||||
|
static async listDependents(req, res) {
|
||||||
|
try {
|
||||||
|
const { alias, id } = req.params;
|
||||||
|
const result = await db.query(alias,
|
||||||
|
`SELECT DEPC_CODIGO_ID, DEPC_CLIENTE_ID, DEPC_NOME, DEPC_SITUACAO, DEPC_PARENTESCO,
|
||||||
|
DEPC_TELEFONE, DEPC_ADICIONAL, DEPC_VALOR_CONTRIBUICAO, DEPC_DT_FALECIMENTO,
|
||||||
|
DEPC_DT_NASCIMENTO, DEPC_CPF, DEPC_EMAIL
|
||||||
|
FROM DEPENDENTES_CLI
|
||||||
|
WHERE DEPC_CLIENTE_ID = ? AND DEPC_SITUACAO = 'A'
|
||||||
|
ORDER BY DEPC_NOME`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
const dependents = result.map(d => ({
|
||||||
|
id: d.DEPC_CODIGO_ID,
|
||||||
|
clienteId: d.DEPC_CLIENTE_ID,
|
||||||
|
nome: (d.DEPC_NOME || '').trim(),
|
||||||
|
situacao: (d.DEPC_SITUACAO || '').trim(),
|
||||||
|
parentesco: (d.DEPC_PARENTESCO || '').trim(),
|
||||||
|
telefone: (d.DEPC_TELEFONE || '').trim(),
|
||||||
|
adicional: (d.DEPC_ADICIONAL || 'N').trim(),
|
||||||
|
valorContribuicao: d.DEPC_VALOR_CONTRIBUICAO,
|
||||||
|
dtFalecimento: d.DEPC_DT_FALECIMENTO,
|
||||||
|
dtNascimento: d.DEPC_DT_NASCIMENTO,
|
||||||
|
cpf: (d.DEPC_CPF || '').trim(),
|
||||||
|
email: (d.DEPC_EMAIL || '').trim(),
|
||||||
|
}));
|
||||||
|
res.json({ success: true, data: dependents, count: dependents.length });
|
||||||
|
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atualizar telefone do dependente
|
||||||
|
* PUT /api/:alias/dependents/:id/phone
|
||||||
|
*/
|
||||||
|
static async updateDependentPhone(req, res) {
|
||||||
|
try {
|
||||||
|
const { alias, id } = req.params;
|
||||||
|
const { telefone } = req.body;
|
||||||
|
await db.execute(alias,
|
||||||
|
'UPDATE DEPENDENTES_CLI SET DEPC_TELEFONE = ? WHERE DEPC_CODIGO_ID = ?',
|
||||||
|
[telefone, id]);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buscar dependentes para associar (por nome, telefone ou matricula do titular)
|
||||||
|
* GET /api/:alias/dependents/search?q=&empresaId=
|
||||||
|
*/
|
||||||
|
static async searchDependents(req, res) {
|
||||||
|
try {
|
||||||
|
const { alias } = req.params;
|
||||||
|
const q = (req.query.q || '').trim();
|
||||||
|
const empresaId = parseInt(req.query.empresaId) || req.user?.empresas?.[0];
|
||||||
|
if (!q) return res.json({ success: true, data: [] });
|
||||||
|
|
||||||
|
// Trunca para evitar erro de string truncation no VARCHAR(15) do telefone
|
||||||
|
const qTel = q.length > 15 ? q.substring(0, 15) : q;
|
||||||
|
const result = await db.query(alias,
|
||||||
|
`SELECT d.DEPC_CODIGO_ID, d.DEPC_NOME, d.DEPC_TELEFONE, d.DEPC_PARENTESCO,
|
||||||
|
d.DEPC_CLIENTE_ID, c.CLI_NOME AS TITULAR_NOME, c.CLI_MATRICULA AS TITULAR_MATRICULA
|
||||||
|
FROM DEPENDENTES_CLI d
|
||||||
|
LEFT JOIN CLIENTES c ON d.DEPC_CLIENTE_ID = c.CLI_CODIGO_ID
|
||||||
|
WHERE d.DEPC_EMPRESA_ID = ? AND d.DEPC_SITUACAO = 'A'
|
||||||
|
AND (d.DEPC_NOME CONTAINING ? OR d.DEPC_TELEFONE LIKE '%' || ? || '%' OR c.CLI_MATRICULA CONTAINING ? OR c.CLI_NOME CONTAINING ?)
|
||||||
|
ORDER BY d.DEPC_NOME`,
|
||||||
|
[empresaId, q, qTel, q, q]
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = result.map(d => ({
|
||||||
|
id: d.DEPC_CODIGO_ID,
|
||||||
|
nome: (d.DEPC_NOME || '').trim(),
|
||||||
|
telefone: (d.DEPC_TELEFONE || '').trim(),
|
||||||
|
parentesco: (d.DEPC_PARENTESCO || '').trim(),
|
||||||
|
clienteId: d.DEPC_CLIENTE_ID,
|
||||||
|
titularNome: (d.TITULAR_NOME || '').trim(),
|
||||||
|
titularMatricula: (d.TITULAR_MATRICULA || '').trim(),
|
||||||
|
}));
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atualizar dados do cliente (telefone e email)
|
||||||
|
* PUT /api/:alias/clients/:id
|
||||||
|
*/
|
||||||
|
static async updateClient(req, res) {
|
||||||
|
try {
|
||||||
|
const { alias, id } = req.params;
|
||||||
|
const { telefone, email, celular } = req.body;
|
||||||
|
|
||||||
|
// Verifica se pode acessar este cliente
|
||||||
|
const cli = await db.query(alias, 'SELECT CLI_EMPRESA_ID FROM CLIENTES WHERE CLI_CODIGO_ID = ?', [id]);
|
||||||
|
if (cli.length === 0) return res.status(404).json({ success: false, error: 'Cliente não encontrado.' });
|
||||||
|
|
||||||
|
const empresaId = cli[0].CLI_EMPRESA_ID;
|
||||||
|
const userEmpresas = req.user?.empresas || [];
|
||||||
|
if (!userEmpresas.includes(empresaId))
|
||||||
|
return res.status(403).json({ success: false, error: 'Sem permissão.' });
|
||||||
|
|
||||||
|
const updates = [];
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
if (telefone !== undefined) {
|
||||||
|
updates.push('CLI_FONE1 = ?');
|
||||||
|
params.push(String(telefone).substring(0, 15));
|
||||||
|
}
|
||||||
|
if (email !== undefined) {
|
||||||
|
updates.push('CLI_EMAIL = ?');
|
||||||
|
params.push(String(email).substring(0, 100));
|
||||||
|
}
|
||||||
|
if (celular !== undefined) {
|
||||||
|
updates.push('CLI_CELULAR = ?');
|
||||||
|
params.push(String(celular).substring(0, 15));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.length === 0)
|
||||||
|
return res.status(400).json({ success: false, error: 'Nenhum campo para atualizar.' });
|
||||||
|
|
||||||
|
params.push(id);
|
||||||
|
await db.execute(alias,
|
||||||
|
'UPDATE CLIENTES SET ' + updates.join(', ') + ' WHERE CLI_CODIGO_ID = ?', params);
|
||||||
|
|
||||||
|
res.json({ success: true, message: 'Cliente atualizado.' });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static formatCelular(celular) {
|
||||||
|
if (!celular) return null;
|
||||||
|
|
||||||
|
// Remove tudo que não é dígito
|
||||||
|
const digits = celular.replace(/\D/g, '');
|
||||||
|
|
||||||
|
if (digits.length === 11) {
|
||||||
|
// (XX) XXXXX-XXXX
|
||||||
|
return `(${digits.slice(0, 2)}) ${digits.slice(2, 7)}-${digits.slice(7)}`;
|
||||||
|
} else if (digits.length === 10) {
|
||||||
|
// (XX) XXXX-XXXX
|
||||||
|
return `(${digits.slice(0, 2)}) ${digits.slice(2, 6)}-${digits.slice(6)}`;
|
||||||
|
} else if (digits.length > 0) {
|
||||||
|
// Retorna como está se não conseguir formatar
|
||||||
|
return celular.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retorna dados do cliente + carnes no formato para geracao de boleto
|
||||||
|
* GET /api/:alias/clients/:id_cliente/listcarne
|
||||||
|
*/
|
||||||
|
static async clientListCarne(req, res) {
|
||||||
|
try {
|
||||||
|
const { alias, id_cliente } = req.params;
|
||||||
|
|
||||||
|
const clientes = await db.query(alias,
|
||||||
|
'SELECT CLI_CODIGO_ID, CLI_NOME, CLI_NOME_FANTASIA, CLI_CPF, CLI_ENDERECO, CLI_BAIRRO, CLI_CEP, CLI_CIDADES_ID, CLI_EMPRESA_ID FROM CLIENTES WHERE CLI_CODIGO_ID = ?',
|
||||||
|
[id_cliente]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (clientes.length === 0) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Cliente nao encontrado.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const c = clientes[0];
|
||||||
|
|
||||||
|
// Busca dados da empresa
|
||||||
|
var empresaData = null;
|
||||||
|
if (c.CLI_EMPRESA_ID) {
|
||||||
|
var emp = await db.query(alias,
|
||||||
|
'SELECT EMP_NOME, EMP_CNPJ, EMP_FOTO FROM EMPRESAS WHERE EMP_CODIGO_ID = ?',
|
||||||
|
[c.CLI_EMPRESA_ID]
|
||||||
|
);
|
||||||
|
if (emp.length > 0) {
|
||||||
|
var fotoBase64 = null;
|
||||||
|
if (emp[0].EMP_FOTO) {
|
||||||
|
try {
|
||||||
|
var buf = emp[0].EMP_FOTO;
|
||||||
|
if (Buffer.isBuffer(buf)) fotoBase64 = buf.toString('base64');
|
||||||
|
else if (typeof buf === 'string') fotoBase64 = buf;
|
||||||
|
} catch(e) { /* ignora erro de foto */ }
|
||||||
|
}
|
||||||
|
empresaData = {
|
||||||
|
EMP_NOME: (emp[0].EMP_NOME || '').trim(),
|
||||||
|
EMP_CNPJ: (emp[0].EMP_CNPJ || '').trim(),
|
||||||
|
EMP_FOTO: fotoBase64,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let cidadeNome = '';
|
||||||
|
let cidadeUf = '';
|
||||||
|
if (c.CLI_CIDADES_ID) {
|
||||||
|
const cidade = await db.query(alias,
|
||||||
|
'SELECT CID_NOME, CID_UF FROM CIDADES WHERE CID_CODIGO_ID = ?',
|
||||||
|
[c.CLI_CIDADES_ID]
|
||||||
|
);
|
||||||
|
if (cidade.length > 0) {
|
||||||
|
cidadeNome = (cidade[0].CID_NOME || '').trim();
|
||||||
|
cidadeUf = (cidade[0].CID_UF || '').trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const carnes = await db.query(alias,
|
||||||
|
'SELECT CAR_CODIGO_ID, CAR_CODIGO_BARRAS, CAR_DT_VENCIMENTO, CAR_AGEN_COD_CEDENTE, CAR_NUM_BANCARIO, CAR_DT_CADASTRO, CAR_DT_PROCESSAMENTO, CAR_NUMERO_DOCUMENTO, CAR_VALOR_PARCELA, CAR_LINHA_DIGITAVEL, CAR_NOSSO_NUMERO, CAR_PIX_QRCODE FROM CARNES WHERE CAR_CLIENTE_ID = ? AND CAR_SITUACAO = 0 ORDER BY CAR_DT_VENCIMENTO',
|
||||||
|
[id_cliente]
|
||||||
|
);
|
||||||
|
|
||||||
|
function formatCpf(cpf) {
|
||||||
|
if (!cpf) return '';
|
||||||
|
var d = cpf.replace(/\D/g, '');
|
||||||
|
if (d.length === 11) return d.slice(0,3) + '.' + d.slice(3,6) + '.' + d.slice(6,9) + '-' + d.slice(9);
|
||||||
|
return cpf.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(dt) {
|
||||||
|
if (!dt) return null;
|
||||||
|
if (typeof dt === 'string') return dt.split('T')[0];
|
||||||
|
if (dt instanceof Date) return dt.toISOString().split('T')[0];
|
||||||
|
return String(dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultado = {
|
||||||
|
empresa: empresaData,
|
||||||
|
Cliente: {
|
||||||
|
CLI_NOME_FANTASIA: (c.CLI_NOME_FANTASIA || c.CLI_NOME || '').trim(),
|
||||||
|
CLI_CPF_CNPJ: formatCpf(c.CLI_CPF),
|
||||||
|
CLI_ENDERECO: (c.CLI_ENDERECO || '').trim(),
|
||||||
|
CLI_BAIRRO: (c.CLI_BAIRRO || '').trim(),
|
||||||
|
CLI_CEP: (c.CLI_CEP || '').trim(),
|
||||||
|
CLI_CIDADE: cidadeNome,
|
||||||
|
CLI_UF: cidadeUf,
|
||||||
|
},
|
||||||
|
Carnes: carnes.map(function(carne) {
|
||||||
|
return {
|
||||||
|
CAR_CODIGO_BARRAS: (carne.CAR_CODIGO_BARRAS || '').trim(),
|
||||||
|
CAR_DT_VENCIMENTO: fmtDate(carne.CAR_DT_VENCIMENTO),
|
||||||
|
CAR_AGEN_COD_CEDENTE: (carne.CAR_AGEN_COD_CEDENTE || '').trim(),
|
||||||
|
CAR_NUM_BANCARIO: (carne.CAR_NUM_BANCARIO || '').trim(),
|
||||||
|
CAR_DT_CADASTRO: fmtDate(carne.CAR_DT_CADASTRO),
|
||||||
|
CAR_DT_PROCESSAMENTO: fmtDate(carne.CAR_DT_PROCESSAMENTO),
|
||||||
|
CAR_NUMERO_DOCUMENTO: (carne.CAR_NUMERO_DOCUMENTO || '').trim(),
|
||||||
|
CAR_VALOR_PARCELA: carne.CAR_VALOR_PARCELA,
|
||||||
|
CAR_LINHA_DIGITAVEL: (carne.CAR_LINHA_DIGITAVEL || '').trim(),
|
||||||
|
CAR_NOSSO_NUMERO: (carne.CAR_NOSSO_NUMERO || '').trim(),
|
||||||
|
CAR_PIX_QRCODE: (carne.CAR_PIX_QRCODE || '').trim(),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(resultado);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ListCarne] Erro:', err.message);
|
||||||
|
res.status(500).json({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retorna os convalescentes de um cliente
|
||||||
|
* GET /api/:alias/clients/:id/convalescentes
|
||||||
|
*/
|
||||||
|
static async clientConvalescentes(req, res) {
|
||||||
|
try {
|
||||||
|
const { alias, id } = req.params;
|
||||||
|
|
||||||
|
// Verifica se o modulo estoque esta ativo para a empresa do cliente
|
||||||
|
var cliCheck = await db.query(alias,
|
||||||
|
'SELECT CLI_EMPRESA_ID FROM CLIENTES WHERE CLI_CODIGO_ID = ?', [id]);
|
||||||
|
if (cliCheck.length > 0) {
|
||||||
|
var empMod = await db.query(alias,
|
||||||
|
"SELECT COALESCE(EMP_MODULO_ESTOQUE, 'N') AS MOD FROM EMPRESAS WHERE EMP_CODIGO_ID = ?",
|
||||||
|
[cliCheck[0].CLI_EMPRESA_ID]);
|
||||||
|
if (empMod.length > 0 && (empMod[0].MOD || 'N').trim() !== 'S') {
|
||||||
|
return res.json({ success: true, data: [], moduloInativo: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const covs = await db.query(alias,
|
||||||
|
`SELECT
|
||||||
|
c.COV_CODIGO_ID,
|
||||||
|
c.COV_SITUACAO,
|
||||||
|
c.COV_VALOR_TOTAL,
|
||||||
|
c.COV_DT_SAIDA,
|
||||||
|
c.COV_DT_PREVISAO_RETORNO,
|
||||||
|
c.COV_DT_CANCELOU,
|
||||||
|
c.COV_USUARIO_ID,
|
||||||
|
u.USU_NOME AS USUARIO_NOME
|
||||||
|
FROM COVALESCENTES c
|
||||||
|
LEFT JOIN USUARIOS u ON c.COV_USUARIO_ID = u.USU_CODIGO_ID
|
||||||
|
WHERE c.COV_CLIENTE_ID = ?
|
||||||
|
ORDER BY c.COV_DT_SAIDA DESC`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
function fmtSituacaoCarne(sit) {
|
||||||
|
if (sit === 0) return { label: 'Aberto', color: '#dbeafe', textColor: '#1e40af' };
|
||||||
|
if (sit === 1) return { label: 'Pago', color: '#d1fae5', textColor: '#065f46' };
|
||||||
|
if (sit === 2) return { label: 'Baixado Parcial', color: '#fef3c7', textColor: '#92400e' };
|
||||||
|
if (sit === 3) return { label: 'Cancelado', color: '#fef2f2', textColor: '#991b1b' };
|
||||||
|
return { label: 'Desconhecido', color: '#f3f4f6', textColor: '#6b7280' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Busca carnês vinculados a cada convalescente
|
||||||
|
var resultado = [];
|
||||||
|
for (var i = 0; i < covs.length; i++) {
|
||||||
|
var cov = covs[i];
|
||||||
|
var carnes = [];
|
||||||
|
try {
|
||||||
|
carnes = await db.query(alias,
|
||||||
|
`SELECT CAR_DT_VENCIMENTO, CAR_VALOR_PARCELA, CAR_SITUACAO
|
||||||
|
FROM CARNES_COVALESCENTES WHERE CAR_COVALESCENTES_ID = ?
|
||||||
|
ORDER BY CAR_DT_VENCIMENTO`,
|
||||||
|
[cov.COV_CODIGO_ID]
|
||||||
|
);
|
||||||
|
} catch(e) {
|
||||||
|
carnes = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var itens = [];
|
||||||
|
try {
|
||||||
|
itens = await db.query(alias,
|
||||||
|
`SELECT i.ITC_QUANTIDADE, p.PRD_DESCRICAO
|
||||||
|
FROM ITENS_COVALESCENTES i
|
||||||
|
LEFT JOIN PRODUTOS p ON i.ITC_PRODUTOS_ID = p.PRD_CODIGO_ID
|
||||||
|
WHERE i.ITC_COVALESCENTES_ID = ?`,
|
||||||
|
[cov.COV_CODIGO_ID]
|
||||||
|
);
|
||||||
|
} catch(e) {
|
||||||
|
itens = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
resultado.push({
|
||||||
|
id: cov.COV_CODIGO_ID,
|
||||||
|
situacao: (cov.COV_SITUACAO || '').trim(),
|
||||||
|
valorTotal: cov.COV_VALOR_TOTAL,
|
||||||
|
dtSaida: cov.COV_DT_SAIDA,
|
||||||
|
dtPrevisaoRetorno: cov.COV_DT_PREVISAO_RETORNO,
|
||||||
|
dtCancelou: cov.COV_DT_CANCELOU,
|
||||||
|
usuarioNome: (cov.USUARIO_NOME || '').trim(),
|
||||||
|
carnes: carnes.map(function(c) {
|
||||||
|
return {
|
||||||
|
vencimento: c.CAR_DT_VENCIMENTO,
|
||||||
|
valorParcela: c.CAR_VALOR_PARCELA,
|
||||||
|
situacao: fmtSituacaoCarne(c.CAR_SITUACAO),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
itens: itens.map(function(item) {
|
||||||
|
return {
|
||||||
|
quantidade: item.ITC_QUANTIDADE,
|
||||||
|
produto: (item.PRD_DESCRICAO || '').trim(),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, data: resultado });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Convalescentes] Erro:', err.message);
|
||||||
|
res.status(500).json({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Historico de conversas do cliente (inclui dependentes)
|
||||||
|
* GET /api/:alias/clients/:id/conversations
|
||||||
|
*/
|
||||||
|
static async clientConversations(req, res) {
|
||||||
|
try {
|
||||||
|
const { alias, id } = req.params;
|
||||||
|
const clienteId = parseInt(id);
|
||||||
|
|
||||||
|
// Busca dados do cliente: telefones e nome
|
||||||
|
const cliente = await db.query(alias,
|
||||||
|
'SELECT CLI_CODIGO_ID, CLI_NOME, CLI_CELULAR, CLI_FONE1 FROM CLIENTES WHERE CLI_CODIGO_ID = ?',
|
||||||
|
[clienteId]);
|
||||||
|
|
||||||
|
if (cliente.length === 0) {
|
||||||
|
return res.json({ success: true, data: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
var cli = cliente[0];
|
||||||
|
var nomeTitular = (cli.CLI_NOME || '').trim();
|
||||||
|
var celular = (cli.CLI_CELULAR || '').replace(/\D/g, '');
|
||||||
|
var telefone = (cli.CLI_FONE1 || '').replace(/\D/g, '');
|
||||||
|
var numerosTitular = [celular, telefone].filter(function(n) { return n.length >= 8; });
|
||||||
|
|
||||||
|
// Busca dependentes ativos com telefone
|
||||||
|
var dependentes = await db.query(alias,
|
||||||
|
"SELECT DEPC_NOME, DEPC_TELEFONE, DEPC_PARENTESCO FROM DEPENDENTES_CLI WHERE DEPC_CLIENTE_ID = ? AND DEPC_SITUACAO = 'A'",
|
||||||
|
[clienteId]);
|
||||||
|
|
||||||
|
// Mapeia dependentes com telefones limpos
|
||||||
|
var depsMap = {};
|
||||||
|
dependentes.forEach(function(d) {
|
||||||
|
var tel = (d.DEPC_TELEFONE || '').replace(/\D/g, '');
|
||||||
|
if (tel.length >= 8) {
|
||||||
|
if (!depsMap[tel]) depsMap[tel] = [];
|
||||||
|
depsMap[tel].push({ nome: (d.DEPC_NOME || '').trim(), parentesco: (d.DEPC_PARENTESCO || '').trim() });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
var numerosDeps = Object.keys(depsMap);
|
||||||
|
|
||||||
|
// Busca conversas: pelo cliente_id OU pelo numero (titular ou dependente)
|
||||||
|
var allConversas = [];
|
||||||
|
|
||||||
|
// 1. Conversas vinculadas ao cliente_id
|
||||||
|
var convsCliente = await db.query(alias,
|
||||||
|
"SELECT * FROM CHATC2_CONVERSAS WHERE CON_CLIENTE_ID = ? AND CON_SITUACAO = 'A' ORDER BY CON_DT_ULTIMA_MSG DESC",
|
||||||
|
[clienteId]);
|
||||||
|
allConversas = allConversas.concat(convsCliente);
|
||||||
|
|
||||||
|
// 2. Conversas pelo numero (titular ou dependentes)
|
||||||
|
var numerosBuscar = numerosTitular.concat(numerosDeps);
|
||||||
|
var idsJaTem = convsCliente.map(function(c) { return c.CON_CODIGO_ID; });
|
||||||
|
|
||||||
|
for (var n = 0; n < numerosBuscar.length; n++) {
|
||||||
|
var num = numerosBuscar[n];
|
||||||
|
var ultimos8 = num.slice(-8);
|
||||||
|
try {
|
||||||
|
var convsNum = await db.query(alias,
|
||||||
|
"SELECT * FROM CHATC2_CONVERSAS WHERE (CON_NUMERO = ? OR CON_NUMERO LIKE '%' || ? || '%') AND CON_SITUACAO = 'A' ORDER BY CON_DT_ULTIMA_MSG DESC",
|
||||||
|
[num, ultimos8]);
|
||||||
|
convsNum.forEach(function(cn) {
|
||||||
|
if (idsJaTem.indexOf(cn.CON_CODIGO_ID) === -1) {
|
||||||
|
idsJaTem.push(cn.CON_CODIGO_ID);
|
||||||
|
allConversas.push(cn);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ordena por data mais recente e remove duplicatas
|
||||||
|
allConversas.sort(function(a, b) {
|
||||||
|
var da = a.CON_DT_ULTIMA_MSG || a.CON_DT_INICIO || '';
|
||||||
|
var db2 = b.CON_DT_ULTIMA_MSG || b.CON_DT_INICIO || '';
|
||||||
|
return db2 > da ? 1 : db2 < da ? -1 : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Limita a 50
|
||||||
|
allConversas = allConversas.slice(0, 50);
|
||||||
|
|
||||||
|
// Mapeia resultado
|
||||||
|
var resultado = allConversas.map(function(c) {
|
||||||
|
var numConv = (c.CON_NUMERO || '').trim();
|
||||||
|
var isTitular = numerosTitular.some(function(tn) {
|
||||||
|
return numConv.indexOf(tn.slice(-8)) !== -1;
|
||||||
|
});
|
||||||
|
if (c.CON_CLIENTE_ID === clienteId) isTitular = true;
|
||||||
|
|
||||||
|
var quemFalou = isTitular ? nomeTitular : '';
|
||||||
|
var parentesco = '';
|
||||||
|
if (!isTitular && numConv) {
|
||||||
|
for (var dt in depsMap) {
|
||||||
|
if (numConv.indexOf(dt.slice(-8)) !== -1) {
|
||||||
|
quemFalou = depsMap[dt][0].nome;
|
||||||
|
parentesco = depsMap[dt][0].parentesco;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: c.CON_CODIGO_ID,
|
||||||
|
empresaId: c.CON_EMPRESA_ID,
|
||||||
|
numero: numConv,
|
||||||
|
nomeContato: (c.CON_NOME_CONTATO || '').trim(),
|
||||||
|
status: (c.CON_STATUS || '').trim(),
|
||||||
|
dtUltimaMsg: c.CON_DT_ULTIMA_MSG || c.CON_DT_INICIO,
|
||||||
|
usuarioId: c.CON_USUARIO_ID,
|
||||||
|
usuarioNome: (c.CON_USUARIO_NOME || '').trim(),
|
||||||
|
equipeId: c.CON_EQUIPE_ID,
|
||||||
|
equipeNome: (c.CON_EQUIPE_NOME || '').trim(),
|
||||||
|
isTitular: isTitular,
|
||||||
|
quemFalou: quemFalou || nomeTitular,
|
||||||
|
parentesco: parentesco,
|
||||||
|
nomeTitular: nomeTitular,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, data: resultado });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Conversations] Erro:', err.message);
|
||||||
|
res.status(500).json({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = ClientController;
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
const db = require('../database');
|
||||||
|
const { isGerente } = require('../middlewares/roles');
|
||||||
|
|
||||||
|
class ConfigController {
|
||||||
|
// ==================== CHATC2_EQUIPES ====================
|
||||||
|
static async listTeams(req, res) {
|
||||||
|
try {
|
||||||
|
const { alias } = req.params;
|
||||||
|
const empresaId = parseInt(req.query.empresaId) || req.user?.empresas?.[0];
|
||||||
|
if (!req.user?.empresas?.includes(empresaId)) return res.status(403).json({ success: false, error: 'Sem permissão.' });
|
||||||
|
|
||||||
|
const teams = await db.query(alias,
|
||||||
|
'SELECT * FROM CHATC2_EQUIPES WHERE EQU_EMPRESA_ID = ? AND EQU_SITUACAO = \'A\' ORDER BY EQU_ORDEM, EQU_NOME', [empresaId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Busca membros de cada equipe
|
||||||
|
for (const t of teams) {
|
||||||
|
const members = await db.query(alias, `
|
||||||
|
SELECT u.USU_CODIGO_ID, u.USU_NOME, u.USU_LOGIN
|
||||||
|
FROM USUARIOS u INNER JOIN CHATC2_USU_EQUIPES eu ON u.USU_CODIGO_ID = eu.EQU_USUARIO_ID
|
||||||
|
WHERE eu.EQU_EQUIPE_ID = ? AND u.USU_STATUS = 'A'
|
||||||
|
`, [t.EQU_CODIGO_ID]);
|
||||||
|
t.members = members.map(m => ({ id: m.USU_CODIGO_ID, nome: (m.USU_NOME || '').trim(), login: (m.USU_LOGIN || '').trim() }));
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, data: teams.map(t => ({ id: t.EQU_CODIGO_ID, nome: (t.EQU_NOME || '').trim(), ordem: t.EQU_ORDEM || 0, membros: t.members })) });
|
||||||
|
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
static async createTeam(req, res) {
|
||||||
|
try {
|
||||||
|
if (!(await isGerente(req))) return res.status(403).json({ success: false, error: 'Apenas gerentes podem gerenciar equipes.' });
|
||||||
|
const { alias } = req.params;
|
||||||
|
const { nome, membros } = req.body;
|
||||||
|
const empresaId = req.user?.empresas?.[0] || req.body.empresaId;
|
||||||
|
if (!nome) return res.status(400).json({ success: false, error: 'Nome obrigatório.' });
|
||||||
|
|
||||||
|
const maxId = await db.query(alias, 'SELECT MAX(EQU_CODIGO_ID) AS ID FROM CHATC2_EQUIPES');
|
||||||
|
const newId = (maxId[0]?.ID || 0) + 1;
|
||||||
|
const ordem = req.body.ordem !== undefined ? req.body.ordem : 0;
|
||||||
|
|
||||||
|
await db.execute(alias, 'INSERT INTO CHATC2_EQUIPES (EQU_CODIGO_ID, EQU_EMPRESA_ID, EQU_NOME, EQU_ORDEM) VALUES (?, ?, ?, ?)', [newId, empresaId, nome, ordem]);
|
||||||
|
|
||||||
|
if (membros && membros.length > 0) {
|
||||||
|
for (const userId of membros) {
|
||||||
|
await db.execute(alias, 'INSERT INTO CHATC2_USU_EQUIPES (EQU_EQUIPE_ID, EQU_USUARIO_ID) VALUES (?, ?)', [newId, userId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, data: { id: newId } });
|
||||||
|
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
static async updateTeam(req, res) {
|
||||||
|
try {
|
||||||
|
if (!(await isGerente(req))) return res.status(403).json({ success: false, error: 'Apenas gerentes podem gerenciar equipes.' });
|
||||||
|
const { alias, id } = req.params;
|
||||||
|
const { nome, membros } = req.body;
|
||||||
|
|
||||||
|
if (nome) await db.execute(alias, 'UPDATE CHATC2_EQUIPES SET EQU_NOME = ? WHERE EQU_CODIGO_ID = ?', [nome, id]);
|
||||||
|
if (req.body.ordem !== undefined) await db.execute(alias, 'UPDATE CHATC2_EQUIPES SET EQU_ORDEM = ? WHERE EQU_CODIGO_ID = ?', [req.body.ordem, id]);
|
||||||
|
|
||||||
|
if (membros) {
|
||||||
|
await db.execute(alias, 'DELETE FROM CHATC2_USU_EQUIPES WHERE EQU_EQUIPE_ID = ?', [id]);
|
||||||
|
for (const userId of membros) {
|
||||||
|
await db.execute(alias, 'INSERT INTO CHATC2_USU_EQUIPES (EQU_EQUIPE_ID, EQU_USUARIO_ID) VALUES (?, ?)', [id, userId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
static async deleteTeam(req, res) {
|
||||||
|
try {
|
||||||
|
if (!(await isGerente(req))) return res.status(403).json({ success: false, error: 'Apenas gerentes podem gerenciar equipes.' });
|
||||||
|
const { alias, id } = req.params;
|
||||||
|
await db.execute(alias, 'UPDATE CHATC2_EQUIPES SET EQU_SITUACAO = \'I\' WHERE EQU_CODIGO_ID = ?', [id]);
|
||||||
|
await db.execute(alias, 'DELETE FROM CHATC2_USU_EQUIPES WHERE EQU_EQUIPE_ID = ?', [id]);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== USUÁRIOS DA EMPRESA ====================
|
||||||
|
static async listCompanyUsers(req, res) {
|
||||||
|
try {
|
||||||
|
const { alias } = req.params;
|
||||||
|
const empresaId = parseInt(req.query.empresaId) || req.user?.empresas?.[0];
|
||||||
|
|
||||||
|
const users = await db.query(alias, `
|
||||||
|
SELECT u.USU_CODIGO_ID, u.USU_NOME, u.USU_LOGIN, u.USU_STATUS, u.USU_TIPO_CHAT
|
||||||
|
FROM USUARIOS u INNER JOIN USUARIOS_EMPRESA ue ON u.USU_CODIGO_ID = ue.USE_USUARIO_ID
|
||||||
|
WHERE ue.USE_EMPRESA_ID = ? AND u.USU_STATUS = 'A'
|
||||||
|
ORDER BY u.USU_NOME
|
||||||
|
`, [empresaId]);
|
||||||
|
|
||||||
|
res.json({ success: true, data: users.map(u => ({ id: u.USU_CODIGO_ID, nome: (u.USU_NOME || '').trim(), login: (u.USU_LOGIN || '').trim(), tipoChat: (u.USU_TIPO_CHAT || 'A').trim() })) });
|
||||||
|
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== CHATC2_ETIQUETAS ====================
|
||||||
|
static async listLabels(req, res) {
|
||||||
|
try {
|
||||||
|
const { alias } = req.params;
|
||||||
|
const empresaId = parseInt(req.query.empresaId) || req.user?.empresas?.[0];
|
||||||
|
|
||||||
|
const labels = await db.query(alias,
|
||||||
|
'SELECT * FROM CHATC2_ETIQUETAS WHERE ETI_EMPRESA_ID = ? AND ETI_SITUACAO = \'A\' ORDER BY ETI_NOME', [empresaId]
|
||||||
|
);
|
||||||
|
res.json({ success: true, data: labels.map(l => ({ id: l.ETI_CODIGO_ID, nome: (l.ETI_NOME || '').trim(), cor: (l.ETI_COR || '#667eea').trim() })) });
|
||||||
|
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
static async createLabel(req, res) {
|
||||||
|
try {
|
||||||
|
if (!(await isGerente(req))) return res.status(403).json({ success: false, error: 'Apenas gerentes podem gerenciar etiquetas.' });
|
||||||
|
const { alias } = req.params;
|
||||||
|
const { nome, cor } = req.body;
|
||||||
|
const empresaId = req.user?.empresas?.[0];
|
||||||
|
|
||||||
|
const maxId = await db.query(alias, 'SELECT MAX(ETI_CODIGO_ID) AS ID FROM CHATC2_ETIQUETAS');
|
||||||
|
const newId = (maxId[0]?.ID || 0) + 1;
|
||||||
|
|
||||||
|
await db.execute(alias, 'INSERT INTO CHATC2_ETIQUETAS (ETI_CODIGO_ID, ETI_EMPRESA_ID, ETI_NOME, ETI_COR) VALUES (?, ?, ?, ?)', [newId, empresaId, nome, cor || '#667eea']);
|
||||||
|
res.json({ success: true, data: { id: newId } });
|
||||||
|
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
static async updateLabel(req, res) {
|
||||||
|
try {
|
||||||
|
if (!(await isGerente(req))) return res.status(403).json({ success: false, error: 'Apenas gerentes podem gerenciar etiquetas.' });
|
||||||
|
const { alias, id } = req.params;
|
||||||
|
const { nome, cor } = req.body;
|
||||||
|
if (nome) await db.execute(alias, 'UPDATE CHATC2_ETIQUETAS SET ETI_NOME = ? WHERE ETI_CODIGO_ID = ?', [nome, id]);
|
||||||
|
if (cor) await db.execute(alias, 'UPDATE CHATC2_ETIQUETAS SET ETI_COR = ? WHERE ETI_CODIGO_ID = ?', [cor, id]);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
static async deleteLabel(req, res) {
|
||||||
|
try {
|
||||||
|
if (!(await isGerente(req))) return res.status(403).json({ success: false, error: 'Apenas gerentes podem gerenciar etiquetas.' });
|
||||||
|
const { alias, id } = req.params;
|
||||||
|
await db.execute(alias, 'UPDATE CHATC2_ETIQUETAS SET ETI_SITUACAO = \'I\' WHERE ETI_CODIGO_ID = ?', [id]);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== CONFIGURAÇÕES EMPRESA ====================
|
||||||
|
static async getCompanyConfig(req, res) {
|
||||||
|
try {
|
||||||
|
const { alias } = req.params;
|
||||||
|
const empresaId = parseInt(req.query.empresaId) || req.user?.empresas?.[0];
|
||||||
|
|
||||||
|
let config = await db.query(alias,
|
||||||
|
'SELECT * FROM CHATC2_CONFIGURACOES_EMPRESA WHERE CFE_EMPRESA_ID = ?', [empresaId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (config.length === 0) {
|
||||||
|
const cfg = { empresaId, fotoCelular: 'N', saudacaoAtiva: 'S', saudacaoMensagem: '', csatAtivo: 'N', csatMensagem: '', enviarNomeUsuario: 'N', triagemAtiva: 'N', triagemMsgWelcome: '', triagemMsgAfter: '', triagemBoletoNumero: '0', instanciaPadraoId: null };
|
||||||
|
res.json({ success: true, data: cfg });
|
||||||
|
} else {
|
||||||
|
const c = config[0];
|
||||||
|
res.json({ success: true, data: {
|
||||||
|
empresaId: c.CFE_EMPRESA_ID,
|
||||||
|
instanciaPadraoId: c.CFE_INSTANCIA_PADRAO_ID,
|
||||||
|
fotoCelular: (c.CFE_FOTO_CELULAR || 'N').trim(),
|
||||||
|
saudacaoAtiva: (c.CFE_SAUDACAO_ATIVA || 'S').trim(),
|
||||||
|
saudacaoMensagem: c.CFE_SAUDACAO_MENSAGEM || '',
|
||||||
|
csatAtivo: (c.CFE_CSAT_ATIVO || 'N').trim(),
|
||||||
|
csatMensagem: c.CFE_CSAT_MENSAGEM || '',
|
||||||
|
enviarNomeUsuario: (c.CFE_ENVIAR_NOME_USUARIO || 'N').trim(),
|
||||||
|
triagemAtiva: (c.CFE_TRIAGEM_ATIVA || 'N').trim(),
|
||||||
|
triagemMsgWelcome: c.CFE_TRIAGEM_MSG_WELCOME || '',
|
||||||
|
triagemMsgAfter: c.CFE_TRIAGEM_MSG_AFTER || '',
|
||||||
|
triagemBoletoNumero: (c.CFE_TRIAGEM_BOLETO_NUMERO || '0').trim(),
|
||||||
|
}});
|
||||||
|
}
|
||||||
|
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
static async saveCompanyConfig(req, res) {
|
||||||
|
try {
|
||||||
|
if (!(await isGerente(req))) return res.status(403).json({ success: false, error: 'Apenas gerentes podem alterar as configurações da empresa.' });
|
||||||
|
const { alias } = req.params;
|
||||||
|
const data = req.body;
|
||||||
|
const empresaId = data.empresaId || req.user?.empresas?.[0];
|
||||||
|
|
||||||
|
// Upsert
|
||||||
|
const exists = await db.query(alias, 'SELECT COUNT(*) AS T FROM CHATC2_CONFIGURACOES_EMPRESA WHERE CFE_EMPRESA_ID = ?', [empresaId]);
|
||||||
|
|
||||||
|
if (exists[0].T > 0) {
|
||||||
|
await db.execute(alias, `
|
||||||
|
UPDATE CHATC2_CONFIGURACOES_EMPRESA SET
|
||||||
|
CFE_INSTANCIA_PADRAO_ID = ?, CFE_FOTO_CELULAR = ?, CFE_SAUDACAO_ATIVA = ?,
|
||||||
|
CFE_SAUDACAO_MENSAGEM = ?, CFE_CSAT_ATIVO = ?, CFE_CSAT_MENSAGEM = ?,
|
||||||
|
CFE_ENVIAR_NOME_USUARIO = ?, CFE_TRIAGEM_ATIVA = ?, CFE_TRIAGEM_MSG_WELCOME = ?,
|
||||||
|
CFE_TRIAGEM_MSG_AFTER = ?, CFE_TRIAGEM_BOLETO_NUMERO = ?
|
||||||
|
WHERE CFE_EMPRESA_ID = ?
|
||||||
|
`, [data.instanciaPadraoId || null, data.fotoCelular || 'N', data.saudacaoAtiva || 'S', data.saudacaoMensagem || '', data.csatAtivo || 'N', data.csatMensagem || '', data.enviarNomeUsuario || 'N', data.triagemAtiva || 'N', data.triagemMsgWelcome || '', data.triagemMsgAfter || '', data.triagemBoletoNumero || '0', empresaId]);
|
||||||
|
} else {
|
||||||
|
await db.execute(alias, `
|
||||||
|
INSERT INTO CHATC2_CONFIGURACOES_EMPRESA (CFE_EMPRESA_ID, CFE_INSTANCIA_PADRAO_ID, CFE_FOTO_CELULAR,
|
||||||
|
CFE_SAUDACAO_ATIVA, CFE_SAUDACAO_MENSAGEM, CFE_CSAT_ATIVO, CFE_CSAT_MENSAGEM, CFE_ENVIAR_NOME_USUARIO,
|
||||||
|
CFE_TRIAGEM_ATIVA, CFE_TRIAGEM_MSG_WELCOME, CFE_TRIAGEM_MSG_AFTER, CFE_TRIAGEM_BOLETO_NUMERO)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, [empresaId, data.instanciaPadraoId || null, data.fotoCelular || 'N', data.saudacaoAtiva || 'S', data.saudacaoMensagem || '', data.csatAtivo || 'N', data.csatMensagem || '', data.enviarNomeUsuario || 'N', data.triagemAtiva || 'N', data.triagemMsgWelcome || '', data.triagemMsgAfter || '', data.triagemBoletoNumero || '0']);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== USUÁRIOS TIPO CHAT ====================
|
||||||
|
static async updateUserChatType(req, res) {
|
||||||
|
try {
|
||||||
|
const { alias, id } = req.params;
|
||||||
|
const { tipoChat } = req.body;
|
||||||
|
|
||||||
|
// Somente Gerente pode alterar o tipo de usuário
|
||||||
|
if (!(await isGerente(req))) {
|
||||||
|
return res.status(403).json({ success: false, error: 'Apenas gerentes podem alterar o tipo de usuário.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valida o valor (evita gravar tipos arbitrários)
|
||||||
|
const t = String(tipoChat || 'A').trim().toUpperCase();
|
||||||
|
if (t !== 'A' && t !== 'G') {
|
||||||
|
return res.status(400).json({ success: false, error: 'tipoChat inválido (use "A" ou "G").' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// O usuário-alvo precisa pertencer a uma empresa do gerente
|
||||||
|
const alvo = await db.query(alias,
|
||||||
|
'SELECT USE_EMPRESA_ID FROM USUARIOS_EMPRESA WHERE USE_USUARIO_ID = ?', [id]);
|
||||||
|
const empresasAlvo = alvo.map(function (e) { return e.USE_EMPRESA_ID; });
|
||||||
|
const minhas = req.user && req.user.empresas ? req.user.empresas : [];
|
||||||
|
if (empresasAlvo.length > 0 && !empresasAlvo.some(function (e) { return minhas.includes(e); })) {
|
||||||
|
return res.status(403).json({ success: false, error: 'Sem permissão sobre este usuário.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.execute(alias, 'UPDATE USUARIOS SET USU_TIPO_CHAT = ? WHERE USU_CODIGO_ID = ?', [t, id]);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = ConfigController;
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
const { addDatabase, removeDatabase, listAllDatabases, DEFAULT_DRIVER } = require('../database');
|
||||||
|
const { isGerente } = require('../middlewares/roles');
|
||||||
|
|
||||||
|
class DatabaseController {
|
||||||
|
/**
|
||||||
|
* Lista todas as conexões disponíveis
|
||||||
|
* GET /api/databases
|
||||||
|
*/
|
||||||
|
static async list(req, res) {
|
||||||
|
try {
|
||||||
|
if (!(await isGerente(req))) return res.status(403).json({ success: false, error: 'Apenas gerentes podem gerenciar conexões de banco.' });
|
||||||
|
var all = listAllDatabases();
|
||||||
|
// Oculta senhas na resposta
|
||||||
|
var sanitized = {};
|
||||||
|
Object.keys(all).forEach(function(alias) {
|
||||||
|
sanitized[alias] = Object.assign({}, all[alias]);
|
||||||
|
if (sanitized[alias].password) {
|
||||||
|
sanitized[alias].password = '******';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
res.json({ success: true, data: sanitized });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adiciona ou atualiza uma conexão
|
||||||
|
* POST /api/databases
|
||||||
|
* Body: { alias: 'meu-banco', config: { host, port, database, user, password, ... } }
|
||||||
|
* Ou: { 'meu-banco': { host, port, database, ... } }
|
||||||
|
*/
|
||||||
|
static async save(req, res) {
|
||||||
|
try {
|
||||||
|
if (!(await isGerente(req))) return res.status(403).json({ success: false, error: 'Apenas gerentes podem gerenciar conexões de banco.' });
|
||||||
|
var body = req.body;
|
||||||
|
var alias, config;
|
||||||
|
|
||||||
|
if (body.alias && body.config) {
|
||||||
|
alias = body.alias;
|
||||||
|
config = body.config;
|
||||||
|
} else {
|
||||||
|
// Aceita no formato: { 'meu-alias': { host, port, database, ... } }
|
||||||
|
var keys = Object.keys(body);
|
||||||
|
if (keys.length === 0) {
|
||||||
|
return res.status(400).json({ success: false, error: 'Envie { alias, config } ou { "meu-alias": { ... } }' });
|
||||||
|
}
|
||||||
|
alias = keys[0];
|
||||||
|
config = body[alias];
|
||||||
|
}
|
||||||
|
|
||||||
|
addDatabase(alias, config);
|
||||||
|
res.json({ success: true, data: { alias: alias.toLowerCase().replace(/[^a-z0-9_]/g, '_') } });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove uma conexão
|
||||||
|
* DELETE /api/databases/:alias
|
||||||
|
*/
|
||||||
|
static async remove(req, res) {
|
||||||
|
try {
|
||||||
|
if (!(await isGerente(req))) return res.status(403).json({ success: false, error: 'Apenas gerentes podem gerenciar conexões de banco.' });
|
||||||
|
var alias = req.params.alias;
|
||||||
|
if (!alias) {
|
||||||
|
return res.status(400).json({ success: false, error: 'Alias obrigatório.' });
|
||||||
|
}
|
||||||
|
var removed = removeDatabase(alias);
|
||||||
|
if (!removed) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Alias não encontrado ou é estático.' });
|
||||||
|
}
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Testa uma conexão
|
||||||
|
* POST /api/databases/test
|
||||||
|
* Body: { host, port, database, user, password }
|
||||||
|
*/
|
||||||
|
static async test(req, res) {
|
||||||
|
try {
|
||||||
|
if (!(await isGerente(req))) return res.status(403).json({ success: false, error: 'Apenas gerentes podem testar conexões de banco.' });
|
||||||
|
if (!req.body.database) {
|
||||||
|
return res.status(400).json({ success: false, error: 'database é obrigatório.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
var driver = (req.body.driver || DEFAULT_DRIVER).toLowerCase();
|
||||||
|
|
||||||
|
if (driver === 'postgres') {
|
||||||
|
var pg = require('pg');
|
||||||
|
var schema = String(req.body.schema || 'public').trim();
|
||||||
|
var safe = /^[A-Za-z_][A-Za-z0-9_$]*$/.test(schema) ? schema : 'public';
|
||||||
|
var searchPath = safe === 'public' ? 'public' : safe + ',public';
|
||||||
|
var client = new pg.Client({
|
||||||
|
host: req.body.host || '127.0.0.1',
|
||||||
|
port: req.body.port || 5432,
|
||||||
|
database: req.body.database,
|
||||||
|
user: req.body.user || 'postgres',
|
||||||
|
password: req.body.password || 'postgres',
|
||||||
|
ssl: req.body.ssl ? { rejectUnauthorized: false } : false,
|
||||||
|
options: '-c search_path=' + searchPath,
|
||||||
|
connectionTimeoutMillis: 8000,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
await client.query('SELECT 1');
|
||||||
|
// Confirma que o schema existe
|
||||||
|
var sc = await client.query('SELECT 1 FROM information_schema.schemata WHERE schema_name = $1', [safe]);
|
||||||
|
if (sc.rowCount === 0) {
|
||||||
|
throw new Error('Schema "' + safe + '" não encontrado no banco "' + req.body.database + '".');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await client.end().catch(function () {});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var Firebird = require('node-firebird');
|
||||||
|
var config = {
|
||||||
|
host: req.body.host || 'localhost',
|
||||||
|
port: req.body.port || 3050,
|
||||||
|
database: req.body.database,
|
||||||
|
user: req.body.user || 'SYSDBA',
|
||||||
|
password: req.body.password || 'masterkey',
|
||||||
|
encoding: 'UTF-8',
|
||||||
|
lowercase_keys: false,
|
||||||
|
pageSize: 4096,
|
||||||
|
};
|
||||||
|
await new Promise(function (resolve, reject) {
|
||||||
|
Firebird.attach(config, function (err, conn) {
|
||||||
|
if (err) return reject(new Error('Falha ao conectar: ' + err.message));
|
||||||
|
conn.query('SELECT 1 AS TEST FROM RDB$DATABASE', [], function (qErr, rows) {
|
||||||
|
conn.detach();
|
||||||
|
if (qErr) return reject(new Error('Falha na consulta: ' + qErr.message));
|
||||||
|
resolve(rows);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, message: '✅ Conexão (' + driver + ') estabelecida com sucesso!' });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = DatabaseController;
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const db = require('../database');
|
||||||
|
|
||||||
|
class EmpresaController {
|
||||||
|
static async getLogo(req, res) {
|
||||||
|
const { alias } = req.params;
|
||||||
|
const respond = (data) => { if (!res.headersSent) res.json(data); };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await db.query(alias,
|
||||||
|
'SELECT EMP_CODIGO_ID, EMP_NOME_FANTASIA, EMP_NOME, EMP_NOME_FOTO, EMP_FOTO, EMP_COR_STRING_HEXA FROM EMPRESAS ORDER BY EMP_CODIGO_ID FETCH FIRST 1 ROWS ONLY');
|
||||||
|
|
||||||
|
if (result.length === 0) {
|
||||||
|
return respond({ success: true, fotoUrl: null, iniciais: 'EM' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const e = result[0];
|
||||||
|
const nomeFantasia = (e.EMP_NOME_FANTASIA || e.EMP_NOME || '').trim();
|
||||||
|
const nomeFoto = (e.EMP_NOME_FOTO || '').trim();
|
||||||
|
const corHexa = (e.EMP_COR_STRING_HEXA || '#667eea').trim();
|
||||||
|
const iniciais = getIniciais(nomeFantasia);
|
||||||
|
|
||||||
|
// 1. EMP_NOME_FOTO como URL ou caminho de arquivo
|
||||||
|
if (nomeFoto) {
|
||||||
|
if (nomeFoto.startsWith('http://') || nomeFoto.startsWith('https://')) {
|
||||||
|
return respond({ success: true, fotoUrl: nomeFoto, nomeFantasia, iniciais, cor: corHexa });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(nomeFoto)) {
|
||||||
|
const data = fs.readFileSync(nomeFoto).toString('base64');
|
||||||
|
const ext = nomeFoto.split('.').pop().toLowerCase();
|
||||||
|
const mime = ext === 'png' ? 'image/png'
|
||||||
|
: ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg'
|
||||||
|
: ext === 'gif' ? 'image/gif'
|
||||||
|
: ext === 'webp' ? 'image/webp' : 'image/png';
|
||||||
|
return respond({ success: true, fotoUrl: 'data:' + mime + ';base64,' + data, nomeFantasia, iniciais, cor: corHexa });
|
||||||
|
}
|
||||||
|
} catch (fe) { /* ignora e tenta o BLOB */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. BLOB EMP_FOTO (Buffer no postgres/firebird; string base64 em alguns casos)
|
||||||
|
const blobData = e.EMP_FOTO;
|
||||||
|
let base64;
|
||||||
|
if (Buffer.isBuffer(blobData)) {
|
||||||
|
base64 = blobData.toString('base64');
|
||||||
|
} else if (typeof blobData === 'string' && blobData.length > 0) {
|
||||||
|
base64 = /^[A-Za-z0-9+/=]+$/.test(blobData.substring(0, 100))
|
||||||
|
? blobData
|
||||||
|
: Buffer.from(blobData).toString('base64');
|
||||||
|
} else if (blobData && blobData.type === 'Buffer') {
|
||||||
|
base64 = Buffer.from(blobData.data || blobData).toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (base64 && base64.length > 10) {
|
||||||
|
return respond({ success: true, fotoUrl: 'data:image/png;base64,' + base64, nomeFantasia, iniciais, cor: corHexa });
|
||||||
|
}
|
||||||
|
|
||||||
|
return respond({ success: true, fotoUrl: null, nomeFantasia, iniciais, cor: corHexa });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[EmpresaController.getLogo]', err.message);
|
||||||
|
return respond({ success: true, fotoUrl: null, iniciais: 'EM' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIniciais(nome) {
|
||||||
|
if (!nome) return 'EM';
|
||||||
|
const partes = nome.split(' ').filter(p => p.length > 0 && !['DE', 'DA', 'DO', 'DAS', 'DOS', 'E'].includes(p.toUpperCase()));
|
||||||
|
if (partes.length === 0) return nome.substring(0, 2).toUpperCase();
|
||||||
|
if (partes.length === 1) return partes[0].substring(0, 2).toUpperCase();
|
||||||
|
return (partes[0][0] + partes[partes.length - 1][0]).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = EmpresaController;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,136 @@
|
|||||||
|
const db = require('../database');
|
||||||
|
const { listAliases } = db;
|
||||||
|
const { isGerente } = require('../middlewares/roles');
|
||||||
|
|
||||||
|
class GenericController {
|
||||||
|
/**
|
||||||
|
* Executa uma consulta SQL genérica (SELECT)
|
||||||
|
*/
|
||||||
|
static async query(req, res) {
|
||||||
|
try {
|
||||||
|
const { alias } = req.params;
|
||||||
|
const { sql, params } = req.body;
|
||||||
|
|
||||||
|
if (!(await isGerente(req))) {
|
||||||
|
return res.status(403).json({ success: false, error: 'Apenas gerentes podem executar SQL direto.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sql) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'O campo "sql" é obrigatório.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.query(alias, sql, params || []);
|
||||||
|
res.json({ success: true, alias, data: result, count: result.length });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erro no controller query:', err);
|
||||||
|
res.status(500).json({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executa um comando SQL (INSERT, UPDATE, DELETE)
|
||||||
|
*/
|
||||||
|
static async execute(req, res) {
|
||||||
|
try {
|
||||||
|
const { alias } = req.params;
|
||||||
|
const { sql, params } = req.body;
|
||||||
|
|
||||||
|
if (!(await isGerente(req))) {
|
||||||
|
return res.status(403).json({ success: false, error: 'Apenas gerentes podem executar SQL direto.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sql) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'O campo "sql" é obrigatório.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.execute(alias, sql, params || []);
|
||||||
|
res.json({ success: true, alias, ...result });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erro no controller execute:', err);
|
||||||
|
res.status(500).json({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lista todas as tabelas do banco
|
||||||
|
*/
|
||||||
|
static async listTables(req, res) {
|
||||||
|
try {
|
||||||
|
const { alias } = req.params;
|
||||||
|
const tables = await db.listTables(alias);
|
||||||
|
res.json({ success: true, alias, data: tables, count: tables.length });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erro ao listar tabelas:', err);
|
||||||
|
res.status(500).json({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retorna a estrutura de uma tabela (colunas e tipos)
|
||||||
|
*/
|
||||||
|
static async tableInfo(req, res) {
|
||||||
|
try {
|
||||||
|
const { alias, tableName } = req.params;
|
||||||
|
|
||||||
|
if (!tableName) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'O parâmetro "tableName" é obrigatório.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = await db.tableInfo(alias, tableName);
|
||||||
|
res.json({ success: true, alias, tableName, columns, count: columns.length });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erro ao obter info da tabela:', err);
|
||||||
|
res.status(500).json({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Health check do alias específico
|
||||||
|
*/
|
||||||
|
static async healthCheck(req, res) {
|
||||||
|
try {
|
||||||
|
const { alias } = req.params;
|
||||||
|
const isConnected = await db.testConnection(alias);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
alias,
|
||||||
|
connected: isConnected,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lista todos os aliases disponíveis
|
||||||
|
*/
|
||||||
|
static async listAliases(req, res) {
|
||||||
|
try {
|
||||||
|
const aliases = listAliases();
|
||||||
|
const statuses = {};
|
||||||
|
|
||||||
|
for (const a of aliases) {
|
||||||
|
statuses[a] = await db.testConnection(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, aliases, status: statuses });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = GenericController;
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
const db = require('../database');
|
||||||
|
const { isGerente } = require('../middlewares/roles');
|
||||||
|
|
||||||
|
class MenuController {
|
||||||
|
/**
|
||||||
|
* Garante que a tabela CHATC2_MENUS_EMPRESA existe
|
||||||
|
*/
|
||||||
|
static async garantirTabela(alias) {
|
||||||
|
var tabelaExiste = false;
|
||||||
|
try {
|
||||||
|
tabelaExiste = await db.tableExists(alias, 'CHATC2_MENUS_EMPRESA');
|
||||||
|
} catch(e) { /* ignora */ }
|
||||||
|
|
||||||
|
if (!tabelaExiste) {
|
||||||
|
try {
|
||||||
|
await db.execute(alias, `
|
||||||
|
CREATE TABLE CHATC2_MENUS_EMPRESA (
|
||||||
|
MNE_CODIGO_ID INTEGER NOT NULL PRIMARY KEY,
|
||||||
|
MNE_EMPRESA_ID INTEGER,
|
||||||
|
MNE_EQUIPE_ID INTEGER,
|
||||||
|
MNE_MENU_PAI_ID INTEGER,
|
||||||
|
MNE_ORDEM INTEGER DEFAULT 0,
|
||||||
|
MNE_TITULO VARCHAR(100),
|
||||||
|
MNE_TIPO CHAR(1) DEFAULT 'M',
|
||||||
|
MNE_TEXTO BLOB SUB_TYPE TEXT,
|
||||||
|
MNE_ACAO_ROTA VARCHAR(100),
|
||||||
|
MNE_ACAO_METODO VARCHAR(10),
|
||||||
|
MNE_ACAO_PROMPT VARCHAR(300),
|
||||||
|
MNE_SITUACAO CHAR(1) DEFAULT 'A'
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
console.log('[MenuController] Tabela CHATC2_MENUS_EMPRESA criada com sucesso');
|
||||||
|
} catch(e) {
|
||||||
|
if (!e.message.includes('already exists')) {
|
||||||
|
console.error('[MenuController] Erro ao criar tabela:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tenta adicionar colunas que podem estar faltando
|
||||||
|
// Primeiro verifica se a coluna já existe para evitar erros
|
||||||
|
var colunas = [
|
||||||
|
{ nome: 'MNE_ACAO_ROTA', sql: 'ALTER TABLE CHATC2_MENUS_EMPRESA ADD MNE_ACAO_ROTA VARCHAR(100)' },
|
||||||
|
{ nome: 'MNE_ACAO_METODO', sql: 'ALTER TABLE CHATC2_MENUS_EMPRESA ADD MNE_ACAO_METODO VARCHAR(10)' },
|
||||||
|
{ nome: 'MNE_ACAO_PROMPT', sql: 'ALTER TABLE CHATC2_MENUS_EMPRESA ADD MNE_ACAO_PROMPT VARCHAR(300)' },
|
||||||
|
{ nome: 'MNE_ETIQUETA_IDS', sql: 'ALTER TABLE CHATC2_MENUS_EMPRESA ADD MNE_ETIQUETA_IDS VARCHAR(200)' },
|
||||||
|
];
|
||||||
|
for (var c of colunas) {
|
||||||
|
try {
|
||||||
|
// Verifica se coluna já existe
|
||||||
|
var colExiste = await db.columnExists(alias, 'CHATC2_MENUS_EMPRESA', c.nome);
|
||||||
|
if (colExiste) continue; // já existe, pula
|
||||||
|
|
||||||
|
await db.execute(alias, c.sql);
|
||||||
|
console.log('[MenuController] Coluna adicionada:', c.nome);
|
||||||
|
} catch(e) {
|
||||||
|
// Ignora qualquer erro (coluna pode ter sido adicionada por outro processo)
|
||||||
|
if (!e.message.includes('already exists') && !e.message.includes('duplicate')) {
|
||||||
|
console.log('[MenuController] Aviso ao adicionar coluna ' + c.nome + ': ' + e.message.substring(0, 80));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lista menus organizados por equipe
|
||||||
|
* GET /api/:alias/menus?empresaId=X
|
||||||
|
*/
|
||||||
|
static async listMenus(req, res) {
|
||||||
|
try {
|
||||||
|
const { alias } = req.params;
|
||||||
|
const empresaId = parseInt(req.query.empresaId) || req.user?.empresas?.[0];
|
||||||
|
if (!req.user?.empresas?.includes(empresaId))
|
||||||
|
return res.status(403).json({ success: false, error: 'Sem permissão.' });
|
||||||
|
|
||||||
|
await MenuController.garantirTabela(alias);
|
||||||
|
|
||||||
|
const equipes = await db.query(alias,
|
||||||
|
"SELECT EQU_CODIGO_ID, EQU_NOME FROM CHATC2_EQUIPES WHERE EQU_EMPRESA_ID = ? AND EQU_SITUACAO = 'A' ORDER BY EQU_ORDEM, EQU_NOME",
|
||||||
|
[empresaId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const todos = await db.query(alias,
|
||||||
|
"SELECT * FROM CHATC2_MENUS_EMPRESA WHERE MNE_EMPRESA_ID = ? AND MNE_SITUACAO = 'A' ORDER BY MNE_ORDEM, MNE_CODIGO_ID",
|
||||||
|
[empresaId]
|
||||||
|
);
|
||||||
|
|
||||||
|
function montarArvore(paiId) {
|
||||||
|
return todos
|
||||||
|
.filter(function(m) { return (m.MNE_MENU_PAI_ID || null) === (paiId || null); })
|
||||||
|
.map(function(m) {
|
||||||
|
return {
|
||||||
|
id: m.MNE_CODIGO_ID,
|
||||||
|
titulo: (m.MNE_TITULO || '').trim(),
|
||||||
|
ordem: m.MNE_ORDEM || 0,
|
||||||
|
tipo: (m.MNE_TIPO || 'M').trim(),
|
||||||
|
texto: m.MNE_TEXTO ? m.MNE_TEXTO.toString() : '',
|
||||||
|
equipeId: m.MNE_EQUIPE_ID,
|
||||||
|
acaoRota: (m.MNE_ACAO_ROTA || '').trim(),
|
||||||
|
acaoMetodo: (m.MNE_ACAO_METODO || '').trim(),
|
||||||
|
acaoPrompt: (m.MNE_ACAO_PROMPT || '').trim(),
|
||||||
|
etiquetaIds: (m.MNE_ETIQUETA_IDS || '').trim(),
|
||||||
|
filhos: montarArvore(m.MNE_CODIGO_ID),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var resultado = equipes.map(function(eq) {
|
||||||
|
return {
|
||||||
|
equipeId: eq.EQU_CODIGO_ID,
|
||||||
|
equipeNome: (eq.EQU_NOME || '').trim(),
|
||||||
|
menus: montarArvore(null).filter(function(m) { return m.equipeId === eq.EQU_CODIGO_ID; }),
|
||||||
|
};
|
||||||
|
}).filter(function(g) { return g.menus.length > 0; });
|
||||||
|
|
||||||
|
res.json({ success: true, data: resultado });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lista menus em formato plano (para selects/combos)
|
||||||
|
* GET /api/:alias/menus/flat?empresaId=X&equipeId=Y
|
||||||
|
*/
|
||||||
|
static async listMenusFlat(req, res) {
|
||||||
|
try {
|
||||||
|
const { alias } = req.params;
|
||||||
|
const empresaId = parseInt(req.query.empresaId);
|
||||||
|
const equipeId = req.query.equipeId ? parseInt(req.query.equipeId) : null;
|
||||||
|
|
||||||
|
if (!req.user?.empresas?.includes(empresaId))
|
||||||
|
return res.status(403).json({ success: false, error: 'Sem permissão.' });
|
||||||
|
|
||||||
|
await MenuController.garantirTabela(alias);
|
||||||
|
|
||||||
|
let sql = "SELECT * FROM CHATC2_MENUS_EMPRESA WHERE MNE_EMPRESA_ID = ? AND MNE_SITUACAO = 'A'";
|
||||||
|
let params = [empresaId];
|
||||||
|
if (equipeId) {
|
||||||
|
sql += ' AND MNE_EQUIPE_ID = ?';
|
||||||
|
params.push(equipeId);
|
||||||
|
}
|
||||||
|
sql += ' ORDER BY MNE_ORDEM, MNE_CODIGO_ID';
|
||||||
|
|
||||||
|
const menus = await db.query(alias, sql, params);
|
||||||
|
res.json({ success: true, data: menus.map(function(m) {
|
||||||
|
return {
|
||||||
|
id: m.MNE_CODIGO_ID,
|
||||||
|
titulo: (m.MNE_TITULO || '').trim(),
|
||||||
|
paiId: m.MNE_MENU_PAI_ID,
|
||||||
|
equipeId: m.MNE_EQUIPE_ID,
|
||||||
|
tipo: (m.MNE_TIPO || 'M').trim(),
|
||||||
|
};
|
||||||
|
})});
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cria um item de menu
|
||||||
|
* POST /api/:alias/menus
|
||||||
|
*/
|
||||||
|
static async createMenu(req, res) {
|
||||||
|
try {
|
||||||
|
if (!(await isGerente(req))) return res.status(403).json({ success: false, error: 'Apenas gerentes podem gerenciar menus.' });
|
||||||
|
const { alias } = req.params;
|
||||||
|
const { empresaId, equipeId, titulo, tipo, texto, acaoRota, acaoMetodo, acaoPrompt, menuPaiId, ordem, etiquetaIds } = req.body;
|
||||||
|
|
||||||
|
if (!empresaId || !titulo)
|
||||||
|
return res.status(400).json({ success: false, error: 'empresaId e titulo obrigatórios.' });
|
||||||
|
|
||||||
|
await MenuController.garantirTabela(alias);
|
||||||
|
|
||||||
|
const maxId = await db.query(alias, 'SELECT MAX(MNE_CODIGO_ID) AS ID FROM CHATC2_MENUS_EMPRESA');
|
||||||
|
const newId = (maxId[0]?.ID || 0) + 1;
|
||||||
|
|
||||||
|
// Se tem menuPaiId, o equipeId é herdado do pai
|
||||||
|
var equipeIdFinal = menuPaiId ? null : (equipeId || null);
|
||||||
|
|
||||||
|
await db.execute(alias,
|
||||||
|
`INSERT INTO CHATC2_MENUS_EMPRESA (MNE_CODIGO_ID, MNE_EMPRESA_ID, MNE_EQUIPE_ID, MNE_MENU_PAI_ID,
|
||||||
|
MNE_ORDEM, MNE_TITULO, MNE_TIPO, MNE_TEXTO, MNE_ACAO_ROTA, MNE_ACAO_METODO, MNE_ACAO_PROMPT, MNE_ETIQUETA_IDS)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[newId, empresaId, equipeIdFinal, menuPaiId || null,
|
||||||
|
ordem || 0, titulo, tipo || 'M', texto || null,
|
||||||
|
acaoRota || null, acaoMetodo || null, acaoPrompt || null, etiquetaIds || null]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ success: true, data: { id: newId } });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atualiza um item de menu
|
||||||
|
* PUT /api/:alias/menus/:id
|
||||||
|
*/
|
||||||
|
static async updateMenu(req, res) {
|
||||||
|
try {
|
||||||
|
if (!(await isGerente(req))) return res.status(403).json({ success: false, error: 'Apenas gerentes podem gerenciar menus.' });
|
||||||
|
const { alias, id } = req.params;
|
||||||
|
const { titulo, tipo, texto, acaoRota, acaoMetodo, acaoPrompt, menuPaiId, ordem, equipeId, etiquetaIds } = req.body;
|
||||||
|
|
||||||
|
await MenuController.garantirTabela(alias);
|
||||||
|
|
||||||
|
if (titulo !== undefined) await db.execute(alias, 'UPDATE CHATC2_MENUS_EMPRESA SET MNE_TITULO = ? WHERE MNE_CODIGO_ID = ?', [titulo, id]);
|
||||||
|
if (tipo !== undefined) await db.execute(alias, "UPDATE CHATC2_MENUS_EMPRESA SET MNE_TIPO = ? WHERE MNE_CODIGO_ID = ?", [tipo, id]);
|
||||||
|
if (ordem !== undefined) await db.execute(alias, 'UPDATE CHATC2_MENUS_EMPRESA SET MNE_ORDEM = ? WHERE MNE_CODIGO_ID = ?', [ordem, id]);
|
||||||
|
if (texto !== undefined) await db.execute(alias, 'UPDATE CHATC2_MENUS_EMPRESA SET MNE_TEXTO = ? WHERE MNE_CODIGO_ID = ?', [texto, id]);
|
||||||
|
if (acaoRota !== undefined) await db.execute(alias, 'UPDATE CHATC2_MENUS_EMPRESA SET MNE_ACAO_ROTA = ? WHERE MNE_CODIGO_ID = ?', [acaoRota, id]);
|
||||||
|
if (acaoMetodo !== undefined) await db.execute(alias, 'UPDATE CHATC2_MENUS_EMPRESA SET MNE_ACAO_METODO = ? WHERE MNE_CODIGO_ID = ?', [acaoMetodo, id]);
|
||||||
|
if (acaoPrompt !== undefined) await db.execute(alias, 'UPDATE CHATC2_MENUS_EMPRESA SET MNE_ACAO_PROMPT = ? WHERE MNE_CODIGO_ID = ?', [acaoPrompt, id]);
|
||||||
|
if (menuPaiId !== undefined) await db.execute(alias, 'UPDATE CHATC2_MENUS_EMPRESA SET MNE_MENU_PAI_ID = ? WHERE MNE_CODIGO_ID = ?', [menuPaiId || null, id]);
|
||||||
|
if (equipeId !== undefined) await db.execute(alias, 'UPDATE CHATC2_MENUS_EMPRESA SET MNE_EQUIPE_ID = ? WHERE MNE_CODIGO_ID = ?', [equipeId, id]);
|
||||||
|
if (etiquetaIds !== undefined) await db.execute(alias, 'UPDATE CHATC2_MENUS_EMPRESA SET MNE_ETIQUETA_IDS = ? WHERE MNE_CODIGO_ID = ?', [etiquetaIds || null, id]);
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exclui um item de menu (e seus filhos recursivamente)
|
||||||
|
* DELETE /api/:alias/menus/:id
|
||||||
|
*/
|
||||||
|
static async deleteMenu(req, res) {
|
||||||
|
try {
|
||||||
|
if (!(await isGerente(req))) return res.status(403).json({ success: false, error: 'Apenas gerentes podem gerenciar menus.' });
|
||||||
|
const { alias, id } = req.params;
|
||||||
|
|
||||||
|
await MenuController.garantirTabela(alias);
|
||||||
|
|
||||||
|
async function excluirFilhos(paiId) {
|
||||||
|
var filhos = await db.query(alias, 'SELECT MNE_CODIGO_ID FROM CHATC2_MENUS_EMPRESA WHERE MNE_MENU_PAI_ID = ?', [paiId]);
|
||||||
|
for (var f of filhos) await excluirFilhos(f.MNE_CODIGO_ID);
|
||||||
|
await db.execute(alias, "UPDATE CHATC2_MENUS_EMPRESA SET MNE_SITUACAO = 'I' WHERE MNE_CODIGO_ID = ?", [paiId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
await excluirFilhos(parseInt(id));
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = MenuController;
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
/**
|
||||||
|
* Controller que descobre automaticamente as rotas registradas no Express e
|
||||||
|
* enriquece cada uma com metadados de documentação:
|
||||||
|
* - descrição
|
||||||
|
* - autenticação (pública x requer token)
|
||||||
|
* - parâmetros de path (todos obrigatórios)
|
||||||
|
* - parâmetros de query (com indicação de obrigatório)
|
||||||
|
* - exemplo de body (JSON) + campos obrigatórios
|
||||||
|
*/
|
||||||
|
class RoutesController {
|
||||||
|
// ============================================================
|
||||||
|
// Descoberta de rotas no Express
|
||||||
|
// ============================================================
|
||||||
|
static listarRotas() {
|
||||||
|
var routesModule = require('../routes');
|
||||||
|
var seen = {};
|
||||||
|
var rotas = [];
|
||||||
|
|
||||||
|
function addRota(method, path) {
|
||||||
|
var key = method + '|' + path;
|
||||||
|
if (!seen[key]) {
|
||||||
|
seen[key] = true;
|
||||||
|
rotas.push({ method: method, path: path });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function percorrer(stack, prefixo) {
|
||||||
|
if (!stack) return;
|
||||||
|
stack.forEach(function (layer) {
|
||||||
|
if (layer.route) {
|
||||||
|
var methods = Object.keys(layer.route.methods);
|
||||||
|
var path = prefixo + layer.route.path;
|
||||||
|
path = path.replace(/:([a-zA-Z_]+)/g, '{$1}');
|
||||||
|
methods.forEach(function (m) { addRota(m.toUpperCase(), path); });
|
||||||
|
} else if (layer.handle && layer.handle.stack) {
|
||||||
|
var subPrefixo = RoutesController.extrairPrefixo(layer.regexp, layer.name);
|
||||||
|
percorrer(layer.handle.stack, prefixo + subPrefixo);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
percorrer(routesModule.stack, '');
|
||||||
|
return rotas;
|
||||||
|
}
|
||||||
|
|
||||||
|
static extrairPrefixo(regexp) {
|
||||||
|
try {
|
||||||
|
if (!regexp) return '';
|
||||||
|
var source = regexp.source;
|
||||||
|
if (source.includes('\\/api')) return '/api';
|
||||||
|
if (source.includes('\\/app')) return '/app';
|
||||||
|
return '';
|
||||||
|
} catch (e) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Garante que o path tenha o prefixo /api ou /app. */
|
||||||
|
static normalizar(path) {
|
||||||
|
if (path.startsWith('/api/') || path.startsWith('/app/') ||
|
||||||
|
path === '/api' || path === '/app') {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
var paginas = ['/login', '/dashboard', '/clients', '/settings', '/routes',
|
||||||
|
'/csat', '/conversation/', '/conversations/all'];
|
||||||
|
var isPagina = paginas.some(function (p) { return path.endsWith(p) || path.includes(p); });
|
||||||
|
return (isPagina ? '/app' : '/api') + path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Autenticação
|
||||||
|
// ============================================================
|
||||||
|
static requerAuth(method, path) {
|
||||||
|
var key = method + ' ' + path;
|
||||||
|
var publicas = {
|
||||||
|
'GET /api/aliases': 1,
|
||||||
|
'GET /api/routes': 1,
|
||||||
|
'GET /api/{alias}/empresa/logo': 1,
|
||||||
|
'GET /api/{alias}/webhook/ping': 1,
|
||||||
|
'POST /api/webhook/evolution': 1,
|
||||||
|
'POST /api/webhook/evolution/{evento}': 1,
|
||||||
|
'POST /api/{alias}/webhook/evolution': 1,
|
||||||
|
'POST /api/{alias}/webhook/evolution/{evento}': 1,
|
||||||
|
'GET /api/{alias}/media/{mediaId}': 1,
|
||||||
|
'POST /api/{alias}/csat/avaliar': 1,
|
||||||
|
'POST /app/{alias}/login': 1,
|
||||||
|
};
|
||||||
|
if (publicas[key]) return false;
|
||||||
|
// Páginas HTML servidas em /app (proteção feita no cliente) — exceto /me
|
||||||
|
if (method === 'GET' && path.indexOf('/app/') === 0 && !path.endsWith('/me')) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Parâmetros de path (extraídos do {token})
|
||||||
|
// ============================================================
|
||||||
|
static descricaoParam(nome) {
|
||||||
|
var mapa = {
|
||||||
|
alias: 'Alias da conexão de banco (ex.: novo_local)',
|
||||||
|
tableName: 'Nome da tabela',
|
||||||
|
id: 'ID do registro',
|
||||||
|
id_cliente: 'ID do cliente',
|
||||||
|
id_empresa: 'ID da empresa',
|
||||||
|
mediaId: 'ID da mídia',
|
||||||
|
conversaId: 'ID da conversa',
|
||||||
|
evento: 'Nome do evento Evolution (ex.: messages-upsert)',
|
||||||
|
};
|
||||||
|
return mapa[nome] || ('Parâmetro "' + nome + '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
static paramsDePath(path) {
|
||||||
|
var params = [];
|
||||||
|
var re = /\{([a-zA-Z_]+)\}/g;
|
||||||
|
var m;
|
||||||
|
while ((m = re.exec(path)) !== null) {
|
||||||
|
params.push({ nome: m[1], obrigatorio: true, desc: RoutesController.descricaoParam(m[1]) });
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Catálogo de metadados (desc, query, body, obrigatórios)
|
||||||
|
// body = objeto exemplo; req = campos de body obrigatórios
|
||||||
|
// q = [ [nome, obrigatorio(bool), desc], ... ]
|
||||||
|
// ============================================================
|
||||||
|
static catalogo() {
|
||||||
|
return {
|
||||||
|
// ---- Banco de Dados ----
|
||||||
|
'GET /api/aliases': { desc: 'Lista os aliases de banco disponíveis e o status de conexão.' },
|
||||||
|
'GET /api/routes': { desc: 'Lista todas as rotas da API (esta página).' },
|
||||||
|
'GET /api/{alias}/health': { desc: 'Health check do banco do alias.' },
|
||||||
|
'GET /api/{alias}/tables': { desc: 'Lista as tabelas do banco.' },
|
||||||
|
'GET /api/{alias}/tables/{tableName}': { desc: 'Estrutura (colunas e tipos) de uma tabela.' },
|
||||||
|
'POST /api/{alias}/query': {
|
||||||
|
desc: 'Executa uma consulta SELECT. Aceita placeholders "?" nos parâmetros.',
|
||||||
|
body: { sql: 'SELECT CLI_NOME FROM CLIENTES WHERE CLI_CODIGO_ID = ?', params: [1] },
|
||||||
|
req: ['sql'],
|
||||||
|
note: 'O campo "params" é opcional (array, na ordem dos "?").',
|
||||||
|
},
|
||||||
|
'POST /api/{alias}/execute': {
|
||||||
|
desc: 'Executa INSERT / UPDATE / DELETE. Retorna affectedRows.',
|
||||||
|
body: { sql: 'UPDATE CLIENTES SET CLI_EMAIL = ? WHERE CLI_CODIGO_ID = ?', params: ['a@b.com', 1] },
|
||||||
|
req: ['sql'],
|
||||||
|
},
|
||||||
|
'GET /api/databases': { desc: 'Lista as conexões de banco cadastradas (senhas ocultas).' },
|
||||||
|
'POST /api/databases': {
|
||||||
|
desc: 'Adiciona ou atualiza uma conexão de banco (persistida em databases_custom.json).',
|
||||||
|
body: { alias: 'novo_dev', config: { driver: 'postgres', host: '127.0.0.1', port: 15433, database: 'novo_local', schema: 'dev', user: 'postgres', password: 'postgres' } },
|
||||||
|
req: ['alias', 'config'],
|
||||||
|
note: 'driver: "postgres" (usa schema) ou "firebird" (database = caminho do .FDB).',
|
||||||
|
},
|
||||||
|
'DELETE /api/databases/{alias}': { desc: 'Remove uma conexão customizada (estáticas não podem ser removidas).' },
|
||||||
|
'POST /api/databases/test': {
|
||||||
|
desc: 'Testa uma conexão sem cadastrá-la.',
|
||||||
|
body: { driver: 'postgres', host: '127.0.0.1', port: 15433, database: 'novo_local', schema: 'public', user: 'postgres', password: 'postgres' },
|
||||||
|
req: ['database'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- Autenticação ----
|
||||||
|
'POST /app/{alias}/login': {
|
||||||
|
desc: 'Autentica o usuário e retorna o token JWT.',
|
||||||
|
body: { USU_LOGIN: 'SUPORTE', USU_SENHA: 'sua-senha' },
|
||||||
|
req: ['USU_LOGIN', 'USU_SENHA'],
|
||||||
|
},
|
||||||
|
'GET /app/{alias}/me': { desc: 'Dados do usuário autenticado.' },
|
||||||
|
|
||||||
|
// ---- Clientes ----
|
||||||
|
'GET /api/{alias}/empresas': { desc: 'Empresas que o usuário logado tem acesso.' },
|
||||||
|
'GET /api/{alias}/empresa/logo': { desc: 'Logo/identidade visual da empresa (usada no login).' },
|
||||||
|
'GET /api/{alias}/clients/search': {
|
||||||
|
desc: 'Busca clientes por nome, CPF ou matrícula (paginado).',
|
||||||
|
q: [['q', false, 'Termo de busca (nome/CPF/matrícula)'], ['empresaId', false, 'Filtra por empresa'], ['page', false, 'Página (padrão 1)'], ['limit', false, 'Itens por página (padrão 20)']],
|
||||||
|
},
|
||||||
|
'GET /api/{alias}/clients/{id}/dependents': { desc: 'Lista os dependentes de um cliente.' },
|
||||||
|
'PUT /api/{alias}/dependents/{id}/phone': {
|
||||||
|
desc: 'Atualiza o telefone de um dependente.',
|
||||||
|
body: { telefone: '75988447843' }, req: ['telefone'],
|
||||||
|
},
|
||||||
|
'GET /api/{alias}/dependents/search': {
|
||||||
|
desc: 'Busca dependentes para associar a uma conversa.',
|
||||||
|
q: [['q', false, 'Termo de busca'], ['empresaId', false, 'Filtra por empresa']],
|
||||||
|
},
|
||||||
|
'GET /api/{alias}/company/{id_empresa}/client/{id_cliente}': { desc: 'Dados completos de um cliente.' },
|
||||||
|
'PUT /api/{alias}/clients/{id}': {
|
||||||
|
desc: 'Atualiza dados de contato do cliente.',
|
||||||
|
body: { telefone: '7532112233', email: 'cliente@email.com', celular: '75988447843' },
|
||||||
|
note: 'Todos os campos são opcionais — envie só o que deseja alterar.',
|
||||||
|
},
|
||||||
|
'GET /api/{alias}/clients/{id_cliente}/carnes': {
|
||||||
|
desc: 'Títulos (carnês) do cliente, paginados.',
|
||||||
|
q: [['page', false, 'Página (padrão 1)'], ['limit', false, 'Itens por página (padrão 20)'], ['tipo', false, 'Filtro: abertos, vencidos, pagos (separados por vírgula)']],
|
||||||
|
},
|
||||||
|
'GET /api/{alias}/clients/{id_cliente}/listcarne': { desc: 'Cliente + carnês formatados para geração de boleto PDF.' },
|
||||||
|
'GET /api/{alias}/clients/{id}/convalescentes': { desc: 'Itens de convalescença (módulo estoque) do cliente.' },
|
||||||
|
'GET /api/{alias}/clients/{id}/conversations': { desc: 'Histórico de conversas do cliente (inclui dependentes).' },
|
||||||
|
|
||||||
|
// ---- Chat / Conversas ----
|
||||||
|
'POST /api/{alias}/conversations/create': {
|
||||||
|
desc: 'Cria uma conversa manualmente (admin).',
|
||||||
|
body: { empresaId: 1, numero: '5575988447843', nomeContato: 'Fulano', mensagem: 'Olá', instanciaId: 1, clienteId: null },
|
||||||
|
req: ['numero'],
|
||||||
|
},
|
||||||
|
'GET /api/{alias}/conversations': {
|
||||||
|
desc: 'Lista conversas com filtros.',
|
||||||
|
q: [['empresaId', false, 'Empresa (padrão: a do usuário)'], ['status', false, "Status: A,E,F (padrão 'A,E')"], ['filter', false, "'mine' | 'unassigned' | 'all'"]],
|
||||||
|
},
|
||||||
|
'GET /api/{alias}/conversations/{id}': { desc: 'Detalhes de uma conversa (inclui dados do cliente).' },
|
||||||
|
'GET /api/{alias}/conversations/{id}/messages': {
|
||||||
|
desc: 'Mensagens de uma conversa (paginado).',
|
||||||
|
q: [['page', false, 'Página (padrão 1)'], ['limit', false, 'Itens por página (padrão 50)']],
|
||||||
|
},
|
||||||
|
'POST /api/{alias}/conversations/{id}/messages': {
|
||||||
|
desc: 'Envia uma mensagem (texto ou mídia) na conversa.',
|
||||||
|
body: { mensagem: 'Olá, tudo bem?', tipo: 'text', privada: 'N', nomeArquivo: null, midiaBase64: null },
|
||||||
|
req: ['mensagem'],
|
||||||
|
note: "tipo: text|image|audio|video|document. Para mídia, envie midiaBase64 + nomeArquivo. privada 'S' = nota interna.",
|
||||||
|
},
|
||||||
|
'POST /api/{alias}/conversations/{id}/finalize': { desc: 'Finaliza (encerra) a conversa.' },
|
||||||
|
'POST /api/{alias}/conversations/{id}/assign': {
|
||||||
|
desc: 'Atribui a conversa a um atendente.',
|
||||||
|
body: { usuarioId: 2 }, req: ['usuarioId'],
|
||||||
|
},
|
||||||
|
'POST /api/{alias}/conversations/{id}/assign-team': {
|
||||||
|
desc: 'Atribui a conversa a uma equipe.',
|
||||||
|
body: { equipeId: 1 }, req: ['equipeId'],
|
||||||
|
},
|
||||||
|
'POST /api/{alias}/conversations/{id}/labels': {
|
||||||
|
desc: 'Adiciona ou remove uma etiqueta da conversa.',
|
||||||
|
body: { etiquetaId: 3, ativar: true }, req: ['etiquetaId'],
|
||||||
|
note: 'ativar: true adiciona, false remove.',
|
||||||
|
},
|
||||||
|
'POST /api/{alias}/conversations/{id}/link-client': {
|
||||||
|
desc: 'Associa o contato da conversa a um cliente do cadastro.',
|
||||||
|
body: { clienteId: 1234 }, req: ['clienteId'],
|
||||||
|
},
|
||||||
|
'GET /api/{alias}/media/{mediaId}': { desc: 'Download do binário de uma mídia (imagem/áudio/documento). Retorna o arquivo, não JSON.' },
|
||||||
|
'POST /api/{alias}/csat/avaliar': {
|
||||||
|
desc: 'Registra a avaliação de satisfação (CSAT) — formulário público.',
|
||||||
|
body: { conversaId: 10, empresaId: 1, nota: 5, comentario: 'Ótimo atendimento' },
|
||||||
|
req: ['conversaId', 'nota'],
|
||||||
|
note: 'nota: 1 a 5.',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- Configurações ----
|
||||||
|
'GET /api/{alias}/teams': {
|
||||||
|
desc: 'Lista as equipes da empresa.',
|
||||||
|
q: [['empresaId', false, 'Empresa (padrão: a do usuário)']],
|
||||||
|
},
|
||||||
|
'POST /api/{alias}/teams': {
|
||||||
|
desc: 'Cria uma equipe.',
|
||||||
|
body: { nome: 'Financeiro', ordem: 1, membros: [2, 5] },
|
||||||
|
req: ['nome'],
|
||||||
|
note: 'membros: array de IDs de usuários (opcional).',
|
||||||
|
},
|
||||||
|
'PUT /api/{alias}/teams/{id}': {
|
||||||
|
desc: 'Atualiza uma equipe.',
|
||||||
|
body: { nome: 'Financeiro', ordem: 2, membros: [2, 5] },
|
||||||
|
},
|
||||||
|
'DELETE /api/{alias}/teams/{id}': { desc: 'Exclui uma equipe.' },
|
||||||
|
'GET /api/{alias}/company/users': { desc: 'Usuários da empresa (para montar equipes).' },
|
||||||
|
'PUT /api/{alias}/users/{id}/chat-type': {
|
||||||
|
desc: 'Altera o tipo de chat do usuário (A=Atendente, G=Gerente).',
|
||||||
|
body: { tipoChat: 'G' }, req: ['tipoChat'],
|
||||||
|
},
|
||||||
|
'GET /api/{alias}/labels': {
|
||||||
|
desc: 'Lista as etiquetas da empresa.',
|
||||||
|
q: [['empresaId', false, 'Empresa (padrão: a do usuário)']],
|
||||||
|
},
|
||||||
|
'POST /api/{alias}/labels': {
|
||||||
|
desc: 'Cria uma etiqueta.',
|
||||||
|
body: { nome: 'Urgente', cor: '#dc2626' }, req: ['nome'],
|
||||||
|
},
|
||||||
|
'PUT /api/{alias}/labels/{id}': {
|
||||||
|
desc: 'Atualiza uma etiqueta.',
|
||||||
|
body: { nome: 'Urgente', cor: '#dc2626' },
|
||||||
|
},
|
||||||
|
'DELETE /api/{alias}/labels/{id}': { desc: 'Exclui uma etiqueta.' },
|
||||||
|
'GET /api/{alias}/company/config': {
|
||||||
|
desc: 'Configurações da empresa (saudação, triagem, CSAT, foto...).',
|
||||||
|
q: [['empresaId', false, 'Empresa (padrão: a do usuário)']],
|
||||||
|
},
|
||||||
|
'POST /api/{alias}/company/config': {
|
||||||
|
desc: 'Salva as configurações da empresa.',
|
||||||
|
body: { empresaId: 1, saudacao: 'Bem-vindo!', triagemAtiva: 'S', csatAtivo: 'S', instanciaPadraoId: 1 },
|
||||||
|
note: 'Envie os campos de configuração que deseja salvar.',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- WhatsApp / Evolution ----
|
||||||
|
'GET /api/{alias}/evolution/instances': {
|
||||||
|
desc: 'Lista as instâncias WhatsApp (Evolution API).',
|
||||||
|
q: [['empresaId', false, 'Empresa (padrão: a do usuário)']],
|
||||||
|
},
|
||||||
|
'POST /api/{alias}/evolution/connect': {
|
||||||
|
desc: 'Cria/conecta uma instância WhatsApp na Evolution API.',
|
||||||
|
body: { INS_NOME: 'Atendimento', INS_URL: 'https://evo.exemplo.com', INS_API_KEY: 'sua-api-key', INS_INSTANCE_NAME: 'atendimento1', INS_EMPRESA_ID: 1 },
|
||||||
|
req: ['INS_NOME', 'INS_URL', 'INS_API_KEY', 'INS_INSTANCE_NAME'],
|
||||||
|
},
|
||||||
|
'PUT /api/{alias}/evolution/instances/{id}': {
|
||||||
|
desc: 'Atualiza uma instância.',
|
||||||
|
body: { INS_NOME: 'Atendimento', INS_URL: 'https://evo.exemplo.com', INS_API_KEY: 'sua-api-key', INS_INSTANCE_NAME: 'atendimento1' },
|
||||||
|
},
|
||||||
|
'DELETE /api/{alias}/evolution/instances/{id}': { desc: 'Exclui uma instância.' },
|
||||||
|
'POST /api/{alias}/evolution/qrcode/{id}': { desc: 'Gera o QR Code para parear o WhatsApp da instância.' },
|
||||||
|
'POST /api/{alias}/evolution/refresh-photo/{conversaId}': { desc: 'Atualiza a foto de perfil do contato da conversa.' },
|
||||||
|
'GET /api/{alias}/webhook/ping': { desc: 'Health check do webhook.' },
|
||||||
|
'POST /api/{alias}/webhook/evolution': {
|
||||||
|
desc: 'Recebe eventos da Evolution API (mensagens, status). Chamado pela Evolution, não manualmente.',
|
||||||
|
note: 'O alias também pode ser omitido (POST /api/webhook/evolution) — é detectado pelo nome da instância.',
|
||||||
|
},
|
||||||
|
'POST /api/webhook/evolution': { desc: 'Webhook Evolution sem alias na URL (alias detectado pela instância).' },
|
||||||
|
|
||||||
|
// ---- Fluxo (Menus) ----
|
||||||
|
'GET /api/{alias}/menus': {
|
||||||
|
desc: 'Menus de atendimento (árvore) por equipe.',
|
||||||
|
q: [['empresaId', false, 'Empresa (padrão: a do usuário)']],
|
||||||
|
},
|
||||||
|
'GET /api/{alias}/menus/flat': { desc: 'Menus em formato plano (lista).' },
|
||||||
|
'POST /api/{alias}/menus': {
|
||||||
|
desc: 'Cria um item de menu/submenu do fluxo.',
|
||||||
|
body: { empresaId: 1, equipeId: 1, titulo: 'Financeiro', tipo: 'M', texto: null, acaoRota: null, acaoMetodo: null, acaoPrompt: null, menuPaiId: null, ordem: 0, etiquetaIds: '3,5' },
|
||||||
|
req: ['titulo'],
|
||||||
|
note: "tipo: M=submenu, T=texto, R=rota. etiquetaIds: IDs separados por vírgula.",
|
||||||
|
},
|
||||||
|
'PUT /api/{alias}/menus/{id}': {
|
||||||
|
desc: 'Atualiza um item de menu.',
|
||||||
|
body: { titulo: 'Financeiro', tipo: 'M', texto: null, acaoRota: null, ordem: 0, etiquetaIds: '3,5' },
|
||||||
|
},
|
||||||
|
'DELETE /api/{alias}/menus/{id}': { desc: 'Exclui um item de menu.' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static categorizar(path) {
|
||||||
|
if (path.includes('/webhook/') || path.includes('/evolution/')) return '📱 WhatsApp / Evolution API';
|
||||||
|
if (path.includes('/csat')) return '⭐ CSAT';
|
||||||
|
if (path.includes('/media/')) return '📁 Mídia';
|
||||||
|
if (path.includes('/aliases') || path.includes('/health') || path.includes('/tables') || path.includes('/query') || path.includes('/execute') || path.includes('/databases')) return '🗄️ Banco de Dados';
|
||||||
|
if (path.includes('/clients') || path.includes('/dependents') || path.includes('/empresa/logo') || path.includes('/empresas') || path.includes('/company/')) return '👥 Clientes';
|
||||||
|
if (path.includes('/conversations') || path.includes('/conversation')) return '💬 Chat / Conversas';
|
||||||
|
if (path.includes('/teams') || path.includes('/users') || path.includes('/labels') || path.includes('/company/config') || path.includes('/config')) return '⚙️ Configurações';
|
||||||
|
if (path.includes('/menus')) return '📋 Fluxo';
|
||||||
|
if (path.includes('/routes')) return '📡 Rotas';
|
||||||
|
if (path.includes('/login') || path.includes('/dashboard') || path.includes('/me')) return '🔐 Autenticação';
|
||||||
|
return '📦 Outros';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Marca rotas que servem páginas HTML (não retornam JSON). */
|
||||||
|
static ehPagina(method, path) {
|
||||||
|
if (method !== 'GET' || path.indexOf('/app/') !== 0) return false;
|
||||||
|
return !path.endsWith('/me');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// GET /api/routes
|
||||||
|
// ============================================================
|
||||||
|
static async list(req, res) {
|
||||||
|
try {
|
||||||
|
var rotas = RoutesController.listarRotas();
|
||||||
|
var cat = RoutesController.catalogo();
|
||||||
|
|
||||||
|
rotas.forEach(function (r) {
|
||||||
|
r.path = RoutesController.normalizar(r.path);
|
||||||
|
var key = r.method + ' ' + r.path;
|
||||||
|
var meta = cat[key] || {};
|
||||||
|
r.desc = meta.desc || '';
|
||||||
|
r.note = meta.note || '';
|
||||||
|
r.cat = RoutesController.categorizar(r.path);
|
||||||
|
r.auth = RoutesController.requerAuth(r.method, r.path);
|
||||||
|
r.pagina = RoutesController.ehPagina(r.method, r.path);
|
||||||
|
r.params = RoutesController.paramsDePath(r.path);
|
||||||
|
r.query = (meta.q || []).map(function (x) {
|
||||||
|
return { nome: x[0], obrigatorio: !!x[1], desc: x[2] || '' };
|
||||||
|
});
|
||||||
|
r.body = meta.body || null;
|
||||||
|
r.bodyReq = meta.req || [];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove duplicatas por method+path (após normalizar)
|
||||||
|
var vistos = {};
|
||||||
|
rotas = rotas.filter(function (r) {
|
||||||
|
var k = r.method + ' ' + r.path;
|
||||||
|
if (vistos[k]) return false;
|
||||||
|
vistos[k] = true;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
var metodoOrder = { GET: 1, POST: 2, PUT: 3, DELETE: 4, PATCH: 5 };
|
||||||
|
rotas.sort(function (a, b) {
|
||||||
|
var mA = metodoOrder[a.method] || 99;
|
||||||
|
var mB = metodoOrder[b.method] || 99;
|
||||||
|
if (mA !== mB) return mA - mB;
|
||||||
|
return a.path.localeCompare(b.path);
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, data: rotas, total: rotas.length });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = RoutesController;
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
const { execFile, spawnSync } = require('child_process');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
|
|
||||||
|
function logTranscricao(msg) {
|
||||||
|
try {
|
||||||
|
var logDir = path.join(__dirname, '../../logs');
|
||||||
|
if (!fs.existsSync(logDir)) fs.mkdirSync(logDir, { recursive: true });
|
||||||
|
fs.appendFileSync(path.join(logDir, 'transcricao_' + new Date().toISOString().slice(0,10) + '.log'),
|
||||||
|
'[' + new Date().toISOString() + '] ' + msg + '\n', 'utf8');
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const WHISPER_DIR = path.resolve(__dirname, '../../whisper');
|
||||||
|
const MODEL_PATH = path.resolve(WHISPER_DIR, 'models/ggml-tiny.bin');
|
||||||
|
|
||||||
|
// Detecta binario correto por plataforma
|
||||||
|
var WHISPER_EXE;
|
||||||
|
var isWindows = os.platform() === 'win32';
|
||||||
|
if (isWindows) {
|
||||||
|
WHISPER_EXE = path.resolve(WHISPER_DIR, 'main.exe');
|
||||||
|
} else {
|
||||||
|
// Linux/Mac - tenta 'main' primeiro, fallback para 'main.exe' (via Wine)
|
||||||
|
WHISPER_EXE = path.resolve(WHISPER_DIR, 'main');
|
||||||
|
if (!fs.existsSync(WHISPER_EXE)) {
|
||||||
|
WHISPER_EXE = path.resolve(WHISPER_DIR, 'main.exe');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detecta ffmpeg
|
||||||
|
var ffmpegPath = 'ffmpeg'; // default: espera no PATH
|
||||||
|
if (isWindows) {
|
||||||
|
var winPaths = [
|
||||||
|
path.resolve('node_modules/ffmpeg-static/ffmpeg.exe'),
|
||||||
|
'C:/Users/Suporte 03/AppData/Local/Microsoft/WinGet/Packages/Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe/ffmpeg-8.1.1-full_build/bin/ffmpeg.exe',
|
||||||
|
];
|
||||||
|
for (var wp of winPaths) {
|
||||||
|
try { fs.accessSync(wp); ffmpegPath = wp; break; } catch(e) {}
|
||||||
|
}
|
||||||
|
if (ffmpegPath === 'ffmpeg') {
|
||||||
|
try { ffmpegPath = require('ffmpeg-static'); } catch(e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transcreve um buffer de audio usando Whisper.cpp
|
||||||
|
* @param {Buffer} audioBuffer - Buffer do audio (qualquer formato suportado pelo ffmpeg)
|
||||||
|
* @param {string} [idioma='pt'] - Codigo do idioma (pt, en, etc)
|
||||||
|
* @returns {Promise<{texto: string, duracao: number}>}
|
||||||
|
*/
|
||||||
|
function transcreverAudio(audioBuffer, idioma = 'pt') {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
logTranscricao('transcreverAudio iniciado - tamanho: ' + (audioBuffer ? audioBuffer.length : 0));
|
||||||
|
|
||||||
|
if (!audioBuffer || audioBuffer.length < 100) {
|
||||||
|
logTranscricao('ERRO: Audio muito curto ou vazio');
|
||||||
|
return reject(new Error('Audio muito curto ou vazio'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(WHISPER_EXE)) {
|
||||||
|
logTranscricao('ERRO: Whisper.exe nao encontrado em: ' + WHISPER_EXE);
|
||||||
|
return reject(new Error('Whisper.cpp nao encontrado em: ' + WHISPER_EXE));
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(MODEL_PATH)) {
|
||||||
|
logTranscricao('ERRO: Modelo nao encontrado em: ' + MODEL_PATH);
|
||||||
|
return reject(new Error('Modelo nao encontrado em: ' + MODEL_PATH));
|
||||||
|
}
|
||||||
|
logTranscricao('Arquivos OK, convertendo audio...');
|
||||||
|
|
||||||
|
// Salva audio temporario em WAV (formato que whisper.cpp prefere)
|
||||||
|
var tmpDir = path.resolve(__dirname, '../../tmp');
|
||||||
|
if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
|
||||||
|
|
||||||
|
var inputWav = path.join(tmpDir, 'whisper_input_' + Date.now() + '.wav');
|
||||||
|
var outputFile = path.join(tmpDir, 'whisper_output_' + Date.now());
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Converte para WAV 16kHz mono usando ffmpeg
|
||||||
|
logTranscricao('Usando ffmpeg: ' + ffmpegPath);
|
||||||
|
|
||||||
|
var inputBufferPath = path.join(tmpDir, 'whisper_raw_' + Date.now() + '.ogg');
|
||||||
|
fs.writeFileSync(inputBufferPath, audioBuffer);
|
||||||
|
|
||||||
|
// Salva copia para debug
|
||||||
|
var debugPath = path.join(tmpDir, 'debug_audio_' + Date.now() + '.ogg');
|
||||||
|
fs.writeFileSync(debugPath, audioBuffer);
|
||||||
|
|
||||||
|
// Tenta converter com ffmpeg; se falhar com .ogg, tenta como .opus
|
||||||
|
var ffmpegResult = spawnSync(ffmpegPath, [
|
||||||
|
'-y', '-i', inputBufferPath,
|
||||||
|
'-ar', '16000', '-ac', '1', '-c:a', 'pcm_s16le',
|
||||||
|
inputWav
|
||||||
|
], { stdio: 'pipe', timeout: 30000 });
|
||||||
|
|
||||||
|
// Se falhou, tenta extensão .opus (WhatsApp usa opus em container ogg)
|
||||||
|
if (ffmpegResult.status !== 0) {
|
||||||
|
var inputOpus = path.join(tmpDir, 'whisper_raw_' + Date.now() + '.opus');
|
||||||
|
fs.writeFileSync(inputOpus, audioBuffer);
|
||||||
|
try { fs.unlinkSync(inputBufferPath); } catch(e) {}
|
||||||
|
inputBufferPath = inputOpus;
|
||||||
|
ffmpegResult = spawnSync(ffmpegPath, [
|
||||||
|
'-y', '-f', 'ogg', '-i', inputBufferPath,
|
||||||
|
'-ar', '16000', '-ac', '1', '-c:a', 'pcm_s16le',
|
||||||
|
inputWav
|
||||||
|
], { stdio: 'pipe', timeout: 30000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limpa buffer temporario
|
||||||
|
try { fs.unlinkSync(inputBufferPath); } catch(e) {}
|
||||||
|
|
||||||
|
if (ffmpegResult.status !== 0) {
|
||||||
|
var stderrMsg = (ffmpegResult.stderr || '').toString();
|
||||||
|
var errLines = stderrMsg.split('\n').filter(function(l) { return l.toLowerCase().includes('error') || l.includes('Error'); });
|
||||||
|
logTranscricao('ERRO ffmpeg: status=' + ffmpegResult.status + ' | erros: ' + (errLines.join(' | ') || stderrMsg.substring(0, 200)));
|
||||||
|
logTranscricao('Audio debug salvo em: ' + debugPath);
|
||||||
|
try { fs.unlinkSync(inputWav); } catch(e) {}
|
||||||
|
return reject(new Error('Erro ao converter audio: ' + (errLines.join(';') || stderrMsg.substring(0, 200))));
|
||||||
|
}
|
||||||
|
try { fs.unlinkSync(debugPath); } catch(e) {}
|
||||||
|
logTranscricao('Audio convertido para WAV com sucesso: ' + inputWav);
|
||||||
|
|
||||||
|
// Executa whisper.cpp
|
||||||
|
var whisperArgs = [
|
||||||
|
'-m', MODEL_PATH,
|
||||||
|
'-f', inputWav,
|
||||||
|
'-l', idioma,
|
||||||
|
'-otxt', // Saida em arquivo TXT
|
||||||
|
'-of', outputFile,
|
||||||
|
'--no-prints', // Menos output no console
|
||||||
|
];
|
||||||
|
|
||||||
|
execFile(WHISPER_EXE, whisperArgs, { timeout: 120000 }, function(err, stdout, stderr) {
|
||||||
|
logTranscricao('Whisper executado - err: ' + (err ? err.message : 'null') + ' | stdout tamanho: ' + (stdout||'').length);
|
||||||
|
try { fs.unlinkSync(inputWav); } catch(e) {}
|
||||||
|
|
||||||
|
var transcricao = '';
|
||||||
|
var txtPath = outputFile + '.txt';
|
||||||
|
|
||||||
|
if (fs.existsSync(txtPath)) {
|
||||||
|
try {
|
||||||
|
transcricao = fs.readFileSync(txtPath, 'utf8').trim();
|
||||||
|
logTranscricao('Arquivo saida: ' + transcricao.substring(0, 200));
|
||||||
|
fs.unlinkSync(txtPath);
|
||||||
|
} catch(e) { logTranscricao('Erro ler arquivo: ' + (e.message||'')); }
|
||||||
|
} else {
|
||||||
|
logTranscricao('Arquivo nao encontrado: ' + txtPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!transcricao && stdout) {
|
||||||
|
var linhas = stdout.split('\n').filter(function(l) { return l.includes(']'); });
|
||||||
|
transcricao = linhas.map(function(l) {
|
||||||
|
var p = l.split(']');
|
||||||
|
return p.length > 1 ? p[1].trim() : '';
|
||||||
|
}).filter(Boolean).join(' ');
|
||||||
|
logTranscricao('Fallback stdout: ' + transcricao.substring(0, 200));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!transcricao) {
|
||||||
|
transcricao = '(transcricao nao disponivel)';
|
||||||
|
logTranscricao('Transcricao vazia');
|
||||||
|
}
|
||||||
|
|
||||||
|
var duracao = Math.round((audioBuffer.length / 16000 / 2) * 100) / 100;
|
||||||
|
logTranscricao('Resultado: texto=' + transcricao.substring(0, 100) + ' duracao=' + duracao);
|
||||||
|
|
||||||
|
resolve({ texto: transcricao, duracao: duracao });
|
||||||
|
});
|
||||||
|
} catch(e) {
|
||||||
|
// Limpa arquivos em caso de erro
|
||||||
|
try { fs.unlinkSync(inputWav); } catch(ex) {}
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { transcreverAudio };
|
||||||
File diff suppressed because it is too large
Load Diff
+682
@@ -0,0 +1,682 @@
|
|||||||
|
/**
|
||||||
|
* CAMADA DE BANCO DE DADOS (arquivo único)
|
||||||
|
* ========================================
|
||||||
|
* Concentra tudo que diz respeito a banco de dados em UM lugar:
|
||||||
|
*
|
||||||
|
* 1) CONFIGURAÇÃO — aliases, drivers, conexões estáticas e customizadas
|
||||||
|
* 2) DRIVER FIREBIRD
|
||||||
|
* 3) DRIVER POSTGRES (pool + tradutor de SQL + schema)
|
||||||
|
* 4) DISPATCHER — API pública usada por todo o sistema
|
||||||
|
*
|
||||||
|
* Suporta múltiplos drivers ('postgres' padrão, 'firebird' legado). Cada
|
||||||
|
* conexão declara o campo `driver`. Conexões adicionadas em runtime (via API
|
||||||
|
* /api/databases) são persistidas em databases_custom.json (ao lado deste
|
||||||
|
* arquivo) — é um arquivo de DADOS, não de código.
|
||||||
|
*/
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 1) CONFIGURAÇÃO
|
||||||
|
// ============================================================
|
||||||
|
const CUSTOM_DB_PATH = path.resolve(__dirname, 'databases_custom.json');
|
||||||
|
|
||||||
|
// Conexões dinâmicas salvas em arquivo JSON
|
||||||
|
var customDatabases = {};
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(CUSTOM_DB_PATH)) {
|
||||||
|
customDatabases = JSON.parse(fs.readFileSync(CUSTOM_DB_PATH, 'utf8'));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Databases] Erro ao carregar databases_custom.json:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DRIVERS = ['postgres', 'firebird'];
|
||||||
|
const DEFAULT_DRIVER = (process.env.DB_DRIVER || 'postgres').toLowerCase();
|
||||||
|
|
||||||
|
// Valores padrão por driver
|
||||||
|
const DRIVER_DEFAULTS = {
|
||||||
|
postgres: {
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 5432,
|
||||||
|
user: 'postgres',
|
||||||
|
password: 'postgres',
|
||||||
|
schema: 'public',
|
||||||
|
ssl: false,
|
||||||
|
max: 10,
|
||||||
|
idleTimeoutMillis: 30000,
|
||||||
|
connectionTimeoutMillis: 10000,
|
||||||
|
},
|
||||||
|
firebird: {
|
||||||
|
host: 'localhost',
|
||||||
|
port: 3050,
|
||||||
|
user: 'SYSDBA',
|
||||||
|
password: 'masterkey',
|
||||||
|
encoding: 'UTF-8',
|
||||||
|
lowercase_keys: false,
|
||||||
|
pageSize: 4096,
|
||||||
|
wireCrypt: 1, // WIRE_CRYPT_ENABLE
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conexões estáticas.
|
||||||
|
* - `novo_local` → PostgreSQL (conexão principal)
|
||||||
|
* - `firebird_local`→ Firebird (informe o CAMINHO do arquivo .FDB em `database`)
|
||||||
|
* Sobrescrevíveis pelo .env (PG_* para Postgres, DB_* para Firebird).
|
||||||
|
*/
|
||||||
|
const databases = {
|
||||||
|
novo_local: {
|
||||||
|
driver: 'postgres',
|
||||||
|
host: process.env.PG_HOST || '127.0.0.1',
|
||||||
|
port: parseInt(process.env.PG_PORT, 10) || 15433,
|
||||||
|
user: process.env.PG_USER || 'postgres',
|
||||||
|
password: process.env.PG_PASSWORD || 'postgres',
|
||||||
|
database: process.env.PG_DATABASE || 'novo_local',
|
||||||
|
schema: process.env.PG_SCHEMA || 'public',
|
||||||
|
},
|
||||||
|
|
||||||
|
firebird_local: {
|
||||||
|
driver: 'firebird',
|
||||||
|
host: process.env.DB_HOST || 'localhost',
|
||||||
|
port: parseInt(process.env.DB_PORT, 10) || 3050,
|
||||||
|
// CAMINHO do arquivo .FDB: defina DB_DATABASE no .env (absoluto) ou ajuste
|
||||||
|
// o path.resolve abaixo. Ex.: path.resolve(__dirname, '../db/NOVO.FDB').
|
||||||
|
database: process.env.DB_DATABASE || path.resolve(__dirname, '../NOVO.FDB'),
|
||||||
|
user: process.env.DB_USER || 'SYSDBA',
|
||||||
|
password: process.env.DB_PASSWORD || 'masterkey',
|
||||||
|
encoding: process.env.DB_ENCODING || 'UTF-8',
|
||||||
|
lowercase_keys: false,
|
||||||
|
pageSize: 4096,
|
||||||
|
wireCrypt: 1, // WIRE_CRYPT_ENABLE
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function salvarCustomDatabases() {
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(CUSTOM_DB_PATH, JSON.stringify(customDatabases, null, 2), 'utf8');
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Databases] Erro ao salvar databases_custom.json:', e.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normaliza uma config bruta aplicando o driver e seus defaults. */
|
||||||
|
function normalize(raw) {
|
||||||
|
const driver = DRIVERS.includes((raw.driver || '').toLowerCase())
|
||||||
|
? raw.driver.toLowerCase()
|
||||||
|
: DEFAULT_DRIVER;
|
||||||
|
const d = DRIVER_DEFAULTS[driver];
|
||||||
|
|
||||||
|
if (driver === 'postgres') {
|
||||||
|
return {
|
||||||
|
driver,
|
||||||
|
host: raw.host || d.host,
|
||||||
|
port: raw.port || d.port,
|
||||||
|
database: raw.database,
|
||||||
|
user: raw.user || d.user,
|
||||||
|
password: raw.password || d.password,
|
||||||
|
schema: (raw.schema || d.schema || 'public'),
|
||||||
|
ssl: raw.ssl !== undefined ? raw.ssl : d.ssl,
|
||||||
|
max: raw.max || d.max,
|
||||||
|
idleTimeoutMillis: raw.idleTimeoutMillis || d.idleTimeoutMillis,
|
||||||
|
connectionTimeoutMillis: raw.connectionTimeoutMillis || d.connectionTimeoutMillis,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// firebird
|
||||||
|
return {
|
||||||
|
driver,
|
||||||
|
host: raw.host || d.host,
|
||||||
|
port: raw.port || d.port,
|
||||||
|
database: raw.database,
|
||||||
|
user: raw.user || d.user,
|
||||||
|
password: raw.password || d.password,
|
||||||
|
encoding: raw.encoding || d.encoding,
|
||||||
|
lowercase_keys: raw.lowercase_keys || d.lowercase_keys,
|
||||||
|
role: raw.role || null,
|
||||||
|
pageSize: raw.pageSize || d.pageSize,
|
||||||
|
wireCrypt: raw.wireCrypt !== undefined ? raw.wireCrypt : d.wireCrypt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Configuração normalizada (inclui `driver`) de um alias. */
|
||||||
|
function getConfig(alias) {
|
||||||
|
if (!alias) throw new Error('Alias do banco não informado.');
|
||||||
|
const aliasLower = alias.toLowerCase();
|
||||||
|
const raw = customDatabases[aliasLower] || databases[aliasLower];
|
||||||
|
if (!raw) {
|
||||||
|
throw new Error(`Alias "${alias}" não encontrado. Aliases disponíveis: ${listAliases().join(', ')}`);
|
||||||
|
}
|
||||||
|
return normalize(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lista de aliases disponíveis. */
|
||||||
|
function listAliases() {
|
||||||
|
return Object.keys(databases).concat(
|
||||||
|
Object.keys(customDatabases).filter((a) => !databases[a])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Todos os aliases com suas configs (custom + estáticas). */
|
||||||
|
function listAllDatabases() {
|
||||||
|
var result = {};
|
||||||
|
Object.keys(databases).forEach(function (alias) {
|
||||||
|
result[alias] = Object.assign({}, databases[alias], { _tipo: 'estatico' });
|
||||||
|
});
|
||||||
|
Object.keys(customDatabases).forEach(function (alias) {
|
||||||
|
result[alias] = Object.assign({}, customDatabases[alias], { _tipo: 'custom' });
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adiciona/atualiza uma conexão customizada. */
|
||||||
|
function addDatabase(alias, config) {
|
||||||
|
if (!alias || !config || !config.database) {
|
||||||
|
throw new Error('Alias e database são obrigatórios.');
|
||||||
|
}
|
||||||
|
var aliasLower = alias.toLowerCase().replace(/[^a-z0-9_]/g, '_');
|
||||||
|
var driver = DRIVERS.includes((config.driver || '').toLowerCase())
|
||||||
|
? config.driver.toLowerCase()
|
||||||
|
: DEFAULT_DRIVER;
|
||||||
|
|
||||||
|
var stored = { driver: driver };
|
||||||
|
['host', 'port', 'database', 'schema', 'user', 'password', 'ssl',
|
||||||
|
'encoding', 'lowercase_keys', 'role', 'pageSize', 'wireCrypt',
|
||||||
|
'max', 'idleTimeoutMillis', 'connectionTimeoutMillis']
|
||||||
|
.forEach(function (k) {
|
||||||
|
if (config[k] !== undefined && config[k] !== null && config[k] !== '') {
|
||||||
|
stored[k] = config[k];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
customDatabases[aliasLower] = stored;
|
||||||
|
return salvarCustomDatabases();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove uma conexão customizada. */
|
||||||
|
function removeDatabase(alias) {
|
||||||
|
if (!alias) return false;
|
||||||
|
var aliasLower = alias.toLowerCase();
|
||||||
|
if (!customDatabases[aliasLower]) return false;
|
||||||
|
delete customDatabases[aliasLower];
|
||||||
|
return salvarCustomDatabases();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 2) DRIVER FIREBIRD
|
||||||
|
// ============================================================
|
||||||
|
const firebirdDriver = (() => {
|
||||||
|
const Firebird = require('node-firebird');
|
||||||
|
|
||||||
|
function readBlob(blobFunc) {
|
||||||
|
return new Promise(function (resolve, reject) {
|
||||||
|
if (typeof blobFunc !== 'function') return resolve(blobFunc);
|
||||||
|
blobFunc(function (err, name, emitter) {
|
||||||
|
if (err) return reject(err);
|
||||||
|
if (!emitter || typeof emitter.on !== 'function') return resolve(null);
|
||||||
|
var chunks = [];
|
||||||
|
var total = 0;
|
||||||
|
emitter.on('data', function (c) { chunks.push(c); total += c.length; });
|
||||||
|
emitter.on('end', function () { resolve(Buffer.concat(chunks, total)); });
|
||||||
|
emitter.on('error', reject);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function query(config, sql, params = []) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
Firebird.attach(config, (err, db) => {
|
||||||
|
if (err) return reject(new Error(`Erro ao conectar (firebird): ${err.message}`));
|
||||||
|
var allRows = [];
|
||||||
|
db.sequentially(sql, params, function (row) {
|
||||||
|
var keys = Object.keys(row);
|
||||||
|
var blobPromises = keys.map(function (key) {
|
||||||
|
var val = row[key];
|
||||||
|
if (typeof val === 'function') {
|
||||||
|
return readBlob(val).then(function (data) { row[key] = data; });
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
});
|
||||||
|
return Promise.all(blobPromises).then(function () { allRows.push(row); });
|
||||||
|
}, function (queryErr) {
|
||||||
|
db.detach();
|
||||||
|
if (queryErr) return reject(new Error(`Erro na consulta (firebird): ${queryErr.message}`));
|
||||||
|
resolve(allRows);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function execute(config, sql, params = []) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
Firebird.attach(config, (err, db) => {
|
||||||
|
if (err) return reject(new Error(`Erro ao conectar (firebird): ${err.message}`));
|
||||||
|
db.transaction(Firebird.ISOLATION_READ_COMMITTED, (transErr, transaction) => {
|
||||||
|
if (transErr) {
|
||||||
|
db.detach();
|
||||||
|
return reject(new Error(`Erro ao iniciar transação (firebird): ${transErr.message}`));
|
||||||
|
}
|
||||||
|
transaction.query(sql, params, (queryErr, result) => {
|
||||||
|
if (queryErr) {
|
||||||
|
transaction.rollback();
|
||||||
|
db.detach();
|
||||||
|
return reject(new Error(`Erro na execução (firebird): ${queryErr.message}`));
|
||||||
|
}
|
||||||
|
transaction.commit((commitErr) => {
|
||||||
|
db.detach();
|
||||||
|
if (commitErr) return reject(new Error(`Erro ao commitar (firebird): ${commitErr.message}`));
|
||||||
|
resolve({ affectedRows: result ? result.length : 0, result });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testConnection(config) {
|
||||||
|
await query(config, 'SELECT 1 FROM RDB$DATABASE');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listTables(config) {
|
||||||
|
const rows = await query(config, `
|
||||||
|
SELECT TRIM(RDB$RELATION_NAME) AS TABLE_NAME
|
||||||
|
FROM RDB$RELATIONS
|
||||||
|
WHERE RDB$SYSTEM_FLAG = 0 AND RDB$RELATION_TYPE = 0
|
||||||
|
ORDER BY RDB$RELATION_NAME
|
||||||
|
`);
|
||||||
|
return rows.map((r) => (r.TABLE_NAME || '').trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tableInfo(config, tableName) {
|
||||||
|
const rows = await query(config, `
|
||||||
|
SELECT
|
||||||
|
rf.RDB$FIELD_NAME AS COLUMN_NAME,
|
||||||
|
rf.RDB$FIELD_POSITION AS ORDINAL_POSITION,
|
||||||
|
CASE f.RDB$FIELD_TYPE
|
||||||
|
WHEN 7 THEN 'SMALLINT' WHEN 8 THEN 'INTEGER' WHEN 16 THEN 'BIGINT'
|
||||||
|
WHEN 9 THEN 'QUAD' WHEN 10 THEN 'FLOAT' WHEN 27 THEN 'DOUBLE PRECISION'
|
||||||
|
WHEN 12 THEN 'DATE' WHEN 13 THEN 'TIME' WHEN 35 THEN 'TIMESTAMP'
|
||||||
|
WHEN 37 THEN 'VARCHAR' WHEN 40 THEN 'CSTRING' WHEN 45 THEN 'BLOB_ID'
|
||||||
|
WHEN 261 THEN 'BLOB' WHEN 14 THEN 'CHAR' WHEN 41 THEN 'NUMERIC'
|
||||||
|
ELSE 'UNKNOWN'
|
||||||
|
END AS DATA_TYPE,
|
||||||
|
f.RDB$FIELD_LENGTH AS FIELD_LENGTH,
|
||||||
|
f.RDB$FIELD_SCALE AS FIELD_SCALE,
|
||||||
|
f.RDB$FIELD_PRECISION AS FIELD_PRECISION,
|
||||||
|
rf.RDB$NULL_FLAG AS NULL_FLAG
|
||||||
|
FROM RDB$RELATION_FIELDS rf
|
||||||
|
INNER JOIN RDB$FIELDS f ON rf.RDB$FIELD_SOURCE = f.RDB$FIELD_NAME
|
||||||
|
WHERE rf.RDB$RELATION_NAME = ?
|
||||||
|
ORDER BY rf.RDB$FIELD_POSITION
|
||||||
|
`, [String(tableName).toUpperCase()]);
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
name: (row.COLUMN_NAME || '').trim(),
|
||||||
|
position: row.ORDINAL_POSITION,
|
||||||
|
type: (row.DATA_TYPE || '').trim(),
|
||||||
|
length: row.FIELD_LENGTH,
|
||||||
|
precision: row.FIELD_PRECISION,
|
||||||
|
scale: row.FIELD_SCALE,
|
||||||
|
nullable: row.NULL_FLAG !== 1,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tableExists(config, tableName) {
|
||||||
|
const r = await query(config,
|
||||||
|
"SELECT COUNT(*) AS CT FROM RDB$RELATIONS WHERE RDB$RELATION_NAME = ?",
|
||||||
|
[String(tableName).toUpperCase()]);
|
||||||
|
return (r[0] && r[0].CT > 0) || false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function columnExists(config, tableName, columnName) {
|
||||||
|
const r = await query(config,
|
||||||
|
"SELECT COUNT(*) AS CT FROM RDB$RELATION_FIELDS WHERE RDB$RELATION_NAME = ? AND RDB$FIELD_NAME = ?",
|
||||||
|
[String(tableName).toUpperCase(), String(columnName).toUpperCase()]);
|
||||||
|
return (r[0] && r[0].CT > 0) || false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function close() { /* Firebird abre conexão por consulta; nada a encerrar */ }
|
||||||
|
|
||||||
|
return { query, execute, testConnection, listTables, tableInfo, tableExists, columnExists, close };
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 3) DRIVER POSTGRES (pool + tradutor de SQL Firebird->PG + schema)
|
||||||
|
// ============================================================
|
||||||
|
const postgresDriver = (() => {
|
||||||
|
const pg = require('pg');
|
||||||
|
|
||||||
|
// COUNT()/bigint chegam como string no pg; o código histórico usa números.
|
||||||
|
pg.types.setTypeParser(20, (v) => (v === null ? null : parseInt(v, 10))); // int8 / bigint
|
||||||
|
pg.types.setTypeParser(1700, (v) => (v === null ? null : parseFloat(v))); // numeric / decimal
|
||||||
|
|
||||||
|
const pools = new Map();
|
||||||
|
|
||||||
|
function safeSchema(schema) {
|
||||||
|
const s = String(schema || 'public').trim();
|
||||||
|
return /^[A-Za-z_][A-Za-z0-9_$]*$/.test(s) ? s : 'public';
|
||||||
|
}
|
||||||
|
|
||||||
|
function poolKey(config) {
|
||||||
|
return [config.host, config.port, config.database, config.user, safeSchema(config.schema)].join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schemas que o pool enxerga (configurado + public como fallback)
|
||||||
|
function schemasFor(config) {
|
||||||
|
const schema = safeSchema(config.schema);
|
||||||
|
return schema === 'public' ? ['public'] : [schema, 'public'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEntry(config) {
|
||||||
|
const key = poolKey(config);
|
||||||
|
let entry = pools.get(key);
|
||||||
|
if (!entry) {
|
||||||
|
const schemas = schemasFor(config);
|
||||||
|
const pool = new pg.Pool({
|
||||||
|
host: config.host,
|
||||||
|
port: config.port,
|
||||||
|
database: config.database,
|
||||||
|
user: config.user,
|
||||||
|
password: config.password,
|
||||||
|
ssl: config.ssl ? { rejectUnauthorized: false } : false,
|
||||||
|
options: '-c search_path=' + schemas.join(','),
|
||||||
|
max: config.max || 10,
|
||||||
|
idleTimeoutMillis: config.idleTimeoutMillis || 30000,
|
||||||
|
connectionTimeoutMillis: config.connectionTimeoutMillis || 10000,
|
||||||
|
});
|
||||||
|
pool.on('error', (err) => console.error('[postgres] Erro inesperado no pool:', err.message));
|
||||||
|
entry = { pool, schemas, tableSet: null, loadingTables: null };
|
||||||
|
pools.set(key, entry);
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Carrega (uma vez por pool) os nomes de tabela MAIÚSCULOS p/ decidir aspas
|
||||||
|
async function ensureTableSet(entry) {
|
||||||
|
if (entry.tableSet) return entry.tableSet;
|
||||||
|
if (!entry.loadingTables) {
|
||||||
|
entry.loadingTables = entry.pool
|
||||||
|
.query('SELECT table_name FROM information_schema.tables WHERE table_schema = ANY($1)', [entry.schemas])
|
||||||
|
.then((res) => {
|
||||||
|
entry.tableSet = new Set(res.rows.map((r) => String(r.table_name).toUpperCase()));
|
||||||
|
return entry.tableSet;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('[postgres] Falha ao carregar nomes de tabela:', err.message);
|
||||||
|
entry.tableSet = new Set();
|
||||||
|
return entry.tableSet;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return entry.loadingTables;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ? -> $1, $2, ... (ignora literais de string)
|
||||||
|
function convertPlaceholders(sql) {
|
||||||
|
let out = '', i = 0, n = 1, inStr = false;
|
||||||
|
while (i < sql.length) {
|
||||||
|
const ch = sql[i];
|
||||||
|
if (inStr) {
|
||||||
|
out += ch;
|
||||||
|
if (ch === "'") {
|
||||||
|
if (sql[i + 1] === "'") { out += sql[i + 1]; i += 2; continue; }
|
||||||
|
inStr = false;
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === "'") { inStr = true; out += ch; i++; continue; }
|
||||||
|
if (ch === '?') { out += '$' + (n++); i++; continue; }
|
||||||
|
out += ch; i++;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aspas nos nomes de tabela conhecidos (MAIÚSCULOS) após FROM/JOIN/INTO/UPDATE/ALTER/DROP
|
||||||
|
function quoteTableNames(sql, tableSet) {
|
||||||
|
if (!tableSet || tableSet.size === 0) return sql;
|
||||||
|
return sql.replace(
|
||||||
|
/(\b(?:FROM|JOIN|INTO|UPDATE|ALTER\s+TABLE|DROP\s+TABLE)\s+)("?)([A-Za-z_][A-Za-z0-9_$]*)("?)/gi,
|
||||||
|
(match, kw, q1, name, q2) => {
|
||||||
|
if (q1 === '"' || q2 === '"') return match;
|
||||||
|
if (tableSet.has(name.toUpperCase())) return kw + '"' + name.toUpperCase() + '"';
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rede de segurança: FIRST n [SKIP m] no topo -> LIMIT/OFFSET (SQL legado)
|
||||||
|
function translateFirstSkip(sql) {
|
||||||
|
return sql.replace(
|
||||||
|
/^(\s*SELECT\s+)FIRST\s+(\d+)(?:\s+SKIP\s+(\d+))?\s+/i,
|
||||||
|
(m, sel, first, skip) => sel + ' LIMITTAIL LIMIT ' + first + (skip ? ' OFFSET ' + skip : '') + ' '
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function applyLimitTail(sql) {
|
||||||
|
const marker = / LIMITTAIL( LIMIT \d+(?: OFFSET \d+)?) /;
|
||||||
|
const m = sql.match(marker);
|
||||||
|
if (!m) return sql;
|
||||||
|
return sql.replace(marker, ' ').trimEnd() + m[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function translateSql(sql, tableSet) {
|
||||||
|
let out = sql;
|
||||||
|
out = out.replace(/\bCONTAINING\s+(\?|\$\d+|'(?:[^']|'')*')/gi, "ILIKE ('%' || $1 || '%')");
|
||||||
|
out = out.replace(/\bFROM\s+RDB\$DATABASE\b/gi, '');
|
||||||
|
out = quoteTableNames(out, tableSet);
|
||||||
|
out = translateFirstSkip(out);
|
||||||
|
out = applyLimitTail(out);
|
||||||
|
out = convertPlaceholders(out);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function upperKeys(rows) {
|
||||||
|
return rows.map((row) => {
|
||||||
|
const o = {};
|
||||||
|
for (const k in row) o[k.toUpperCase()] = row[k];
|
||||||
|
return o;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function query(config, sql, params = []) {
|
||||||
|
const entry = getEntry(config);
|
||||||
|
await ensureTableSet(entry);
|
||||||
|
const text = translateSql(sql, entry.tableSet);
|
||||||
|
try {
|
||||||
|
const res = await entry.pool.query(text, params);
|
||||||
|
return upperKeys(res.rows);
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`Erro na consulta (postgres): ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function execute(config, sql, params = []) {
|
||||||
|
const entry = getEntry(config);
|
||||||
|
await ensureTableSet(entry);
|
||||||
|
const text = translateSql(sql, entry.tableSet);
|
||||||
|
try {
|
||||||
|
const res = await entry.pool.query(text, params);
|
||||||
|
return { affectedRows: res.rowCount || 0, result: upperKeys(res.rows || []) };
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`Erro na execução (postgres): ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testConnection(config) {
|
||||||
|
const entry = getEntry(config);
|
||||||
|
await entry.pool.query('SELECT 1');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listTables(config) {
|
||||||
|
const entry = getEntry(config);
|
||||||
|
const res = await entry.pool.query(
|
||||||
|
`SELECT table_name FROM information_schema.tables
|
||||||
|
WHERE table_schema = $1 AND table_type = 'BASE TABLE'
|
||||||
|
ORDER BY table_name`,
|
||||||
|
[safeSchema(config.schema)]
|
||||||
|
);
|
||||||
|
return res.rows.map((r) => r.table_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tableInfo(config, tableName) {
|
||||||
|
const entry = getEntry(config);
|
||||||
|
// Resolve no primeiro schema do search_path que contém a tabela
|
||||||
|
const res = await entry.pool.query(
|
||||||
|
`SELECT column_name, ordinal_position, data_type,
|
||||||
|
character_maximum_length, numeric_precision, numeric_scale, is_nullable
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE UPPER(table_name) = UPPER($2)
|
||||||
|
AND table_schema = (
|
||||||
|
SELECT table_schema FROM information_schema.tables
|
||||||
|
WHERE UPPER(table_name) = UPPER($2) AND table_schema = ANY($1)
|
||||||
|
ORDER BY array_position($1, table_schema) LIMIT 1
|
||||||
|
)
|
||||||
|
ORDER BY ordinal_position`,
|
||||||
|
[entry.schemas, tableName]
|
||||||
|
);
|
||||||
|
return res.rows.map((row) => ({
|
||||||
|
name: row.column_name,
|
||||||
|
position: row.ordinal_position,
|
||||||
|
type: (row.data_type || '').toUpperCase(),
|
||||||
|
length: row.character_maximum_length,
|
||||||
|
precision: row.numeric_precision,
|
||||||
|
scale: row.numeric_scale,
|
||||||
|
nullable: row.is_nullable === 'YES',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tableExists(config, tableName) {
|
||||||
|
const entry = getEntry(config);
|
||||||
|
await ensureTableSet(entry);
|
||||||
|
if (entry.tableSet) return entry.tableSet.has(String(tableName).toUpperCase());
|
||||||
|
const res = await entry.pool.query(
|
||||||
|
`SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_schema = ANY($1) AND UPPER(table_name) = UPPER($2) LIMIT 1`,
|
||||||
|
[entry.schemas, tableName]
|
||||||
|
);
|
||||||
|
return res.rowCount > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function columnExists(config, tableName, columnName) {
|
||||||
|
const entry = getEntry(config);
|
||||||
|
const res = await entry.pool.query(
|
||||||
|
`SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = ANY($1) AND UPPER(table_name) = UPPER($2)
|
||||||
|
AND UPPER(column_name) = UPPER($3) LIMIT 1`,
|
||||||
|
[entry.schemas, tableName, columnName]
|
||||||
|
);
|
||||||
|
return res.rowCount > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function close() {
|
||||||
|
const all = Array.from(pools.values()).map((e) => e.pool.end().catch(() => {}));
|
||||||
|
pools.clear();
|
||||||
|
await Promise.all(all);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
query, execute, testConnection, listTables, tableInfo, tableExists, columnExists, close,
|
||||||
|
_translateSql: translateSql, // exportado para testes
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 4) DISPATCHER — API pública
|
||||||
|
// ============================================================
|
||||||
|
const drivers = { postgres: postgresDriver, firebird: firebirdDriver };
|
||||||
|
|
||||||
|
function getDriver(alias) {
|
||||||
|
const config = getConfig(alias);
|
||||||
|
const driver = drivers[config.driver];
|
||||||
|
if (!driver) {
|
||||||
|
throw new Error(`Driver "${config.driver}" não suportado para o alias "${alias}".`);
|
||||||
|
}
|
||||||
|
return { config, driver };
|
||||||
|
}
|
||||||
|
|
||||||
|
function query(alias, sql, params = []) {
|
||||||
|
let d;
|
||||||
|
try { d = getDriver(alias); } catch (err) { return Promise.reject(err); }
|
||||||
|
return d.driver.query(d.config, sql, params).catch((err) => { throw new Error(`[${alias}] ${err.message}`); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function execute(alias, sql, params = []) {
|
||||||
|
let d;
|
||||||
|
try { d = getDriver(alias); } catch (err) { return Promise.reject(err); }
|
||||||
|
return d.driver.execute(d.config, sql, params).catch((err) => { throw new Error(`[${alias}] ${err.message}`); });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testConnection(alias) {
|
||||||
|
try {
|
||||||
|
const { config, driver } = getDriver(alias);
|
||||||
|
await driver.testConnection(config);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[${alias}] Falha na conexão:`, err.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testAllConnections() {
|
||||||
|
const results = {};
|
||||||
|
for (const alias of listAliases()) {
|
||||||
|
results[alias] = await testConnection(alias);
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listTables(alias) {
|
||||||
|
const { config, driver } = getDriver(alias);
|
||||||
|
return driver.listTables(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tableInfo(alias, tableName) {
|
||||||
|
const { config, driver } = getDriver(alias);
|
||||||
|
return driver.tableInfo(config, tableName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tableExists(alias, tableName) {
|
||||||
|
const { config, driver } = getDriver(alias);
|
||||||
|
return driver.tableExists(config, tableName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function columnExists(alias, tableName, columnName) {
|
||||||
|
const { config, driver } = getDriver(alias);
|
||||||
|
return driver.columnExists(config, tableName, columnName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nome do driver de um alias (ex.: 'postgres'). */
|
||||||
|
function driverOf(alias) {
|
||||||
|
return getConfig(alias).driver;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Encerra os pools de todos os drivers (shutdown gracioso). */
|
||||||
|
async function closeAll() {
|
||||||
|
await Promise.all(Object.values(drivers).map((d) => d.close && d.close()));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
// Acesso a dados
|
||||||
|
query,
|
||||||
|
execute,
|
||||||
|
testConnection,
|
||||||
|
testAllConnections,
|
||||||
|
listTables,
|
||||||
|
tableInfo,
|
||||||
|
tableExists,
|
||||||
|
columnExists,
|
||||||
|
driverOf,
|
||||||
|
closeAll,
|
||||||
|
// Configuração / aliases
|
||||||
|
databases,
|
||||||
|
DRIVERS,
|
||||||
|
DEFAULT_DRIVER,
|
||||||
|
getConfig,
|
||||||
|
listAliases,
|
||||||
|
listAllDatabases,
|
||||||
|
addDatabase,
|
||||||
|
removeDatabase,
|
||||||
|
};
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const db = require('../database');
|
||||||
|
const authConfig = require('../config/auth');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware de autenticação
|
||||||
|
* Aceita duas formas de autenticação:
|
||||||
|
* 1. JWT via header: Authorization: Bearer <jwt>
|
||||||
|
* 2. USU_TOKEN via header: X-Usu-Token: <token> (ou Authorization: Bearer <token>)
|
||||||
|
*
|
||||||
|
* Deve ser aplicado apenas nas rotas que exigem autenticação.
|
||||||
|
* O alias do banco é extraído de req.params.alias.
|
||||||
|
*/
|
||||||
|
async function authenticateToken(req, res, next) {
|
||||||
|
// Extrai o token dos headers
|
||||||
|
const authHeader = req.headers['authorization'];
|
||||||
|
const usuTokenHeader = req.headers['x-usu-token'];
|
||||||
|
let token = null;
|
||||||
|
let tokenSource = null; // 'jwt' ou 'usu_token'
|
||||||
|
|
||||||
|
// 1. Se veio X-Usu-Token, é USU_TOKEN direto
|
||||||
|
if (usuTokenHeader) {
|
||||||
|
token = usuTokenHeader;
|
||||||
|
tokenSource = 'usu_token';
|
||||||
|
}
|
||||||
|
// 2. Se veio Authorization, extrai o Bearer
|
||||||
|
else if (authHeader) {
|
||||||
|
const parts = authHeader.split(' ');
|
||||||
|
if (parts.length === 2 && parts[0].toLowerCase() === 'bearer') {
|
||||||
|
token = parts[1];
|
||||||
|
tokenSource = 'unknown'; // Tenta JWT primeiro, depois USU_TOKEN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Token de acesso não fornecido. Use Authorization: Bearer <token> ou X-Usu-Token: <token>.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tenta validar como JWT ---
|
||||||
|
if (tokenSource !== 'usu_token') {
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, authConfig.secret, { issuer: authConfig.issuer });
|
||||||
|
req.user = decoded;
|
||||||
|
req.authType = 'jwt';
|
||||||
|
return next();
|
||||||
|
} catch (jwtErr) {
|
||||||
|
if (tokenSource === 'unknown') {
|
||||||
|
// JWT inválido/expirado — tenta como USU_TOKEN
|
||||||
|
tokenSource = 'usu_token';
|
||||||
|
} else {
|
||||||
|
if (jwtErr.name === 'TokenExpiredError') {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Token JWT expirado. Faça login novamente.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Token JWT inválido.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tenta validar como USU_TOKEN ---
|
||||||
|
if (tokenSource === 'usu_token') {
|
||||||
|
try {
|
||||||
|
const alias = req.params.alias;
|
||||||
|
if (!alias) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Alias do banco não informado na URL.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.query(
|
||||||
|
alias,
|
||||||
|
`SELECT USU_CODIGO_ID, USU_NIVEL_ID, USU_NOME, USU_LOGIN,
|
||||||
|
USU_EMAIL, USU_STATUS, USU_TIPO, USU_ACESSO_WEB
|
||||||
|
FROM USUARIOS
|
||||||
|
WHERE USU_TOKEN = ?
|
||||||
|
AND USU_STATUS = 'A'
|
||||||
|
AND COALESCE(USU_ACESSO_WEB, 0) = 1`,
|
||||||
|
[token]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.length === 0) {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Token inválido ou usuário sem permissão.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = result[0];
|
||||||
|
|
||||||
|
// Busca as empresas que o usuário tem acesso
|
||||||
|
let empresasIds = [];
|
||||||
|
try {
|
||||||
|
const empresas = await db.query(alias,
|
||||||
|
'SELECT USE_EMPRESA_ID FROM USUARIOS_EMPRESA WHERE USE_USUARIO_ID = ?',
|
||||||
|
[user.USU_CODIGO_ID]
|
||||||
|
);
|
||||||
|
empresasIds = empresas.map(e => e.USE_EMPRESA_ID);
|
||||||
|
} catch (e) {
|
||||||
|
// Se a tabela não existir, ignora
|
||||||
|
}
|
||||||
|
|
||||||
|
req.user = {
|
||||||
|
id: user.USU_CODIGO_ID,
|
||||||
|
nivelId: user.USU_NIVEL_ID,
|
||||||
|
nome: user.USU_NOME.trim(),
|
||||||
|
login: user.USU_LOGIN.trim(),
|
||||||
|
email: user.USU_EMAIL ? user.USU_EMAIL.trim() : null,
|
||||||
|
tipo: user.USU_TIPO ? user.USU_TIPO.trim() : null,
|
||||||
|
alias,
|
||||||
|
empresas: empresasIds,
|
||||||
|
authType: 'usu_token',
|
||||||
|
};
|
||||||
|
req.authType = 'usu_token';
|
||||||
|
return next();
|
||||||
|
} catch (dbErr) {
|
||||||
|
console.error('Erro ao validar USU_TOKEN:', dbErr.message);
|
||||||
|
|
||||||
|
// Alias inexistente na URL → 404 (mensagem genérica, sem listar aliases)
|
||||||
|
if (dbErr.message.includes('não encontrado') || dbErr.message.includes('Alias')) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Alias não encontrado.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Qualquer outra falha de validação → nega o acesso (fail closed)
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Token inválido ou não pôde ser validado.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Token de acesso não fornecido.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = authenticateToken;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* Verificação de papel (Gerente)
|
||||||
|
* ==============================
|
||||||
|
* Determina se o usuário autenticado é Gerente (USU_TIPO_CHAT = 'G').
|
||||||
|
* - Auth via JWT: o papel já vem no token (req.user.tipoChat).
|
||||||
|
* - Auth via USU_TOKEN: o papel é consultado no banco pelo id do usuário.
|
||||||
|
*/
|
||||||
|
const db = require('../database');
|
||||||
|
|
||||||
|
async function isGerente(req) {
|
||||||
|
if (req && req.user && req.user.tipoChat) {
|
||||||
|
return String(req.user.tipoChat).trim() === 'G';
|
||||||
|
}
|
||||||
|
// Fallback (USU_TOKEN): consulta o tipo no banco
|
||||||
|
if (req && req.user && req.user.id && req.params && req.params.alias) {
|
||||||
|
try {
|
||||||
|
const r = await db.query(req.params.alias,
|
||||||
|
'SELECT USU_TIPO_CHAT FROM USUARIOS WHERE USU_CODIGO_ID = ?', [req.user.id]);
|
||||||
|
return r.length > 0 && String(r[0].USU_TIPO_CHAT || '').trim() === 'G';
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { isGerente };
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="pt-BR">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Conversas - Chatc2</title>
|
||||||
|
<link rel="stylesheet" href="/css/main.css">
|
||||||
|
<style>
|
||||||
|
body { background:#f3f4f6; display:flex; min-height:100vh; }
|
||||||
|
.sidebar-nav .admin-only { display:none; }
|
||||||
|
.main { flex:1; display:flex; flex-direction:column; min-width:0; }
|
||||||
|
.container { flex:1; padding:24px; overflow-y:auto; }
|
||||||
|
.filters { display:flex; gap:10px; margin-bottom:20px; flex-wrap:wrap; align-items:center; }
|
||||||
|
.filters select, .filters input { padding:9px 14px; border:2px solid #e5e7eb; border-radius:8px; font-size:13px; outline:none; background:#fff; color:#374151; transition:border-color .15s; }
|
||||||
|
.filters select:focus, .filters input:focus { border-color:#667eea; }
|
||||||
|
.filters button { padding:9px 16px; background:#667eea; color:#fff; border:none; border-radius:8px; font-size:13px; font-weight:600; cursor:pointer; transition:background .15s; }
|
||||||
|
.filters button:hover { background:#5a67d8; }
|
||||||
|
.status-badge { display:inline-block; padding:3px 10px; border-radius:10px; font-size:11px; font-weight:600; }
|
||||||
|
.status-E { background:#fef3c7; color:#92400e; }
|
||||||
|
.status-A { background:#d1fae5; color:#065f46; }
|
||||||
|
.status-F { background:#f3f4f6; color:#6b7280; }
|
||||||
|
.atender-btn { padding:5px 12px; border:none; border-radius:6px; background:#667eea; color:#fff; font-size:12px; font-weight:600; cursor:pointer; text-decoration:none; transition:background .15s; display:inline-block; }
|
||||||
|
.atender-btn:hover { background:#5a67d8; }
|
||||||
|
.loading { text-align:center; padding:40px; color:#9ca3af; }
|
||||||
|
</style>
|
||||||
|
<link rel="stylesheet" href="/css/dark-mode.css">
|
||||||
|
<script>function darkModeToggle(){var e=document.body;if(!e)return;var a=localStorage.getItem('chatc2_dark_mode')!=='true';e.classList.toggle('dark-mode',a);localStorage.setItem('chatc2_dark_mode',a?'true':'false');document.querySelectorAll('.dark-mode-toggle').forEach(function(b){b.innerHTML=a?'☀️ Claro':'🌙 Escuro'});}
|
||||||
|
window.darkModeApply=function(a){var e=document.body;if(!e)return;e.classList.toggle('dark-mode',a);localStorage.setItem('chatc2_dark_mode',a?'true':'false');document.querySelectorAll('.dark-mode-toggle').forEach(function(b){b.innerHTML=a?'☀️ Claro':'🌙 Escuro'});};
|
||||||
|
window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')==='true';};</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebar-brand"><div class="logo">C2</div><div><h2>Chatc2</h2><span id="sidebarAlias">-</span></div></div>
|
||||||
|
<nav class="sidebar-nav">
|
||||||
|
<div class="nav-label">Principal</div>
|
||||||
|
<a href="#" id="navDashboard"><span class="icon">📊</span> Dashboard</a>
|
||||||
|
<a href="#" id="navClients"><span class="icon">👥</span> Clientes</a>
|
||||||
|
<a href="#" class="active" id="navChat"><span class="icon">💬</span> Conversas</a>
|
||||||
|
<div class="nav-label admin-only" id="adminLabel">Administrador</div>
|
||||||
|
<a href="#" id="navConfig" class="admin-only"><span class="icon">⚙️</span> Configurações</a>
|
||||||
|
<a href="#" id="navRoutes" class="admin-only"><span class="icon">📡</span> Rotas</a>
|
||||||
|
</nav>
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<button class="dark-mode-toggle" onclick="darkModeToggle()" style="width:100%;margin-bottom:8px;padding:8px">🌙 Escuro</button>
|
||||||
|
<a onclick="logout()"><span class="icon">🚪</span> Sair</a></div>
|
||||||
|
</aside>
|
||||||
|
<div class="main">
|
||||||
|
<div class="topbar">
|
||||||
|
<span class="topbar-title">💬 Todas as Conversas</span>
|
||||||
|
<span id="totalInfo" style="font-size:13px;color:#6b7280"></span>
|
||||||
|
</div>
|
||||||
|
<div class="container">
|
||||||
|
<div class="filters">
|
||||||
|
<select id="filterStatus" onchange="carregar()">
|
||||||
|
<option value="A,E">Em aberto</option>
|
||||||
|
<option value="E">Em espera</option>
|
||||||
|
<option value="A">Em atendimento</option>
|
||||||
|
<option value="F">Finalizadas</option>
|
||||||
|
<option value="A,E,F">Todas</option>
|
||||||
|
</select>
|
||||||
|
<select id="filterAtribuicao" onchange="carregar()">
|
||||||
|
<option value="">Todas</option>
|
||||||
|
<option value="me">Minhas conversas</option>
|
||||||
|
<option value="sem">Sem atendente</option>
|
||||||
|
</select>
|
||||||
|
<input type="text" id="buscaContato" placeholder="Buscar por nome ou número..." onkeyup="if(event.key==='Enter')carregar()">
|
||||||
|
<button onclick="carregar()">🔄 Atualizar</button>
|
||||||
|
</div>
|
||||||
|
<div class="card" id="tabelaContainer">
|
||||||
|
<div class="loading"><div class="spinner"></div><p style="margin-top:8px">Carregando conversas...</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
'use strict';
|
||||||
|
const token = localStorage.getItem('chatc2_token');
|
||||||
|
const pathParts = window.location.pathname.split('/');
|
||||||
|
const alias = pathParts[2] || localStorage.getItem('chatc2_alias') || 'lajedo';
|
||||||
|
localStorage.setItem('chatc2_alias', alias);
|
||||||
|
const user = JSON.parse(localStorage.getItem('chatc2_user') || '{}');
|
||||||
|
const empresaId = user.empresas?.[0] || 1;
|
||||||
|
|
||||||
|
if (!token) { window.location.href = '/app/' + alias + '/login'; return; }
|
||||||
|
|
||||||
|
document.getElementById('sidebarAlias').textContent = alias;
|
||||||
|
|
||||||
|
function navClick(e, url) { e.preventDefault(); window.location.href = url; }
|
||||||
|
document.getElementById('navDashboard').onclick = function(e) { navClick(e, '/app/'+alias+'/dashboard'); };
|
||||||
|
document.getElementById('navClients').onclick = function(e) { navClick(e, '/app/'+alias+'/clients'); };
|
||||||
|
document.getElementById('navChat').onclick = function(e) { e.preventDefault(); carregar(); };
|
||||||
|
document.getElementById('navConfig').onclick = function(e) { navClick(e, '/app/'+alias+'/settings'); };
|
||||||
|
document.getElementById('navRoutes').onclick = function(e) { navClick(e, '/app/'+alias+'/routes'); };
|
||||||
|
|
||||||
|
const tc = user.tipoChat || 'A';
|
||||||
|
if (tc === 'G') {
|
||||||
|
document.querySelectorAll('.admin-only').forEach(function(el) { el.style.display = ''; });
|
||||||
|
}
|
||||||
|
|
||||||
|
window.logout = function() {
|
||||||
|
['chatc2_token','chatc2_alias','chatc2_user'].forEach(function(k) { localStorage.removeItem(k); });
|
||||||
|
window.location.href = '/app/' + alias + '/login';
|
||||||
|
};
|
||||||
|
|
||||||
|
function api(path) {
|
||||||
|
return fetch('/api/' + alias + path, { headers: { 'Authorization': 'Bearer ' + token } }).then(function(r) { return r.json(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
window.carregar = async function() {
|
||||||
|
const container = document.getElementById('tabelaContainer');
|
||||||
|
container.innerHTML = '<div class="loading"><div class="spinner"></div><p style="margin-top:8px">Carregando conversas...</p></div>';
|
||||||
|
|
||||||
|
const status = document.getElementById('filterStatus').value;
|
||||||
|
const atrib = document.getElementById('filterAtribuicao').value;
|
||||||
|
let busca = document.getElementById('buscaContato').value.trim();
|
||||||
|
|
||||||
|
const data = await api('/conversations?empresaId=' + empresaId + '&status=' + status);
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
container.innerHTML = '<div class="empty-state"><div class="icon">⚠️</div><p>' + data.error + '</p></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let convs = data.data || [];
|
||||||
|
|
||||||
|
// Filtro de atribuição
|
||||||
|
if (atrib === 'me') {
|
||||||
|
convs = convs.filter(function(c) { return c.usuarioId === user.id; });
|
||||||
|
} else if (atrib === 'sem') {
|
||||||
|
convs = convs.filter(function(c) { return !c.usuarioId; });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtro de busca
|
||||||
|
if (busca) {
|
||||||
|
const q = busca.toLowerCase();
|
||||||
|
convs = convs.filter(function(c) {
|
||||||
|
return (c.nomeContato && c.nomeContato.toLowerCase().includes(q)) ||
|
||||||
|
(c.numero && c.numero.includes(q));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('totalInfo').textContent = convs.length + ' conversa(s)';
|
||||||
|
|
||||||
|
if (convs.length === 0) {
|
||||||
|
container.innerHTML = '<div class="empty-state"><div class="icon">💬</div><p>Nenhuma conversa encontrada</p></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '<table><thead><tr><th>Contato</th><th>Número</th><th style="width:100px">Status</th><th>Atendente</th><th>Última msg</th><th style="width:80px">Ação</th></tr></thead><tbody>';
|
||||||
|
|
||||||
|
convs.forEach(function(c) {
|
||||||
|
const statusLabel = c.status === 'E' ? '🟡 Espera' : c.status === 'A' ? '🟢 Atendimento' : '⚫ Finalizado';
|
||||||
|
const labelsHtml = (c.labels || []).map(function(l) {
|
||||||
|
return '<span style="display:inline-block;padding:0 6px;border-radius:3px;background:'+(l.cor||'#667eea')+';color:#fff;font-size:10px;margin:1px">'+(l.nome||'')+'</span>';
|
||||||
|
}).join('');
|
||||||
|
const ultima = (c.ultimaMsg || '').substring(0, 60);
|
||||||
|
const usuarioNome = c.usuarioId ? 'Usuário ' + c.usuarioId : '-';
|
||||||
|
|
||||||
|
html += '<tr>' +
|
||||||
|
'<td><strong>' + (c.nomeContato || 'Desconhecido') + '</strong>' + (labelsHtml ? '<br>' + labelsHtml : '') + '</td>' +
|
||||||
|
'<td>' + (c.numero || '-') + '</td>' +
|
||||||
|
'<td><span class="status-badge status-' + c.status + '">' + statusLabel + '</span></td>' +
|
||||||
|
'<td style="font-size:12px">' + usuarioNome + '</td>' +
|
||||||
|
'<td style="font-size:12px;color:#6b7280;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + ultima + '</td>' +
|
||||||
|
'<td><a href="/app/' + alias + '/company/' + empresaId + '/conversation/' + c.id + '" class="atender-btn">Atender</a></td>' +
|
||||||
|
'</tr>';
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</tbody></table>';
|
||||||
|
container.innerHTML = html;
|
||||||
|
};
|
||||||
|
|
||||||
|
carregar();
|
||||||
|
|
||||||
|
// Auto refresh
|
||||||
|
setInterval(carregar, 10000);
|
||||||
|
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<script src="/js/dark-mode.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,741 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="pt-BR">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Detalhes do Cliente - Chatc2</title>
|
||||||
|
<link rel="stylesheet" href="/css/main.css">
|
||||||
|
<style>
|
||||||
|
body { background:#f3f4f6; display:flex; min-height:100vh; }
|
||||||
|
.sidebar-nav .admin-only { display:none; }
|
||||||
|
.main { flex:1; display:flex; flex-direction:column; min-width:0; }
|
||||||
|
.container { flex:1; padding:24px; overflow-y:auto; }
|
||||||
|
.loading { text-align:center; padding:80px 20px; color:#9ca3af; }
|
||||||
|
.error-box { background:#fef2f2; border:1px solid #fecaca; border-radius:12px; padding:32px; text-align:center; color:#991b1b; }
|
||||||
|
.error-box .icon { font-size:48px; margin-bottom:12px; }
|
||||||
|
.client-header { background:#fff; border-radius:14px; padding:24px; box-shadow:0 1px 4px rgba(0,0,0,0.08); margin-bottom:20px; display:flex; align-items:center; gap:20px; border:1px solid #f3f4f6; }
|
||||||
|
.client-avatar { width:68px; height:68px; border-radius:50%; background:linear-gradient(135deg,#667eea,#764ba2); display:flex; align-items:center; justify-content:center; color:#fff; font-size:26px; font-weight:800; flex-shrink:0; box-shadow:0 4px 14px rgba(102,126,234,0.3); }
|
||||||
|
.client-header-info h1 { font-size:22px; font-weight:800; color:#111827; margin-bottom:4px; letter-spacing:-0.3px; }
|
||||||
|
.client-header-info .matricula { font-size:14px; color:#6b7280; }
|
||||||
|
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(280px,1fr)); gap:18px; margin-bottom:20px; }
|
||||||
|
.card h3 { font-size:12px; text-transform:uppercase; letter-spacing:0.07em; color:#9ca3af; margin-bottom:14px; font-weight:700; }
|
||||||
|
.field { margin-bottom:12px; }
|
||||||
|
.field:last-child { margin-bottom:0; }
|
||||||
|
.field .label { font-size:11px; text-transform:uppercase; letter-spacing:0.06em; color:#9ca3af; margin-bottom:3px; font-weight:600; }
|
||||||
|
.field .value { font-size:15px; color:#111827; font-weight:500; }
|
||||||
|
.carnes-section { margin-top:20px; }
|
||||||
|
.carnes-filters { display:flex; gap:10px; margin-bottom:16px; flex-wrap:wrap; align-items:center; }
|
||||||
|
.carnes-filters label { display:flex; align-items:center; gap:6px; font-size:13px; color:#374151; cursor:pointer; padding:7px 14px; border-radius:8px; border:1px solid #e5e7eb; background:#fff; transition:all 0.15s; user-select:none; font-weight:500; }
|
||||||
|
.carnes-filters label:hover { border-color:#667eea; color:#667eea; }
|
||||||
|
.carnes-filters label.filtro-ativo { border-color:#667eea; background:#eef2ff; color:#667eea; font-weight:600; }
|
||||||
|
.carnes-filters label input { accent-color:#667eea; }
|
||||||
|
table { font-size:13px; }
|
||||||
|
.valor { text-align:right; font-family:'SF Mono',monospace; }
|
||||||
|
.centro { text-align:center; }
|
||||||
|
.sit-badge { display:inline-block; padding:2px 8px; border-radius:10px; font-size:11px; font-weight:600; }
|
||||||
|
.vencido-dot { display:inline-block; width:8px; height:8px; border-radius:50%; background:#ef4444; margin-right:4px; }
|
||||||
|
.back-btn { margin-right:14px; padding:7px 14px; background:#f3f4f6; border:1px solid #e5e7eb; border-radius:8px; font-size:13px; font-weight:500; cursor:pointer; color:#374151; text-decoration:none; transition:all .15s; display:inline-flex; align-items:center; gap:4px; }
|
||||||
|
.back-btn:hover { background:#e5e7eb; border-color:#d1d5db; }
|
||||||
|
.empty-msg { text-align:center; padding:32px; color:#9ca3af; font-size:14px; }
|
||||||
|
</style>
|
||||||
|
<link rel="stylesheet" href="/css/dark-mode.css">
|
||||||
|
<script>function darkModeToggle(){var e=document.body;if(!e)return;var a=localStorage.getItem('chatc2_dark_mode')!=='true';e.classList.toggle('dark-mode',a);localStorage.setItem('chatc2_dark_mode',a?'true':'false');document.querySelectorAll('.dark-mode-toggle').forEach(function(b){b.innerHTML=a?'☀️ Claro':'🌙 Escuro'});}
|
||||||
|
window.darkModeApply=function(a){var e=document.body;if(!e)return;e.classList.toggle('dark-mode',a);localStorage.setItem('chatc2_dark_mode',a?'true':'false');document.querySelectorAll('.dark-mode-toggle').forEach(function(b){b.innerHTML=a?'☀️ Claro':'🌙 Escuro'});};
|
||||||
|
window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')==='true';};</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebar-brand">
|
||||||
|
<div class="logo">C2</div>
|
||||||
|
<div><h2>Chatc2</h2><span id="sidebarAlias">-</span></div>
|
||||||
|
</div>
|
||||||
|
<nav class="sidebar-nav">
|
||||||
|
<div class="nav-label">Principal</div>
|
||||||
|
<a href="#" id="navDashboard"><span class="icon">📊</span> Dashboard</a>
|
||||||
|
<a href="#" class="active" id="navClients"><span class="icon">👥</span> Clientes</a>
|
||||||
|
<a href="#" id="navChatDetail"><span class="icon">💬</span> Conversas</a>
|
||||||
|
<div class="nav-label admin-only">Administrador</div>
|
||||||
|
<a href="#" id="navConfigDetail" class="admin-only"><span class="icon">⚙️</span> Configurações</a>
|
||||||
|
<a href="#" id="navRoutesDetail" class="admin-only"><span class="icon">📡</span> Rotas</a>
|
||||||
|
</nav>
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<button class="dark-mode-toggle" onclick="darkModeToggle()" style="width:100%;margin-bottom:8px;padding:8px">🌙 Escuro</button>
|
||||||
|
<a onclick="logout()"><span class="icon">🚪</span> Sair</a>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="main">
|
||||||
|
<div class="topbar">
|
||||||
|
<a href="#" class="back-btn" id="backBtn">← Voltar</a>
|
||||||
|
<span class="topbar-title">Detalhes do Cliente</span>
|
||||||
|
<a href="#" class="back-btn" id="btnIniciarConv" style="font-size:13px;margin-left:auto" title="Iniciar conversa WhatsApp">💬 Iniciar Conversa</a>
|
||||||
|
</div>
|
||||||
|
<div class="container" id="container">
|
||||||
|
<div class="loading" id="loadingState">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p style="margin-top:12px">Carregando dados do cliente...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal Nova Conversa -->
|
||||||
|
<div class="modal-overlay" id="modalNovaConvDet" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);align-items:center;justify-content:center;z-index:1000">
|
||||||
|
<div style="background:#fff;border-radius:12px;padding:24px;width:90%;max-width:450px">
|
||||||
|
<h3 style="margin-bottom:16px">💬 Iniciar Conversa</h3>
|
||||||
|
<input type="hidden" id="convClienteIdDet">
|
||||||
|
<input type="hidden" id="convEmpresaIdDet">
|
||||||
|
<div class="form-group"><label>Número</label><input type="text" id="convNumeroDet" readonly style="width:100%;padding:10px;border:2px solid #e5e7eb;border-radius:8px;font-size:14px;background:#f9fafb;margin-bottom:12px"></div>
|
||||||
|
<div class="form-group"><label>Cliente</label><input type="text" id="convNomeDet" readonly style="width:100%;padding:10px;border:2px solid #e5e7eb;border-radius:8px;font-size:14px;background:#f9fafb;margin-bottom:12px"></div>
|
||||||
|
<div class="form-group"><label>Instância WhatsApp</label><select id="convInstanciaDet" style="width:100%;padding:10px;border:2px solid #e5e7eb;border-radius:8px;font-size:14px;margin-bottom:12px"></select></div>
|
||||||
|
<div class="form-group"><label>Mensagem inicial</label><textarea id="convMensagemDet" rows="3" style="width:100%;padding:10px;border:2px solid #e5e7eb;border-radius:8px;font-size:14px;resize:vertical;margin-bottom:12px" placeholder="Digite a mensagem que será enviada..."></textarea></div>
|
||||||
|
<div style="display:flex;gap:8px;justify-content:flex-end">
|
||||||
|
<button onclick="document.getElementById('modalNovaConvDet').style.display='none'" style="padding:8px 16px;border:1px solid #d1d5db;border-radius:8px;background:#fff;cursor:pointer">Cancelar</button>
|
||||||
|
<button onclick="iniciarConversaDet()" style="padding:8px 16px;background:#667eea;color:#fff;border:none;border-radius:8px;cursor:pointer">💬 Enviar e Abrir Chat</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ---- Dados da URL ----
|
||||||
|
var pathParts = window.location.pathname.split('/');
|
||||||
|
var alias = pathParts[2];
|
||||||
|
var empresaId = pathParts[4];
|
||||||
|
var clienteId = pathParts[6];
|
||||||
|
|
||||||
|
// ---- Token ----
|
||||||
|
var token = localStorage.getItem('chatc2_token');
|
||||||
|
if (!token) { window.location.href = '/app/' + alias + '/login'; return; }
|
||||||
|
localStorage.setItem('chatc2_alias', alias);
|
||||||
|
|
||||||
|
// ---- Elementos ----
|
||||||
|
var container = document.getElementById('container');
|
||||||
|
var sidebarAlias = document.getElementById('sidebarAlias');
|
||||||
|
var backBtn = document.getElementById('backBtn');
|
||||||
|
|
||||||
|
sidebarAlias.textContent = alias;
|
||||||
|
backBtn.href = '/app/' + alias + '/clients';
|
||||||
|
|
||||||
|
// Botão iniciar conversa - abre modal
|
||||||
|
document.getElementById('btnIniciarConv').onclick = function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
// Pega dados do cliente do último load (cache na variável global)
|
||||||
|
var celular = window._clienteData?.celular?.original || window._clienteData?.celular || '';
|
||||||
|
var nome = window._clienteData?.nome || '';
|
||||||
|
abrirModalConversa(parseInt(empresaId), parseInt(clienteId), nome, celular.replace(/\D/g,''));
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Navegação ----
|
||||||
|
document.getElementById("navDashboard").onclick = function(e) { e.preventDefault(); window.location.href = "/app/" + alias + "/dashboard"; };
|
||||||
|
document.getElementById("navClients").onclick = function(e) { e.preventDefault(); window.location.href = "/app/" + alias + "/clients"; };
|
||||||
|
document.getElementById("navChatDetail").onclick = function(e) { e.preventDefault(); window.location.href = "/app/" + alias + "/company/" + empresaId + "/conversation/0"; };
|
||||||
|
document.getElementById("navConfigDetail").onclick = function(e) { e.preventDefault(); window.location.href = "/app/" + alias + "/settings"; };
|
||||||
|
document.getElementById("navRoutesDetail").onclick = function(e) { e.preventDefault(); window.location.href = "/app/" + alias + "/routes"; };
|
||||||
|
var userDetail = JSON.parse(localStorage.getItem('chatc2_user') || '{}');
|
||||||
|
var tc = userDetail.tipoChat || "A";
|
||||||
|
if (tc === "G") {
|
||||||
|
var admins = document.querySelectorAll(".admin-only");
|
||||||
|
for (var i = 0; i < admins.length; i++) admins[i].style.display = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
window.logout = function() {
|
||||||
|
localStorage.removeItem('chatc2_token');
|
||||||
|
localStorage.removeItem('chatc2_alias');
|
||||||
|
localStorage.removeItem('chatc2_user');
|
||||||
|
window.location.href = '/app/' + alias + '/login';
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Utilitário fetch com token ----
|
||||||
|
function apiFetch(url) {
|
||||||
|
return fetch(url, { headers: { 'Authorization': 'Bearer ' + token } })
|
||||||
|
.then(function(r) {
|
||||||
|
if (r.status === 401 || r.status === 403) {
|
||||||
|
localStorage.removeItem('chatc2_token');
|
||||||
|
localStorage.removeItem('chatc2_user');
|
||||||
|
window.location.href = '/app/' + alias + '/login';
|
||||||
|
throw new Error('redirect');
|
||||||
|
}
|
||||||
|
return r.json();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Formata moeda ----
|
||||||
|
function fmtMoney(v) { return 'R$ ' + (v || 0).toFixed(2).replace('.', ','); }
|
||||||
|
|
||||||
|
// ---- Formata data ----
|
||||||
|
function fmtDate(d) {
|
||||||
|
if (!d) return '-';
|
||||||
|
var date = new Date(d + (d.includes('T') ? '' : 'T12:00:00'));
|
||||||
|
return date.toLocaleDateString('pt-BR');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// CARREGAR CLIENTE
|
||||||
|
// ============================================================
|
||||||
|
function loadClient() {
|
||||||
|
apiFetch('/api/' + alias + '/company/' + empresaId + '/client/' + clienteId)
|
||||||
|
.then(function(data) {
|
||||||
|
if (!data.success) {
|
||||||
|
container.innerHTML = '<div class="error-box"><div class="icon">⚠️</div><h2>Erro</h2><p>' + data.error + '</p></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderClient(data.data);
|
||||||
|
carregarConversas();
|
||||||
|
// Verifica se o modulo estoque esta ativo para esconder a aba convalescentes
|
||||||
|
fetch('/api/' + alias + '/clients/' + clienteId + '/convalescentes', {
|
||||||
|
headers: { 'Authorization': 'Bearer ' + token }
|
||||||
|
}).then(function(r){ return r.json(); }).then(function(d) {
|
||||||
|
var btnC = document.getElementById('abaConvalescentes');
|
||||||
|
if (btnC && d.moduloInativo === true) {
|
||||||
|
btnC.style.display = 'none';
|
||||||
|
}
|
||||||
|
}).catch(function(){});
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
if (err.message === 'redirect') return;
|
||||||
|
container.innerHTML = '<div class="error-box"><div class="icon">⚠️</div><h2>Erro de conexão</h2><p>' + err.message + '</p></div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guarda dados do cliente para uso em outros lugares
|
||||||
|
window._clienteData = null;
|
||||||
|
|
||||||
|
function renderClient(c) {
|
||||||
|
window._clienteData = c;
|
||||||
|
var sitClass = c.situacao && c.situacao.codigo === 'A' ? 'ativo' : 'inativo';
|
||||||
|
var sitLabel = c.situacao ? c.situacao.descricao : 'Desconhecido';
|
||||||
|
var iniciais = c.nome ? c.nome.split(' ').map(function(s) { return s[0]; }).slice(0,2).join('').toUpperCase() : '?';
|
||||||
|
|
||||||
|
container.innerHTML =
|
||||||
|
'<div class="client-header">' +
|
||||||
|
'<div class="client-avatar">' + iniciais + '</div>' +
|
||||||
|
'<div class="client-header-info">' +
|
||||||
|
'<h1>' + (c.nome || '-') + '</h1>' +
|
||||||
|
'<div class="matricula">Matrícula: ' + (c.matricula || '-') + '</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<span class="situacao-badge ' + sitClass + '">' + sitLabel + '</span>' +
|
||||||
|
'</div>' +
|
||||||
|
|
||||||
|
'<div class="grid">' +
|
||||||
|
'<div class="card"><h3>📞 Contato <button onclick="editarContato()" style="float:right;padding:2px 8px;border:1px solid #d1d5db;border-radius:4px;background:#fff;cursor:pointer;font-size:11px">✏️ Editar</button></h3>' +
|
||||||
|
'<div class="field"><div class="label">Email</div><div class="value" id="campoEmail">' + (c.email || '-') + '</div></div>' +
|
||||||
|
'<div class="field"><div class="label">Celular</div><div class="value" id="campoCelular">' + (c.celular && c.celular.formatado ? c.celular.formatado : c.celular && c.celular.original ? c.celular.original : '-') + '</div></div>' +
|
||||||
|
'<div class="field"><div class="label">Telefone</div><div class="value" id="campoTelefone">' + (c.telefone || '-') + '</div></div>' +
|
||||||
|
'<div id="editContatoForm" style="display:none;margin-top:12px;padding:12px;background:#f9fafb;border-radius:8px">' +
|
||||||
|
'<input type="email" id="editEmail" placeholder="Email" style="width:100%;padding:8px;border:1px solid #e5e7eb;border-radius:6px;font-size:13px;margin-bottom:8px">' +
|
||||||
|
'<input type="text" id="editCelular" placeholder="Celular" style="width:100%;padding:8px;border:1px solid #e5e7eb;border-radius:6px;font-size:13px;margin-bottom:8px">' +
|
||||||
|
'<input type="text" id="editTelefone" placeholder="Telefone" style="width:100%;padding:8px;border:1px solid #e5e7eb;border-radius:6px;font-size:13px;margin-bottom:8px">' +
|
||||||
|
'<div style="display:flex;gap:8px">' +
|
||||||
|
'<button onclick="salvarContato()" style="padding:6px 14px;background:#667eea;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:12px">💾 Salvar</button>' +
|
||||||
|
'<button onclick="cancelarEditarContato()" style="padding:6px 14px;border:1px solid #d1d5db;border-radius:6px;background:#fff;cursor:pointer;font-size:12px">Cancelar</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="card"><h3>📍 Endereço</h3>' +
|
||||||
|
'<div class="field"><div class="label">Logradouro</div><div class="value">' + (c.enderecoFaturamento && c.enderecoFaturamento.logradouro || '-') + '</div></div>' +
|
||||||
|
'<div class="field"><div class="label">Número</div><div class="value">' + (c.enderecoFaturamento && c.enderecoFaturamento.numero || '-') + '</div></div>' +
|
||||||
|
'<div class="field"><div class="label">Complemento</div><div class="value">' + (c.enderecoFaturamento && c.enderecoFaturamento.complemento || '-') + '</div></div>' +
|
||||||
|
'<div class="field"><div class="label">CEP</div><div class="value">' + (c.enderecoFaturamento && c.enderecoFaturamento.cep || '-') + '</div></div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="card"><h3>🏙️ Localização</h3>' +
|
||||||
|
'<div class="field"><div class="label">Cidade</div><div class="value">' + (c.cidade && c.cidade.nome || '-') + '</div></div>' +
|
||||||
|
'<div class="field"><div class="label">Bairro</div><div class="value">' + (c.bairro && c.bairro.nome || '-') + '</div></div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="card"><h3>💰 Cobrança</h3>' +
|
||||||
|
'<div class="field"><div class="label">Cobrador</div><div class="value">' + (c.cobrador && c.cobrador.nome || 'Não definido') + '</div></div>' +
|
||||||
|
'<div class="field"><div class="label">Dia</div><div class="value">' + (c.diaCobranca ? c.diaCobranca + 'º dia' : 'Não definido') + '</div></div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
|
||||||
|
// ABAS: Conversas | Títulos | Dependentes | Convalescentes
|
||||||
|
'<div style="display:flex;gap:0;margin-bottom:16px;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.08)">' +
|
||||||
|
'<button class="aba-ativa" onclick="mudarAba(\'conversas\')" id="abaConversas" style="flex:1;padding:10px;border:none;background:#667eea;color:#fff;font-size:13px;font-weight:600;cursor:pointer">💬 Conversas</button>' +
|
||||||
|
'<button onclick="mudarAba(\'titulos\')" id="abaTitulos" style="flex:1;padding:10px;border:none;background:#f3f4f6;color:#374151;font-size:13px;cursor:pointer">📄 Títulos</button>' +
|
||||||
|
'<button onclick="mudarAba(\'dependentes\')" id="abaDependentes" style="flex:1;padding:10px;border:none;background:#f3f4f6;color:#374151;font-size:13px;cursor:pointer">👥 Dependentes</button>' +
|
||||||
|
'<button onclick="mudarAba(\'convalescentes\')" id="abaConvalescentes" style="flex:1;padding:10px;border:none;background:#f3f4f6;color:#374151;font-size:13px;cursor:pointer">🛏️ Convalescentes</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div id="conteudoAba">' +
|
||||||
|
// CONVERSAS (padrão - primeira aba)
|
||||||
|
'<div class="card" id="conteudoConversas">' +
|
||||||
|
'<h3>💬 Histórico de Conversas</h3>' +
|
||||||
|
'<div id="conversasArea"><div class="loading" style="padding:20px"><div class="spinner"></div><p style="margin-top:8px">Carregando histórico de conversas...</p></div></div>' +
|
||||||
|
'</div>' +
|
||||||
|
// TÍTULOS
|
||||||
|
'<div class="carnes-section card" id="conteudoTitulos" style="display:none">' +
|
||||||
|
'<h3>📄 Títulos</h3>' +
|
||||||
|
'<div class="carnes-filters" id="carnesFilters">' +
|
||||||
|
'<label class="filtro-ativo" data-tipo="abertos"><input type="checkbox" checked onchange="filtroClick(this)"> Abertos (<span id="cntAbertos">0</span>)</label>' +
|
||||||
|
'<label data-tipo="baixados"><input type="checkbox" onchange="filtroClick(this)"> Baixados (<span id="cntBaixados">0</span>)</label>' +
|
||||||
|
'<label data-tipo="parcial"><input type="checkbox" onchange="filtroClick(this)"> Parcial (<span id="cntParcial">0</span>)</label>' +
|
||||||
|
'<label class="filtro-ativo" data-tipo="vencidos" style="border-color:#fca5a5;"><input type="checkbox" checked onchange="filtroClick(this)"> 🔴 Vencidos (<span id="cntVencidos">0</span>)</label>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div id="carnesArea"><div class="loading" style="padding:20px"><div class="spinner"></div><p style="margin-top:8px">Carregando títulos...</p></div></div>' +
|
||||||
|
'</div>' +
|
||||||
|
// DEPENDENTES
|
||||||
|
'<div class="card" id="conteudoDependentes" style="display:none">' +
|
||||||
|
'<h3>👥 Dependentes</h3>' +
|
||||||
|
'<div id="dependentesArea"><div class="loading" style="padding:20px"><div class="spinner"></div><p style="margin-top:8px">Carregando dependentes...</p></div></div>' +
|
||||||
|
'</div>' +
|
||||||
|
// CONVALESCENTES
|
||||||
|
'<div class="card" id="conteudoConvalescentes" style="display:none">' +
|
||||||
|
'<h3>🛏️ Convalescentes</h3>' +
|
||||||
|
'<div id="convalescentesArea"><div class="loading" style="padding:20px"><div class="spinner"></div><p style="margin-top:8px">Carregando convalescentes...</p></div></div>' +
|
||||||
|
'</div>'
|
||||||
|
'</div>'; // fecha conteudoAba
|
||||||
|
}
|
||||||
|
|
||||||
|
function getParentescoLabel(cod) {
|
||||||
|
var map = {'01':'Conjugue','02':'Companheiro(a)','03':'Filho(a)','04':'Enteado(a)','05':'Esposo(a)','06':'Excluído','07':'Neto(a)','08':'Pai','09':'Mãe','10':'Sobrinho(a)','11':'Tio(a)','12':'Primo(a)','13':'Irmão(ã)','14':'Sogro(a)','15':'Cunhado(a)','16':'Outros','17':'Agregado','18':'Genro/Nora','19':'Avô(ó)'};
|
||||||
|
return map[cod] || cod;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.mudarAba = function(aba) {
|
||||||
|
var btnV = document.getElementById('abaConversas');
|
||||||
|
var btnT = document.getElementById('abaTitulos');
|
||||||
|
var btnD = document.getElementById('abaDependentes');
|
||||||
|
var btnC = document.getElementById('abaConvalescentes');
|
||||||
|
var cV = document.getElementById('conteudoConversas');
|
||||||
|
var cT = document.getElementById('conteudoTitulos');
|
||||||
|
var cD = document.getElementById('conteudoDependentes');
|
||||||
|
var cC = document.getElementById('conteudoConvalescentes');
|
||||||
|
var btns = [btnV, btnT, btnD, btnC];
|
||||||
|
var contents = [cV, cT, cD, cC];
|
||||||
|
btns.forEach(function(b) { if (b) { b.style.background = '#f3f4f6'; b.style.color = '#374151'; b.style.fontWeight = '400'; } });
|
||||||
|
contents.forEach(function(c) { if (c) c.style.display = 'none'; });
|
||||||
|
if (aba === 'conversas') {
|
||||||
|
btnV.style.background = '#667eea'; btnV.style.color = '#fff'; btnV.style.fontWeight = '600';
|
||||||
|
cV.style.display = 'block';
|
||||||
|
carregarConversas();
|
||||||
|
} else if (aba === 'titulos') {
|
||||||
|
btnT.style.background = '#667eea'; btnT.style.color = '#fff'; btnT.style.fontWeight = '600';
|
||||||
|
cT.style.display = 'block';
|
||||||
|
loadCarnes(1);
|
||||||
|
} else if (aba === 'dependentes') {
|
||||||
|
btnD.style.background = '#667eea'; btnD.style.color = '#fff'; btnD.style.fontWeight = '600';
|
||||||
|
cD.style.display = 'block';
|
||||||
|
carregarDependentes();
|
||||||
|
} else if (aba === 'convalescentes') {
|
||||||
|
btnC.style.background = '#667eea'; btnC.style.color = '#fff'; btnC.style.fontWeight = '600';
|
||||||
|
cC.style.display = 'block';
|
||||||
|
carregarConvalescentes();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function carregarConversas() {
|
||||||
|
var area = document.getElementById('conversasArea');
|
||||||
|
area.innerHTML = '<div class="loading" style="padding:20px"><div class="spinner"></div><p style="margin-top:8px">Carregando histórico...</p></div>';
|
||||||
|
try {
|
||||||
|
var res = await fetch('/api/' + alias + '/clients/' + clienteId + '/conversations', {
|
||||||
|
headers: { 'Authorization': 'Bearer ' + token }
|
||||||
|
});
|
||||||
|
var data = await res.json();
|
||||||
|
if (!data.success || !data.data || data.data.length === 0) {
|
||||||
|
area.innerHTML = '<div class="empty-msg">🔍 Nenhuma conversa encontrada para este cliente</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var html = '<div style="overflow-x:auto"><table><thead><tr>' +
|
||||||
|
'<th>Data</th><th>Contato</th><th>Tipo</th><th>Status</th><th>Atendente</th><th>Equipe</th><th></th>' +
|
||||||
|
'</tr></thead><tbody>';
|
||||||
|
data.data.forEach(function(cv) {
|
||||||
|
var dataStr = cv.dtUltimaMsg ? new Date(cv.dtUltimaMsg + (cv.dtUltimaMsg.includes('T') ? '' : 'T12:00:00')).toLocaleDateString('pt-BR') + ' ' + new Date(cv.dtUltimaMsg + (cv.dtUltimaMsg.includes('T') ? '' : 'T12:00:00')).toLocaleTimeString('pt-BR', {hour:'2-digit',minute:'2-digit'}) : '-';
|
||||||
|
var quem = cv.quemFalou || cv.nomeContato || cv.numero || '-';
|
||||||
|
var tipoPessoa = cv.isTitular ? '👤 Titular' : (cv.parentesco ? '👥 ' + cv.parentesco : '👥 Dependente');
|
||||||
|
var statusLabel = cv.status === 'A' ? '🟢 Aberta' : cv.status === 'E' ? '🟡 Em espera' : cv.status === 'F' ? '⚫ Finalizada' : cv.status;
|
||||||
|
var usuario = cv.usuarioNome || (cv.usuarioId ? '#' + cv.usuarioId : '-');
|
||||||
|
var equipe = cv.equipeNome || (cv.equipeId ? '#' + cv.equipeId : '-');
|
||||||
|
html += '<tr style="cursor:pointer" onclick="window.open(\'/app/' + alias + '/company/' + cv.empresaId + '/conversation/' + cv.id + '\', \'_blank\')">' +
|
||||||
|
'<td style="white-space:nowrap;font-size:12px">' + dataStr + '</td>' +
|
||||||
|
'<td><strong>' + quem + '</strong></td>' +
|
||||||
|
'<td style="font-size:12px">' + tipoPessoa + '</td>' +
|
||||||
|
'<td style="font-size:12px">' + statusLabel + '</td>' +
|
||||||
|
'<td style="font-size:12px">' + usuario + '</td>' +
|
||||||
|
'<td style="font-size:12px">' + equipe + '</td>' +
|
||||||
|
'<td><span style="color:#667eea;font-size:12px">Abrir →</span></td>' +
|
||||||
|
'</tr>';
|
||||||
|
});
|
||||||
|
html += '</tbody></table></div>';
|
||||||
|
area.innerHTML = html;
|
||||||
|
} catch(err) {
|
||||||
|
area.innerHTML = '<div class="error-box" style="padding:16px">Erro ao carregar histórico: ' + err.message + '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function carregarDependentes() {
|
||||||
|
var area = document.getElementById('dependentesArea');
|
||||||
|
area.innerHTML = '<div class="loading" style="padding:20px"><div class="spinner"></div><p style="margin-top:8px">Carregando...</p></div>';
|
||||||
|
try {
|
||||||
|
var res = await fetch('/api/' + alias + '/clients/' + clienteId + '/dependents', {
|
||||||
|
headers: { 'Authorization': 'Bearer ' + token }
|
||||||
|
});
|
||||||
|
var data = await res.json();
|
||||||
|
if (!data.success || !data.data || data.data.length === 0) {
|
||||||
|
area.innerHTML = '<div class="empty-msg">🔍 Nenhum dependente encontrado</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var html = '<div style="overflow-x:auto"><table><thead><tr><th>Nome</th><th>Parentesco</th><th>Telefone</th><th>Adicional</th><th>Valor</th><th>Situação</th></tr></thead><tbody>';
|
||||||
|
data.data.forEach(function(d) {
|
||||||
|
var sit = d.situacao === 'A' ? '<span class="situacao-badge ativo" style="font-size:11px">Ativo</span>' : '<span class="situacao-badge inativo" style="font-size:11px">Inativo</span>';
|
||||||
|
var adicional = d.adicional === 'S' ? '✅ Sim' : '❌ Não';
|
||||||
|
var telEdit = d.telefone || '';
|
||||||
|
html += '<tr><td><strong>' + d.nome + '</strong></td><td>' + getParentescoLabel(d.parentesco) + '</td><td>' +
|
||||||
|
'<span id="depTel_' + d.id + '">' + (telEdit || '-') + '</span> ' +
|
||||||
|
'<button onclick="editarTelDep(' + d.id + ')" data-tel="' + telEdit.replace(/"/g,'"') + '" style="padding:1px 6px;border:1px solid #d1d5db;border-radius:4px;background:#fff;cursor:pointer;font-size:10px">✏️</button></td><td>' + adicional + '</td><td style="text-align:right;font-family:monospace">R$ ' + (d.valorContribuicao / 100).toFixed(2) + '</td><td>' + sit + '</td></tr>';
|
||||||
|
});
|
||||||
|
html += '</tbody></table></div>';
|
||||||
|
area.innerHTML = html;
|
||||||
|
} catch(e) {
|
||||||
|
area.innerHTML = '<div class="empty-msg">⚠️ Erro ao carregar: ' + e.message + '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function carregarConvalescentes() {
|
||||||
|
var area = document.getElementById('convalescentesArea');
|
||||||
|
area.innerHTML = '<div class="loading" style="padding:20px"><div class="spinner"></div><p style="margin-top:8px">Carregando...</p></div>';
|
||||||
|
try {
|
||||||
|
var res = await fetch('/api/' + alias + '/clients/' + clienteId + '/convalescentes', {
|
||||||
|
headers: { 'Authorization': 'Bearer ' + token }
|
||||||
|
});
|
||||||
|
var data = await res.json();
|
||||||
|
if (!data.success || !data.data || data.data.length === 0) {
|
||||||
|
area.innerHTML = '<div class="empty-msg">🛏️ Nenhum convalescente encontrado</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var html = '';
|
||||||
|
data.data.forEach(function(c) {
|
||||||
|
var sitLabel = c.situacao || '-';
|
||||||
|
var sitBadge = c.situacao === 'D' ? '<span class="sit-badge" style="background:#d1fae5;color:#065f46">Devolvido</span>' :
|
||||||
|
c.situacao === 'E' ? '<span class="sit-badge" style="background:#fef3c7;color:#92400e">Entregue</span>' :
|
||||||
|
c.situacao === 'A' ? '<span class="sit-badge" style="background:#dbeafe;color:#1e40af">Ativo</span>' :
|
||||||
|
c.situacao === 'C' ? '<span class="sit-badge" style="background:#fef2f2;color:#991b1b">Cancelado</span>' :
|
||||||
|
'<span class="sit-badge" style="background:#f3f4f6;color:#6b7280">' + c.situacao + '</span>';
|
||||||
|
html += '<div style="border:1px solid #e5e7eb;border-radius:8px;padding:16px;margin-bottom:12px">' +
|
||||||
|
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">' +
|
||||||
|
'<h4 style="margin:0;font-size:15px">🛏️ Convalescente #' + c.id + '</h4>' +
|
||||||
|
sitBadge +
|
||||||
|
'</div>' +
|
||||||
|
'<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px;font-size:13px;margin-bottom:12px">' +
|
||||||
|
'<div><span style="color:#9ca3af">Valor Total:</span> <strong>R$ ' + (c.valorTotal || 0).toFixed(2) + '</strong></div>' +
|
||||||
|
'<div><span style="color:#9ca3af">Data Saída:</span> ' + fmtDate(c.dtSaida) + '</div>' +
|
||||||
|
'<div><span style="color:#9ca3af">Previsão Retorno:</span> ' + fmtDate(c.dtPrevisaoRetorno) + '</div>' +
|
||||||
|
(c.dtCancelou ? '<div><span style="color:#9ca3af">Data Cancelamento:</span> ' + fmtDate(c.dtCancelou) + '</div>' : '') +
|
||||||
|
'<div><span style="color:#9ca3af">Cadastrado por:</span> ' + (c.usuarioNome || '-') + '</div>' +
|
||||||
|
'</div>';
|
||||||
|
// SUB-ABAS: Boletos | Itens
|
||||||
|
var temCarnes = c.carnes && c.carnes.length > 0;
|
||||||
|
var temItens = c.itens && c.itens.length > 0;
|
||||||
|
if (temCarnes || temItens) {
|
||||||
|
html += '<div style="margin-top:12px;padding-top:12px;border-top:1px solid #e5e7eb">' +
|
||||||
|
'<div style="display:flex;gap:0;margin-bottom:8px;border-radius:6px;overflow:hidden">';
|
||||||
|
if (temCarnes) {
|
||||||
|
html += '<button class="subaba-ativa" onclick="mudarSubaba(\'carnes_' + c.id + '\',this)" id="subCarnes_' + c.id + '" style="flex:1;padding:6px 10px;border:none;background:#667eea;color:#fff;font-size:11px;font-weight:600;cursor:pointer">📄 Boletos (' + c.carnes.length + ')</button>';
|
||||||
|
}
|
||||||
|
if (temItens) {
|
||||||
|
html += '<button onclick="mudarSubaba(\'itens_' + c.id + '\',this)" id="subItens_' + c.id + '" style="flex:1;padding:6px 10px;border:none;background:#f3f4f6;color:#374151;font-size:11px;cursor:pointer">📦 Itens (' + c.itens.length + ')</button>';
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
// Conteudo Boletos
|
||||||
|
if (temCarnes) {
|
||||||
|
html += '<div id="conteudoCarnes_' + c.id + '" style="overflow-x:auto"><table style="font-size:12px"><thead><tr>' +
|
||||||
|
'<th>Vencimento</th><th style="text-align:right">Valor</th><th>Situação</th>' +
|
||||||
|
'</tr></thead><tbody>';
|
||||||
|
c.carnes.forEach(function(b) {
|
||||||
|
html += '<tr>' +
|
||||||
|
'<td>' + fmtDate(b.vencimento) + '</td>' +
|
||||||
|
'<td style="text-align:right;font-family:monospace">R$ ' + (b.valorParcela || 0).toFixed(2) + '</td>' +
|
||||||
|
'<td><span class="sit-badge" style="background:' + b.situacao.color + ';color:' + b.situacao.textColor + '">' + b.situacao.label + '</span></td>' +
|
||||||
|
'</tr>';
|
||||||
|
});
|
||||||
|
html += '</tbody></table></div>';
|
||||||
|
} else {
|
||||||
|
html += '<div id="conteudoCarnes_' + c.id + '" style="display:none"></div>';
|
||||||
|
}
|
||||||
|
// Conteudo Itens
|
||||||
|
html += '<div id="conteudoItens_' + c.id + '" style="' + (temCarnes && !temItens ? 'display:none' : '') + ';overflow-x:auto">';
|
||||||
|
if (temItens) {
|
||||||
|
html += '<table style="font-size:12px"><thead><tr>' +
|
||||||
|
'<th>Produto</th><th style="text-align:center">Quantidade</th>' +
|
||||||
|
'</tr></thead><tbody>';
|
||||||
|
c.itens.forEach(function(item) {
|
||||||
|
html += '<tr>' +
|
||||||
|
'<td>' + (item.produto || '-') + '</td>' +
|
||||||
|
'<td style="text-align:center;font-weight:600">' + (item.quantidade || 0) + '</td>' +
|
||||||
|
'</tr>';
|
||||||
|
});
|
||||||
|
html += '</tbody></table>';
|
||||||
|
} else {
|
||||||
|
html += '<div class="empty-msg" style="padding:12px;font-size:12px">📦 Nenhum item vinculado</div>';
|
||||||
|
}
|
||||||
|
html += '</div></div>';
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
});
|
||||||
|
area.innerHTML = html;
|
||||||
|
} catch(e) {
|
||||||
|
area.innerHTML = '<div class="empty-msg">⚠️ Erro: ' + e.message + '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alterna entre sub-abas (Boletos / Itens) dentro de cada convalescente
|
||||||
|
window.mudarSubaba = function(id, btn) {
|
||||||
|
var container = btn.parentElement.parentElement;
|
||||||
|
var botoes = container.querySelectorAll('button[id^="sub"]');
|
||||||
|
botoes.forEach(function(b) { b.style.background = '#f3f4f6'; b.style.color = '#374151'; });
|
||||||
|
btn.style.background = '#667eea'; btn.style.color = '#fff';
|
||||||
|
var prefixo = id.split('_')[0];
|
||||||
|
var covId = id.split('_')[1];
|
||||||
|
var cCarnes = document.getElementById('conteudoCarnes_' + covId);
|
||||||
|
var cItens = document.getElementById('conteudoItens_' + covId);
|
||||||
|
if (cCarnes) cCarnes.style.display = prefixo === 'carnes' ? '' : 'none';
|
||||||
|
if (cItens) cItens.style.display = prefixo === 'itens' ? '' : 'none';
|
||||||
|
};
|
||||||
|
|
||||||
|
window.editarTelDep = function(id) {
|
||||||
|
var btn = document.querySelector('button[onclick="editarTelDep(' + id + ')"]');
|
||||||
|
var telAtual = btn ? (btn.getAttribute('data-tel') || '') : '';
|
||||||
|
var novo = prompt('Editar telefone do dependente:', telAtual || '');
|
||||||
|
if (novo === null || novo.trim() === telAtual) return;
|
||||||
|
fetch('/api/' + alias + '/dependents/' + id + '/phone', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
|
||||||
|
body: JSON.stringify({ telefone: novo.trim() })
|
||||||
|
}).then(function(r) { return r.json(); }).then(function(d) {
|
||||||
|
if (d.success) { document.getElementById('depTel_' + id).textContent = novo.trim(); alert('Telefone atualizado!'); }
|
||||||
|
else alert('Erro: ' + d.error);
|
||||||
|
}).catch(function(e) { alert('Erro: ' + e.message); });
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// CARNES / TÍTULOS
|
||||||
|
// ============================================================
|
||||||
|
var carnesPage = 1;
|
||||||
|
|
||||||
|
// Chamado sempre que um checkbox de filtro é marcado/desmarcado
|
||||||
|
window.filtroClick = function(checkbox) {
|
||||||
|
var label = checkbox.closest('label');
|
||||||
|
if (checkbox.checked) {
|
||||||
|
label.classList.add('filtro-ativo');
|
||||||
|
} else {
|
||||||
|
label.classList.remove('filtro-ativo');
|
||||||
|
}
|
||||||
|
carnesPage = 1;
|
||||||
|
loadCarnes();
|
||||||
|
};
|
||||||
|
|
||||||
|
function getTiposAtivos() {
|
||||||
|
var tipos = [];
|
||||||
|
var labels = document.querySelectorAll('#carnesFilters label[data-tipo]');
|
||||||
|
for (var i = 0; i < labels.length; i++) {
|
||||||
|
if (labels[i].classList.contains('filtro-ativo')) {
|
||||||
|
tipos.push(labels[i].getAttribute('data-tipo'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tipos;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Torna global para os onclick dos botões de paginação
|
||||||
|
window.loadCarnes = function(page) {
|
||||||
|
if (page) carnesPage = page;
|
||||||
|
var area = document.getElementById('carnesArea');
|
||||||
|
if (!area) return;
|
||||||
|
|
||||||
|
var tipos = getTiposAtivos();
|
||||||
|
if (tipos.length === 0) {
|
||||||
|
area.innerHTML = '<div class="empty-msg">✅ Selecione ao menos um filtro</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var params = 'page=' + carnesPage + '&limit=20&tipo=' + tipos.join(',');
|
||||||
|
|
||||||
|
area.innerHTML = '<div class="loading" style="padding:20px"><div class="spinner"></div><p style="margin-top:8px">Carregando títulos...</p></div>';
|
||||||
|
|
||||||
|
apiFetch('/api/' + alias + '/clients/' + clienteId + '/carnes?' + params)
|
||||||
|
.then(function(data) {
|
||||||
|
if (!data.success) {
|
||||||
|
area.innerHTML = '<div class="empty-msg">⚠️ ' + data.error + '</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Atualiza contagens nos filtros
|
||||||
|
if (data.contagens) {
|
||||||
|
document.getElementById('cntAbertos').textContent = data.contagens.abertos || 0;
|
||||||
|
document.getElementById('cntBaixados').textContent = data.contagens.baixados || 0;
|
||||||
|
document.getElementById('cntParcial').textContent = data.contagens.parcial || 0;
|
||||||
|
document.getElementById('cntVencidos').textContent = data.contagens.vencidos || 0;
|
||||||
|
}
|
||||||
|
if (!data.data || data.data.length === 0) {
|
||||||
|
area.innerHTML = '<div class="empty-msg">🔍 Nenhum título encontrado</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderCarnes(data, area);
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
if (err.message === 'redirect') return;
|
||||||
|
area.innerHTML = '<div class="empty-msg">⚠️ ' + err.message + '</div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCarnes(data, area) {
|
||||||
|
var html = '<div style="overflow-x:auto"><table>' +
|
||||||
|
'<thead><tr>' +
|
||||||
|
'<th>#</th><th>Situação</th><th>Vencimento</th><th>Valor</th>' +
|
||||||
|
'<th>Pagamento</th><th>Agend. Cobrança</th><th>Nosso Número</th><th>Parcela</th>' +
|
||||||
|
'</tr></thead><tbody>';
|
||||||
|
|
||||||
|
for (var i = 0; i < data.data.length; i++) {
|
||||||
|
var c = data.data[i];
|
||||||
|
var bg = c.situacao.color || '#f3f4f6';
|
||||||
|
var tc = c.situacao.textColor || '#6b7280';
|
||||||
|
var vencDot = c.vencido ? '<span class="vencido-dot"></span>' : '';
|
||||||
|
|
||||||
|
html += '<tr>' +
|
||||||
|
'<td>' + c.id + '</td>' +
|
||||||
|
'<td><span class="sit-badge" style="background:' + bg + ';color:' + tc + '">' + c.situacao.descricao + '</span></td>' +
|
||||||
|
'<td>' + vencDot + fmtDate(c.vencimento) + '</td>' +
|
||||||
|
'<td class="valor">' + fmtMoney(c.valorParcela) + '</td>' +
|
||||||
|
'<td>' + fmtDate(c.dataPagamento) + '</td>' +
|
||||||
|
'<td style="font-size:11px">' + fmtDate(c.agendamentoCobranca) + '</td>' +
|
||||||
|
'<td>' + (c.nossoNumero || '-') + '</td>' +
|
||||||
|
'<td class="centro">' + (c.parcela || '-') + '/' + (c.totalParcelas || '-') + '</td>' +
|
||||||
|
'</tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</tbody></table></div>';
|
||||||
|
|
||||||
|
// Paginação
|
||||||
|
if (data.totalPages > 1) {
|
||||||
|
html += '<div class="pagination">';
|
||||||
|
html += '<button onclick="loadCarnes(' + (carnesPage - 1) + ')" ' + (carnesPage <= 1 ? 'disabled' : '') + '>← Anterior</button>';
|
||||||
|
|
||||||
|
var start = Math.max(1, carnesPage - 2);
|
||||||
|
var end = Math.min(data.totalPages, carnesPage + 2);
|
||||||
|
|
||||||
|
if (start > 1) {
|
||||||
|
html += '<button onclick="loadCarnes(1)">1</button>';
|
||||||
|
if (start > 2) html += '<button disabled>...</button>';
|
||||||
|
}
|
||||||
|
for (var p = start; p <= end; p++) {
|
||||||
|
html += '<button onclick="loadCarnes(' + p + ')" class="' + (p === carnesPage ? 'active' : '') + '">' + p + '</button>';
|
||||||
|
}
|
||||||
|
if (end < data.totalPages) {
|
||||||
|
if (end < data.totalPages - 1) html += '<button disabled>...</button>';
|
||||||
|
html += '<button onclick="loadCarnes(' + data.totalPages + ')">' + data.totalPages + '</button>';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '<button onclick="loadCarnes(' + (carnesPage + 1) + ')" ' + (carnesPage >= data.totalPages ? 'disabled' : '') + '>Próximo →</button>';
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
area.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== EDITAR CONTATO =====
|
||||||
|
window.editarContato = function() {
|
||||||
|
var form = document.getElementById('editContatoForm');
|
||||||
|
document.getElementById('editEmail').value = document.getElementById('campoEmail').textContent === '-' ? '' : document.getElementById('campoEmail').textContent;
|
||||||
|
document.getElementById('editCelular').value = document.getElementById('campoCelular').textContent === '-' ? '' : document.getElementById('campoCelular').textContent;
|
||||||
|
document.getElementById('editTelefone').value = document.getElementById('campoTelefone').textContent === '-' ? '' : document.getElementById('campoTelefone').textContent;
|
||||||
|
form.style.display = 'block';
|
||||||
|
};
|
||||||
|
|
||||||
|
window.cancelarEditarContato = function() {
|
||||||
|
document.getElementById('editContatoForm').style.display = 'none';
|
||||||
|
};
|
||||||
|
|
||||||
|
window.salvarContato = async function() {
|
||||||
|
var email = document.getElementById('editEmail').value.trim();
|
||||||
|
var celular = document.getElementById('editCelular').value.trim();
|
||||||
|
var telefone = document.getElementById('editTelefone').value.trim();
|
||||||
|
|
||||||
|
var data = {};
|
||||||
|
if (email) data.email = email;
|
||||||
|
if (celular) data.celular = celular;
|
||||||
|
if (telefone) data.telefone = telefone;
|
||||||
|
|
||||||
|
if (Object.keys(data).length === 0) { alert('Preencha ao menos um campo'); return; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
var r = await fetch('/api/' + alias + '/clients/' + clienteId, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
var d = await r.json();
|
||||||
|
if (d.success) {
|
||||||
|
alert('Dados atualizados!');
|
||||||
|
document.getElementById('editContatoForm').style.display = 'none';
|
||||||
|
loadClient(); // Recarrega os dados
|
||||||
|
} else {
|
||||||
|
alert('Erro: ' + d.error);
|
||||||
|
}
|
||||||
|
} catch(e) { alert('Erro: ' + e.message); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== MODAL NOVA CONVERSA =====
|
||||||
|
window.abrirModalConversa = async function(empresaId, clienteId, nome, celular) {
|
||||||
|
document.getElementById('convEmpresaIdDet').value = empresaId;
|
||||||
|
document.getElementById('convClienteIdDet').value = clienteId;
|
||||||
|
document.getElementById('convNumeroDet').value = celular || '';
|
||||||
|
document.getElementById('convNomeDet').value = nome || '';
|
||||||
|
document.getElementById('convMensagemDet').value = 'Olá ' + (nome || '') + '! Tudo bem? Como podemos ajudar?';
|
||||||
|
|
||||||
|
var sel = document.getElementById('convInstanciaDet');
|
||||||
|
sel.innerHTML = '<option value="">Carregando...</option>';
|
||||||
|
try {
|
||||||
|
var r = await fetch('/api/' + alias + '/evolution/instances?empresaId=' + empresaId, {
|
||||||
|
headers: { 'Authorization': 'Bearer ' + token }
|
||||||
|
});
|
||||||
|
var d = await r.json();
|
||||||
|
if (d.success && d.data.length > 0) {
|
||||||
|
sel.innerHTML = d.data.map(function(i) {
|
||||||
|
return '<option value="' + i.id + '">' + i.nome + ' (' + i.instanceName + ')</option>';
|
||||||
|
}).join('');
|
||||||
|
} else {
|
||||||
|
sel.innerHTML = '<option value="">Nenhuma instância disponível</option>';
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
sel.innerHTML = '<option value="">Erro ao carregar</option>';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('modalNovaConvDet').style.display = 'flex';
|
||||||
|
};
|
||||||
|
|
||||||
|
window.iniciarConversaDet = async function() {
|
||||||
|
var empresaId = parseInt(document.getElementById('convEmpresaIdDet').value);
|
||||||
|
var numero = document.getElementById('convNumeroDet').value;
|
||||||
|
var nome = document.getElementById('convNomeDet').value;
|
||||||
|
var mensagem = document.getElementById('convMensagemDet').value.trim();
|
||||||
|
var instanciaId = document.getElementById('convInstanciaDet').value;
|
||||||
|
var clienteId = parseInt(document.getElementById('convClienteIdDet').value);
|
||||||
|
|
||||||
|
if (!numero || !mensagem) { alert('Informe o número e a mensagem'); return; }
|
||||||
|
var numCompleto = numero.startsWith('55') ? numero : '55' + numero;
|
||||||
|
|
||||||
|
try {
|
||||||
|
var r = await fetch('/api/' + alias + '/conversations/create', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
|
||||||
|
body: JSON.stringify({ empresaId: empresaId, numero: numCompleto, nomeContato: nome, mensagem: mensagem, instanciaId: instanciaId ? parseInt(instanciaId) : null, clienteId: clienteId || null })
|
||||||
|
});
|
||||||
|
var d = await r.json();
|
||||||
|
if (d.success) {
|
||||||
|
document.getElementById('modalNovaConvDet').style.display = 'none';
|
||||||
|
window.location.href = '/app/' + alias + '/company/' + empresaId + '/conversation/' + d.data.id;
|
||||||
|
} else {
|
||||||
|
alert('Erro: ' + d.error);
|
||||||
|
}
|
||||||
|
} catch(e) { alert('Erro de conexão: ' + e.message); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fechar modal ao clicar fora
|
||||||
|
document.getElementById('modalNovaConvDet').onclick = function(e) { if (e.target === this) this.style.display = 'none'; };
|
||||||
|
|
||||||
|
// ---- Iniciar ----
|
||||||
|
loadClient();
|
||||||
|
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<script src="/js/dark-mode.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="pt-BR">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Clientes - Chatc2</title>
|
||||||
|
<link rel="stylesheet" href="/css/main.css">
|
||||||
|
<style>
|
||||||
|
body { background: #f3f4f6; display: flex; min-height: 100vh; }
|
||||||
|
.sidebar-nav .admin-only { display: none; }
|
||||||
|
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
||||||
|
.container { flex: 1; padding: 24px; overflow-y: auto; }
|
||||||
|
</style>
|
||||||
|
<link rel="stylesheet" href="/css/dark-mode.css">
|
||||||
|
<script>function darkModeToggle(){var e=document.body;if(!e)return;var a=localStorage.getItem('chatc2_dark_mode')!=='true';e.classList.toggle('dark-mode',a);localStorage.setItem('chatc2_dark_mode',a?'true':'false');document.querySelectorAll('.dark-mode-toggle').forEach(function(b){b.innerHTML=a?'☀️ Claro':'🌙 Escuro'});}
|
||||||
|
window.darkModeApply=function(a){var e=document.body;if(!e)return;e.classList.toggle('dark-mode',a);localStorage.setItem('chatc2_dark_mode',a?'true':'false');document.querySelectorAll('.dark-mode-toggle').forEach(function(b){b.innerHTML=a?'☀️ Claro':'🌙 Escuro'});};
|
||||||
|
window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')==='true';};</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebar-brand">
|
||||||
|
<div class="logo">C2</div>
|
||||||
|
<div><h2>Chatc2</h2><span id="sidebarAlias">lajedo</span></div>
|
||||||
|
</div>
|
||||||
|
<nav class="sidebar-nav">
|
||||||
|
<div class="nav-label">Principal</div>
|
||||||
|
<a href="#" id="navDashboard"><span class="icon">📊</span> Dashboard</a>
|
||||||
|
<a href="#" class="active" id="navClients"><span class="icon">👥</span> Clientes</a>
|
||||||
|
<a href="#" id="navChat"><span class="icon">💬</span> Conversas</a>
|
||||||
|
<div class="nav-label admin-only" id="adminLabel">Administrador</div>
|
||||||
|
<a href="#" id="navConfig" class="admin-only"><span class="icon">⚙️</span> Configurações</a>
|
||||||
|
<a href="#" id="navRoutes" class="admin-only"><span class="icon">📡</span> Rotas</a>
|
||||||
|
</nav>
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<button class="dark-mode-toggle" onclick="darkModeToggle()" style="width:100%;margin-bottom:8px;padding:8px">🌙 Escuro</button>
|
||||||
|
<a onclick="logout()"><span class="icon">🚪</span> Sair</a>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="main">
|
||||||
|
<div class="topbar">
|
||||||
|
<span class="topbar-title">👥 Clientes</span>
|
||||||
|
<div class="user-info">
|
||||||
|
<span class="status-badge online">● Online</span>
|
||||||
|
<span class="user-name" id="userName"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="search-bar">
|
||||||
|
<select id="empresaSelect">
|
||||||
|
<option value="">Todas as empresas</option>
|
||||||
|
</select>
|
||||||
|
<input type="text" id="searchInput" placeholder="Buscar por nome, matrícula ou CPF..." autofocus />
|
||||||
|
<button id="btnSearch">🔍 Buscar</button>
|
||||||
|
<span class="total-info" id="totalInfo"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Matrícula</th>
|
||||||
|
<th>Nome</th>
|
||||||
|
<th>CPF</th>
|
||||||
|
<th>Telefone</th>
|
||||||
|
<th>Empresa</th>
|
||||||
|
<th>Situação</th>
|
||||||
|
<th>Ação</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="tableBody">
|
||||||
|
<tr class="loading-row"><td colspan="7"><div class="spinner"></div><div style="margin-top:8px">Carregando...</div></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="pagination" id="pagination"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const pathParts = window.location.pathname.split('/');
|
||||||
|
const alias = pathParts[2] || localStorage.getItem('chatc2_alias') || 'lajedo';
|
||||||
|
localStorage.setItem('chatc2_alias', alias);
|
||||||
|
|
||||||
|
const token = localStorage.getItem('chatc2_token');
|
||||||
|
const user = JSON.parse(localStorage.getItem('chatc2_user') || '{}');
|
||||||
|
|
||||||
|
if (!token) { window.location.href = '/app/' + alias + '/login'; }
|
||||||
|
|
||||||
|
// Sidebar admin
|
||||||
|
const tipoChat = user.tipoChat || 'A';
|
||||||
|
if (tipoChat === 'G') {
|
||||||
|
document.querySelectorAll('.admin-only').forEach(function(el) { el.style.display = ''; });
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('sidebarAlias').textContent = alias;
|
||||||
|
document.getElementById('userName').textContent = user.nome || '-';
|
||||||
|
|
||||||
|
function navClick(e, url) { e.preventDefault(); window.location.href = url; }
|
||||||
|
document.getElementById('navDashboard').onclick = function(e) { navClick(e, '/app/' + alias + '/dashboard'); };
|
||||||
|
document.getElementById('navClients').onclick = function(e) { navClick(e, '/app/' + alias + '/clients'); };
|
||||||
|
document.getElementById('navChat').onclick = function(e) { navClick(e, '/app/' + alias + '/company/' + (user.empresas?.[0] || 1) + '/conversation/0'); };
|
||||||
|
document.getElementById('navConfig').onclick = function(e) { navClick(e, '/app/' + alias + '/settings'); };
|
||||||
|
document.getElementById('navRoutes').onclick = function(e) { navClick(e, '/app/' + alias + '/routes'); };
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
['chatc2_token','chatc2_alias','chatc2_user'].forEach(k => localStorage.removeItem(k));
|
||||||
|
window.location.href = '/app/' + alias + '/login';
|
||||||
|
}
|
||||||
|
|
||||||
|
// === ESTADO ===
|
||||||
|
let currentPage = 1, currentSearch = '', currentEmpresa = '';
|
||||||
|
const LIMIT = 20;
|
||||||
|
|
||||||
|
const searchInput = document.getElementById('searchInput');
|
||||||
|
const btnSearch = document.getElementById('btnSearch');
|
||||||
|
const tableBody = document.getElementById('tableBody');
|
||||||
|
const paginationEl = document.getElementById('pagination');
|
||||||
|
const totalInfo = document.getElementById('totalInfo');
|
||||||
|
const empresaSelect = document.getElementById('empresaSelect');
|
||||||
|
|
||||||
|
// === CARREGAR EMPRESAS DO USUÁRIO ===
|
||||||
|
async function loadEmpresas() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/' + alias + '/empresas', {
|
||||||
|
headers: { 'Authorization': 'Bearer ' + token }
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success && data.data.length > 1) {
|
||||||
|
data.data.forEach(emp => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = emp.id;
|
||||||
|
opt.textContent = emp.nomeFantasia || emp.nome;
|
||||||
|
empresaSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
empresaSelect.style.display = '';
|
||||||
|
} else {
|
||||||
|
empresaSelect.style.display = 'none';
|
||||||
|
}
|
||||||
|
} catch (e) { /* ignora */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// === BUSCAR CLIENTES ===
|
||||||
|
async function fetchClients(page = 1, search = '', empresaId = '') {
|
||||||
|
tableBody.innerHTML = '<tr class=\"loading-row\"><td colspan=\"7\"><div class=\"spinner\"></div><div style=\"margin-top:8px\">Carregando...</div></td></tr>';
|
||||||
|
|
||||||
|
const params = new URLSearchParams({ q: search, page: page, limit: LIMIT });
|
||||||
|
if (empresaId) params.set('empresaId', empresaId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/' + alias + '/clients/search?' + params, {
|
||||||
|
headers: { 'Authorization': 'Bearer ' + token }
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!data.success) { showError(data.error); return; }
|
||||||
|
|
||||||
|
currentPage = data.page;
|
||||||
|
currentSearch = search;
|
||||||
|
currentEmpresa = empresaId;
|
||||||
|
|
||||||
|
renderTable(data.data, data.empresasPermitidas);
|
||||||
|
renderPagination(data.total, data.page, data.totalPages);
|
||||||
|
updateTotalInfo(data.total, data.page, data.totalPages);
|
||||||
|
} catch (err) {
|
||||||
|
showError('Erro de conexão: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable(clients, empresasPermitidas) {
|
||||||
|
if (!clients || clients.length === 0) {
|
||||||
|
tableBody.innerHTML = '<tr><td colspan=\"7\"><div class=\"empty-state\"><div class=\"icon\">🔍</div><p>Nenhum cliente encontrado</p></div></td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mapeia empresaId -> nome
|
||||||
|
const empMap = {};
|
||||||
|
if (empresaSelect.options.length > 1) {
|
||||||
|
for (let i = 1; i < empresaSelect.options.length; i++) {
|
||||||
|
empMap[empresaSelect.options[i].value] = empresaSelect.options[i].textContent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tableBody.innerHTML = clients.map(c => {
|
||||||
|
const sit = c.situacao === 'A' ? 'ativo' : 'inativo';
|
||||||
|
const sitLabel = c.situacao === 'A' ? 'Ativo' : 'Inativo';
|
||||||
|
const cel = c.celular || c.telefone || '-';
|
||||||
|
const empNome = empMap[c.empresaId] || 'Empresa ' + c.empresaId;
|
||||||
|
|
||||||
|
return '<tr>' +
|
||||||
|
'<td><strong>' + (c.matricula || '-') + '</strong></td>' +
|
||||||
|
'<td><a class="client-link" href="/app/' + alias + '/company/' + c.empresaId + '/client/' + c.id + '">' + c.nome + '</a></td>' +
|
||||||
|
'<td>' + (c.cpf || '-') + '</td>' +
|
||||||
|
'<td>' + cel + '</td>' +
|
||||||
|
'<td><span class="empresa-tag">' + empNome + '</span></td>' +
|
||||||
|
'<td><span class="situacao-badge ' + sit + '">' + sitLabel + '</span></td>' +
|
||||||
|
'<td>' +
|
||||||
|
'<a class="client-link" href="/app/' + alias + '/company/' + c.empresaId + '/client/' + c.id + '" title="Ver detalhes">Ver →</a>' +
|
||||||
|
' <a class="client-link" href="#" onclick="abrirModalConversa(' + c.empresaId + ',' + c.id + ',\'' + (c.nome||'').replace(/'/g,"\\'") + '\',\'' + (c.celular||'').replace(/\D/g,'') + '\');return false" title="Iniciar conversa">💬</a>' +
|
||||||
|
'</tr>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPagination(total, page, totalPages) {
|
||||||
|
if (!total || total === 0) { paginationEl.innerHTML = ''; return; }
|
||||||
|
|
||||||
|
let html = '<button onclick="goToPage(' + (page - 1) + ')" ' + (page <= 1 ? 'disabled' : '') + '>← Anterior</button>';
|
||||||
|
|
||||||
|
const start = Math.max(1, page - 2);
|
||||||
|
const end = Math.min(totalPages, page + 2);
|
||||||
|
|
||||||
|
if (start > 1) {
|
||||||
|
html += '<button onclick="goToPage(1)">1</button>';
|
||||||
|
if (start > 2) html += '<button disabled>...</button>';
|
||||||
|
}
|
||||||
|
for (let i = start; i <= end; i++) {
|
||||||
|
html += '<button onclick="goToPage(' + i + ')" class="' + (i === page ? 'active' : '') + '">' + i + '</button>';
|
||||||
|
}
|
||||||
|
if (end < totalPages) {
|
||||||
|
if (end < totalPages - 1) html += '<button disabled>...</button>';
|
||||||
|
html += '<button onclick="goToPage(' + totalPages + ')">' + totalPages + '</button>';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '<button onclick="goToPage(' + (page + 1) + ')" ' + (page >= totalPages ? 'disabled' : '') + '>Próximo →</button>';
|
||||||
|
paginationEl.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTotalInfo(total, page, totalPages) {
|
||||||
|
if (total === 0) { totalInfo.textContent = ''; return; }
|
||||||
|
const start = ((page - 1) * LIMIT) + 1;
|
||||||
|
const end = Math.min(page * LIMIT, total);
|
||||||
|
totalInfo.textContent = start + '-' + end + ' de ' + total + ' (pág ' + page + '/' + totalPages + ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToPage(page) { if (page < 1) return; fetchClients(page, currentSearch, currentEmpresa); }
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
tableBody.innerHTML = '<tr><td colspan=\"7\"><div class=\"empty-state\"><div class=\"icon\">⚠️</div><p>' + msg + '</p></div></td></tr>';
|
||||||
|
paginationEl.innerHTML = '';
|
||||||
|
totalInfo.textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// === EVENTOS ===
|
||||||
|
btnSearch.addEventListener('click', () => fetchClients(1, searchInput.value.trim(), empresaSelect.value));
|
||||||
|
searchInput.addEventListener('keydown', e => { if (e.key === 'Enter') fetchClients(1, searchInput.value.trim(), empresaSelect.value); });
|
||||||
|
empresaSelect.addEventListener('change', () => fetchClients(1, searchInput.value.trim(), empresaSelect.value));
|
||||||
|
|
||||||
|
// === INICIAR ===
|
||||||
|
loadEmpresas().then(() => fetchClients(1, '', ''));
|
||||||
|
</script>
|
||||||
|
<script src="/js/dark-mode.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
|
<!-- Modal Nova Conversa -->
|
||||||
|
<div class="modal-overlay" id="modalNovaConv" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);align-items:center;justify-content:center;z-index:1000">
|
||||||
|
<div style="background:#fff;border-radius:12px;padding:24px;width:90%;max-width:450px">
|
||||||
|
<h3 style="margin-bottom:16px">💬 Iniciar Conversa</h3>
|
||||||
|
<input type="hidden" id="convClienteId">
|
||||||
|
<input type="hidden" id="convEmpresaId">
|
||||||
|
<div class="form-group"><label>Número</label><input type="text" id="convNumero" readonly style="background:#f9fafb"></div>
|
||||||
|
<div class="form-group"><label>Cliente</label><input type="text" id="convNome" readonly style="background:#f9fafb"></div>
|
||||||
|
<div class="form-group"><label>Instância WhatsApp</label><select id="convInstancia" style="width:100%;padding:10px;border:2px solid #e5e7eb;border-radius:8px;font-size:14px"></select></div>
|
||||||
|
<div class="form-group"><label>Mensagem inicial</label><textarea id="convMensagem" rows="3" style="width:100%;padding:10px;border:2px solid #e5e7eb;border-radius:8px;font-size:14px;resize:vertical" placeholder="Digite a mensagem que será enviada..."></textarea></div>
|
||||||
|
<div style="display:flex;gap:8px;justify-content:flex-end">
|
||||||
|
<button class="btn" onclick="fecharModalConv()" style="padding:8px 16px;border:1px solid #d1d5db;border-radius:8px;background:#fff;cursor:pointer">Cancelar</button>
|
||||||
|
<button class="btn btn-primary" onclick="iniciarConversa()" style="padding:8px 16px;background:#667eea;color:#fff;border:none;border-radius:8px;cursor:pointer">💬 Enviar e Abrir Chat</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ===== NOVA CONVERSA =====
|
||||||
|
var modalConvEl = document.getElementById('modalNovaConv');
|
||||||
|
|
||||||
|
window.abrirModalConversa = async function(empresaId, clienteId, nome, celular) {
|
||||||
|
document.getElementById('convEmpresaId').value = empresaId;
|
||||||
|
document.getElementById('convClienteId').value = clienteId;
|
||||||
|
document.getElementById('convNumero').value = celular || '';
|
||||||
|
document.getElementById('convNome').value = nome || '';
|
||||||
|
document.getElementById('convMensagem').value = 'Olá ' + (nome || '') + '! Tudo bem? Como podemos ajudar?';
|
||||||
|
|
||||||
|
// Carregar instâncias
|
||||||
|
var sel = document.getElementById('convInstancia');
|
||||||
|
sel.innerHTML = '<option value="">Carregando...</option>';
|
||||||
|
try {
|
||||||
|
var r = await fetch('/api/' + alias + '/evolution/instances?empresaId=' + empresaId, {
|
||||||
|
headers: { 'Authorization': 'Bearer ' + token }
|
||||||
|
});
|
||||||
|
var d = await r.json();
|
||||||
|
if (d.success && d.data.length > 0) {
|
||||||
|
sel.innerHTML = d.data.map(function(i) {
|
||||||
|
return '<option value="' + i.id + '">' + i.nome + ' (' + i.instanceName + ')</option>';
|
||||||
|
}).join('');
|
||||||
|
} else {
|
||||||
|
sel.innerHTML = '<option value="">Nenhuma instância disponível</option>';
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
sel.innerHTML = '<option value="">Erro ao carregar</option>';
|
||||||
|
}
|
||||||
|
|
||||||
|
modalConvEl.style.display = 'flex';
|
||||||
|
};
|
||||||
|
|
||||||
|
window.fecharModalConv = function() {
|
||||||
|
modalConvEl.style.display = 'none';
|
||||||
|
};
|
||||||
|
|
||||||
|
window.iniciarConversa = async function() {
|
||||||
|
var empresaId = document.getElementById('convEmpresaId').value;
|
||||||
|
var numero = document.getElementById('convNumero').value;
|
||||||
|
var nome = document.getElementById('convNome').value;
|
||||||
|
var mensagem = document.getElementById('convMensagem').value.trim();
|
||||||
|
var instanciaId = document.getElementById('convInstancia').value;
|
||||||
|
var clienteId = document.getElementById('convClienteId').value;
|
||||||
|
|
||||||
|
if (!numero || !mensagem) {
|
||||||
|
alert('Informe o número e a mensagem');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adicionar código do país se necessário
|
||||||
|
var numCompleto = numero.startsWith('55') ? numero : '55' + numero;
|
||||||
|
|
||||||
|
try {
|
||||||
|
var r = await fetch('/api/' + alias + '/conversations/create', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
|
||||||
|
body: JSON.stringify({ empresaId: parseInt(empresaId), numero: numCompleto, nomeContato: nome, mensagem: mensagem, instanciaId: instanciaId ? parseInt(instanciaId) : null, clienteId: clienteId ? parseInt(clienteId) : null })
|
||||||
|
});
|
||||||
|
var d = await r.json();
|
||||||
|
if (d.success) {
|
||||||
|
fecharModalConv();
|
||||||
|
window.location.href = '/app/' + alias + '/company/' + empresaId + '/conversation/' + d.data.id;
|
||||||
|
} else {
|
||||||
|
alert('Erro: ' + d.error);
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
alert('Erro de conexão: ' + e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fechar modal ao clicar fora
|
||||||
|
modalConvEl.addEventListener('click', function(e) { if (e.target === this) this.style.display = 'none'; });
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="pt-BR">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Avalie seu Atendimento</title>
|
||||||
|
<style>
|
||||||
|
* { margin:0; padding:0; box-sizing:border-box; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 40px;
|
||||||
|
max-width: 480px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 20px 60px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
.logo { font-size: 48px; margin-bottom: 16px; }
|
||||||
|
h1 { font-size: 24px; color: #1f2937; margin-bottom: 8px; }
|
||||||
|
.subtitle { font-size: 14px; color: #6b7280; margin-bottom: 32px; }
|
||||||
|
|
||||||
|
.stars {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
.stars input { display: none; }
|
||||||
|
.stars label {
|
||||||
|
font-size: 48px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #d1d5db;
|
||||||
|
transition: color .2s, transform .15s;
|
||||||
|
}
|
||||||
|
.stars label:hover,
|
||||||
|
.stars label:hover ~ label,
|
||||||
|
.stars input:checked ~ label {
|
||||||
|
color: #f59e0b;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
.stars input:checked + label {
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 2px solid #e5e7eb;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 80px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color .2s;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
textarea:focus { border-color: #667eea; }
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity .2s;
|
||||||
|
}
|
||||||
|
.btn:hover { opacity: .9; }
|
||||||
|
.btn:disabled { opacity: .5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.rating-text {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6b7280;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
min-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success { display: none; }
|
||||||
|
.success .icon { font-size: 64px; margin-bottom: 16px; }
|
||||||
|
.success h2 { color: #059669; margin-bottom: 8px; }
|
||||||
|
.success p { color: #6b7280; }
|
||||||
|
|
||||||
|
.erro {
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 12px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container" id="app">
|
||||||
|
<div class="logo">💬</div>
|
||||||
|
<h1>Avalie seu Atendimento</h1>
|
||||||
|
<p class="subtitle">Sua opinião é muito importante para melhorarmos nosso serviço</p>
|
||||||
|
|
||||||
|
<div class="rating-text" id="ratingText">Toque nas estrelas para avaliar</div>
|
||||||
|
|
||||||
|
<div class="stars" id="starContainer">
|
||||||
|
<input type="radio" name="star" id="star5" value="5">
|
||||||
|
<label for="star5" title="Excelente">★</label>
|
||||||
|
<input type="radio" name="star" id="star4" value="4">
|
||||||
|
<label for="star4" title="Bom">★</label>
|
||||||
|
<input type="radio" name="star" id="star3" value="3">
|
||||||
|
<label for="star3" title="Regular">★</label>
|
||||||
|
<input type="radio" name="star" id="star2" value="2">
|
||||||
|
<label for="star2" title="Ruim">★</label>
|
||||||
|
<input type="radio" name="star" id="star1" value="1">
|
||||||
|
<label for="star1" title="Péssimo">★</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea id="comentario" placeholder="Deixe seu comentário (opcional)..."></textarea>
|
||||||
|
<button class="btn" id="btnEnviar" onclick="enviar()">Enviar Avaliação</button>
|
||||||
|
<div class="erro" id="erro"></div>
|
||||||
|
|
||||||
|
<div class="success" id="success">
|
||||||
|
<div class="icon">✅</div>
|
||||||
|
<h2>Agradecemos sua avaliação!</h2>
|
||||||
|
<p>Seu feedback nos ajuda a melhorar cada vez mais.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var alias, conversaId, empresaId;
|
||||||
|
|
||||||
|
function getParams() {
|
||||||
|
var params = new URLSearchParams(window.location.search);
|
||||||
|
alias = params.get('alias');
|
||||||
|
conversaId = params.get('conversa');
|
||||||
|
empresaId = params.get('empresa');
|
||||||
|
|
||||||
|
if (!alias || !conversaId || !empresaId) {
|
||||||
|
document.getElementById('erro').textContent = 'Link inválido. Entre em contato conosco.';
|
||||||
|
document.getElementById('erro').style.display = 'block';
|
||||||
|
document.querySelector('.stars').style.display = 'none';
|
||||||
|
document.querySelector('textarea').style.display = 'none';
|
||||||
|
document.getElementById('btnEnviar').style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var nota = 0;
|
||||||
|
document.querySelectorAll('.stars input').forEach(function(input) {
|
||||||
|
input.addEventListener('change', function() {
|
||||||
|
nota = parseInt(this.value);
|
||||||
|
var textos = ['', 'Péssimo', 'Ruim', 'Regular', 'Bom', 'Excelente!'];
|
||||||
|
document.getElementById('ratingText').textContent = textos[nota] || '';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function enviar() {
|
||||||
|
if (nota === 0) {
|
||||||
|
document.getElementById('erro').textContent = 'Selecione uma avaliação de 1 a 5 estrelas.';
|
||||||
|
document.getElementById('erro').style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var btn = document.getElementById('btnEnviar');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Enviando...';
|
||||||
|
document.getElementById('erro').style.display = 'none';
|
||||||
|
|
||||||
|
try {
|
||||||
|
var resp = await fetch('/api/' + alias + '/csat/avaliar', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
conversaId: parseInt(conversaId),
|
||||||
|
empresaId: parseInt(empresaId),
|
||||||
|
nota: nota,
|
||||||
|
comentario: document.getElementById('comentario').value.trim()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
var data = await resp.json();
|
||||||
|
if (data.success) {
|
||||||
|
document.getElementById('app').querySelector('.stars').style.display = 'none';
|
||||||
|
document.querySelector('textarea').style.display = 'none';
|
||||||
|
document.getElementById('btnEnviar').style.display = 'none';
|
||||||
|
document.getElementById('ratingText').style.display = 'none';
|
||||||
|
document.getElementById('success').style.display = 'block';
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error || 'Erro ao enviar');
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
document.getElementById('erro').textContent = 'Erro ao enviar: ' + e.message;
|
||||||
|
document.getElementById('erro').style.display = 'block';
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Enviar Avaliação';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getParams();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,648 @@
|
|||||||
|
/* =====================================================
|
||||||
|
CHATC2 - Design System Compartilhado
|
||||||
|
===================================================== */
|
||||||
|
|
||||||
|
/* ===== VARIÁVEIS ===== */
|
||||||
|
:root {
|
||||||
|
--primary: #667eea;
|
||||||
|
--primary-dark: #5a67d8;
|
||||||
|
--secondary: #764ba2;
|
||||||
|
--sidebar-from: #1e1b4b;
|
||||||
|
--sidebar-to: #312e81;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-2: #f9fafb;
|
||||||
|
--surface-3: #f3f4f6;
|
||||||
|
--border: #e5e7eb;
|
||||||
|
--border-light: #f3f4f6;
|
||||||
|
--text-primary: #111827;
|
||||||
|
--text-secondary: #374151;
|
||||||
|
--text-muted: #6b7280;
|
||||||
|
--text-faint: #9ca3af;
|
||||||
|
--success: #059669;
|
||||||
|
--success-bg: #d1fae5;
|
||||||
|
--success-text: #065f46;
|
||||||
|
--danger: #ef4444;
|
||||||
|
--danger-bg: #fef2f2;
|
||||||
|
--danger-text: #991b1b;
|
||||||
|
--warning: #f59e0b;
|
||||||
|
--warning-bg: #fef3c7;
|
||||||
|
--warning-text: #92400e;
|
||||||
|
--radius-sm: 6px;
|
||||||
|
--radius-md: 10px;
|
||||||
|
--radius-lg: 16px;
|
||||||
|
--shadow-sm: 0 1px 3px rgba(0,0,0,0.08);
|
||||||
|
--shadow-md: 0 4px 16px rgba(0,0,0,0.10);
|
||||||
|
--shadow-lg: 0 20px 60px rgba(0,0,0,0.18);
|
||||||
|
--transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== RESET GLOBAL ===== */
|
||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== SIDEBAR COMPARTILHADA ===== */
|
||||||
|
.sidebar {
|
||||||
|
width: 240px;
|
||||||
|
background: linear-gradient(180deg, var(--sidebar-from) 0%, var(--sidebar-to) 100%);
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-brand {
|
||||||
|
padding: 20px 18px;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-brand .logo {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
background: rgba(255,255,255,0.18);
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 15px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-brand .logo img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-brand h2 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
letter-spacing: -0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-brand span {
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(255,255,255,0.45);
|
||||||
|
display: block;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav {
|
||||||
|
padding: 12px 10px;
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav::-webkit-scrollbar { width: 3px; }
|
||||||
|
.sidebar-nav::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
.sidebar-nav::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 3px; }
|
||||||
|
|
||||||
|
.sidebar-nav .nav-label {
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: rgba(255,255,255,0.35);
|
||||||
|
padding: 10px 10px 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav a {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: rgba(255,255,255,0.72);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: var(--transition);
|
||||||
|
margin-bottom: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav a:hover {
|
||||||
|
background: rgba(255,255,255,0.10);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav a.active {
|
||||||
|
background: rgba(255,255,255,0.16);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav a .icon {
|
||||||
|
width: 22px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer {
|
||||||
|
padding: 12px 10px;
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer .dark-mode-toggle {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: rgba(255,255,255,0.07);
|
||||||
|
border: 1px solid rgba(255,255,255,0.12);
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(255,255,255,0.75);
|
||||||
|
transition: var(--transition);
|
||||||
|
text-align: left;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer .dark-mode-toggle:hover {
|
||||||
|
background: rgba(255,255,255,0.14);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer a {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: rgba(255,255,255,0.55);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 13px;
|
||||||
|
transition: var(--transition);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer a:hover {
|
||||||
|
background: rgba(255,255,255,0.10);
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== TOPBAR ===== */
|
||||||
|
.topbar {
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 14px 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-title {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
letter-spacing: -0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.online {
|
||||||
|
background: var(--success-bg);
|
||||||
|
color: var(--success-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== CARDS ===== */
|
||||||
|
.card {
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h3 {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== INFO GRID ===== */
|
||||||
|
.info-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item {
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item .label {
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item .value {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== BOTÕES ===== */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 9px 18px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition: var(--transition);
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
.btn-primary:hover { background: var(--primary-dark); border-color: var(--primary-dark); }
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
.btn-secondary:hover { background: var(--surface-2); }
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: var(--danger);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--danger);
|
||||||
|
}
|
||||||
|
.btn-danger:hover { background: #dc2626; }
|
||||||
|
|
||||||
|
/* ===== FORMULÁRIOS ===== */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control,
|
||||||
|
input[type="text"],
|
||||||
|
input[type="password"],
|
||||||
|
input[type="email"],
|
||||||
|
input[type="number"],
|
||||||
|
input[type="search"],
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--surface-2);
|
||||||
|
outline: none;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control:focus,
|
||||||
|
input[type="text"]:focus,
|
||||||
|
input[type="password"]:focus,
|
||||||
|
select:focus,
|
||||||
|
textarea:focus {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: 0 0 0 3px rgba(102,126,234,0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== TABELAS ===== */
|
||||||
|
.table-wrapper {
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead {
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 700;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:hover td { background: var(--surface-2); }
|
||||||
|
tr:last-child td { border-bottom: none; }
|
||||||
|
|
||||||
|
/* ===== BADGES ===== */
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.situacao-badge { display: inline-block; padding: 3px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; }
|
||||||
|
.situacao-badge.ativo { background: var(--success-bg); color: var(--success-text); }
|
||||||
|
.situacao-badge.inativo { background: var(--danger-bg); color: var(--danger-text); }
|
||||||
|
|
||||||
|
.empresa-tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: #ede9fe;
|
||||||
|
color: #5b21b6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-link {
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.client-link:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* ===== PAGINAÇÃO ===== */
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-top: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination button {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination button:hover:not(:disabled) { background: var(--surface-2); border-color: var(--text-faint); }
|
||||||
|
.pagination button.active { background: var(--primary); border-color: var(--primary); color: #fff; }
|
||||||
|
.pagination button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
.pagination .page-info { font-size: 13px; color: var(--text-muted); padding: 0 10px; }
|
||||||
|
|
||||||
|
/* ===== MODAL ===== */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0;
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
background: rgba(0,0,0,0.48);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay.show { display: flex; }
|
||||||
|
|
||||||
|
.modal-box {
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 28px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 440px;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
animation: modalIn 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modalIn {
|
||||||
|
from { opacity: 0; transform: scale(0.96) translateY(-10px); }
|
||||||
|
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-box h3 {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== ESTADOS VAZIOS ===== */
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state .icon { font-size: 48px; margin-bottom: 12px; display: block; }
|
||||||
|
.empty-state p { font-size: 14px; }
|
||||||
|
|
||||||
|
/* ===== SPINNERS ===== */
|
||||||
|
.spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 22px; height: 22px;
|
||||||
|
border: 3px solid var(--border);
|
||||||
|
border-top-color: var(--primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.6s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.loading-row td {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== TOKEN BOX ===== */
|
||||||
|
.token-box {
|
||||||
|
background: #1f2937;
|
||||||
|
color: #e5e7eb;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-family: 'SF Mono', 'Cascadia Code', Monaco, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
word-break: break-all;
|
||||||
|
line-height: 1.6;
|
||||||
|
max-height: 120px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-copy {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: #374151;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.btn-copy:hover { background: #4b5563; }
|
||||||
|
|
||||||
|
/* ===== SEARCH BAR ===== */
|
||||||
|
.search-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 180px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
background: var(--surface);
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(102,126,234,0.1); }
|
||||||
|
|
||||||
|
.search-bar select {
|
||||||
|
padding: 10px 16px;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
background: var(--surface);
|
||||||
|
min-width: 200px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar select:focus { border-color: var(--primary); }
|
||||||
|
|
||||||
|
.search-bar button {
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.search-bar button:hover { background: var(--primary-dark); }
|
||||||
|
.search-bar button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.search-bar .total-info {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-left: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== BACK BUTTON ===== */
|
||||||
|
.back-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
.back-btn:hover { color: var(--primary-dark); }
|
||||||
|
|
||||||
|
/* ===== SCROLLBAR CUSTOMIZADA ===== */
|
||||||
|
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { background: #d1d5db; border-radius: 3px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: #9ca3af; }
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="pt-BR">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Dashboard - Chatc2</title>
|
||||||
|
<link rel="stylesheet" href="/css/main.css">
|
||||||
|
<style>
|
||||||
|
body { background: #f3f4f6; display: flex; min-height: 100vh; }
|
||||||
|
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
||||||
|
.container { flex: 1; padding: 24px; overflow-y: auto; }
|
||||||
|
</style>
|
||||||
|
<link rel="stylesheet" href="/css/dark-mode.css">
|
||||||
|
<script>function darkModeToggle(){var e=document.body;if(!e)return;var a=localStorage.getItem('chatc2_dark_mode')!=='true';e.classList.toggle('dark-mode',a);localStorage.setItem('chatc2_dark_mode',a?'true':'false');document.querySelectorAll('.dark-mode-toggle').forEach(function(b){b.innerHTML=a?'☀️ Claro':'🌙 Escuro'});}
|
||||||
|
window.darkModeApply=function(a){var e=document.body;if(!e)return;e.classList.toggle('dark-mode',a);localStorage.setItem('chatc2_dark_mode',a?'true':'false');document.querySelectorAll('.dark-mode-toggle').forEach(function(b){b.innerHTML=a?'☀️ Claro':'🌙 Escuro'});};
|
||||||
|
window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')==='true';};</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- SIDEBAR -->
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebar-brand">
|
||||||
|
<div class="logo">C2</div>
|
||||||
|
<div>
|
||||||
|
<h2>Chatc2</h2>
|
||||||
|
<span id="sidebarAlias">lajedo</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<nav class="sidebar-nav">
|
||||||
|
<div class="nav-label">Principal</div>
|
||||||
|
<a href="#" class="active" id="navDashboard">
|
||||||
|
<span class="icon">📊</span> Dashboard
|
||||||
|
</a>
|
||||||
|
<a href="#" id="navClients">
|
||||||
|
<span class="icon">👥</span> Clientes
|
||||||
|
</a>
|
||||||
|
<a href="#" id="navChat">
|
||||||
|
<span class="icon">💬</span> Conversas
|
||||||
|
</a>
|
||||||
|
<div class="nav-label" id="adminLabel" style="display:none">Administrador</div>
|
||||||
|
<a href="#" id="navConfig" style="display:none">
|
||||||
|
<span class="icon">⚙️</span> Configurações
|
||||||
|
</a>
|
||||||
|
<a href="#" id="navAllConvs" style="display:none"><span class="icon">💬</span> Todas Conversas</a>
|
||||||
|
<a href="#" id="navRoutes" style="display:none">
|
||||||
|
<span class="icon">📡</span> Rotas
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<button class="dark-mode-toggle" onclick="darkModeToggle()" style="width:100%;margin-bottom:8px;padding:8px">🌙 Escuro</button>
|
||||||
|
<a onclick="logout()">
|
||||||
|
<span class="icon">🚪</span> Sair
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- MAIN -->
|
||||||
|
<div class="main">
|
||||||
|
<div class="topbar">
|
||||||
|
<span class="topbar-title">Dashboard</span>
|
||||||
|
<div class="user-info">
|
||||||
|
<span class="status-badge online">● Online</span>
|
||||||
|
<span class="user-name" id="userName"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="card">
|
||||||
|
<h3>👤 Dados do Usuário</h3>
|
||||||
|
<div class="info-grid">
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="label">ID</div>
|
||||||
|
<div class="value" id="userId">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="label">Nome</div>
|
||||||
|
<div class="value" id="userNameDisplay">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="label">Login</div>
|
||||||
|
<div class="value" id="userLogin">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="label">Email</div>
|
||||||
|
<div class="value" id="userEmail">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="label">Nível</div>
|
||||||
|
<div class="value" id="userNivel">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="label">Tipo</div>
|
||||||
|
<div class="value" id="userTipo">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="label">Autenticação</div>
|
||||||
|
<div class="value" id="userAuthType">-</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>🔑 Token de Acesso</h3>
|
||||||
|
<button class="btn-copy" onclick="showToken()" id="btnShowToken" style="margin-bottom:8px">Mostrar Token</button>
|
||||||
|
<div class="token-box" id="tokenDisplay" style="display:none"></div>
|
||||||
|
<button class="btn-copy" onclick="copyToken()" id="btnCopyToken" style="display:none">Copiar Token</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Extrai alias da URL
|
||||||
|
const pathParts = window.location.pathname.split('/');
|
||||||
|
const alias = pathParts[2] || localStorage.getItem('chatc2_alias') || 'lajedo';
|
||||||
|
localStorage.setItem('chatc2_alias', alias);
|
||||||
|
|
||||||
|
const token = localStorage.getItem('chatc2_token');
|
||||||
|
const user = JSON.parse(localStorage.getItem('chatc2_user') || '{}');
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
window.location.href = '/app/' + alias + '/login';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sidebar - mostra opções de admin se for Gerente
|
||||||
|
const tipoChat = user.tipoChat || 'A';
|
||||||
|
if (tipoChat === 'G') {
|
||||||
|
document.getElementById('adminLabel').style.display = '';
|
||||||
|
document.getElementById('navConfig').style.display = '';
|
||||||
|
document.getElementById('navRoutes').style.display = '';
|
||||||
|
} else {
|
||||||
|
// Agente não vê dashboard - redireciona para conversas
|
||||||
|
window.location.href = '/app/' + alias + '/company/' + (user.empresas?.[0] || 1) + '/conversation/0';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('sidebarAlias').textContent = alias;
|
||||||
|
document.getElementById('userId').textContent = user.id || '-';
|
||||||
|
document.getElementById('userNameDisplay').textContent = user.nome || '-';
|
||||||
|
document.getElementById('userName').textContent = user.nome || '-';
|
||||||
|
document.getElementById('userLogin').textContent = user.login || '-';
|
||||||
|
document.getElementById('userEmail').textContent = user.email || '-';
|
||||||
|
document.getElementById('userNivel').textContent = user.nivelId || '-';
|
||||||
|
document.getElementById('userTipo').textContent = user.tipo || '-';
|
||||||
|
document.getElementById('userAuthType').textContent = user.authType || 'jwt';
|
||||||
|
// Token foi carregado, mas fica oculto até clicar em Mostrar Token
|
||||||
|
|
||||||
|
// Navegação sidebar
|
||||||
|
function navClick(e, url) {
|
||||||
|
e.preventDefault();
|
||||||
|
window.location.href = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('navDashboard').onclick = function(e) { navClick(e, '/app/' + alias + '/dashboard'); };
|
||||||
|
document.getElementById('navClients').onclick = function(e) { navClick(e, '/app/' + alias + '/clients'); };
|
||||||
|
document.getElementById('navChat').onclick = function(e) { navClick(e, '/app/' + alias + '/company/' + (user.empresas?.[0] || 1) + '/conversation/0'); };
|
||||||
|
document.getElementById('navConfig').onclick = function(e) { navClick(e, '/app/' + alias + '/settings'); };
|
||||||
|
document.getElementById('navRoutes').onclick = function(e) { navClick(e, '/app/' + alias + '/routes'); };
|
||||||
|
|
||||||
|
function copyToken() {
|
||||||
|
navigator.clipboard.writeText(token).then(() => {
|
||||||
|
alert('Token copiado!');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
localStorage.removeItem('chatc2_token');
|
||||||
|
localStorage.removeItem('chatc2_alias');
|
||||||
|
localStorage.removeItem('chatc2_user');
|
||||||
|
window.location.href = '/app/' + alias + '/login';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToken() {
|
||||||
|
const display = document.getElementById('tokenDisplay');
|
||||||
|
const btnShow = document.getElementById('btnShowToken');
|
||||||
|
const btnCopy = document.getElementById('btnCopyToken');
|
||||||
|
if (display.style.display === 'none') {
|
||||||
|
display.textContent = token;
|
||||||
|
display.style.display = 'block';
|
||||||
|
btnShow.textContent = 'Ocultar Token';
|
||||||
|
btnCopy.style.display = 'inline-block';
|
||||||
|
} else {
|
||||||
|
display.style.display = 'none';
|
||||||
|
btnShow.textContent = 'Mostrar Token';
|
||||||
|
btnCopy.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<script src="/js/dark-mode.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// Dark Mode Controller - Only handles initialization/persistence
|
||||||
|
(function() {
|
||||||
|
if (typeof window.darkModeToggle !== 'function') {
|
||||||
|
// Ensure function exists (inline script in head should have defined it)
|
||||||
|
window.darkModeToggle = function() {
|
||||||
|
var el = document.body;
|
||||||
|
if (!el) return;
|
||||||
|
var isDark = localStorage.getItem('chatc2_dark_mode') === 'true';
|
||||||
|
var enable = !isDark;
|
||||||
|
el.classList.toggle('dark-mode', enable);
|
||||||
|
localStorage.setItem('chatc2_dark_mode', enable ? 'true' : 'false');
|
||||||
|
document.querySelectorAll('.dark-mode-toggle').forEach(function(btn) {
|
||||||
|
btn.innerHTML = enable ? '☀️ Claro' : '🌙 Escuro';
|
||||||
|
});
|
||||||
|
};
|
||||||
|
window.darkModeApply = function(enable) {
|
||||||
|
var el = document.body;
|
||||||
|
if (!el) return;
|
||||||
|
el.classList.toggle('dark-mode', enable);
|
||||||
|
localStorage.setItem('chatc2_dark_mode', enable ? 'true' : 'false');
|
||||||
|
document.querySelectorAll('.dark-mode-toggle').forEach(function(btn) {
|
||||||
|
btn.innerHTML = enable ? '☀️ Claro' : '🌙 Escuro';
|
||||||
|
});
|
||||||
|
};
|
||||||
|
window.darkModeIsDark = function() { return localStorage.getItem('chatc2_dark_mode') === 'true'; };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply on load
|
||||||
|
if (localStorage.getItem('chatc2_dark_mode') === 'true') {
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
document.body.classList.add('dark-mode');
|
||||||
|
document.querySelectorAll('.dark-mode-toggle').forEach(function(btn) {
|
||||||
|
btn.innerHTML = '☀️ Claro';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
document.body.classList.add('dark-mode');
|
||||||
|
document.querySelectorAll('.dark-mode-toggle').forEach(function(btn) {
|
||||||
|
btn.innerHTML = '☀️ Claro';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="pt-BR">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Login - Chatc2</title>
|
||||||
|
<link rel="stylesheet" href="/css/main.css">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
|
||||||
|
background-size: 400% 400%;
|
||||||
|
animation: gradientShift 12s ease infinite;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gradientShift {
|
||||||
|
0% { background-position: 0% 50%; }
|
||||||
|
50% { background-position: 100% 50%; }
|
||||||
|
100% { background-position: 0% 50%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-container {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 44px 40px 36px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
box-shadow: 0 32px 80px rgba(0,0,0,0.28), 0 0 0 1px rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header .logo {
|
||||||
|
width: 76px;
|
||||||
|
height: 76px;
|
||||||
|
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||||
|
border-radius: 20px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
font-size: 30px;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 800;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 8px 24px rgba(102,126,234,0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header .logo img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #111827;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header p {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
margin-bottom: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 2px solid #e5e7eb;
|
||||||
|
border-radius: 11px;
|
||||||
|
font-size: 15px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
outline: none;
|
||||||
|
background: #f9fafb;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus {
|
||||||
|
border-color: #667eea;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 0 0 4px rgba(102,126,234,0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input.error {
|
||||||
|
border-color: #ef4444;
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-wrapper { position: relative; }
|
||||||
|
.password-wrapper input { padding-right: 46px; }
|
||||||
|
|
||||||
|
.toggle-password {
|
||||||
|
position: absolute;
|
||||||
|
right: 12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 18px;
|
||||||
|
padding: 4px;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
.toggle-password:hover { color: #6b7280; }
|
||||||
|
|
||||||
|
.btn-login {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px;
|
||||||
|
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 11px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s, transform 0.1s;
|
||||||
|
margin-top: 4px;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
.btn-login:hover { opacity: 0.92; }
|
||||||
|
.btn-login:active { transform: scale(0.98); }
|
||||||
|
.btn-login:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #dc2626;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
display: none;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.error-message.show { display: block; }
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
display: none;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 3px solid rgba(255,255,255,0.35);
|
||||||
|
border-top-color: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.6s linear infinite;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.btn-login.loading .btn-text { display: none; }
|
||||||
|
.btn-login.loading .loading-spinner { display: block; }
|
||||||
|
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* Dark mode toggle no login */
|
||||||
|
.login-dark-toggle {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 24px;
|
||||||
|
right: 24px;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
.login-dark-toggle button {
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: 24px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.3);
|
||||||
|
background: rgba(255,255,255,0.15);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.login-dark-toggle button:hover {
|
||||||
|
background: rgba(255,255,255,0.25);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<link rel="stylesheet" href="/css/dark-mode.css">
|
||||||
|
<script>function darkModeToggle(){var e=document.body;if(!e)return;var a=localStorage.getItem('chatc2_dark_mode')!=='true';e.classList.toggle('dark-mode',a);localStorage.setItem('chatc2_dark_mode',a?'true':'false');document.querySelectorAll('.dark-mode-toggle').forEach(function(b){b.innerHTML=a?'☀️ Claro':'🌙 Escuro'});}
|
||||||
|
window.darkModeApply=function(a){var e=document.body;if(!e)return;e.classList.toggle('dark-mode',a);localStorage.setItem('chatc2_dark_mode',a?'true':'false');document.querySelectorAll('.dark-mode-toggle').forEach(function(b){b.innerHTML=a?'☀️ Claro':'🌙 Escuro'});};
|
||||||
|
window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')==='true';};</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="login-container">
|
||||||
|
<div class="login-header">
|
||||||
|
<div class="logo" id="logoEl">C2</div>
|
||||||
|
<h1>Bem-vindo</h1>
|
||||||
|
<p id="empresaNome">Faça login para acessar o Chatc2</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="errorMessage" class="error-message"></div>
|
||||||
|
|
||||||
|
<form id="loginForm" autocomplete="off">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="USU_LOGIN">Usuário</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="USU_LOGIN"
|
||||||
|
name="USU_LOGIN"
|
||||||
|
placeholder="Digite seu usuário"
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="USU_SENHA">Senha</label>
|
||||||
|
<div class="password-wrapper">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="USU_SENHA"
|
||||||
|
name="USU_SENHA"
|
||||||
|
placeholder="Digite sua senha"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button type="button" class="toggle-password" id="togglePassword" tabindex="-1">
|
||||||
|
👁️
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn-login" id="btnLogin">
|
||||||
|
<span class="btn-text">Entrar</span>
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Extrai o alias da URL: /app/:alias/login
|
||||||
|
const pathParts = window.location.pathname.split('/');
|
||||||
|
const alias = pathParts[2] || 'lajedo';
|
||||||
|
|
||||||
|
const form = document.getElementById('loginForm');
|
||||||
|
const btnLogin = document.getElementById('btnLogin');
|
||||||
|
const errorMsg = document.getElementById('errorMessage');
|
||||||
|
const loginInput = document.getElementById('USU_LOGIN');
|
||||||
|
const senhaInput = document.getElementById('USU_SENHA');
|
||||||
|
const togglePassword = document.getElementById('togglePassword');
|
||||||
|
const logoEl = document.getElementById('logoEl');
|
||||||
|
const empresaNome = document.getElementById('empresaNome');
|
||||||
|
|
||||||
|
// Mostrar/ocultar senha
|
||||||
|
togglePassword.addEventListener('click', () => {
|
||||||
|
const type = senhaInput.getAttribute('type') === 'password' ? 'text' : 'password';
|
||||||
|
senhaInput.setAttribute('type', type);
|
||||||
|
togglePassword.textContent = type === 'password' ? '👁️' : '👁️🗨️';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Limpar erro ao digitar
|
||||||
|
loginInput.addEventListener('input', () => {
|
||||||
|
errorMsg.classList.remove('show');
|
||||||
|
loginInput.classList.remove('error');
|
||||||
|
senhaInput.classList.remove('error');
|
||||||
|
});
|
||||||
|
senhaInput.addEventListener('input', () => {
|
||||||
|
errorMsg.classList.remove('show');
|
||||||
|
senhaInput.classList.remove('error');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Carrega logo da empresa
|
||||||
|
async function carregarLogo() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/' + alias + '/empresa/logo');
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
if (data.fotoUrl) {
|
||||||
|
logoEl.innerHTML = '<img src="' + data.fotoUrl + '" alt="Logo">';
|
||||||
|
} else if (data.iniciais) {
|
||||||
|
logoEl.textContent = data.iniciais;
|
||||||
|
logoEl.style.background = data.cor || 'linear-gradient(135deg, #667eea, #764ba2)';
|
||||||
|
}
|
||||||
|
if (data.nomeFantasia) {
|
||||||
|
empresaNome.textContent = data.nomeFantasia;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch(e) { /* usa padrão */ }
|
||||||
|
}
|
||||||
|
carregarLogo();
|
||||||
|
|
||||||
|
// Submit do formulário
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const USU_LOGIN = loginInput.value.trim().toUpperCase();
|
||||||
|
const USU_SENHA = senhaInput.value;
|
||||||
|
|
||||||
|
if (!USU_LOGIN || !USU_SENHA) {
|
||||||
|
showError('Preencha todos os campos.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
btnLogin.classList.add('loading');
|
||||||
|
btnLogin.disabled = true;
|
||||||
|
errorMsg.classList.remove('show');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/app/' + alias + '/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ USU_LOGIN, USU_SENHA }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok || !data.success) {
|
||||||
|
showError(data.error || 'Erro ao fazer login.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Salva o token, dados do usuário e alias
|
||||||
|
localStorage.setItem('chatc2_token', data.token);
|
||||||
|
localStorage.setItem('chatc2_alias', data.alias || alias);
|
||||||
|
localStorage.setItem('chatc2_user', JSON.stringify(data.user));
|
||||||
|
|
||||||
|
// Redireciona para o dashboard com o alias
|
||||||
|
window.location.href = '/app/' + alias + '/dashboard';
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
showError('Erro de conexão com o servidor.');
|
||||||
|
} finally {
|
||||||
|
btnLogin.classList.remove('loading');
|
||||||
|
btnLogin.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function showError(message) {
|
||||||
|
errorMsg.textContent = message;
|
||||||
|
errorMsg.classList.add('show');
|
||||||
|
loginInput.classList.add('error');
|
||||||
|
senhaInput.classList.add('error');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<div class="login-dark-toggle">
|
||||||
|
<button class="dark-mode-toggle" onclick="darkModeToggle()">🌙 Escuro</button>
|
||||||
|
</div>
|
||||||
|
<script src="/js/dark-mode.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="pt-BR">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Rotas - Chatc2 API</title>
|
||||||
|
<link rel="stylesheet" href="/css/main.css">
|
||||||
|
<style>
|
||||||
|
body { background:#f3f4f6; display:flex; min-height:100vh; }
|
||||||
|
.sidebar-nav .admin-only { display:none; }
|
||||||
|
.main { flex:1; display:flex; flex-direction:column; min-width:0; }
|
||||||
|
.container { flex:1; padding:24px; overflow-y:auto; }
|
||||||
|
|
||||||
|
/* Barra de busca/filtros */
|
||||||
|
.search-box { margin-bottom:18px; display:flex; gap:10px; flex-wrap:wrap; align-items:center; }
|
||||||
|
.search-box input { flex:1; min-width:220px; padding:10px 16px; border:2px solid #e5e7eb; border-radius:8px; font-size:14px; outline:none; transition:border-color .15s; background:#fff; color:#1f2937; }
|
||||||
|
.search-box input:focus { border-color:#667eea; }
|
||||||
|
.search-box select { padding:10px 12px; border:2px solid #e5e7eb; border-radius:8px; font-size:13px; outline:none; background:#fff; cursor:pointer; color:#374151; }
|
||||||
|
.search-box select:focus { border-color:#667eea; }
|
||||||
|
.btn-ghost { padding:9px 14px; border:2px solid #e5e7eb; border-radius:8px; background:#fff; cursor:pointer; font-size:13px; color:#374151; }
|
||||||
|
.btn-ghost:hover { background:#f3f4f6; }
|
||||||
|
|
||||||
|
/* Legenda */
|
||||||
|
.legenda { display:flex; gap:16px; flex-wrap:wrap; font-size:12px; color:#6b7280; margin-bottom:16px; align-items:center; }
|
||||||
|
.legenda .pill { display:inline-flex; align-items:center; gap:5px; }
|
||||||
|
|
||||||
|
/* Categoria */
|
||||||
|
.cat-group { margin-bottom:26px; }
|
||||||
|
.cat-title { display:flex; align-items:center; gap:8px; font-size:15px; font-weight:700; color:#374151; margin:0 0 10px; }
|
||||||
|
.cat-title .count { font-size:11px; background:#e5e7eb; color:#6b7280; padding:2px 8px; border-radius:10px; font-weight:600; }
|
||||||
|
|
||||||
|
/* Card de rota (expansível) */
|
||||||
|
.route { background:#fff; border:1px solid #e5e7eb; border-radius:10px; margin-bottom:8px; overflow:hidden; }
|
||||||
|
.route[open] { box-shadow:0 1px 6px rgba(0,0,0,.06); border-color:#d1d5db; }
|
||||||
|
.route-head { list-style:none; cursor:pointer; display:flex; align-items:center; gap:10px; padding:12px 14px; flex-wrap:wrap; }
|
||||||
|
.route-head::-webkit-details-marker { display:none; }
|
||||||
|
.route-head:hover { background:#fafafa; }
|
||||||
|
.route[open] .route-head { border-bottom:1px solid #f0f0f0; background:#fafbff; }
|
||||||
|
.chev { color:#9ca3af; font-size:11px; transition:transform .15s; }
|
||||||
|
.route[open] .chev { transform:rotate(90deg); }
|
||||||
|
|
||||||
|
.method { display:inline-block; padding:3px 9px; border-radius:5px; font-size:11px; font-weight:700; text-transform:uppercase; color:#fff; min-width:54px; text-align:center; }
|
||||||
|
.method.get { background:#059669; }
|
||||||
|
.method.post { background:#2563eb; }
|
||||||
|
.method.put { background:#d97706; }
|
||||||
|
.method.delete { background:#dc2626; }
|
||||||
|
.method.patch { background:#7c3aed; }
|
||||||
|
|
||||||
|
.route-path { font-family:'SF Mono',Monaco,'Cascadia Code',monospace; font-size:13px; color:#1f2937; word-break:break-all; }
|
||||||
|
.route-path .ph { color:#7c3aed; font-style:normal; }
|
||||||
|
.route-summary-desc { color:#6b7280; font-size:12.5px; flex:1 1 100%; margin-left:64px; }
|
||||||
|
|
||||||
|
.auth-badge { font-size:10.5px; font-weight:600; padding:2px 8px; border-radius:20px; white-space:nowrap; }
|
||||||
|
.auth-yes { background:#fef3c7; color:#92400e; }
|
||||||
|
.auth-no { background:#d1fae5; color:#065f46; }
|
||||||
|
|
||||||
|
/* Detalhe expandido */
|
||||||
|
.route-detail { padding:14px 16px 16px; }
|
||||||
|
.block { margin-top:14px; }
|
||||||
|
.block:first-child { margin-top:0; }
|
||||||
|
.block-title { font-size:12px; font-weight:700; color:#6b7280; text-transform:uppercase; letter-spacing:.4px; margin-bottom:7px; display:flex; align-items:center; gap:8px; }
|
||||||
|
|
||||||
|
.param-table { width:100%; border-collapse:collapse; font-size:13px; }
|
||||||
|
.param-table th { text-align:left; font-size:10.5px; text-transform:uppercase; color:#9ca3af; padding:4px 10px; border-bottom:1px solid #eee; font-weight:600; }
|
||||||
|
.param-table td { padding:6px 10px; border-bottom:1px solid #f3f4f6; vertical-align:top; color:#374151; }
|
||||||
|
.param-table td.pname { font-family:'SF Mono',monospace; color:#1f2937; font-weight:600; white-space:nowrap; }
|
||||||
|
|
||||||
|
.tag { font-size:10px; font-weight:700; padding:2px 7px; border-radius:12px; white-space:nowrap; }
|
||||||
|
.tag.req { background:#fee2e2; color:#b91c1c; }
|
||||||
|
.tag.opt { background:#f3f4f6; color:#6b7280; }
|
||||||
|
|
||||||
|
.code-json { background:#0f172a; color:#e2e8f0; border-radius:8px; padding:12px 14px; font-family:'SF Mono',Monaco,'Cascadia Code',monospace; font-size:12.5px; line-height:1.55; overflow-x:auto; margin:0; white-space:pre; }
|
||||||
|
.code-curl { background:#111827; color:#a7f3d0; border-radius:8px; padding:12px 14px; font-family:'SF Mono',monospace; font-size:12px; line-height:1.5; overflow-x:auto; margin:0; white-space:pre; }
|
||||||
|
.req-note { font-size:12px; color:#6b7280; margin-top:7px; }
|
||||||
|
.req-note code { background:#f3f4f6; padding:1px 6px; border-radius:4px; color:#b91c1c; font-weight:600; }
|
||||||
|
.note-box { font-size:12.5px; color:#475569; background:#f8fafc; border-left:3px solid #cbd5e1; padding:8px 12px; border-radius:0 6px 6px 0; }
|
||||||
|
|
||||||
|
.copy { padding:3px 9px; border:none; border-radius:5px; background:#e5e7eb; cursor:pointer; font-size:11px; color:#374151; }
|
||||||
|
.copy:hover { background:#d1d5db; }
|
||||||
|
.copy-light { background:rgba(255,255,255,.12); color:#e2e8f0; }
|
||||||
|
.copy-light:hover { background:rgba(255,255,255,.24); }
|
||||||
|
|
||||||
|
.empty { text-align:center; padding:50px; color:#9ca3af; font-size:14px; }
|
||||||
|
</style>
|
||||||
|
<link rel="stylesheet" href="/css/dark-mode.css">
|
||||||
|
<script>function darkModeToggle(){var e=document.body;if(!e)return;var a=localStorage.getItem('chatc2_dark_mode')!=='true';e.classList.toggle('dark-mode',a);localStorage.setItem('chatc2_dark_mode',a?'true':'false');document.querySelectorAll('.dark-mode-toggle').forEach(function(b){b.innerHTML=a?'☀️ Claro':'🌙 Escuro'});}
|
||||||
|
window.darkModeApply=function(a){var e=document.body;if(!e)return;e.classList.toggle('dark-mode',a);localStorage.setItem('chatc2_dark_mode',a?'true':'false');document.querySelectorAll('.dark-mode-toggle').forEach(function(b){b.innerHTML=a?'☀️ Claro':'🌙 Escuro'});};
|
||||||
|
window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')==='true';};</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebar-brand">
|
||||||
|
<div class="logo">C2</div>
|
||||||
|
<div><h2>Chatc2</h2><span>API Routes</span></div>
|
||||||
|
</div>
|
||||||
|
<nav class="sidebar-nav">
|
||||||
|
<div class="nav-label">Principal</div>
|
||||||
|
<a href="#" id="navDashboard"><span class="icon">📊</span> Dashboard</a>
|
||||||
|
<a href="#" id="navClients"><span class="icon">👥</span> Clientes</a>
|
||||||
|
<a href="#" id="navChatRoutes"><span class="icon">💬</span> Conversas</a>
|
||||||
|
<div class="nav-label admin-only">Administrador</div>
|
||||||
|
<a href="#" id="navConfigRoutes" class="admin-only"><span class="icon">⚙️</span> Configurações</a>
|
||||||
|
<a href="#" class="active admin-only" id="navRoutesActive"><span class="icon">📡</span> Rotas</a>
|
||||||
|
</nav>
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<button class="dark-mode-toggle" onclick="darkModeToggle()" style="width:100%;margin-bottom:8px;padding:8px">🌙 Escuro</button>
|
||||||
|
<a onclick="logout()"><span class="icon">🚪</span> Sair</a>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<div class="main">
|
||||||
|
<div class="topbar">
|
||||||
|
<span class="topbar-title">📡 Rotas da API</span>
|
||||||
|
<span id="totalRotas" style="font-size:13px;color:#6b7280"></span>
|
||||||
|
</div>
|
||||||
|
<div class="container">
|
||||||
|
<div class="search-box">
|
||||||
|
<input type="text" id="searchInput" placeholder="Buscar por método, path ou descrição..." oninput="filtrarRotas()">
|
||||||
|
<select id="filterCat" onchange="filtrarRotas()"><option value="">Todas as categorias</option></select>
|
||||||
|
<select id="filterMethod" onchange="filtrarRotas()">
|
||||||
|
<option value="">Todos os métodos</option>
|
||||||
|
<option value="GET">GET</option><option value="POST">POST</option>
|
||||||
|
<option value="PUT">PUT</option><option value="DELETE">DELETE</option>
|
||||||
|
</select>
|
||||||
|
<select id="filterAuth" onchange="filtrarRotas()">
|
||||||
|
<option value="">Pública e protegida</option>
|
||||||
|
<option value="yes">🔒 Requer token</option>
|
||||||
|
<option value="no">🔓 Pública</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn-ghost" onclick="expandirTodas(true)">Expandir</button>
|
||||||
|
<button class="btn-ghost" onclick="expandirTodas(false)">Recolher</button>
|
||||||
|
<button class="btn-ghost" onclick="carregarRotas()">🔄 Atualizar</button>
|
||||||
|
</div>
|
||||||
|
<div class="legenda">
|
||||||
|
<span class="pill"><span class="auth-badge auth-yes">🔒 Token</span> requer <code>Authorization: Bearer <token></code></span>
|
||||||
|
<span class="pill"><span class="auth-badge auth-no">🔓 Público</span> sem autenticação</span>
|
||||||
|
<span class="pill"><span class="tag req">obrigatório</span> / <span class="tag opt">opcional</span></span>
|
||||||
|
</div>
|
||||||
|
<div id="statusMsg" class="empty">Carregando rotas...</div>
|
||||||
|
<div id="rotasContainer"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
'use strict';
|
||||||
|
const token = localStorage.getItem('chatc2_token');
|
||||||
|
const alias = localStorage.getItem('chatc2_alias') || 'novo_local';
|
||||||
|
const user = JSON.parse(localStorage.getItem('chatc2_user') || '{}');
|
||||||
|
|
||||||
|
if (!token) { window.location.href = '/app/' + alias + '/login'; return; }
|
||||||
|
|
||||||
|
document.getElementById("navDashboard").onclick = function(e){ e.preventDefault(); window.location.href = "/app/" + alias + "/dashboard"; };
|
||||||
|
document.getElementById("navClients").onclick = function(e){ e.preventDefault(); window.location.href = "/app/" + alias + "/clients"; };
|
||||||
|
document.getElementById("navChatRoutes").onclick = function(e){ e.preventDefault(); window.location.href = "/app/" + alias + "/company/" + (user.empresas?.[0] || 1) + "/conversation/0"; };
|
||||||
|
document.getElementById("navConfigRoutes").onclick = function(e){ e.preventDefault(); window.location.href = "/app/" + alias + "/settings"; };
|
||||||
|
document.getElementById("navRoutesActive").onclick = function(e){ e.preventDefault(); window.location.href = "/app/" + alias + "/routes"; };
|
||||||
|
if ((user.tipoChat || "A") === "G") {
|
||||||
|
document.querySelectorAll(".admin-only").forEach(function(el){ el.style.display = ""; });
|
||||||
|
}
|
||||||
|
|
||||||
|
window.logout = function(){
|
||||||
|
['chatc2_token','chatc2_alias','chatc2_user'].forEach(function(k){ localStorage.removeItem(k); });
|
||||||
|
window.location.href = '/app/' + alias + '/login';
|
||||||
|
};
|
||||||
|
|
||||||
|
var todasRotas = [];
|
||||||
|
|
||||||
|
function esc(s){ return String(s == null ? '' : s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||||
|
|
||||||
|
// Realça os {placeholders} no path; troca {alias} pelo alias real
|
||||||
|
function fmtPath(path){
|
||||||
|
return esc(path.replace(/\{alias\}/g, alias))
|
||||||
|
.replace(/\{([a-zA-Z_]+)\}/g, '<i class="ph">{$1}</i>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyBtn(texto, light){
|
||||||
|
return '<button class="copy' + (light ? ' copy-light' : '') + '" data-copy="' +
|
||||||
|
encodeURIComponent(texto) + '">📋 Copiar</button>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function tabelaParams(titulo, itens){
|
||||||
|
if (!itens || !itens.length) return '';
|
||||||
|
var linhas = itens.map(function(it){
|
||||||
|
return '<tr><td class="pname">' + esc(it.nome) + '</td><td>' +
|
||||||
|
(it.obrigatorio ? '<span class="tag req">obrigatório</span>' : '<span class="tag opt">opcional</span>') +
|
||||||
|
'</td><td>' + esc(it.desc) + '</td></tr>';
|
||||||
|
}).join('');
|
||||||
|
return '<div class="block"><div class="block-title">' + titulo + '</div>' +
|
||||||
|
'<table class="param-table"><thead><tr><th>Nome</th><th>Obrigatório</th><th>Descrição</th></tr></thead>' +
|
||||||
|
'<tbody>' + linhas + '</tbody></table></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function blocoBody(r){
|
||||||
|
if (!r.body) return '';
|
||||||
|
var json = JSON.stringify(r.body, null, 2);
|
||||||
|
var reqNote = (r.bodyReq && r.bodyReq.length)
|
||||||
|
? 'Obrigatórios: ' + r.bodyReq.map(function(f){ return '<code>' + esc(f) + '</code>'; }).join(', ')
|
||||||
|
: 'Todos os campos do exemplo são opcionais.';
|
||||||
|
return '<div class="block"><div class="block-title">Body (JSON) ' + copyBtn(json) + '</div>' +
|
||||||
|
'<pre class="code-json">' + esc(json) + '</pre>' +
|
||||||
|
'<div class="req-note">' + reqNote + '</div></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function exemploCurl(r){
|
||||||
|
if (r.pagina) return '';
|
||||||
|
var url = window.location.origin + r.path.replace(/\{alias\}/g, alias);
|
||||||
|
var p = ['curl -X ' + r.method + ' "' + url + '"'];
|
||||||
|
if (r.auth) p.push('-H "Authorization: Bearer <SEU_TOKEN>"');
|
||||||
|
if (r.body) { p.push('-H "Content-Type: application/json"'); p.push("-d '" + JSON.stringify(r.body) + "'"); }
|
||||||
|
var cmd = p.join(' \\\n ');
|
||||||
|
return '<div class="block"><div class="block-title">Exemplo (cURL) ' + copyBtn(cmd, true) + '</div>' +
|
||||||
|
'<pre class="code-curl">' + esc(cmd) + '</pre></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function cardRota(r){
|
||||||
|
var authBadge = r.auth
|
||||||
|
? '<span class="auth-badge auth-yes">🔒 Token</span>'
|
||||||
|
: '<span class="auth-badge auth-no">🔓 Público</span>';
|
||||||
|
|
||||||
|
var detalhe = '';
|
||||||
|
detalhe += '<div class="note-box">' + (r.auth
|
||||||
|
? 'Requer cabeçalho <code>Authorization: Bearer <token></code>.'
|
||||||
|
: 'Rota pública — não precisa de token.') +
|
||||||
|
(r.pagina ? ' Retorna uma página HTML (abre no navegador).' : '') + '</div>';
|
||||||
|
if (r.desc) detalhe = '<div class="block"><div class="block-title">Descrição</div>' + esc(r.desc) + '</div>' + detalhe;
|
||||||
|
detalhe += tabelaParams('Parâmetros de URL', r.params);
|
||||||
|
detalhe += tabelaParams('Parâmetros de query', r.query);
|
||||||
|
detalhe += blocoBody(r);
|
||||||
|
if (r.note) detalhe += '<div class="block"><div class="note-box">ℹ️ ' + esc(r.note) + '</div></div>';
|
||||||
|
detalhe += exemploCurl(r);
|
||||||
|
|
||||||
|
return '<details class="route">' +
|
||||||
|
'<summary class="route-head">' +
|
||||||
|
'<span class="chev">▶</span>' +
|
||||||
|
'<span class="method ' + r.method.toLowerCase() + '">' + r.method + '</span>' +
|
||||||
|
'<span class="route-path">' + fmtPath(r.path) + '</span>' +
|
||||||
|
authBadge +
|
||||||
|
(r.desc ? '<span class="route-summary-desc">' + esc(r.desc) + '</span>' : '') +
|
||||||
|
'</summary>' +
|
||||||
|
'<div class="route-detail">' + detalhe + '</div>' +
|
||||||
|
'</details>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function categoriasUnicas(rotas){
|
||||||
|
var cats = {};
|
||||||
|
rotas.forEach(function(r){ if (r.cat) cats[r.cat] = true; });
|
||||||
|
return Object.keys(cats).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function carregarRotas(){
|
||||||
|
var statusEl = document.getElementById('statusMsg');
|
||||||
|
var container = document.getElementById('rotasContainer');
|
||||||
|
statusEl.style.display = 'block';
|
||||||
|
statusEl.textContent = 'Carregando rotas...';
|
||||||
|
container.innerHTML = '';
|
||||||
|
try {
|
||||||
|
var res = await fetch('/api/routes');
|
||||||
|
var json = await res.json();
|
||||||
|
if (!json.success || !json.data) {
|
||||||
|
statusEl.textContent = '❌ Erro ao carregar rotas: ' + (json.error || 'resposta inválida');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
todasRotas = json.data;
|
||||||
|
var selectCat = document.getElementById('filterCat');
|
||||||
|
var cats = categoriasUnicas(todasRotas);
|
||||||
|
selectCat.innerHTML = '<option value="">Todas as categorias</option>' +
|
||||||
|
cats.map(function(c){ return '<option value="' + c.replace(/"/g,'"') + '">' + c + '</option>'; }).join('');
|
||||||
|
statusEl.style.display = 'none';
|
||||||
|
renderRotas();
|
||||||
|
} catch(e){
|
||||||
|
statusEl.textContent = '❌ Erro ao carregar: ' + e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRotas(){
|
||||||
|
var container = document.getElementById('rotasContainer');
|
||||||
|
var q = (document.getElementById('searchInput').value || '').toLowerCase();
|
||||||
|
var catFiltro = document.getElementById('filterCat').value;
|
||||||
|
var methodFiltro = document.getElementById('filterMethod').value;
|
||||||
|
var authFiltro = document.getElementById('filterAuth').value;
|
||||||
|
|
||||||
|
var filtradas = todasRotas.filter(function(r){
|
||||||
|
if (catFiltro && r.cat !== catFiltro) return false;
|
||||||
|
if (methodFiltro && r.method !== methodFiltro) return false;
|
||||||
|
if (authFiltro === 'yes' && !r.auth) return false;
|
||||||
|
if (authFiltro === 'no' && r.auth) return false;
|
||||||
|
if (q) {
|
||||||
|
return r.method.toLowerCase().includes(q) ||
|
||||||
|
r.path.toLowerCase().includes(q) ||
|
||||||
|
(r.desc || '').toLowerCase().includes(q);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
var grupos = {};
|
||||||
|
filtradas.forEach(function(r){
|
||||||
|
var cat = r.cat || '📦 Outros';
|
||||||
|
(grupos[cat] = grupos[cat] || []).push(r);
|
||||||
|
});
|
||||||
|
|
||||||
|
var html = '';
|
||||||
|
Object.keys(grupos).sort().forEach(function(cat){
|
||||||
|
var rotas = grupos[cat];
|
||||||
|
html += '<div class="cat-group"><div class="cat-title">' + cat +
|
||||||
|
' <span class="count">' + rotas.length + '</span></div>' +
|
||||||
|
rotas.map(cardRota).join('') + '</div>';
|
||||||
|
});
|
||||||
|
|
||||||
|
container.innerHTML = html || '<div class="empty">Nenhuma rota encontrada com os filtros atuais.</div>';
|
||||||
|
document.getElementById('totalRotas').textContent = filtradas.length + ' de ' + todasRotas.length + ' rotas';
|
||||||
|
}
|
||||||
|
|
||||||
|
window.filtrarRotas = function(){ renderRotas(); };
|
||||||
|
window.expandirTodas = function(abrir){
|
||||||
|
document.querySelectorAll('#rotasContainer details').forEach(function(d){ d.open = abrir; });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cópia (delegação) — funciona para path, body e cURL
|
||||||
|
document.addEventListener('click', function(ev){
|
||||||
|
var btn = ev.target.closest('.copy');
|
||||||
|
if (!btn) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
var texto = decodeURIComponent(btn.getAttribute('data-copy') || '');
|
||||||
|
navigator.clipboard.writeText(texto).then(function(){
|
||||||
|
var orig = btn.innerHTML;
|
||||||
|
btn.innerHTML = '✅ Copiado';
|
||||||
|
setTimeout(function(){ btn.innerHTML = orig; }, 1400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
carregarRotas();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<script src="/js/dark-mode.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,858 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="pt-BR">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Configurações - Chatc2</title>
|
||||||
|
<link rel="stylesheet" href="/css/main.css">
|
||||||
|
<style>
|
||||||
|
body { background:#f3f4f6; display:flex; min-height:100vh; }
|
||||||
|
.sidebar-nav .admin-only { display:none; }
|
||||||
|
.main { flex:1; display:flex; flex-direction:column; min-width:0; }
|
||||||
|
.container { flex:1; padding:24px; overflow-y:auto; }
|
||||||
|
.tabs { display:flex; margin-bottom:24px; background:#fff; border-radius:12px; overflow:hidden; box-shadow:0 1px 3px rgba(0,0,0,0.08); border:1px solid #f3f4f6; }
|
||||||
|
.tabs button { flex:1; padding:13px 16px; border:none; background:#fff; font-size:14px; cursor:pointer; border-bottom:2px solid transparent; transition:all .15s; color:#6b7280; font-weight:500; }
|
||||||
|
.tabs button:hover { background:#f9fafb; color:#374151; }
|
||||||
|
.tabs button.active { border-bottom-color:#667eea; color:#667eea; font-weight:700; background:#fafbff; }
|
||||||
|
.tab-content { display:none; }
|
||||||
|
.tab-content.active { display:block; }
|
||||||
|
.form-group { margin-bottom:16px; }
|
||||||
|
.form-group label { display:block; font-size:13px; font-weight:600; color:#374151; margin-bottom:5px; }
|
||||||
|
.form-group input, .form-group select, .form-group textarea { width:100%; padding:10px 14px; border:2px solid #e5e7eb; border-radius:8px; font-size:14px; outline:none; background:#f9fafb; color:#111827; transition:all .15s; font-family:inherit; }
|
||||||
|
.form-group input:focus, .form-group select:focus, .form-group textarea:focus { border-color:#667eea; background:#fff; box-shadow:0 0 0 3px rgba(102,126,234,0.1); }
|
||||||
|
.form-group textarea { min-height:80px; resize:vertical; }
|
||||||
|
.form-group .toggle { display:flex; align-items:center; gap:8px; }
|
||||||
|
.form-group .toggle input { width:auto; }
|
||||||
|
.btn { display:inline-flex; align-items:center; gap:6px; padding:10px 20px; border:none; border-radius:8px; font-size:14px; font-weight:600; cursor:pointer; transition:all .15s; }
|
||||||
|
.btn-primary { background:#667eea; color:#fff; }
|
||||||
|
.btn-primary:hover { background:#5a67d8; }
|
||||||
|
.btn-danger { background:#ef4444; color:#fff; }
|
||||||
|
.btn-danger:hover { background:#dc2626; }
|
||||||
|
.btn-sm { padding:6px 12px; font-size:12px; border-radius:6px; }
|
||||||
|
.badge { display:inline-block; padding:2px 9px; border-radius:10px; font-size:11px; font-weight:600; }
|
||||||
|
.badge-success { background:#d1fae5; color:#065f46; }
|
||||||
|
.badge-danger { background:#fef2f2; color:#991b1b; }
|
||||||
|
.user-info { display:flex; align-items:center; gap:14px; }
|
||||||
|
.user-name { font-size:14px; font-weight:500; color:#374151; }
|
||||||
|
.status-badge { display:inline-flex; align-items:center; gap:5px; padding:4px 12px; border-radius:20px; font-size:12px; font-weight:600; }
|
||||||
|
.status-badge.online { background:#d1fae5; color:#065f46; }
|
||||||
|
</style>
|
||||||
|
<link rel="stylesheet" href="/css/dark-mode.css">
|
||||||
|
<script>function darkModeToggle(){var e=document.body;if(!e)return;var a=localStorage.getItem('chatc2_dark_mode')!=='true';e.classList.toggle('dark-mode',a);localStorage.setItem('chatc2_dark_mode',a?'true':'false');document.querySelectorAll('.dark-mode-toggle').forEach(function(b){b.innerHTML=a?'☀️ Claro':'🌙 Escuro'});}
|
||||||
|
window.darkModeApply=function(a){var e=document.body;if(!e)return;e.classList.toggle('dark-mode',a);localStorage.setItem('chatc2_dark_mode',a?'true':'false');document.querySelectorAll('.dark-mode-toggle').forEach(function(b){b.innerHTML=a?'☀️ Claro':'🌙 Escuro'});};
|
||||||
|
window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')==='true';};</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebar-brand">
|
||||||
|
<div class="logo">C2</div>
|
||||||
|
<div><h2>Chatc2</h2><span id="sidebarAlias">-</span></div>
|
||||||
|
</div>
|
||||||
|
<nav class="sidebar-nav">
|
||||||
|
<div class="nav-label">Principal</div>
|
||||||
|
<a href="#" id="navDashboard"><span class="icon">📊</span> Dashboard</a>
|
||||||
|
<a href="#" id="navClientsSide"><span class="icon">👥</span> Clientes</a>
|
||||||
|
<a href="#" id="navChat"><span class="icon">💬</span> Conversas</a>
|
||||||
|
<div class="nav-label admin-only" id="adminLabelSet">Administrador</div>
|
||||||
|
<a href="#" class="active admin-only" id="navConfig"><span class="icon">⚙️</span> Configurações</a>
|
||||||
|
<a href="#" id="navRoutesSet" class="admin-only"><span class="icon">📡</span> Rotas</a>
|
||||||
|
</nav>
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<button class="dark-mode-toggle" onclick="darkModeToggle()" style="width:100%;margin-bottom:8px;padding:8px">🌙 Escuro</button>
|
||||||
|
<a onclick="logout()"><span class="icon">🚪</span> Sair</a></div>
|
||||||
|
</aside>
|
||||||
|
<div class="main">
|
||||||
|
<div class="topbar"><span class="topbar-title">⚙️ Configurações</span></div>
|
||||||
|
<div class="container">
|
||||||
|
<div class="tabs">
|
||||||
|
<button class="active" onclick="ativarAba('equipe',this)">👥 Equipe</button>
|
||||||
|
<button onclick="ativarAba('fluxo',this)">📋 Fluxo</button>
|
||||||
|
<button onclick="ativarAba('empresa',this)">🏢 Empresa</button>
|
||||||
|
<button onclick="ativarAba('etiquetas',this)">🏷️ Etiquetas</button>
|
||||||
|
<button onclick="ativarAba('conexao',this)">📱 Conexão</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Aba Fluxo -->
|
||||||
|
<div class="tab-content" id="tabFluxo">
|
||||||
|
<div class="card">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
|
||||||
|
<h3 style="margin:0">📋 Fluxo de Atendimento</h3>
|
||||||
|
</div>
|
||||||
|
<p style="color:#6b7280;font-size:13px;margin-bottom:16px">
|
||||||
|
Configure os submenus que aparecem quando o cliente seleciona uma equipe.
|
||||||
|
As opções iniciais são sempre as <strong>Equipes</strong> cadastradas,
|
||||||
|
com a opção de <strong>Segunda via de Boleto</strong> no final.
|
||||||
|
</p>
|
||||||
|
<div id="menusList"><p style="color:#9ca3af">Carregando...</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Aba Equipe -->
|
||||||
|
<div class="tab-content active" id="tabEquipe">
|
||||||
|
<div class="card">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
|
||||||
|
<h3 style="margin:0">Equipes</h3>
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="mostrarModalEquipe()">+ Nova Equipe</button>
|
||||||
|
</div>
|
||||||
|
<div id="equipesList"><p style="color:#9ca3af">Carregando...</p></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>Usuários da Empresa</h3>
|
||||||
|
<div id="usuariosList"><p style="color:#9ca3af">Carregando...</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Aba Empresa -->
|
||||||
|
<div class="tab-content" id="tabEmpresa">
|
||||||
|
<div class="card">
|
||||||
|
<h3>Configurações da Empresa</h3>
|
||||||
|
<div id="configForm"><p style="color:#9ca3af">Carregando...</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Aba Etiquetas -->
|
||||||
|
<div class="tab-content" id="tabEtiquetas">
|
||||||
|
<div class="card">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
|
||||||
|
<h3 style="margin:0">Etiquetas</h3>
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="mostrarModalEtiqueta()">+ Nova Etiqueta</button>
|
||||||
|
</div>
|
||||||
|
<div id="etiquetasList"><p style="color:#9ca3af">Carregando...</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Aba Conexão -->
|
||||||
|
<div class="tab-content" id="tabConexao">
|
||||||
|
<div class="card">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
|
||||||
|
<h3 style="margin:0">📱 Conexões WhatsApp</h3>
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="mostrarModalConexao()">+ Nova Conexão</button>
|
||||||
|
</div>
|
||||||
|
<div id="conexoesList"><p style="color:#9ca3af">Carregando...</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal Equipe -->
|
||||||
|
<div class="modal-overlay" id="modalEquipe" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);align-items:center;justify-content:center;z-index:1000">
|
||||||
|
<div style="background:#fff;border-radius:12px;padding:24px;width:90%;max-width:400px">
|
||||||
|
<h3 id="modalEquipeTitle">Nova Equipe</h3>
|
||||||
|
<input type="hidden" id="editEquipeId">
|
||||||
|
<div class="form-group"><label>Nome da Equipe</label><input type="text" id="equipeNome" placeholder="Ex: Atendimento"></div>
|
||||||
|
<div class="form-group"><label>Ordem</label><input type="number" id="equipeOrdem" value="0" min="0" style="width:80px"><span style="font-size:12px;color:#9ca3af;margin-left:8px">(menor = aparece primeiro)</span></div>
|
||||||
|
<div class="form-group"><label>Membros</label><div id="equipeMembros"></div></div>
|
||||||
|
<div style="display:flex;gap:8px;justify-content:flex-end">
|
||||||
|
<button class="btn" onclick="fecharModal('modalEquipe')" style="background:#f3f4f6">Cancelar</button>
|
||||||
|
<button class="btn btn-primary" onclick="salvarEquipe()">Salvar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal Etiqueta -->
|
||||||
|
<div class="modal-overlay" id="modalEtiqueta" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);align-items:center;justify-content:center;z-index:1000">
|
||||||
|
<div style="background:#fff;border-radius:12px;padding:24px;width:90%;max-width:400px">
|
||||||
|
<h3 id="modalEtiquetaTitle">Nova Etiqueta</h3>
|
||||||
|
<input type="hidden" id="editEtiquetaId">
|
||||||
|
<div class="form-group"><label>Nome</label><input type="text" id="etiquetaNome" placeholder="Ex: Cliente VIP"></div>
|
||||||
|
<div class="form-group"><label>Cor</label><input type="color" id="etiquetaCor" value="#667eea"></div>
|
||||||
|
<div style="display:flex;gap:8px;justify-content:flex-end">
|
||||||
|
<button class="btn" onclick="fecharModal('modalEtiqueta')" style="background:#f3f4f6">Cancelar</button>
|
||||||
|
<button class="btn btn-primary" onclick="salvarEtiqueta()">Salvar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal Menu -->
|
||||||
|
<div class="modal-overlay" id="modalMenu" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);align-items:center;justify-content:center;z-index:1000">
|
||||||
|
<div style="background:#fff;border-radius:12px;padding:24px;width:90%;max-width:500px">
|
||||||
|
<h3 id="modalMenuTitle">Novo Submenu</h3>
|
||||||
|
<input type="hidden" id="editMenuId">
|
||||||
|
<input type="hidden" id="editMenuEquipeId">
|
||||||
|
<input type="hidden" id="editMenuPaiId">
|
||||||
|
<div class="form-group" id="menuEquipeGroup"><label>Equipe</label><select id="menuEquipe"><option value="">Selecione...</option></select></div>
|
||||||
|
<div class="form-group"><label>Título</label><input type="text" id="menuTitulo" placeholder="Ex: Alteração Cadastral"></div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Tipo</label>
|
||||||
|
<select id="menuTipo" onchange="mudarTipoMenu()">
|
||||||
|
<option value="M">📂 Submenu (mostra mais opções)</option>
|
||||||
|
<option value="T">💬 Texto (envia mensagem fixa)</option>
|
||||||
|
<option value="R">⚙️ Ação (executa alteração via API)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" id="menuTextoGroup" style="display:none"><label>Texto a ser enviado</label><textarea id="menuTexto" rows="3" placeholder="Digite a mensagem..."></textarea></div>
|
||||||
|
<div class="form-group" id="menuRotaGroup" style="display:none">
|
||||||
|
<label>Ação (Rota)</label>
|
||||||
|
<select id="menuAcaoRota">
|
||||||
|
<option value="">Selecione uma ação...</option>
|
||||||
|
<option value="listar_carnes">📄 Listar carnês / Boleto</option>
|
||||||
|
<option value="alterar_email">📧 Alterar E-mail</option>
|
||||||
|
<option value="alterar_celular">📱 Alterar Celular</option>
|
||||||
|
<option value="alterar_endereco">🏠 Alterar Endereço</option>
|
||||||
|
<option value="info_cliente">ℹ️ Mostrar dados do cliente</option>
|
||||||
|
</select>
|
||||||
|
<div style="margin-top:8px"><label>Pergunta ao cliente (se precisar de dados)</label>
|
||||||
|
<textarea id="menuAcaoPrompt" rows="2" placeholder="Ex: Informe seu novo e-mail:" style="width:100%;padding:10px;border:2px solid #e5e7eb;border-radius:8px;font-size:13px;resize:vertical"></textarea></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group"><label>Ordem</label><input type="number" id="menuOrdem" value="0" min="0" style="width:80px"></div>
|
||||||
|
<div class="form-group"><label>Subordinado a (opcional)</label><select id="menuPai"><option value="">Nenhum (raiz da equipe)</option></select></div>
|
||||||
|
<div class="form-group"><label>Etiquetas (adicionar ao selecionar)</label><div id="menuEtiquetasContainer" style="display:flex;flex-wrap:wrap;gap:6px;padding:6px;border:1px solid #e5e7eb;border-radius:8px;min-height:36px"></div></div>
|
||||||
|
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:12px">
|
||||||
|
<button class="btn" onclick="fecharModal('modalMenu')" style="background:#f3f4f6">Cancelar</button>
|
||||||
|
<button class="btn btn-primary" onclick="salvarMenu()">Salvar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal Conexão -->
|
||||||
|
<div class="modal-overlay" id="modalConexao" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);align-items:center;justify-content:center;z-index:1000">
|
||||||
|
<div style="background:#fff;border-radius:12px;padding:24px;width:90%;max-width:500px">
|
||||||
|
<h3 id="modalConexaoTitle">Nova Conexão WhatsApp</h3>
|
||||||
|
<input type="hidden" id="editConexaoId">
|
||||||
|
<div class="form-group"><label>Nome da Instância</label><input type="text" id="conNome" placeholder="Ex: WhatsApp Comercial"></div>
|
||||||
|
<div class="form-group"><label>URL Evolution API</label><input type="text" id="conUrl" value="https://evoatende.c2sistemas.com.br" placeholder="https://evoatende.c2sistemas.com.br"></div>
|
||||||
|
<div class="form-group"><label>API Key</label><input type="text" id="conApiKey" placeholder="API Key"></div>
|
||||||
|
<div class="form-group"><label>Nome da Instância (Evolution)</label><input type="text" id="conInstance" placeholder="Ex: empresa1-wpp"></div>
|
||||||
|
<div style="display:flex;gap:8px;justify-content:flex-end">
|
||||||
|
<button class="btn" onclick="fecharModal('modalConexao')" style="background:#f3f4f6">Cancelar</button>
|
||||||
|
<button class="btn btn-primary" onclick="salvarConexao()" id="btnSalvarConexao">Conectar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
'use strict';
|
||||||
|
const token = localStorage.getItem('chatc2_token');
|
||||||
|
const pathParts = window.location.pathname.split('/');
|
||||||
|
const alias = pathParts[2] || localStorage.getItem('chatc2_alias') || 'lajedo';
|
||||||
|
localStorage.setItem('chatc2_alias', alias);
|
||||||
|
const user = JSON.parse(localStorage.getItem('chatc2_user') || '{}');
|
||||||
|
const empresaId = user.empresas?.[0] || 1;
|
||||||
|
|
||||||
|
if (!token) { window.location.href = '/app/' + alias + '/login'; return; }
|
||||||
|
|
||||||
|
document.getElementById('sidebarAlias').textContent = alias;
|
||||||
|
document.getElementById("navDashboard").onclick = function(e) { e.preventDefault(); window.location.href = "/app/" + alias + "/dashboard"; };
|
||||||
|
document.getElementById("navChat").onclick = function(e) { e.preventDefault(); window.location.href = "/app/" + alias + "/company/" + empresaId + "/conversation/0"; };
|
||||||
|
document.getElementById("navConfig").onclick = function(e) { e.preventDefault(); window.location.href = "/app/" + alias + "/settings"; };
|
||||||
|
var navClientsSide = document.getElementById("navClientsSide");
|
||||||
|
if (navClientsSide) navClientsSide.onclick = function(e) { e.preventDefault(); window.location.href = "/app/" + alias + "/clients"; };
|
||||||
|
var navRoutesSet = document.getElementById("navRoutesSet");
|
||||||
|
if (navRoutesSet) navRoutesSet.onclick = function(e) { e.preventDefault(); window.location.href = "/app/" + alias + "/routes"; };
|
||||||
|
var tc = user.tipoChat || "A";
|
||||||
|
if (tc === "G") {
|
||||||
|
var admins = document.querySelectorAll(".admin-only");
|
||||||
|
for (var i = 0; i < admins.length; i++) admins[i].style.display = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
window.logout = function() {
|
||||||
|
['chatc2_token','chatc2_alias','chatc2_user'].forEach(function(k) { localStorage.removeItem(k); });
|
||||||
|
window.location.href = '/app/' + alias + '/login';
|
||||||
|
};
|
||||||
|
|
||||||
|
function api(path, opts) {
|
||||||
|
return fetch('/api/' + alias + path, Object.assign({
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }
|
||||||
|
}, opts)).then(function(r) { return r.json(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== ABAS =====
|
||||||
|
window.ativarAba = function(aba, btn) {
|
||||||
|
document.querySelectorAll('.tabs button').forEach(function(b) { b.classList.remove('active'); });
|
||||||
|
document.querySelectorAll('.tab-content').forEach(function(t) { t.classList.remove('active'); });
|
||||||
|
btn.classList.add('active');
|
||||||
|
document.getElementById('tab' + aba.charAt(0).toUpperCase() + aba.slice(1)).classList.add('active');
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== EQUIPES =====
|
||||||
|
async function carregarEquipes() {
|
||||||
|
var data = await api('/teams?empresaId=' + empresaId);
|
||||||
|
var div = document.getElementById('equipesList');
|
||||||
|
if (!data.success || !data.data || data.data.length === 0) {
|
||||||
|
div.innerHTML = '<p style="color:#9ca3af">Nenhuma equipe cadastrada</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
div.innerHTML = '<table><thead><tr><th>Ordem</th><th>Nome</th><th>Membros</th><th>Ações</th></tr></thead><tbody>' +
|
||||||
|
data.data.map(function(eq) {
|
||||||
|
var membros = (eq.membros || []).map(function(m) { return m.nome; }).join(', ') || '-';
|
||||||
|
return '<tr><td>' + (eq.ordem || 0) + '</td><td><strong>' + eq.nome + '</strong></td><td>' + membros + '</td><td>' +
|
||||||
|
'<button class="btn btn-sm" onclick="editarEquipe(' + eq.id + ',' + (eq.ordem || 0) + ',\'' + eq.nome.replace(/'/g,"\\'") + '\')" style="background:#f3f4f6;margin-right:4px">✏️</button>' +
|
||||||
|
'<button class="btn btn-sm btn-danger" onclick="excluirEquipe(' + eq.id + ')">🗑️</button></td></tr>';
|
||||||
|
}).join('') + '</tbody></table>';
|
||||||
|
}
|
||||||
|
|
||||||
|
window.mostrarModalEquipe = function() {
|
||||||
|
document.getElementById('editEquipeId').value = '';
|
||||||
|
document.getElementById('equipeOrdem').value = 0;
|
||||||
|
document.getElementById('equipeNome').value = '';
|
||||||
|
document.getElementById('modalEquipeTitle').textContent = 'Nova Equipe';
|
||||||
|
carregarUsuariosCheckbox();
|
||||||
|
document.getElementById('modalEquipe').style.display = 'flex';
|
||||||
|
};
|
||||||
|
|
||||||
|
window.editarEquipe = function(id, ordem, nome) {
|
||||||
|
document.getElementById('editEquipeId').value = id;
|
||||||
|
document.getElementById('equipeOrdem').value = ordem;
|
||||||
|
document.getElementById('equipeNome').value = nome;
|
||||||
|
document.getElementById('modalEquipeTitle').textContent = 'Editar Equipe';
|
||||||
|
carregarUsuariosCheckbox(id);
|
||||||
|
document.getElementById('modalEquipe').style.display = 'flex';
|
||||||
|
};
|
||||||
|
|
||||||
|
async function carregarUsuariosCheckbox(equipeId) {
|
||||||
|
var div = document.getElementById('equipeMembros');
|
||||||
|
var data = await api('/company/users?empresaId=' + empresaId);
|
||||||
|
if (!data.success) { div.innerHTML = '<p style="color:#9ca3af">Erro ao carregar</p>'; return; }
|
||||||
|
|
||||||
|
var membrosAtuais = [];
|
||||||
|
if (equipeId) {
|
||||||
|
var eqData = await api('/teams?empresaId=' + empresaId);
|
||||||
|
var eq = (eqData.data || []).find(function(e) { return e.id === equipeId; });
|
||||||
|
if (eq) membrosAtuais = (eq.membros || []).map(function(m) { return m.id; });
|
||||||
|
}
|
||||||
|
|
||||||
|
div.innerHTML = (data.data || []).map(function(u) {
|
||||||
|
var checked = membrosAtuais.includes(u.id) ? 'checked' : '';
|
||||||
|
return '<label style="display:flex;align-items:center;gap:8px;padding:6px 0;font-size:13px"><input type="checkbox" value="' + u.id + '" ' + checked + '> ' + u.nome + ' (' + u.login + ')</label>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.salvarEquipe = async function() {
|
||||||
|
var id = document.getElementById('editEquipeId').value;
|
||||||
|
var nome = document.getElementById('equipeNome').value.trim();
|
||||||
|
var ordem = parseInt(document.getElementById('equipeOrdem').value) || 0;
|
||||||
|
var membros = Array.from(document.querySelectorAll('#equipeMembros input:checked')).map(function(cb) { return parseInt(cb.value); });
|
||||||
|
|
||||||
|
if (!nome) return alert('Informe o nome da equipe');
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
var r = await api('/teams/' + id, { method: 'PUT', body: JSON.stringify({ nome: nome, ordem: ordem, membros: membros }) });
|
||||||
|
if (r.success) { fecharModal('modalEquipe'); carregarEquipes(); }
|
||||||
|
} else {
|
||||||
|
var r = await api('/teams', { method: 'POST', body: JSON.stringify({ nome: nome, ordem: ordem, membros: membros, empresaId: empresaId }) });
|
||||||
|
if (r.success) { fecharModal('modalEquipe'); carregarEquipes(); }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.excluirEquipe = async function(id) {
|
||||||
|
if (!confirm('Excluir esta equipe?')) return;
|
||||||
|
var r = await api('/teams/' + id, { method: 'DELETE' });
|
||||||
|
if (r.success) carregarEquipes();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== USUÁRIOS =====
|
||||||
|
async function carregarUsuarios() {
|
||||||
|
var data = await api('/company/users?empresaId=' + empresaId);
|
||||||
|
var div = document.getElementById('usuariosList');
|
||||||
|
if (!data.success || !data.data || data.data.length === 0) {
|
||||||
|
div.innerHTML = '<p style="color:#9ca3af">Nenhum usuário encontrado</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
div.innerHTML = '<table><thead><tr><th>Nome</th><th>Login</th><th>Tipo Chat</th></tr></thead><tbody>' +
|
||||||
|
data.data.map(function(u) {
|
||||||
|
var tipo = u.tipoChat === 'A' ? '<span class="badge badge-success">Atendente</span>' : u.tipoChat === 'G' ? '<span class="badge badge-success">Gerente</span>' : '<span class="badge badge-danger">Bloqueado</span>';
|
||||||
|
return '<tr><td>' + u.nome + '</td><td>' + u.login + '</td><td>' + tipo + '</td></tr>';
|
||||||
|
}).join('') + '</tbody></table>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== FLUXO DE ATENDIMENTO (MENUS) =====
|
||||||
|
async function carregarMenus() {
|
||||||
|
var [eqData, menuData] = await Promise.all([
|
||||||
|
api('/teams?empresaId=' + empresaId),
|
||||||
|
api('/menus?empresaId=' + empresaId)
|
||||||
|
]);
|
||||||
|
var div = document.getElementById('menusList');
|
||||||
|
|
||||||
|
if (!eqData.success || !eqData.data || eqData.data.length === 0) {
|
||||||
|
div.innerHTML = '<p style="color:#9ca3af">Crie equipes primeiro para configurar os submenus.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var menusAgrupados = menuData.success ? menuData.data : [];
|
||||||
|
|
||||||
|
var html = '';
|
||||||
|
eqData.data.forEach(function(eq) {
|
||||||
|
var menusEq = menusAgrupados.find(function(g) { return g.equipeId === eq.id; });
|
||||||
|
var temMenus = menusEq && menusEq.menus && menusEq.menus.length > 0;
|
||||||
|
|
||||||
|
html += '<div style="margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;overflow:hidden">';
|
||||||
|
html += '<div style="background:#f9fafb;padding:10px 14px;font-weight:600;font-size:14px;border-bottom:1px solid #e5e7eb;display:flex;align-items:center;justify-content:space-between">';
|
||||||
|
html += '<span>👥 ' + eq.nome + '</span>';
|
||||||
|
html += '<button class="btn btn-sm" onclick="mostrarModalMenu(' + eq.id + ')" style="background:#e0e7ff">+ Submenu</button>';
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
if (temMenus) {
|
||||||
|
html += '<div style="padding:4px 0">' + montarArvoreHtml(menusEq.menus, 0) + '</div>';
|
||||||
|
} else {
|
||||||
|
html += '<p style="padding:12px 14px;color:#9ca3af;font-size:13px;margin:0">Nenhum submenu configurado.</p>';
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
});
|
||||||
|
|
||||||
|
div.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function montarArvoreHtml(menus, nivel) {
|
||||||
|
if (!menus || menus.length === 0) return '';
|
||||||
|
var html = '<div style="padding-left:' + (nivel * 20 + 8) + 'px">';
|
||||||
|
menus.forEach(function(m) {
|
||||||
|
var tipoIcon = m.tipo === 'M' ? '📂' : m.tipo === 'T' ? '💬' : m.tipo === 'R' ? '⚙️' : '❓';
|
||||||
|
html += '<div style="display:flex;align-items:center;padding:6px 10px;border-bottom:1px solid #f3f4f6">';
|
||||||
|
html += '<span style="flex:1;font-size:13px">' + tipoIcon + ' ' + m.titulo + ' <span style="color:#9ca3af;font-size:11px">(' + m.tipo + ')</span></span>';
|
||||||
|
html += '<button class="btn btn-sm" onclick="mostrarModalMenu(' + m.equipeId + ',' + m.id + ')" style="background:#f3f4f6;margin-right:4px;padding:4px 8px;font-size:11px">✏️</button>';
|
||||||
|
html += '<button class="btn btn-sm" onclick="excluirMenu(' + m.id + ',\'' + m.titulo.replace(/'/g,"\\'") + '\')" style="background:#fef2f2;color:#991b1b;padding:4px 8px;font-size:11px">🗑️</button>';
|
||||||
|
html += '<button class="btn btn-sm" onclick="mostrarModalMenu(' + m.equipeId + ',null,' + m.id + ')" style="margin-left:4px;padding:4px 8px;font-size:11px">➕ Sub</button>';
|
||||||
|
html += '</div>';
|
||||||
|
if (m.filhos && m.filhos.length > 0) {
|
||||||
|
html += montarArvoreHtml(m.filhos, nivel + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.mostrarModalMenu = async function(equipeId, editMenuId, parentMenuId) {
|
||||||
|
// Carrega equipes para o select
|
||||||
|
var eqData = await api('/teams?empresaId=' + empresaId);
|
||||||
|
var selectEq = document.getElementById('menuEquipe');
|
||||||
|
selectEq.innerHTML = '<option value="">Selecione...</option>';
|
||||||
|
(eqData.data || []).forEach(function(eq) {
|
||||||
|
var opt = document.createElement('option');
|
||||||
|
opt.value = eq.id;
|
||||||
|
opt.textContent = eq.nome;
|
||||||
|
if (eq.id === equipeId) opt.selected = true;
|
||||||
|
selectEq.appendChild(opt);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('editMenuId').value = editMenuId || '';
|
||||||
|
document.getElementById('editMenuEquipeId').value = equipeId || '';
|
||||||
|
document.getElementById('editMenuPaiId').value = parentMenuId || '';
|
||||||
|
document.getElementById('menuTitulo').value = '';
|
||||||
|
document.getElementById('menuTipo').value = 'M';
|
||||||
|
document.getElementById('menuTexto').value = '';
|
||||||
|
document.getElementById('menuOrdem').value = 0;
|
||||||
|
document.getElementById('menuAcaoRota').value = '';
|
||||||
|
document.getElementById('menuAcaoPrompt').value = '';
|
||||||
|
document.getElementById('menuTextoGroup').style.display = 'none';
|
||||||
|
document.getElementById('menuRotaGroup').style.display = 'none';
|
||||||
|
document.getElementById('menuEquipeGroup').style.display = parentMenuId ? 'none' : 'block';
|
||||||
|
|
||||||
|
// Carrega menus pai disponíveis
|
||||||
|
var selectPai = document.getElementById('menuPai');
|
||||||
|
selectPai.innerHTML = '<option value="">Nenhum (raiz da equipe)</option>';
|
||||||
|
if (!parentMenuId) {
|
||||||
|
var flatData = await api('/menus/flat?empresaId=' + empresaId + '&equipeId=' + equipeId);
|
||||||
|
if (flatData.success && flatData.data) {
|
||||||
|
flatData.data.forEach(function(m) {
|
||||||
|
if (m.id !== editMenuId) {
|
||||||
|
var opt = document.createElement('option');
|
||||||
|
opt.value = m.id;
|
||||||
|
opt.textContent = m.titulo;
|
||||||
|
selectPai.appendChild(opt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Carrega etiquetas disponiveis como checkboxes
|
||||||
|
var etiquetasData = await api('/labels?empresaId=' + empresaId);
|
||||||
|
var etiquetasSelecionadas = editMenuId ? await buscarEtiquetasDoMenu(editMenuId) : [];
|
||||||
|
if (!parentMenuId) etiquetasSelecionadas = etiquetasSelecionadas || [];
|
||||||
|
renderEtiquetasCheckbox(etiquetasData, etiquetasSelecionadas);
|
||||||
|
|
||||||
|
if (editMenuId) {
|
||||||
|
// Modo edição: busca dados
|
||||||
|
document.getElementById('modalMenuTitle').textContent = 'Editar Submenu';
|
||||||
|
|
||||||
|
// Como a API retorna agrupado, precisamos buscar os dados de outra forma
|
||||||
|
// Vamos usar uma chamada direta
|
||||||
|
var allData = await api('/menus?empresaId=' + empresaId);
|
||||||
|
if (allData.success) {
|
||||||
|
var found = null;
|
||||||
|
allData.data.forEach(function(g) {
|
||||||
|
function buscar(m, id) {
|
||||||
|
if (m.id === id) return m;
|
||||||
|
if (m.filhos) {
|
||||||
|
for (var f of m.filhos) {
|
||||||
|
var r = buscar(f, id);
|
||||||
|
if (r) return r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
(g.menus || []).forEach(function(m) {
|
||||||
|
var r = buscar(m, editMenuId);
|
||||||
|
if (r) found = r;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (found) {
|
||||||
|
document.getElementById('menuTitulo').value = found.titulo || '';
|
||||||
|
document.getElementById('menuTipo').value = found.tipo || 'M';
|
||||||
|
document.getElementById('menuTexto').value = found.texto || '';
|
||||||
|
document.getElementById('menuOrdem').value = found.ordem || 0;
|
||||||
|
document.getElementById('menuAcaoRota').value = found.acaoRota || '';
|
||||||
|
document.getElementById('menuAcaoPrompt').value = found.acaoPrompt || '';
|
||||||
|
|
||||||
|
if (found.paiId) {
|
||||||
|
selectPai.value = found.paiId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (found.tipo === 'T') {
|
||||||
|
document.getElementById('menuTextoGroup').style.display = 'block';
|
||||||
|
document.getElementById('menuRotaGroup').style.display = 'none';
|
||||||
|
} else if (found.tipo === 'R') {
|
||||||
|
document.getElementById('menuTextoGroup').style.display = 'none';
|
||||||
|
document.getElementById('menuRotaGroup').style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
document.getElementById('modalMenuTitle').textContent = 'Novo Submenu';
|
||||||
|
|
||||||
|
// Se tem um pai, marca no select
|
||||||
|
if (parentMenuId) {
|
||||||
|
selectPai.value = parentMenuId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('modalMenu').style.display = 'flex';
|
||||||
|
};
|
||||||
|
|
||||||
|
window.mudarTipoMenu = function() {
|
||||||
|
var tipo = document.getElementById('menuTipo').value;
|
||||||
|
document.getElementById('menuTextoGroup').style.display = tipo === 'T' ? 'block' : 'none';
|
||||||
|
document.getElementById('menuRotaGroup').style.display = tipo === 'R' ? 'block' : 'none';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Busca etiquetas configuradas em um menu (percorre a arvore da API)
|
||||||
|
async function buscarEtiquetasDoMenu(menuId) {
|
||||||
|
var allData = await api('/menus?empresaId=' + empresaId);
|
||||||
|
console.log('[Menu] buscarEtiquetasDoMenu - menuId:', menuId, '| allData:', JSON.stringify(allData).substring(0, 300));
|
||||||
|
var ids = [];
|
||||||
|
function percorrer(lista) {
|
||||||
|
(lista || []).forEach(function(m) {
|
||||||
|
if (m.id === menuId) {
|
||||||
|
if (m.etiquetaIds) ids = m.etiquetaIds.split(',').map(function(x){return parseInt(x.trim());}).filter(function(x){return !isNaN(x);});
|
||||||
|
}
|
||||||
|
if (m.filhos) percorrer(m.filhos);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
allData.data.forEach(function(g) { percorrer(g.menus); });
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renderiza checkboxes de etiquetas no modal
|
||||||
|
function renderEtiquetasCheckbox(data, selecionadas) {
|
||||||
|
var container = document.getElementById('menuEtiquetasContainer');
|
||||||
|
if (!data.success || !data.data || data.data.length === 0) {
|
||||||
|
container.innerHTML = '<span style="color:#9ca3af;font-size:12px">Nenhuma etiqueta cadastrada</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = data.data.map(function(e) {
|
||||||
|
var checked = selecionadas.includes(e.id) ? 'checked' : '';
|
||||||
|
return '<label style="display:flex;align-items:center;gap:4px;padding:4px 8px;background:#f3f4f6;border-radius:6px;font-size:12px;cursor:pointer">' +
|
||||||
|
'<input type="checkbox" value="' + e.id + '" ' + checked + ' style="accent-color:#667eea"> ' +
|
||||||
|
'<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:' + (e.cor || '#667eea') + '"></span> ' +
|
||||||
|
e.nome +
|
||||||
|
'</label>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.salvarMenu = async function() {
|
||||||
|
var editId = document.getElementById('editMenuId').value;
|
||||||
|
var equipeId = document.getElementById('editMenuEquipeId').value || document.getElementById('menuEquipe').value;
|
||||||
|
var paiId = document.getElementById('editMenuPaiId').value || document.getElementById('menuPai').value;
|
||||||
|
var titulo = document.getElementById('menuTitulo').value.trim();
|
||||||
|
var tipo = document.getElementById('menuTipo').value;
|
||||||
|
var texto = document.getElementById('menuTexto').value;
|
||||||
|
var ordem = parseInt(document.getElementById('menuOrdem').value) || 0;
|
||||||
|
var acaoRota = document.getElementById('menuAcaoRota').value;
|
||||||
|
var acaoPrompt = document.getElementById('menuAcaoPrompt').value;
|
||||||
|
// Etiquetas selecionadas
|
||||||
|
var etiquetaIds = Array.from(document.querySelectorAll('#menuEtiquetasContainer input[type="checkbox"]:checked')).map(function(cb) { return parseInt(cb.value); }).filter(function(v) { return !isNaN(v); }).join(',');
|
||||||
|
|
||||||
|
if (!titulo) return alert('Informe o título');
|
||||||
|
if (!equipeId) return alert('Selecione a equipe');
|
||||||
|
|
||||||
|
var body = {
|
||||||
|
empresaId: empresaId,
|
||||||
|
equipeId: parseInt(equipeId),
|
||||||
|
titulo: titulo,
|
||||||
|
tipo: tipo,
|
||||||
|
texto: tipo === 'T' ? texto : null,
|
||||||
|
ordem: ordem,
|
||||||
|
acaoRota: tipo === 'R' ? acaoRota : null,
|
||||||
|
acaoPrompt: tipo === 'R' ? acaoPrompt : null,
|
||||||
|
menuPaiId: paiId ? parseInt(paiId) : null,
|
||||||
|
etiquetaIds: etiquetaIds || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('[Menu] Salvando body:', JSON.stringify(body));
|
||||||
|
if (editId) {
|
||||||
|
var r = await api('/menus/' + editId, { method: 'PUT', body: JSON.stringify(body) });
|
||||||
|
console.log('[Menu] Resposta save:', JSON.stringify(r));
|
||||||
|
if (r.success) { fecharModal('modalMenu'); carregarMenus(); }
|
||||||
|
else alert('Erro: ' + (r.error || 'desconhecido'));
|
||||||
|
} else {
|
||||||
|
var r = await api('/menus', { method: 'POST', body: JSON.stringify(body) });
|
||||||
|
if (r.success) { fecharModal('modalMenu'); carregarMenus(); }
|
||||||
|
else alert('Erro: ' + (r.error || 'desconhecido'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.excluirMenu = async function(id, titulo) {
|
||||||
|
if (!confirm('Excluir o submenu "' + titulo + '" e todos os seus sub-itens?')) return;
|
||||||
|
var r = await api('/menus/' + id, { method: 'DELETE' });
|
||||||
|
if (r.success) carregarMenus();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== ETIQUETAS =====
|
||||||
|
async function carregarEtiquetas() {
|
||||||
|
var data = await api('/labels?empresaId=' + empresaId);
|
||||||
|
var div = document.getElementById('etiquetasList');
|
||||||
|
if (!data.success || !data.data || data.data.length === 0) {
|
||||||
|
div.innerHTML = '<p style="color:#9ca3af">Nenhuma etiqueta cadastrada</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
div.innerHTML = '<table><thead><tr><th>Nome</th><th>Cor</th><th>Ações</th></tr></thead><tbody>' +
|
||||||
|
data.data.map(function(l) {
|
||||||
|
return '<tr><td><strong>' + l.nome + '</strong></td><td><span class="tag" style="background:' + l.cor + '">' + l.cor + '</span></td><td>' +
|
||||||
|
'<button class="btn btn-sm" onclick="editarEtiqueta(' + l.id + ',\'' + l.nome.replace(/'/g,"\\'") + '\',\'' + l.cor + '\')" style="background:#f3f4f6;margin-right:4px">✏️</button>' +
|
||||||
|
'<button class="btn btn-sm btn-danger" onclick="excluirEtiqueta(' + l.id + ')">🗑️</button></td></tr>';
|
||||||
|
}).join('') + '</tbody></table>';
|
||||||
|
}
|
||||||
|
|
||||||
|
window.mostrarModalEtiqueta = function() {
|
||||||
|
document.getElementById('editEtiquetaId').value = '';
|
||||||
|
document.getElementById('etiquetaNome').value = '';
|
||||||
|
document.getElementById('etiquetaCor').value = '#667eea';
|
||||||
|
document.getElementById('modalEtiquetaTitle').textContent = 'Nova Etiqueta';
|
||||||
|
document.getElementById('modalEtiqueta').style.display = 'flex';
|
||||||
|
};
|
||||||
|
|
||||||
|
window.editarEtiqueta = function(id, nome, cor) {
|
||||||
|
document.getElementById('editEtiquetaId').value = id;
|
||||||
|
document.getElementById('etiquetaNome').value = nome;
|
||||||
|
document.getElementById('etiquetaCor').value = cor;
|
||||||
|
document.getElementById('modalEtiquetaTitle').textContent = 'Editar Etiqueta';
|
||||||
|
document.getElementById('modalEtiqueta').style.display = 'flex';
|
||||||
|
};
|
||||||
|
|
||||||
|
window.salvarEtiqueta = async function() {
|
||||||
|
var id = document.getElementById('editEtiquetaId').value;
|
||||||
|
var nome = document.getElementById('etiquetaNome').value.trim();
|
||||||
|
var cor = document.getElementById('etiquetaCor').value;
|
||||||
|
if (!nome) return alert('Informe o nome');
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
var r = await api('/labels/' + id, { method: 'PUT', body: JSON.stringify({ nome: nome, cor: cor }) });
|
||||||
|
if (r.success) { fecharModal('modalEtiqueta'); carregarEtiquetas(); }
|
||||||
|
} else {
|
||||||
|
var r = await api('/labels', { method: 'POST', body: JSON.stringify({ nome: nome, cor: cor }) });
|
||||||
|
if (r.success) { fecharModal('modalEtiqueta'); carregarEtiquetas(); }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.excluirEtiqueta = async function(id) {
|
||||||
|
if (!confirm('Excluir esta etiqueta?')) return;
|
||||||
|
var r = await api('/labels/' + id, { method: 'DELETE' });
|
||||||
|
if (r.success) carregarEtiquetas();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== CONFIGURAÇÕES EMPRESA =====
|
||||||
|
async function carregarConfig() {
|
||||||
|
var data = await api('/company/config?empresaId=' + empresaId);
|
||||||
|
var div = document.getElementById('configForm');
|
||||||
|
if (!data.success) { div.innerHTML = '<p style="color:#ef4444">Erro ao carregar</p>'; return; }
|
||||||
|
|
||||||
|
var cfg = data.data;
|
||||||
|
div.innerHTML =
|
||||||
|
'<div class="form-group"><label>Instância Padrão</label><select id="cfgInstancia"><option value="">Nenhuma</option></select></div>' +
|
||||||
|
'<div class="form-group"><div class="toggle"><input type="checkbox" id="cfgFoto" ' + (cfg.fotoCelular === 'S' ? 'checked' : '') + '> <label for="cfgFoto">Atualizar foto do cliente conforme WhatsApp</label></div></div>' +
|
||||||
|
'<div class="form-group"><div class="toggle"><input type="checkbox" id="cfgSaudacao" ' + (cfg.saudacaoAtiva === 'S' ? 'checked' : '') + '> <label for="cfgSaudacao">Ativar saudação automática</label></div></div>' +
|
||||||
|
'<div class="form-group"><label>Mensagem de Saudação</label><textarea id="cfgSaudacaoMsg">' + (cfg.saudacaoMensagem || '') + '</textarea></div>' +
|
||||||
|
'<div class="form-group"><div class="toggle"><input type="checkbox" id="cfgNomeUser" ' + (cfg.enviarNomeUsuario === 'S' ? 'checked' : '') + '> <label for="cfgNomeUser">Mostrar nome do usuário nas mensagens ("Nome: Mensagem")</label></div></div>' +
|
||||||
|
'<div class="form-group"><div class="toggle"><input type="checkbox" id="cfgTriagem" ' + (cfg.triagemAtiva === 'S' ? 'checked' : '') + '> <label for="cfgTriagem">📋 Ativar fluxo de triagem (menu de opções)</label></div></div>' +
|
||||||
|
'<div class="form-group" id="triagemConfig" style="display:' + (cfg.triagemAtiva === 'S' ? 'block' : 'none') + ';padding:12px;background:#f9fafb;border-radius:8px;margin-bottom:12px">' +
|
||||||
|
'<div class="form-group"><label>Mensagem de boas-vindas (use {EMPRESA} para o nome)</label><textarea id="cfgTriagemWelcome" rows="2" style="width:100%;padding:10px;border:2px solid #e5e7eb;border-radius:8px;font-size:13px;resize:vertical">' + (cfg.triagemMsgWelcome || '') + '</textarea></div>' +
|
||||||
|
'<div class="form-group"><label>Mensagem após escolha</label><textarea id="cfgTriagemAfter" rows="2" style="width:100%;padding:10px;border:2px solid #e5e7eb;border-radius:8px;font-size:13px;resize:vertical">' + (cfg.triagemMsgAfter || '') + '</textarea></div>' +
|
||||||
|
'<div class="form-group"><label>Número da opção "Segunda via de Boleto"</label><input type="text" id="cfgTriagemBoletoNum" value="' + (cfg.triagemBoletoNumero || '0') + '" style="width:100%;padding:10px;border:2px solid #e5e7eb;border-radius:8px;font-size:14px"></div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="form-group"><div class="toggle"><input type="checkbox" id="cfgCsat" ' + (cfg.csatAtivo === 'S' ? 'checked' : '') + '> <label for="cfgCsat">Ativar CSAT (Pesquisa de satisfação)</label></div></div>' +
|
||||||
|
'<div class="form-group"><label>Mensagem CSAT</label><textarea id="cfgCsatMsg">' + (cfg.csatMensagem || '') + '</textarea></div>' +
|
||||||
|
'<button class="btn btn-primary" onclick="salvarConfig()">💾 Salvar Configurações</button>';
|
||||||
|
|
||||||
|
// Evento para toggle da triagem
|
||||||
|
document.getElementById('cfgTriagem').addEventListener('change', function() {
|
||||||
|
document.getElementById('triagemConfig').style.display = this.checked ? 'block' : 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Carrega instâncias no select
|
||||||
|
var instData = await api('/evolution/instances?empresaId=' + empresaId);
|
||||||
|
if (instData.success && instData.data) {
|
||||||
|
var select = document.getElementById('cfgInstancia');
|
||||||
|
instData.data.forEach(function(ins) {
|
||||||
|
var opt = document.createElement('option');
|
||||||
|
opt.value = ins.id;
|
||||||
|
opt.textContent = ins.nome + ' (' + ins.instanceName + ')';
|
||||||
|
if (ins.id === cfg.instanciaPadraoId) opt.selected = true;
|
||||||
|
select.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.salvarConfig = async function() {
|
||||||
|
var data = {
|
||||||
|
empresaId: empresaId,
|
||||||
|
instanciaPadraoId: parseInt(document.getElementById('cfgInstancia').value) || null,
|
||||||
|
fotoCelular: document.getElementById('cfgFoto').checked ? 'S' : 'N',
|
||||||
|
saudacaoAtiva: document.getElementById('cfgSaudacao').checked ? 'S' : 'N',
|
||||||
|
saudacaoMensagem: document.getElementById('cfgSaudacaoMsg').value,
|
||||||
|
enviarNomeUsuario: document.getElementById('cfgNomeUser').checked ? 'S' : 'N',
|
||||||
|
triagemAtiva: document.getElementById('cfgTriagem').checked ? 'S' : 'N',
|
||||||
|
triagemMsgWelcome: document.getElementById('cfgTriagemWelcome').value,
|
||||||
|
triagemMsgAfter: document.getElementById('cfgTriagemAfter').value,
|
||||||
|
triagemBoletoNumero: document.getElementById('cfgTriagemBoletoNum').value,
|
||||||
|
csatAtivo: document.getElementById('cfgCsat').checked ? 'S' : 'N',
|
||||||
|
csatMensagem: document.getElementById('cfgCsatMsg').value,
|
||||||
|
};
|
||||||
|
|
||||||
|
var r = await api('/company/config', { method: 'POST', body: JSON.stringify(data) });
|
||||||
|
if (r.success) alert('Configurações salvas com sucesso!');
|
||||||
|
};
|
||||||
|
|
||||||
|
// O addEventListener do cfgTriagem é adicionado dentro do carregarConfig() após criar o HTML
|
||||||
|
|
||||||
|
// ===== CONEXÕES =====
|
||||||
|
async function carregarConexoes() {
|
||||||
|
var data = await api('/evolution/instances?empresaId=' + empresaId);
|
||||||
|
var div = document.getElementById('conexoesList');
|
||||||
|
if (!data.success || !data.data || data.data.length === 0) {
|
||||||
|
div.innerHTML = '<p style="color:#9ca3af">Nenhuma conexão cadastrada</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
div.innerHTML = '<table><thead><tr><th>Nome</th><th>Instância</th><th>URL</th><th>Status</th><th>Ações</th></tr></thead><tbody>' +
|
||||||
|
data.data.map(function(ins) {
|
||||||
|
var status = ins.status === 'A' ? '<span class="badge badge-success">✅ Conectado</span>' :
|
||||||
|
ins.status === 'C' ? '<span class="badge badge-success">🟡 Conectando</span>' :
|
||||||
|
ins.status === 'D' ? '<span class="badge badge-danger">⛔ Desconectado</span>' : '<span class="badge badge-danger">❌ ' + ins.status + '</span>';
|
||||||
|
var nomeEsc = (ins.nome || '').replace(/'/g, "\\'");
|
||||||
|
return '<tr><td><strong>' + ins.nome + '</strong></td><td>' + ins.instanceName + '</td><td style="font-size:11px;max-width:150px;overflow:hidden;text-overflow:ellipsis">' + ins.url + '</td><td>' + status + '</td><td>' +
|
||||||
|
'<button class="btn btn-sm" onclick="editarConexao(' + ins.id + ')" style="background:#f3f4f6;margin-right:4px">✏️</button>' +
|
||||||
|
'<button class="btn btn-sm" onclick="excluirConexao(' + ins.id + ',\'' + nomeEsc + '\')" style="background:#fef2f2;color:#991b1b;margin-right:4px">🗑️</button>' +
|
||||||
|
'<button class="btn btn-sm" onclick="gerarQRCode(' + ins.id + ')" style="background:#f3f4f6">📱 QR Code</button></td></tr>';
|
||||||
|
}).join('') + '</tbody></table>';
|
||||||
|
}
|
||||||
|
|
||||||
|
window.mostrarModalConexao = function() {
|
||||||
|
document.getElementById('editConexaoId').value = '';
|
||||||
|
document.getElementById('conNome').value = '';
|
||||||
|
document.getElementById('conUrl').value = 'https://evoatende.c2sistemas.com.br';
|
||||||
|
document.getElementById('conApiKey').value = '';
|
||||||
|
document.getElementById('conInstance').value = '';
|
||||||
|
document.getElementById('modalConexaoTitle').textContent = 'Nova Conexão WhatsApp';
|
||||||
|
document.getElementById('btnSalvarConexao').textContent = 'Conectar';
|
||||||
|
document.getElementById('modalConexao').style.display = 'flex';
|
||||||
|
};
|
||||||
|
|
||||||
|
window.gerarQRCode = async function(id) {
|
||||||
|
var r = await api('/evolution/qrcode/' + id, { method: 'POST' });
|
||||||
|
if (r.success && r.data && (r.data.qrcode || r.data.base64)) {
|
||||||
|
var qr = r.data.qrcode || r.data.base64;
|
||||||
|
var imgSrc = qr.startsWith('data:') ? qr : 'data:image/png;base64,' + qr;
|
||||||
|
var w = window.open('', '_blank', 'width=400,height=500');
|
||||||
|
w.document.write('<html><head><title>QR Code</title></head><body style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;font-family:sans-serif">' +
|
||||||
|
'<h2 style="margin-bottom:20px">Escaneie o QR Code</h2>' +
|
||||||
|
'<img src="' + imgSrc + '" style="max-width:300px">' +
|
||||||
|
'<p style="margin-top:20px;color:#6b7280">Abra o WhatsApp no celular e escaneie</p></body></html>');
|
||||||
|
} else {
|
||||||
|
alert('Erro ao gerar QR Code: ' + JSON.stringify(r.data));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== EDITAR CONEXÃO =====
|
||||||
|
window.editarConexao = async function(id) {
|
||||||
|
// Busca dados atuais da API
|
||||||
|
var data = await api('/evolution/instances?empresaId=' + empresaId);
|
||||||
|
if (!data.success) return alert('Erro ao carregar dados');
|
||||||
|
var ins = data.data.find(function(i) { return i.id === id; });
|
||||||
|
if (!ins) return alert('Instância não encontrada');
|
||||||
|
|
||||||
|
document.getElementById('editConexaoId').value = id;
|
||||||
|
document.getElementById('conNome').value = ins.nome;
|
||||||
|
document.getElementById('conUrl').value = ins.url;
|
||||||
|
document.getElementById('conApiKey').value = ins.apiKey;
|
||||||
|
document.getElementById('conInstance').value = ins.instanceName;
|
||||||
|
document.getElementById('modalConexaoTitle').textContent = 'Editar Conexão WhatsApp';
|
||||||
|
document.getElementById('btnSalvarConexao').textContent = 'Salvar';
|
||||||
|
document.getElementById('modalConexao').style.display = 'flex';
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== EXCLUIR CONEXÃO =====
|
||||||
|
window.excluirConexao = async function(id, nome) {
|
||||||
|
if (!confirm('Excluir a conexão "' + nome + '"?')) return;
|
||||||
|
var r = await api('/evolution/instances/' + id, { method: 'DELETE' });
|
||||||
|
if (r.success) {
|
||||||
|
carregarConexoes();
|
||||||
|
} else {
|
||||||
|
alert('Erro: ' + r.error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.salvarConexao = async function() {
|
||||||
|
var editId = document.getElementById('editConexaoId').value;
|
||||||
|
var data = {
|
||||||
|
INS_NOME: document.getElementById('conNome').value.trim(),
|
||||||
|
INS_URL: document.getElementById('conUrl').value.trim(),
|
||||||
|
INS_API_KEY: document.getElementById('conApiKey').value.trim(),
|
||||||
|
INS_INSTANCE_NAME: document.getElementById('conInstance').value.trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!data.INS_NOME || !data.INS_URL || !data.INS_API_KEY || !data.INS_INSTANCE_NAME)
|
||||||
|
return alert('Preencha todos os campos');
|
||||||
|
|
||||||
|
var r;
|
||||||
|
if (editId) {
|
||||||
|
// Atualizar
|
||||||
|
r = await api('/evolution/instances/' + editId, { method: 'PUT', body: JSON.stringify(data) });
|
||||||
|
} else {
|
||||||
|
// Criar nova
|
||||||
|
data.INS_EMPRESA_ID = empresaId;
|
||||||
|
r = await api('/evolution/connect', { method: 'POST', body: JSON.stringify(data) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (r.success) {
|
||||||
|
fecharModal('modalConexao');
|
||||||
|
document.getElementById('editConexaoId').value = '';
|
||||||
|
document.getElementById('modalConexaoTitle').textContent = 'Nova Conexão WhatsApp';
|
||||||
|
document.getElementById('btnSalvarConexao').textContent = 'Conectar';
|
||||||
|
carregarConexoes();
|
||||||
|
if (!editId && r.data && r.data.id) gerarQRCode(r.data.id);
|
||||||
|
} else {
|
||||||
|
alert('Erro: ' + r.error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== MODAIS =====
|
||||||
|
window.fecharModal = function(id) { document.getElementById(id).style.display = 'none'; };
|
||||||
|
|
||||||
|
// Fechar modal ao clicar fora
|
||||||
|
document.querySelectorAll('.modal-overlay').forEach(function(el) {
|
||||||
|
el.addEventListener('click', function(e) { if (e.target === this) this.style.display = 'none'; });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== INICIAR =====
|
||||||
|
carregarEquipes();
|
||||||
|
carregarUsuarios();
|
||||||
|
carregarMenus();
|
||||||
|
carregarEtiquetas();
|
||||||
|
carregarConexoes();
|
||||||
|
carregarConfig();
|
||||||
|
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<script src="/js/dark-mode.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const AuthController = require('../controllers/authController');
|
||||||
|
const ClientController = require('../controllers/clientController');
|
||||||
|
const authenticateToken = require('../middlewares/auth');
|
||||||
|
|
||||||
|
// === Rotas públicas (sem autenticação) ===
|
||||||
|
|
||||||
|
// Página de login (GET)
|
||||||
|
router.get('/:alias/login', AuthController.loginPage);
|
||||||
|
|
||||||
|
// API de login (POST) - retorna o token JWT
|
||||||
|
router.post('/:alias/login', AuthController.login);
|
||||||
|
|
||||||
|
// Dashboard do app (proteção via JavaScript no cliente)
|
||||||
|
router.get('/:alias/dashboard', AuthController.dashboard);
|
||||||
|
|
||||||
|
// Página de listagem de clientes (proteção via JavaScript no cliente)
|
||||||
|
router.get('/:alias/clients', (req, res) => {
|
||||||
|
const path = require('path');
|
||||||
|
res.sendFile(path.resolve(__dirname, '../public/client-list.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Página de detalhes do cliente (proteção via JavaScript no cliente)
|
||||||
|
router.get('/:alias/company/:id_empresa/client/:id_cliente', (req, res) => {
|
||||||
|
const path = require('path');
|
||||||
|
res.sendFile(path.resolve(__dirname, '../public/client-detail.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Página de configurações
|
||||||
|
router.get('/:alias/settings', (req, res) => {
|
||||||
|
const path = require('path');
|
||||||
|
res.sendFile(path.resolve(__dirname, '../public/settings.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Página de rotas da API
|
||||||
|
router.get('/:alias/routes', (req, res) => {
|
||||||
|
const path = require('path');
|
||||||
|
res.sendFile(path.resolve(__dirname, '../public/routes.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Página de todas as conversas (admin)
|
||||||
|
router.get('/:alias/conversations/all', (req, res) => {
|
||||||
|
const path = require('path');
|
||||||
|
res.sendFile(path.resolve(__dirname, '../public/admin-conversations.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pagina de CSAT (avaliacao) - sem autenticacao
|
||||||
|
router.get('/:alias/csat', (req, res) => {
|
||||||
|
const path = require('path');
|
||||||
|
res.sendFile(path.resolve(__dirname, '../public/csat.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// === Rotas protegidas (com autenticação) ===
|
||||||
|
|
||||||
|
// Dados do usuário logado
|
||||||
|
router.get('/:alias/me', authenticateToken, AuthController.me);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const ChatController = require('../controllers/chatController');
|
||||||
|
const authenticateToken = require('../middlewares/auth');
|
||||||
|
|
||||||
|
// Página do chat (proteção via JavaScript)
|
||||||
|
router.get('/:alias/company/:empresaId/conversation/:conversaId', ChatController.chatPage);
|
||||||
|
|
||||||
|
// Rotas protegidas da API
|
||||||
|
router.post('/:alias/conversations/create', authenticateToken, ChatController.createConversation);
|
||||||
|
router.get('/:alias/conversations', authenticateToken, ChatController.listConversations);
|
||||||
|
router.get('/:alias/conversations/:id', authenticateToken, ChatController.getConversation);
|
||||||
|
router.get('/:alias/conversations/:id/messages', authenticateToken, ChatController.getMessages);
|
||||||
|
router.post('/:alias/conversations/:id/messages', authenticateToken, ChatController.sendMessage);
|
||||||
|
router.post('/:alias/conversations/:id/finalize', authenticateToken, ChatController.finalizeConversation);
|
||||||
|
router.post('/:alias/conversations/:id/assign', authenticateToken, ChatController.assignConversation);
|
||||||
|
router.post('/:alias/conversations/:id/assign-team', authenticateToken, ChatController.assignTeam);
|
||||||
|
router.post('/:alias/conversations/:id/labels', authenticateToken, ChatController.toggleLabel);
|
||||||
|
router.post('/:alias/conversations/:id/link-client', authenticateToken, ChatController.linkClient);
|
||||||
|
router.get('/:alias/media/:mediaId', ChatController.getMedia);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const ConfigController = require('../controllers/configController');
|
||||||
|
const EvolutionController = require('../controllers/evolutionController');
|
||||||
|
const authenticateToken = require('../middlewares/auth');
|
||||||
|
|
||||||
|
// Todas as rotas protegidas
|
||||||
|
router.use('/:alias', authenticateToken);
|
||||||
|
|
||||||
|
// Equipes
|
||||||
|
router.get('/:alias/teams', ConfigController.listTeams);
|
||||||
|
router.post('/:alias/teams', ConfigController.createTeam);
|
||||||
|
router.put('/:alias/teams/:id', ConfigController.updateTeam);
|
||||||
|
router.delete('/:alias/teams/:id', ConfigController.deleteTeam);
|
||||||
|
|
||||||
|
// Usuários da empresa
|
||||||
|
router.get('/:alias/company/users', ConfigController.listCompanyUsers);
|
||||||
|
router.put('/:alias/users/:id/chat-type', ConfigController.updateUserChatType);
|
||||||
|
|
||||||
|
// Etiquetas
|
||||||
|
router.get('/:alias/labels', ConfigController.listLabels);
|
||||||
|
router.post('/:alias/labels', ConfigController.createLabel);
|
||||||
|
router.put('/:alias/labels/:id', ConfigController.updateLabel);
|
||||||
|
router.delete('/:alias/labels/:id', ConfigController.deleteLabel);
|
||||||
|
|
||||||
|
// Configurações da empresa
|
||||||
|
router.get('/:alias/company/config', ConfigController.getCompanyConfig);
|
||||||
|
router.post('/:alias/company/config', ConfigController.saveCompanyConfig);
|
||||||
|
|
||||||
|
// Evolution API
|
||||||
|
router.get('/:alias/evolution/instances', EvolutionController.listInstances);
|
||||||
|
router.post('/:alias/evolution/connect', EvolutionController.connect);
|
||||||
|
router.put('/:alias/evolution/instances/:id', EvolutionController.updateInstance);
|
||||||
|
router.delete('/:alias/evolution/instances/:id', EvolutionController.deleteInstance);
|
||||||
|
router.post('/:alias/evolution/qrcode/:id', EvolutionController.generateQRCode);
|
||||||
|
router.post('/:alias/evolution/refresh-photo/:conversaId', EvolutionController.refreshPhoto);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const DatabaseController = require('../controllers/databaseController');
|
||||||
|
const authenticateToken = require('../middlewares/auth');
|
||||||
|
|
||||||
|
router.get('/databases', authenticateToken, DatabaseController.list);
|
||||||
|
router.post('/databases', authenticateToken, DatabaseController.save);
|
||||||
|
router.delete('/databases/:alias', authenticateToken, DatabaseController.remove);
|
||||||
|
router.post('/databases/test', authenticateToken, DatabaseController.test);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const GenericController = require('../controllers/genericController');
|
||||||
|
const ClientController = require('../controllers/clientController');
|
||||||
|
const EmpresaController = require('../controllers/empresaController');
|
||||||
|
const authenticateToken = require('../middlewares/auth');
|
||||||
|
|
||||||
|
// === Rotas públicas (sem alias, sem autenticação) ===
|
||||||
|
|
||||||
|
// Listar aliases disponíveis
|
||||||
|
router.get('/aliases', GenericController.listAliases);
|
||||||
|
|
||||||
|
// Logo da empresa (pública - usada na tela de login)
|
||||||
|
router.get('/:alias/empresa/logo', EmpresaController.getLogo);
|
||||||
|
|
||||||
|
// === Rotas protegidas (com alias) ===
|
||||||
|
// Aplica autenticação em TODAS as rotas abaixo
|
||||||
|
router.use('/:alias', authenticateToken);
|
||||||
|
|
||||||
|
// Health check do alias
|
||||||
|
router.get('/:alias/health', GenericController.healthCheck);
|
||||||
|
|
||||||
|
// Listar todas as tabelas do banco
|
||||||
|
router.get('/:alias/tables', GenericController.listTables);
|
||||||
|
|
||||||
|
// Informações da estrutura de uma tabela específica
|
||||||
|
router.get('/:alias/tables/:tableName', GenericController.tableInfo);
|
||||||
|
|
||||||
|
// Executar consulta SELECT (via POST)
|
||||||
|
router.post('/:alias/query', GenericController.query);
|
||||||
|
|
||||||
|
// Executar INSERT, UPDATE, DELETE (via POST)
|
||||||
|
router.post('/:alias/execute', GenericController.execute);
|
||||||
|
|
||||||
|
// Empresas do usuário logado (GET)
|
||||||
|
router.get('/:alias/empresas', ClientController.listUserEmpresas);
|
||||||
|
|
||||||
|
// Busca de clientes com paginação (GET)
|
||||||
|
router.get('/:alias/clients/search', ClientController.searchClients);
|
||||||
|
|
||||||
|
// Dependentes de um cliente
|
||||||
|
router.get('/:alias/clients/:id/dependents', ClientController.listDependents);
|
||||||
|
|
||||||
|
// Atualizar telefone do dependente
|
||||||
|
router.put('/:alias/dependents/:id/phone', ClientController.updateDependentPhone);
|
||||||
|
|
||||||
|
// Buscar dependentes para associar
|
||||||
|
router.get('/:alias/dependents/search', ClientController.searchDependents);
|
||||||
|
|
||||||
|
// Dados completos de um cliente (GET) — usado pela página de detalhes
|
||||||
|
router.get('/:alias/company/:id_empresa/client/:id_cliente', ClientController.clientDetails);
|
||||||
|
|
||||||
|
// Atualizar dados do cliente (PUT)
|
||||||
|
router.put('/:alias/clients/:id', ClientController.updateClient);
|
||||||
|
|
||||||
|
// Títulos (carnes) de um cliente
|
||||||
|
router.get('/:alias/clients/:id_cliente/carnes', ClientController.clientCarnes);
|
||||||
|
|
||||||
|
// Dados do cliente + carnes para geracao de boleto
|
||||||
|
router.get('/:alias/clients/:id_cliente/listcarne', ClientController.clientListCarne);
|
||||||
|
router.get('/:alias/clients/:id/convalescentes', ClientController.clientConvalescentes);
|
||||||
|
|
||||||
|
// Historico de conversas do cliente (inclui dependentes)
|
||||||
|
router.get('/:alias/clients/:id/conversations', ClientController.clientConversations);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const genericRoutes = require('./genericRoutes');
|
||||||
|
const authRoutes = require('./authRoutes');
|
||||||
|
const chatRoutes = require('./chatRoutes');
|
||||||
|
const configRoutes = require('./configRoutes');
|
||||||
|
const menuRoutes = require('./menuRoutes');
|
||||||
|
const databaseRoutes = require('./databaseRoutes');
|
||||||
|
const RoutesController = require('../controllers/routesController');
|
||||||
|
const EvolutionController = require('../controllers/evolutionController');
|
||||||
|
|
||||||
|
// Webhook Evolution (sem auth - DEVE vir antes de qualquer middleware de auth)
|
||||||
|
// Suporta URL com alias (ex: /api/lajedo/webhook/evolution) ou sem alias
|
||||||
|
// (ex: /api/webhook/evolution) — o alias é detectado automaticamente
|
||||||
|
router.post('/api/webhook/evolution', EvolutionController.webhook);
|
||||||
|
router.post('/api/webhook/evolution/:evento', EvolutionController.webhook);
|
||||||
|
router.post('/api/:alias/webhook/evolution', EvolutionController.webhook);
|
||||||
|
router.post('/api/:alias/webhook/evolution/:evento', EvolutionController.webhook);
|
||||||
|
router.get('/api/:alias/webhook/ping', EvolutionController.webhookPing);
|
||||||
|
|
||||||
|
// Mídia (sem auth - para carregar imagens/áudios no chat)
|
||||||
|
router.get('/api/:alias/media/:mediaId', require('../controllers/chatController').getMedia);
|
||||||
|
|
||||||
|
// CSAT (sem auth - formulário público de avaliação)
|
||||||
|
router.post('/api/:alias/csat/avaliar', require('../controllers/chatController').csatAvaliar);
|
||||||
|
|
||||||
|
// Rota de descoberta de rotas (sem auth)
|
||||||
|
router.get('/api/routes', RoutesController.list);
|
||||||
|
|
||||||
|
// Rotas da API (autenticação aplicada internamente)
|
||||||
|
router.use('/api', genericRoutes);
|
||||||
|
router.use('/api', chatRoutes);
|
||||||
|
router.use('/api', configRoutes);
|
||||||
|
router.use('/api', menuRoutes);
|
||||||
|
router.use('/api', databaseRoutes);
|
||||||
|
|
||||||
|
// Rotas do aplicativo (proteção aplicada internamente)
|
||||||
|
router.use('/app', authRoutes);
|
||||||
|
router.use('/app', chatRoutes);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const MenuController = require('../controllers/menuController');
|
||||||
|
const authenticateToken = require('../middlewares/auth');
|
||||||
|
|
||||||
|
router.get('/:alias/menus', authenticateToken, MenuController.listMenus);
|
||||||
|
router.get('/:alias/menus/flat', authenticateToken, MenuController.listMenusFlat);
|
||||||
|
router.post('/:alias/menus', authenticateToken, MenuController.createMenu);
|
||||||
|
router.put('/:alias/menus/:id', authenticateToken, MenuController.updateMenu);
|
||||||
|
router.delete('/:alias/menus/:id', authenticateToken, MenuController.deleteMenu);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
require('dotenv').config();
|
||||||
|
const app = require('./app');
|
||||||
|
const db = require('./database');
|
||||||
|
const { listAliases } = db;
|
||||||
|
|
||||||
|
const PORT = parseInt(process.env.PORT, 10) || 3000;
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
console.log('🔄 Testando conexões com os bancos...\n');
|
||||||
|
|
||||||
|
// Testa conexão com todos os aliases configurados
|
||||||
|
const results = await db.testAllConnections();
|
||||||
|
let allOk = true;
|
||||||
|
|
||||||
|
for (const [alias, ok] of Object.entries(results)) {
|
||||||
|
const icon = ok ? '✅' : '❌';
|
||||||
|
console.log(` ${icon} ${alias}: ${ok ? 'Conectado' : 'Falha na conexão'}`);
|
||||||
|
if (!ok) allOk = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allOk) {
|
||||||
|
console.log('\n✅ Todos os bancos conectados com sucesso!');
|
||||||
|
} else {
|
||||||
|
console.log('\n⚠️ Alguns bancos não puderam ser conectados. Verifique as configurações.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = app.listen(PORT, () => {
|
||||||
|
console.log(`\n🚀 API Chatc2 rodando em http://localhost:${PORT}`);
|
||||||
|
console.log(`📋 Aliases disponíveis: ${listAliases().join(', ')}`);
|
||||||
|
console.log(`📋 Login: http://localhost:${PORT}/app/<alias>/login`);
|
||||||
|
console.log(`📋 Listar aliases: http://localhost:${PORT}/api/aliases`);
|
||||||
|
console.log('\nPressione Ctrl+C para parar o servidor.');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Encerramento gracioso (fecha os pools de conexão)
|
||||||
|
const shutdown = async (sig) => {
|
||||||
|
console.log(`\n${sig} recebido. Encerrando...`);
|
||||||
|
server.close(async () => {
|
||||||
|
try { await db.closeAll(); } catch (_) {}
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
// Failsafe caso o close demore
|
||||||
|
setTimeout(() => process.exit(0), 5000).unref();
|
||||||
|
};
|
||||||
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||||
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||||
|
}
|
||||||
|
|
||||||
|
start().catch((err) => {
|
||||||
|
console.error('Erro ao iniciar servidor:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user