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:
2026-06-17 10:02:59 -03:00
commit ae629d1dc2
50 changed files with 17137 additions and 0 deletions
+109
View File
@@ -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;
+19
View File
@@ -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',
};
+185
View File
@@ -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
+987
View File
@@ -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;
+246
View File
@@ -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;
+151
View File
@@ -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;
+74
View File
@@ -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
+136
View File
@@ -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;
+250
View File
@@ -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;
+401
View File
@@ -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;
+177
View File
@@ -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
View File
@@ -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,
};
+149
View File
@@ -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;
+27
View File
@@ -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 };
+182
View File
@@ -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>
+1548
View File
File diff suppressed because it is too large Load Diff
+741
View File
@@ -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,'&quot;') + '" 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>
+346
View File
@@ -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>
+208
View File
@@ -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
+648
View File
@@ -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; }
+188
View File
@@ -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>
+44
View File
@@ -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';
});
}
}
})();
+359
View File
@@ -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>
+336
View File
@@ -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 &lt;token&gt;</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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
// 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 &lt;token&gt;</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,'&quot;') + '">' + 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>
+858
View File
@@ -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>
+59
View File
@@ -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;
+22
View File
@@ -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;
+38
View File
@@ -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;
+11
View File
@@ -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;
+66
View File
@@ -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;
+42
View File
@@ -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;
+12
View File
@@ -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;
+52
View File
@@ -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);
});