Foto automática, boleto na conversa e novo Dashboard

- Foto do cliente atualizada automaticamente ao abrir a conversa (remove botão manual)
- Exibe CLI_FOTO na tela de Dados do Cliente; matrícula com conversão robusta
- Envio de boleto na conversa (checkbox em Configurações > Empresa): lista títulos em aberto e envia texto com linha digitável + PIX ao cliente
- Novo Dashboard (gerente): conversas (abertas/não atendidas/não atribuídas/pendentes), atendentes disponíveis/desconectados e heatmap de tráfego
- Presença de atendentes em memória + ping periódico do chat/dashboard

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-18 15:41:09 -03:00
parent aa74c984d0
commit d9e195d924
13 changed files with 657 additions and 128 deletions
+138
View File
@@ -1167,6 +1167,144 @@ class ChatController {
}); });
} catch (err) { res.status(500).json({ success: false, error: err.message }); } } catch (err) { res.status(500).json({ success: false, error: err.message }); }
} }
/**
* Valida que o cliente informado pertence à mesma empresa da conversa
* (e que o usuário tem acesso à conversa). Retorna { ok, clienteId } ou { status }.
*/
static async checarClienteDaConversa(alias, conversaId, clienteId, req) {
const chk = await ChatController.checarConversaEmpresa(alias, conversaId, req);
if (chk.status) return chk;
const cid = parseInt(clienteId, 10);
if (!cid) return { status: 400 };
const cli = await db.query(alias,
'SELECT CLI_EMPRESA_ID FROM CLIENTES WHERE CLI_CODIGO_ID = ?', [cid]);
if (cli.length === 0) return { status: 404 };
if (chk.empresaId && cli[0].CLI_EMPRESA_ID && cli[0].CLI_EMPRESA_ID !== chk.empresaId)
return { status: 403 };
return { ok: true, clienteId: cid };
}
/**
* Lista títulos (boletos) em aberto do cliente vinculado à conversa,
* para o atendente escolher qual enviar.
* GET /api/:alias/conversations/:id/boletos?clienteId=
*/
static async getBoletos(req, res) {
try {
const { alias, id } = req.params;
const chk = await ChatController.checarClienteDaConversa(alias, id, req.query.clienteId, req);
if (chk.status) return res.status(chk.status).json({ success: false, error: chk.status === 403 ? 'Sem permissão.' : (chk.status === 404 ? 'Cliente não encontrado.' : 'Parâmetros inválidos.') });
const carnes = await db.query(alias,
`SELECT CAR_CODIGO_ID, CAR_DT_VENCIMENTO, CAR_VALOR_PARCELA, CAR_LINHA_DIGITAVEL,
CAR_NOSSO_NUMERO, CAR_PIX_QRCODE, CAR_NUMERO_PARCELA, CAR_NUMERO_TOTAL_PARCELAS,
CAR_CODIGO_BARRAS
FROM CARNES WHERE CAR_CLIENTE_ID = ? AND CAR_SITUACAO = 0
ORDER BY CAR_DT_VENCIMENTO`, [chk.clienteId]);
const 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);
};
res.json({ success: true, data: carnes.map(function(c) {
return {
id: c.CAR_CODIGO_ID,
vencimento: fmtDate(c.CAR_DT_VENCIMENTO),
valor: c.CAR_VALOR_PARCELA,
parcela: c.CAR_NUMERO_PARCELA,
totalParcelas: c.CAR_NUMERO_TOTAL_PARCELAS,
temLinhaDigitavel: !!(c.CAR_LINHA_DIGITAVEL && String(c.CAR_LINHA_DIGITAVEL).trim()),
temPix: !!(c.CAR_PIX_QRCODE && String(c.CAR_PIX_QRCODE).trim()),
};
}) });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
/**
* Envia um boleto escolhido (texto: linha digitável + PIX) ao cliente no chat.
* POST /api/:alias/conversations/:id/send-boleto Body: { clienteId, carneId }
*/
static async sendBoleto(req, res) {
try {
const { alias, id } = req.params;
const { clienteId, carneId } = req.body;
const chk = await ChatController.checarClienteDaConversa(alias, id, clienteId, req);
if (chk.status) return res.status(chk.status).json({ success: false, error: chk.status === 403 ? 'Sem permissão.' : (chk.status === 404 ? 'Cliente não encontrado.' : 'Parâmetros inválidos.') });
const carneIdNum = parseInt(carneId, 10);
if (!carneIdNum) return res.status(400).json({ success: false, error: 'Título inválido.' });
// Título precisa pertencer ao cliente validado (evita IDOR)
const car = await db.query(alias,
`SELECT CAR_CODIGO_ID, CAR_DT_VENCIMENTO, CAR_VALOR_PARCELA, CAR_LINHA_DIGITAVEL,
CAR_PIX_QRCODE, CAR_NUMERO_PARCELA, CAR_NUMERO_TOTAL_PARCELAS, CAR_CODIGO_BARRAS
FROM CARNES WHERE CAR_CODIGO_ID = ? AND CAR_CLIENTE_ID = ?`, [carneIdNum, chk.clienteId]);
if (car.length === 0) return res.status(404).json({ success: false, error: 'Título não encontrado para este cliente.' });
const b = car[0];
const conv = await db.query(alias, 'SELECT * FROM CHATC2_CONVERSAS WHERE CON_CODIGO_ID = ?', [id]);
if (conv.length === 0) return res.status(404).json({ success: false, error: 'Conversa não encontrada.' });
// Monta o texto do boleto
const fmtDate = (dt) => {
if (!dt) return '';
let s = typeof dt === 'string' ? dt.split('T')[0] : (dt instanceof Date ? dt.toISOString().split('T')[0] : String(dt));
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s);
return m ? (m[3] + '/' + m[2] + '/' + m[1]) : s;
};
const fmtValor = (v) => {
const n = Number(v);
if (isNaN(n)) return '';
return 'R$ ' + n.toFixed(2).replace('.', ',');
};
const linha = (b.CAR_LINHA_DIGITAVEL || '').trim() || (b.CAR_CODIGO_BARRAS || '').trim();
const pix = (b.CAR_PIX_QRCODE || '').trim();
let texto = '📄 *Boleto*';
if (b.CAR_NUMERO_PARCELA) {
texto += ' - Parcela ' + b.CAR_NUMERO_PARCELA + (b.CAR_NUMERO_TOTAL_PARCELAS ? '/' + b.CAR_NUMERO_TOTAL_PARCELAS : '');
}
texto += '\n';
if (b.CAR_VALOR_PARCELA != null) texto += '\n💰 Valor: ' + fmtValor(b.CAR_VALOR_PARCELA);
if (b.CAR_DT_VENCIMENTO) texto += '\n📅 Vencimento: ' + fmtDate(b.CAR_DT_VENCIMENTO);
if (linha) texto += '\n\n*Linha digitável:*\n' + linha;
if (pix) texto += '\n\n*PIX (copia e cola):*\n' + pix;
if (!linha && !pix) {
return res.status(400).json({ success: false, error: 'Este título não possui linha digitável nem PIX disponível.' });
}
// Salva mensagem do agente
const maxId = await db.query(alias, 'SELECT MAX(CME_CODIGO_ID) AS ID FROM CHATC2_CONVERSAS_MENSAGENS');
const newId = (maxId[0]?.ID || 0) + 1;
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_USUARIO_ID, CME_TEXTO, CME_TIPO, CME_PRIVADA, CME_DT_ENVIO)
VALUES (?, ?, 'U', ?, ?, 'text', 'N', CURRENT_TIMESTAMP)
`, [newId, id, req.user?.id, texto]);
await db.execute(alias,
'UPDATE CHATC2_CONVERSAS SET CON_DT_ULTIMA_MSG = CURRENT_TIMESTAMP WHERE CON_CODIGO_ID = ?', [id]);
// Envia via Evolution
if (conv[0].CON_NUMERO) {
try {
const instancia = await db.query(alias,
'SELECT * FROM CHATC2_INSTANCIAS WHERE INS_CODIGO_ID = ?', [conv[0].CON_INSTANCIA_ID]);
if (instancia.length > 0) {
await sendEvolutionMessage(instancia[0], conv[0].CON_NUMERO, texto, 'text', null);
}
} catch (evoErr) {
console.error('[Boleto] Erro ao enviar via Evolution:', evoErr.message);
}
}
res.json({ success: true, data: { id: newId } });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
/** /**
* Recebe avaliação CSAT via formulário web * Recebe avaliação CSAT via formulário web
* POST /api/:alias/csat/avaliar * POST /api/:alias/csat/avaliar
+27 -2
View File
@@ -74,7 +74,8 @@ class ClientController {
c.CLI_ENDERECO, c.CLI_ENDERECO,
c.CLI_NUMERO_FAT, c.CLI_NUMERO_FAT,
c.CLI_BAIRRO_FAT, c.CLI_BAIRRO_FAT,
c.CLI_CIDADES_ID c.CLI_CIDADES_ID,
c.CLI_FOTO
FROM CLIENTES c FROM CLIENTES c
WHERE c.CLI_CODIGO_ID = ? WHERE c.CLI_CODIGO_ID = ?
AND c.CLI_EMPRESA_ID = ? AND c.CLI_EMPRESA_ID = ?
@@ -133,6 +134,29 @@ class ClientController {
// Interpreta a situação // Interpreta a situação
const situacao = client.CLI_SITUACAO?.trim() === 'A' ? 'Ativo' : 'Inativo'; const situacao = client.CLI_SITUACAO?.trim() === 'A' ? 'Ativo' : 'Inativo';
// Foto do cliente (CLI_FOTO) — pode vir como Buffer (bytea/blob) ou string base64
let fotoCliente = null;
if (client.CLI_FOTO) {
const f = client.CLI_FOTO;
if (Buffer.isBuffer(f)) {
const s = f.toString('utf8');
if (/^[A-Za-z0-9+/]+=*$/.test(s.slice(0, 30))) {
fotoCliente = s;
} else {
let prefixo = 'data:image/jpeg;base64,';
if (f[0] === 0x89 && f[1] === 0x50) prefixo = 'data:image/png;base64,';
else if (f[0] === 0x47 && f[1] === 0x49) prefixo = 'data:image/gif;base64,';
else if (f[0] === 0x52 && f[1] === 0x49) prefixo = 'data:image/webp;base64,';
fotoCliente = prefixo + f.toString('base64');
}
} else {
fotoCliente = String(f);
}
}
// Matrícula: converte com segurança (pode ser numérica ou string no banco)
const matriculaStr = client.CLI_MATRICULA == null ? '' : String(client.CLI_MATRICULA).trim();
// Monta o endereço completo de faturamento // Monta o endereço completo de faturamento
const enderecoFaturamento = [ const enderecoFaturamento = [
client.CLI_ENDERECO_FAT?.trim(), client.CLI_ENDERECO_FAT?.trim(),
@@ -147,7 +171,8 @@ class ClientController {
clienteId: id_cliente, clienteId: id_cliente,
data: { data: {
nome: client.CLI_NOME?.trim(), nome: client.CLI_NOME?.trim(),
matricula: client.CLI_MATRICULA?.trim(), matricula: matriculaStr,
foto: fotoCliente,
situacao: { situacao: {
codigo: client.CLI_SITUACAO?.trim(), codigo: client.CLI_SITUACAO?.trim(),
descricao: situacao, descricao: situacao,
+9 -6
View File
@@ -159,7 +159,7 @@ class ConfigController {
'SELECT * FROM CHATC2_CONFIGURACOES_EMPRESA WHERE CFE_EMPRESA_ID = ?', [empresaId] 'SELECT * FROM CHATC2_CONFIGURACOES_EMPRESA WHERE CFE_EMPRESA_ID = ?', [empresaId]
); );
const flagsPadrao = { motivoVisualizar: 'N', motivoObrigatorio: 'N', resolucaoVisualizar: 'N', resolucaoObrigatorio: 'N' }; const flagsPadrao = { motivoVisualizar: 'N', motivoObrigatorio: 'N', resolucaoVisualizar: 'N', resolucaoObrigatorio: 'N', enviarBoleto: 'N' };
if (config.length === 0) { if (config.length === 0) {
const cfg = Object.assign({ empresaId, fotoCelular: 'N', saudacaoAtiva: 'S', saudacaoMensagem: '', csatAtivo: 'N', csatMensagem: '', enviarNomeUsuario: 'N', triagemAtiva: 'N', triagemMsgWelcome: '', triagemMsgAfter: '', triagemBoletoNumero: '0', instanciaPadraoId: null }, flagsPadrao); const cfg = Object.assign({ empresaId, fotoCelular: 'N', saudacaoAtiva: 'S', saudacaoMensagem: '', csatAtivo: 'N', csatMensagem: '', enviarNomeUsuario: 'N', triagemAtiva: 'N', triagemMsgWelcome: '', triagemMsgAfter: '', triagemBoletoNumero: '0', instanciaPadraoId: null }, flagsPadrao);
@@ -179,6 +179,7 @@ class ConfigController {
triagemMsgWelcome: c.CFE_TRIAGEM_MSG_WELCOME || '', triagemMsgWelcome: c.CFE_TRIAGEM_MSG_WELCOME || '',
triagemMsgAfter: c.CFE_TRIAGEM_MSG_AFTER || '', triagemMsgAfter: c.CFE_TRIAGEM_MSG_AFTER || '',
triagemBoletoNumero: (c.CFE_TRIAGEM_BOLETO_NUMERO || '0').trim(), triagemBoletoNumero: (c.CFE_TRIAGEM_BOLETO_NUMERO || '0').trim(),
enviarBoleto: (c.CFE_ENVIAR_BOLETO || 'N').trim(),
// Fluxo de Resolução // Fluxo de Resolução
motivoVisualizar: (c.CFE_MOTIVO_VISUALIZAR || 'N').trim(), motivoVisualizar: (c.CFE_MOTIVO_VISUALIZAR || 'N').trim(),
motivoObrigatorio: (c.CFE_MOTIVO_OBRIGATORIO || 'N').trim(), motivoObrigatorio: (c.CFE_MOTIVO_OBRIGATORIO || 'N').trim(),
@@ -261,8 +262,10 @@ class ConfigController {
try { try {
if (!(await isGerente(req))) return res.status(403).json({ success: false, error: 'Apenas gerentes podem alterar as configurações da empresa.' }); 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 { alias } = req.params;
await garantirEstrutura(alias);
const data = req.body; const data = req.body;
const empresaId = data.empresaId || req.user?.empresas?.[0]; const empresaId = data.empresaId || req.user?.empresas?.[0];
const enviarBoleto = (data.enviarBoleto === 'S' || data.enviarBoleto === true) ? 'S' : 'N';
// Upsert // Upsert
const exists = await db.query(alias, 'SELECT COUNT(*) AS T FROM CHATC2_CONFIGURACOES_EMPRESA WHERE CFE_EMPRESA_ID = ?', [empresaId]); const exists = await db.query(alias, 'SELECT COUNT(*) AS T FROM CHATC2_CONFIGURACOES_EMPRESA WHERE CFE_EMPRESA_ID = ?', [empresaId]);
@@ -273,16 +276,16 @@ class ConfigController {
CFE_INSTANCIA_PADRAO_ID = ?, CFE_FOTO_CELULAR = ?, CFE_SAUDACAO_ATIVA = ?, CFE_INSTANCIA_PADRAO_ID = ?, CFE_FOTO_CELULAR = ?, CFE_SAUDACAO_ATIVA = ?,
CFE_SAUDACAO_MENSAGEM = ?, CFE_CSAT_ATIVO = ?, CFE_CSAT_MENSAGEM = ?, CFE_SAUDACAO_MENSAGEM = ?, CFE_CSAT_ATIVO = ?, CFE_CSAT_MENSAGEM = ?,
CFE_ENVIAR_NOME_USUARIO = ?, CFE_TRIAGEM_ATIVA = ?, CFE_TRIAGEM_MSG_WELCOME = ?, CFE_ENVIAR_NOME_USUARIO = ?, CFE_TRIAGEM_ATIVA = ?, CFE_TRIAGEM_MSG_WELCOME = ?,
CFE_TRIAGEM_MSG_AFTER = ?, CFE_TRIAGEM_BOLETO_NUMERO = ? CFE_TRIAGEM_MSG_AFTER = ?, CFE_TRIAGEM_BOLETO_NUMERO = ?, CFE_ENVIAR_BOLETO = ?
WHERE CFE_EMPRESA_ID = ? 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]); `, [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', enviarBoleto, empresaId]);
} else { } else {
await db.execute(alias, ` await db.execute(alias, `
INSERT INTO CHATC2_CONFIGURACOES_EMPRESA (CFE_EMPRESA_ID, CFE_INSTANCIA_PADRAO_ID, CFE_FOTO_CELULAR, 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_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) CFE_TRIAGEM_ATIVA, CFE_TRIAGEM_MSG_WELCOME, CFE_TRIAGEM_MSG_AFTER, CFE_TRIAGEM_BOLETO_NUMERO, CFE_ENVIAR_BOLETO)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 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']); `, [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', enviarBoleto]);
} }
res.json({ success: true }); res.json({ success: true });
+126
View File
@@ -0,0 +1,126 @@
const db = require('../database');
const { isGerente } = require('../middlewares/roles');
const presence = require('../presence');
/**
* Resolve a lista de empresas para a consulta, respeitando a permissão do
* usuário. Se vier empresaId e o usuário tiver acesso, usa só ela; senão usa
* todas as empresas do usuário.
*/
function empresasPermitidas(req) {
const minhas = (req.user && req.user.empresas) || [];
const pedida = parseInt(req.query.empresaId, 10);
if (pedida && minhas.includes(pedida)) return [pedida];
return minhas;
}
class DashboardController {
/**
* Ping de presença — marca o atendente como online.
* POST /api/:alias/dashboard/ping
*/
static async ping(req, res) {
const { alias } = req.params;
if (req.user && req.user.id != null) presence.registrar(alias, req.user.id);
res.json({ success: true });
}
/**
* Estatísticas do dashboard (apenas gerente).
* GET /api/:alias/dashboard/stats?empresaId=
*/
static async stats(req, res) {
try {
if (!(await isGerente(req))) {
return res.status(403).json({ success: false, error: 'Apenas gerentes podem visualizar o dashboard.' });
}
const { alias } = req.params;
const empresas = empresasPermitidas(req);
if (empresas.length === 0) {
return res.json({ success: true, data: {
conversas: { abertas: 0, naoAtendidas: 0, naoAtribuidas: 0, pendentes: 0 },
atendentes: { disponiveis: 0, desconectados: 0, lista: [] },
trafego: [],
} });
}
const ph = empresas.map(() => '?').join(',');
// ===== Conversas =====
// Abertas: têm atendente E equipe
const abertas = await db.query(alias,
`SELECT COUNT(*) AS CT FROM CHATC2_CONVERSAS
WHERE CON_EMPRESA_ID IN (${ph}) AND CON_STATUS IN ('A','E') AND CON_SITUACAO = 'A'
AND CON_USUARIO_ID IS NOT NULL AND CON_EQUIPE_ID IS NOT NULL`, empresas);
// Não atendidas: têm atendente+equipe e a última mensagem foi do cliente ('C')
const naoAtendidas = await db.query(alias,
`SELECT COUNT(*) AS CT FROM CHATC2_CONVERSAS c
WHERE c.CON_EMPRESA_ID IN (${ph}) AND c.CON_STATUS IN ('A','E') AND c.CON_SITUACAO = 'A'
AND c.CON_USUARIO_ID IS NOT NULL AND c.CON_EQUIPE_ID IS NOT NULL
AND (SELECT m.CME_REMETENTE FROM CHATC2_CONVERSAS_MENSAGENS m
WHERE m.CME_CONVERSA_ID = c.CON_CODIGO_ID
ORDER BY m.CME_DT_ENVIO DESC FETCH FIRST 1 ROWS ONLY) = 'C'`, empresas);
// Não atribuídas: sem atendente e sem equipe
const naoAtribuidas = await db.query(alias,
`SELECT COUNT(*) AS CT FROM CHATC2_CONVERSAS
WHERE CON_EMPRESA_ID IN (${ph}) AND CON_STATUS IN ('A','E') AND CON_SITUACAO = 'A'
AND CON_USUARIO_ID IS NULL AND CON_EQUIPE_ID IS NULL`, empresas);
// Pendentes: têm apenas equipe (sem atendente)
const pendentes = await db.query(alias,
`SELECT COUNT(*) AS CT FROM CHATC2_CONVERSAS
WHERE CON_EMPRESA_ID IN (${ph}) AND CON_STATUS IN ('A','E') AND CON_SITUACAO = 'A'
AND CON_USUARIO_ID IS NULL AND CON_EQUIPE_ID IS NOT NULL`, empresas);
// ===== Atendentes =====
const usuarios = await db.query(alias,
`SELECT DISTINCT u.USU_CODIGO_ID, u.USU_NOME
FROM USUARIOS u INNER JOIN USUARIOS_EMPRESA ue ON u.USU_CODIGO_ID = ue.USE_USUARIO_ID
WHERE ue.USE_EMPRESA_ID IN (${ph}) AND u.USU_STATUS = 'A' AND COALESCE(u.USU_ACESSO_WEB,0) = 1
ORDER BY u.USU_NOME`, empresas);
const online = presence.idsOnline(alias);
let disponiveis = 0, desconectados = 0;
const lista = usuarios.map(function (u) {
const on = online.has(Number(u.USU_CODIGO_ID));
if (on) disponiveis++; else desconectados++;
return { id: u.USU_CODIGO_ID, nome: (u.USU_NOME || '').trim(), online: on };
});
// ===== Tráfego das conversas (últimos 365 dias, por dia) =====
const desde = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000);
const desdeStr = desde.toISOString().split('T')[0];
const trafegoRows = await db.query(alias,
`SELECT CAST(COALESCE(CON_DT_INICIO, CON_PRIMEIRA_MSG, CON_DT_ULTIMA_MSG) AS DATE) AS DIA, COUNT(*) AS CT
FROM CHATC2_CONVERSAS
WHERE CON_EMPRESA_ID IN (${ph})
AND COALESCE(CON_DT_INICIO, CON_PRIMEIRA_MSG, CON_DT_ULTIMA_MSG) >= ?
GROUP BY CAST(COALESCE(CON_DT_INICIO, CON_PRIMEIRA_MSG, CON_DT_ULTIMA_MSG) AS DATE)`,
empresas.concat([desdeStr]));
const fmtDia = (d) => {
if (!d) return null;
if (typeof d === 'string') return d.split('T')[0];
if (d instanceof Date) return d.toISOString().split('T')[0];
return String(d);
};
const trafego = trafegoRows
.map(function (r) { return { dia: fmtDia(r.DIA), total: Number(r.CT) || 0 }; })
.filter(function (r) { return r.dia; });
res.json({ success: true, data: {
conversas: {
abertas: Number(abertas[0]?.CT) || 0,
naoAtendidas: Number(naoAtendidas[0]?.CT) || 0,
naoAtribuidas: Number(naoAtribuidas[0]?.CT) || 0,
pendentes: Number(pendentes[0]?.CT) || 0,
},
atendentes: { disponiveis, desconectados, lista },
trafego,
} });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
}
module.exports = DashboardController;
+56
View File
@@ -0,0 +1,56 @@
/**
* Presença de atendentes (em memória).
*
* Mantém um mapa simples de "última atividade" por (alias + usuário). As páginas
* autenticadas (chat, dashboard) enviam um ping periódico; consideramos
* "disponível" quem teve atividade nos últimos TTL_MS milissegundos.
*
* É em memória de propósito: não altera o schema do banco e funciona bem para
* um único processo Node. Em cluster/multi-processo seria preciso um store
* compartilhado (Redis), mas isso foge do escopo atual.
*/
const TTL_MS = 2 * 60 * 1000; // 2 minutos sem ping => desconectado
// chave: alias + '|' + usuarioId => timestamp (ms) do último ping
const ultimoPing = new Map();
function chave(alias, usuarioId) {
return String(alias) + '|' + String(usuarioId);
}
/** Registra atividade do usuário. */
function registrar(alias, usuarioId) {
if (!alias || usuarioId == null) return;
ultimoPing.set(chave(alias, usuarioId), Date.now());
}
/** Retorna true se o usuário teve atividade recente (online). */
function estaOnline(alias, usuarioId) {
const t = ultimoPing.get(chave(alias, usuarioId));
return !!t && (Date.now() - t) <= TTL_MS;
}
/** Conjunto (Set) de IDs de usuários online para um alias. */
function idsOnline(alias) {
const agora = Date.now();
const prefixo = String(alias) + '|';
const set = new Set();
for (const [k, t] of ultimoPing.entries()) {
if (k.startsWith(prefixo) && (agora - t) <= TTL_MS) {
set.add(Number(k.slice(prefixo.length)));
}
}
return set;
}
// Limpeza periódica de entradas expiradas (evita crescimento indefinido)
const intervalo = setInterval(function () {
const agora = Date.now();
for (const [k, t] of ultimoPing.entries()) {
if (agora - t > TTL_MS * 5) ultimoPing.delete(k);
}
}, TTL_MS);
if (intervalo.unref) intervalo.unref();
module.exports = { registrar, estaOnline, idsOnline, TTL_MS };
+96 -16
View File
@@ -651,6 +651,18 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
</div> </div>
</div> </div>
<!-- Modal enviar boleto -->
<div class="modal-overlay" id="modalBoleto">
<div class="modal">
<h3>💳 Enviar boleto</h3>
<p style="font-size:13px;color:#6b7280;margin-bottom:12px">Selecione o título a enviar para o cliente:</p>
<div id="boletoLista" style="max-height:320px;overflow-y:auto"></div>
<div class="btn-group">
<button onclick="fecharModalBoleto()">Fechar</button>
</div>
</div>
</div>
<div class="sidebar-left"> <div class="sidebar-left">
<div class="header"> <div class="header">
<h2>💬 Chatc2</h2> <h2>💬 Chatc2</h2>
@@ -917,7 +929,7 @@ function renderConversas(convs, busca) {
} }
// ===== ABRIR CONVERSA ===== // ===== ABRIR CONVERSA =====
window.abrirConversa = async function(id) { window.abrirConversa = async function(id, skipFotoRefresh) {
conversaAtiva = id; conversaAtiva = id;
// Mobile: alterna para a visão da conversa (e fecha o painel de info) // Mobile: alterna para a visão da conversa (e fecha o painel de info)
document.body.classList.add('chat-aberto'); document.body.classList.add('chat-aberto');
@@ -941,11 +953,26 @@ window.abrirConversa = async function(id) {
var data = await res.json(); var data = await res.json();
if (!data.success) return; if (!data.success) return;
renderInfoConversa(data.data); renderInfoConversa(data.data);
// Foto automática: se o cliente está vinculado mas sem foto, busca via Evolution
// em segundo plano e recarrega o painel quando a foto chegar.
if (!skipFotoRefresh && data.data && data.data.cliente && !data.data.cliente.foto) {
autoAtualizarFoto(id);
}
} catch(e) {} } catch(e) {}
carregarMensagens(); carregarMensagens();
}; };
// Busca a foto do cliente via Evolution (fetchProfile) em segundo plano, sem bloquear.
function autoAtualizarFoto(id) {
fetch('/api/' + alias + '/evolution/refresh-photo/' + id, {
method: 'POST', headers: { 'Authorization': 'Bearer ' + token }
}).then(function(r){ return r.json(); }).then(function(r){
// Recarrega apenas se a foto foi atualizada e ainda estamos na mesma conversa
if (r && r.success && r.atualizada && conversaAtiva === id) abrirConversa(id, true);
}).catch(function(){});
}
function renderInfoConversa(conv) { function renderInfoConversa(conv) {
document.getElementById('chatNome').textContent = conv.nomeContato || conv.numero || 'Desconhecido'; document.getElementById('chatNome').textContent = conv.nomeContato || conv.numero || 'Desconhecido';
document.getElementById('chatStatus').textContent = conv.status === 'E' ? '🟡 Em espera' : conv.status === 'A' ? '🟢 Em atendimento' : '⚫ Finalizado'; document.getElementById('chatStatus').textContent = conv.status === 'E' ? '🟡 Em espera' : conv.status === 'A' ? '🟢 Em atendimento' : '⚫ Finalizado';
@@ -986,8 +1013,7 @@ function renderInfoConversa(conv) {
(c.matricula ? '<div class="field"><div class="label">Matrícula</div><div class="value">' + esc(c.matricula) + '</div></div>' : '') + (c.matricula ? '<div class="field"><div class="label">Matrícula</div><div class="value">' + esc(c.matricula) + '</div></div>' : '') +
(c.plano ? '<div class="field"><div class="label">Plano</div><div class="value">' + esc(c.plano) + '</div></div>' : '') + (c.plano ? '<div class="field"><div class="label">Plano</div><div class="value">' + esc(c.plano) + '</div></div>' : '') +
'<div class="field"><div class="label">Contato</div><div class="value">' + esc(c.celular || c.telefone || conv.numero || '-') + '</div></div>' + '<div class="field"><div class="label">Contato</div><div class="value">' + esc(c.celular || c.telefone || conv.numero || '-') + '</div></div>' +
'<div class="field"><div class="label ' + inadClass + '">' + inadLabel + '</div></div>' + '<div class="field"><div class="label ' + inadClass + '">' + inadLabel + '</div></div>';
'<button id="btnAtualizarFoto" onclick="atualizarFotoCliente()" style="margin-top:8px;padding:6px 10px;border:1px solid #e5e7eb;border-radius:6px;background:#f9fafb;cursor:pointer;font-size:12px;color:#374151">🔄 Atualizar foto (WhatsApp)</button>';
} else if (conv.dependente) { } else if (conv.dependente) {
var dep = conv.dependente; var dep = conv.dependente;
fotoContainer.textContent = (dep.nome || '?').charAt(0).toUpperCase(); fotoContainer.textContent = (dep.nome || '?').charAt(0).toUpperCase();
@@ -1005,6 +1031,13 @@ function renderInfoConversa(conv) {
infoContainer.innerHTML = '<div style="color:#9ca3af;font-size:13px;text-align:center;padding:20px 0">Contato não cadastrado como cliente</div>'; infoContainer.innerHTML = '<div style="color:#9ca3af;font-size:13px;text-align:center;padding:20px 0">Contato não cadastrado como cliente</div>';
} }
// Botão "Enviar boleto" (aparece se a empresa habilitar e houver titular)
var boletoClienteId = conv.cliente ? conv.cliente.id : (conv.dependente ? conv.dependente.titularId : null);
if (cfgResolucao.enviarBoleto === 'S' && boletoClienteId && conv.status !== 'F') {
infoContainer.innerHTML +=
'<button id="btnEnviarBoleto" onclick="abrirModalBoleto(' + boletoClienteId + ')" style="margin-top:10px;width:100%;padding:8px 10px;border:1px solid #c7d2fe;border-radius:6px;background:#eef2ff;cursor:pointer;font-size:13px;color:#4338ca;font-weight:600">💳 Enviar boleto</button>';
}
// Verifica convalescentes pendentes (situação E = entrega) // Verifica convalescentes pendentes (situação E = entrega)
var clienteId = conv.cliente ? conv.cliente.id : (conv.dependente ? conv.dependente.titularId : null); var clienteId = conv.cliente ? conv.cliente.id : (conv.dependente ? conv.dependente.titularId : null);
if (clienteId) { if (clienteId) {
@@ -1488,24 +1521,62 @@ window.toggleInfo = function() {
document.body.classList.toggle('info-aberto'); document.body.classList.toggle('info-aberto');
}; };
// Busca a foto do cliente via Evolution (fetchProfile) e atualiza o painel // ===== ENVIAR BOLETO =====
window.atualizarFotoCliente = async function() { var boletoClienteAtual = null;
if (!conversaAtiva) return; window.abrirModalBoleto = async function(clienteId) {
var btn = document.getElementById('btnAtualizarFoto'); if (!conversaAtiva || !clienteId) return;
if (btn) { btn.disabled = true; btn.textContent = '⏳ Buscando...'; } boletoClienteAtual = clienteId;
var modal = document.getElementById('modalBoleto');
var lista = document.getElementById('boletoLista');
modal.classList.add('show');
lista.innerHTML = '<div style="text-align:center;color:#9ca3af;padding:20px">⏳ Carregando títulos...</div>';
try { try {
var r = await (await fetch('/api/' + alias + '/evolution/refresh-photo/' + conversaAtiva, { var r = await (await fetch('/api/' + alias + '/conversations/' + conversaAtiva + '/boletos?clienteId=' + clienteId, {
method: 'POST', headers: { 'Authorization': 'Bearer ' + token } headers: { 'Authorization': 'Bearer ' + token }
})).json(); })).json();
if (r.success && r.atualizada) { if (!r.success) { lista.innerHTML = '<div style="color:#ef4444;padding:16px">' + esc(r.error || 'Erro ao carregar títulos.') + '</div>'; return; }
abrirConversa(conversaAtiva); // recarrega para exibir a nova foto var carnes = (r.data || []).filter(function(c){ return c.temLinhaDigitavel || c.temPix; });
if (carnes.length === 0) {
lista.innerHTML = '<div style="color:#9ca3af;padding:16px;text-align:center">Nenhum título em aberto com boleto/PIX disponível.</div>';
return;
}
lista.innerHTML = carnes.map(function(c){
var valor = (c.valor != null) ? ('R$ ' + Number(c.valor).toFixed(2).replace('.', ',')) : '-';
var venc = c.vencimento ? c.vencimento.split('-').reverse().join('/') : '-';
var parc = c.parcela ? (' • Parcela ' + c.parcela + (c.totalParcelas ? '/' + c.totalParcelas : '')) : '';
var tags = (c.temPix ? '<span style="font-size:10px;background:#dcfce7;color:#166534;padding:1px 6px;border-radius:8px;margin-left:6px">PIX</span>' : '') +
(c.temLinhaDigitavel ? '<span style="font-size:10px;background:#dbeafe;color:#1e40af;padding:1px 6px;border-radius:8px;margin-left:6px">Boleto</span>' : '');
return '<div style="display:flex;justify-content:space-between;align-items:center;gap:8px;padding:10px;border:1px solid #e5e7eb;border-radius:8px;margin-bottom:8px">' +
'<div><div style="font-weight:600;color:#111827">' + valor + tags + '</div>' +
'<div style="font-size:12px;color:#6b7280">Vencimento: ' + venc + parc + '</div></div>' +
'<button onclick="enviarBoleto(' + c.id + ', this)" style="padding:6px 12px;background:#4f46e5;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:12px;font-weight:600;white-space:nowrap">Enviar</button>' +
'</div>';
}).join('');
} catch(e) {
lista.innerHTML = '<div style="color:#ef4444;padding:16px">Erro de conexão.</div>';
}
};
window.fecharModalBoleto = function() {
document.getElementById('modalBoleto').classList.remove('show');
};
window.enviarBoleto = async function(carneId, btn) {
if (!conversaAtiva || !boletoClienteAtual) return;
if (btn) { btn.disabled = true; btn.textContent = 'Enviando...'; }
try {
var r = await (await fetch('/api/' + alias + '/conversations/' + conversaAtiva + '/send-boleto', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ clienteId: boletoClienteAtual, carneId: carneId })
})).json();
if (r.success) {
fecharModalBoleto();
carregarMensagens();
} else if (btn) { } else if (btn) {
btn.disabled = false; btn.disabled = false; btn.textContent = 'Enviar';
btn.textContent = r.message || 'Nenhuma foto encontrada'; alert(r.error || 'Não foi possível enviar o boleto.');
setTimeout(function(){ btn.textContent = '🔄 Atualizar foto (WhatsApp)'; }, 2500);
} }
} catch(e) { } catch(e) {
if (btn) { btn.disabled = false; btn.textContent = '🔄 Atualizar foto (WhatsApp)'; } if (btn) { btn.disabled = false; btn.textContent = 'Enviar'; }
} }
}; };
@@ -1665,6 +1736,15 @@ setInterval(function() {
if (conversaAtiva) carregarMensagens(); if (conversaAtiva) carregarMensagens();
}, 5000); }, 5000);
// Presença: marca o atendente como "online" para o dashboard
function pingPresenca() {
fetch('/api/' + alias + '/dashboard/ping', {
method: 'POST', headers: { 'Authorization': 'Bearer ' + token }
}).catch(function(){});
}
pingPresenca();
setInterval(pingPresenca, 45000);
// Auto-resize textarea // Auto-resize textarea
document.getElementById('msgInput').addEventListener('input', function() { document.getElementById('msgInput').addEventListener('input', function() {
this.style.height = 'auto'; this.style.height = 'auto';
+8 -1
View File
@@ -209,9 +209,16 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
var sitLabel = c.situacao ? c.situacao.descricao : 'Desconhecido'; 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() : '?'; var iniciais = c.nome ? c.nome.split(' ').map(function(s) { return s[0]; }).slice(0,2).join('').toUpperCase() : '?';
var avatarConteudo = iniciais;
if (c.foto) {
var srcFoto = c.foto;
if (srcFoto.indexOf('data:') !== 0 && srcFoto.length > 50) srcFoto = 'data:image/jpeg;base64,' + srcFoto;
avatarConteudo = '<img src="' + srcFoto + '" alt="" style="width:100%;height:100%;object-fit:cover;border-radius:50%">';
}
container.innerHTML = container.innerHTML =
'<div class="client-header">' + '<div class="client-header">' +
'<div class="client-avatar">' + iniciais + '</div>' + '<div class="client-avatar">' + avatarConteudo + '</div>' +
'<div class="client-header-info">' + '<div class="client-header-info">' +
'<h1>' + (c.nome || '-') + '</h1>' + '<h1>' + (c.nome || '-') + '</h1>' +
'<div class="matricula">Matrícula: ' + (c.matricula || '-') + '</div>' + '<div class="matricula">Matrícula: ' + (c.matricula || '-') + '</div>' +
+178 -103
View File
@@ -9,6 +9,35 @@
body { background: #f3f4f6; display: flex; min-height: 100vh; } body { background: #f3f4f6; display: flex; min-height: 100vh; }
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; } .main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.container { flex: 1; padding: 24px; overflow-y: auto; } .container { flex: 1; padding: 24px; overflow-y: auto; }
.section-title { font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: #6b7280; margin: 4px 0 12px; }
.stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 14px; margin-bottom: 24px; }
.stat-card { background: #fff; border-radius: 12px; padding: 18px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); border-left: 4px solid #9ca3af; }
.stat-card .num { font-size: 32px; font-weight: 800; color: #111827; line-height: 1; }
.stat-card .lbl { font-size: 13px; color: #6b7280; margin-top: 6px; font-weight: 600; }
.stat-card .desc { font-size: 11px; color: #9ca3af; margin-top: 4px; }
.stat-card.abertas { border-left-color: #10b981; }
.stat-card.naoatendidas { border-left-color: #ef4444; }
.stat-card.naoatribuidas { border-left-color: #f59e0b; }
.stat-card.pendentes { border-left-color: #6366f1; }
.stat-card.disponiveis { border-left-color: #10b981; }
.stat-card.desconectados { border-left-color: #9ca3af; }
.card { background:#fff; border-radius:12px; padding:18px; box-shadow:0 1px 3px rgba(0,0,0,0.08); margin-bottom:24px; }
.atendente-row { display:flex; align-items:center; gap:8px; padding:7px 0; border-bottom:1px solid #f3f4f6; font-size:14px; }
.atendente-row:last-child { border-bottom:none; }
.dot { width:9px; height:9px; border-radius:50%; flex-shrink:0; }
.dot.on { background:#10b981; box-shadow:0 0 0 3px rgba(16,185,129,.18); }
.dot.off { background:#cbd5e1; }
.atendente-row .st { margin-left:auto; font-size:12px; color:#6b7280; }
/* Heatmap (estilo GitHub) */
.heatmap-scroll { overflow-x:auto; padding-bottom:6px; }
.heatmap { display:flex; gap:3px; }
.hm-col { display:flex; flex-direction:column; gap:3px; }
.hm-cell { width:12px; height:12px; border-radius:2px; background:#ebedf0; }
.hm-cell.l1 { background:#9be9a8; } .hm-cell.l2 { background:#40c463; }
.hm-cell.l3 { background:#30a14e; } .hm-cell.l4 { background:#216e39; }
.hm-months { display:flex; gap:3px; font-size:10px; color:#9ca3af; margin-bottom:4px; height:12px; }
.hm-legend { display:flex; align-items:center; gap:4px; font-size:11px; color:#9ca3af; margin-top:8px; justify-content:flex-end; }
body.dark-mode .hm-cell { background:#161b22; }
</style> </style>
<link rel="stylesheet" href="/css/dark-mode.css"> <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'});} <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'});}
@@ -27,29 +56,17 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
</div> </div>
<nav class="sidebar-nav"> <nav class="sidebar-nav">
<div class="nav-label">Principal</div> <div class="nav-label">Principal</div>
<a href="#" class="active" id="navDashboard"> <a href="#" class="active" id="navDashboard"><span class="icon">📊</span> Dashboard</a>
<span class="icon">📊</span> Dashboard <a href="#" id="navClients"><span class="icon">👥</span> Clientes</a>
</a> <a href="#" id="navChat"><span class="icon">💬</span> Conversas</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> <div class="nav-label" id="adminLabel" style="display:none">Administrador</div>
<a href="#" id="navConfig" style="display:none"> <a href="#" id="navConfig" style="display:none"><span class="icon">⚙️</span> Configurações</a>
<span class="icon">⚙️</span> Configurações
</a>
<a href="#" id="navAllConvs" style="display:none"><span class="icon">💬</span> Todas Conversas</a> <a href="#" id="navAllConvs" style="display:none"><span class="icon">💬</span> Todas Conversas</a>
<a href="#" id="navRoutes" style="display:none"> <a href="#" id="navRoutes" style="display:none"><span class="icon">📡</span> Rotas</a>
<span class="icon">📡</span> Rotas
</a>
</nav> </nav>
<div class="sidebar-footer"> <div class="sidebar-footer">
<button class="dark-mode-toggle" onclick="darkModeToggle()" style="width:100%;margin-bottom:8px;padding:8px">🌙 Escuro</button> <button class="dark-mode-toggle" onclick="darkModeToggle()" style="width:100%;margin-bottom:8px;padding:8px">🌙 Escuro</button>
<a onclick="logout()"> <a onclick="logout()"><span class="icon">🚪</span> Sair</a>
<span class="icon">🚪</span> Sair
</a>
</div> </div>
</aside> </aside>
@@ -64,123 +81,181 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')
</div> </div>
<div class="container"> <div class="container">
<div class="card"> <div class="section-title">Conversas</div>
<h3>👤 Dados do Usuário</h3> <div class="stat-grid">
<div class="info-grid"> <div class="stat-card abertas"><div class="num" id="cAbertas"></div><div class="lbl">Abertas</div><div class="desc">Com atendente e equipe</div></div>
<div class="info-item"> <div class="stat-card naoatendidas"><div class="num" id="cNaoAtendidas"></div><div class="lbl">Não Atendidas</div><div class="desc">Aguardando resposta do atendente</div></div>
<div class="label">ID</div> <div class="stat-card naoatribuidas"><div class="num" id="cNaoAtribuidas"></div><div class="lbl">Não Atribuídas</div><div class="desc">Sem equipe e sem atendente</div></div>
<div class="value" id="userId">-</div> <div class="stat-card pendentes"><div class="num" id="cPendentes"></div><div class="lbl">Pendentes</div><div class="desc">Somente com equipe</div></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>
<div class="section-title">Atendentes</div>
<div class="stat-grid">
<div class="stat-card disponiveis"><div class="num" id="aDisponiveis"></div><div class="lbl">Disponíveis</div><div class="desc">Acessando a plataforma</div></div>
<div class="stat-card desconectados"><div class="num" id="aDesconectados"></div><div class="lbl">Desconectados</div><div class="desc">Fora da plataforma</div></div>
</div>
<div class="card"> <div class="card">
<h3>🔑 Token de Acesso</h3> <div class="section-title" style="margin-top:0">Situação dos atendentes</div>
<button class="btn-copy" onclick="showToken()" id="btnShowToken" style="margin-bottom:8px">Mostrar Token</button> <div id="atendentesLista"><div style="color:#9ca3af;font-size:13px">Carregando...</div></div>
<div class="token-box" id="tokenDisplay" style="display:none"></div> </div>
<button class="btn-copy" onclick="copyToken()" id="btnCopyToken" style="display:none">Copiar Token</button>
<div class="section-title">Tráfego das Conversas</div>
<div class="card">
<div style="font-size:13px;color:#6b7280;margin-bottom:12px" id="trafegoResumo">Conversas iniciadas nos últimos 12 meses</div>
<div class="heatmap-scroll">
<div class="hm-months" id="hmMonths"></div>
<div class="heatmap" id="heatmap"></div>
</div>
<div class="hm-legend">
Menos
<span class="hm-cell"></span><span class="hm-cell l1"></span><span class="hm-cell l2"></span><span class="hm-cell l3"></span><span class="hm-cell l4"></span>
Mais
</div>
</div> </div>
</div> </div>
</div> </div>
<script> <script>
// Extrai alias da URL var pathParts = window.location.pathname.split('/');
const pathParts = window.location.pathname.split('/'); var alias = pathParts[2] || localStorage.getItem('chatc2_alias') || 'lajedo';
const alias = pathParts[2] || localStorage.getItem('chatc2_alias') || 'lajedo';
localStorage.setItem('chatc2_alias', alias); localStorage.setItem('chatc2_alias', alias);
const token = localStorage.getItem('chatc2_token'); var token = localStorage.getItem('chatc2_token');
const user = JSON.parse(localStorage.getItem('chatc2_user') || '{}'); var user = JSON.parse(localStorage.getItem('chatc2_user') || '{}');
if (!token) { if (!token) { window.location.href = '/app/' + alias + '/login'; }
window.location.href = '/app/' + alias + '/login';
}
// Sidebar - mostra opções de admin se for Gerente var tipoChat = user.tipoChat || 'A';
const tipoChat = user.tipoChat || 'A';
if (tipoChat === 'G') { if (tipoChat === 'G') {
document.getElementById('adminLabel').style.display = ''; document.getElementById('adminLabel').style.display = '';
document.getElementById('navConfig').style.display = ''; document.getElementById('navConfig').style.display = '';
document.getElementById('navAllConvs').style.display = '';
document.getElementById('navRoutes').style.display = ''; document.getElementById('navRoutes').style.display = '';
} else { } else {
// Agente não dashboard - redireciona para conversas // Agente não acessa o dashboard — vai direto para as conversas
window.location.href = '/app/' + alias + '/company/' + (user.empresas?.[0] || 1) + '/conversation/0'; window.location.href = '/app/' + alias + '/company/' + (user.empresas && user.empresas[0] || 1) + '/conversation/0';
} }
document.getElementById('sidebarAlias').textContent = alias; 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('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;
}
function navClick(e, url) { e.preventDefault(); window.location.href = url; }
document.getElementById('navDashboard').onclick = function(e) { navClick(e, '/app/' + alias + '/dashboard'); }; document.getElementById('navDashboard').onclick = function(e) { navClick(e, '/app/' + alias + '/dashboard'); };
document.getElementById('navClients').onclick = function(e) { navClick(e, '/app/' + alias + '/clients'); }; 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('navChat').onclick = function(e) { navClick(e, '/app/' + alias + '/company/' + (user.empresas && user.empresas[0] || 1) + '/conversation/0'); };
document.getElementById('navConfig').onclick = function(e) { navClick(e, '/app/' + alias + '/settings'); }; document.getElementById('navConfig').onclick = function(e) { navClick(e, '/app/' + alias + '/settings'); };
document.getElementById('navAllConvs').onclick = function(e) { navClick(e, '/app/' + alias + '/conversations/all'); };
document.getElementById('navRoutes').onclick = function(e) { navClick(e, '/app/' + alias + '/routes'); }; document.getElementById('navRoutes').onclick = function(e) { navClick(e, '/app/' + alias + '/routes'); };
function copyToken() {
navigator.clipboard.writeText(token).then(() => {
alert('Token copiado!');
});
}
function logout() { function logout() {
localStorage.removeItem('chatc2_token'); ['chatc2_token','chatc2_alias','chatc2_user'].forEach(function(k){ localStorage.removeItem(k); });
localStorage.removeItem('chatc2_alias');
localStorage.removeItem('chatc2_user');
window.location.href = '/app/' + alias + '/login'; window.location.href = '/app/' + alias + '/login';
} }
function showToken() { function esc(s){ return String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
const display = document.getElementById('tokenDisplay');
const btnShow = document.getElementById('btnShowToken'); // ===== Carrega estatísticas =====
const btnCopy = document.getElementById('btnCopyToken'); async function carregarStats() {
if (display.style.display === 'none') { try {
display.textContent = token; var r = await (await fetch('/api/' + alias + '/dashboard/stats', {
display.style.display = 'block'; headers: { 'Authorization': 'Bearer ' + token }
btnShow.textContent = 'Ocultar Token'; })).json();
btnCopy.style.display = 'inline-block'; if (!r.success) return;
} else { var d = r.data;
display.style.display = 'none'; document.getElementById('cAbertas').textContent = d.conversas.abertas;
btnShow.textContent = 'Mostrar Token'; document.getElementById('cNaoAtendidas').textContent = d.conversas.naoAtendidas;
btnCopy.style.display = 'none'; document.getElementById('cNaoAtribuidas').textContent = d.conversas.naoAtribuidas;
document.getElementById('cPendentes').textContent = d.conversas.pendentes;
document.getElementById('aDisponiveis').textContent = d.atendentes.disponiveis;
document.getElementById('aDesconectados').textContent = d.atendentes.desconectados;
renderAtendentes(d.atendentes.lista);
renderHeatmap(d.trafego);
} catch(e) {}
}
function renderAtendentes(lista) {
var el = document.getElementById('atendentesLista');
if (!lista || lista.length === 0) {
el.innerHTML = '<div style="color:#9ca3af;font-size:13px">Nenhum atendente cadastrado.</div>';
return;
} }
// Online primeiro
lista.sort(function(a,b){ return (b.online?1:0) - (a.online?1:0) || a.nome.localeCompare(b.nome); });
el.innerHTML = lista.map(function(a){
return '<div class="atendente-row">' +
'<span class="dot ' + (a.online ? 'on' : 'off') + '"></span>' +
'<span>' + esc(a.nome) + '</span>' +
'<span class="st">' + (a.online ? 'Disponível' : 'Desconectado') + '</span>' +
'</div>';
}).join('');
}
// ===== Heatmap estilo GitHub =====
function nivel(n) {
if (!n) return '';
if (n <= 2) return 'l1';
if (n <= 5) return 'l2';
if (n <= 10) return 'l3';
return 'l4';
}
function renderHeatmap(trafego) {
var mapa = {}; var totalGeral = 0;
(trafego || []).forEach(function(t){ mapa[t.dia] = t.total; totalGeral += t.total; });
var hoje = new Date(); hoje.setHours(0,0,0,0);
var inicio = new Date(hoje); inicio.setDate(inicio.getDate() - 364);
inicio.setDate(inicio.getDate() - inicio.getDay()); // alinha ao domingo
var meses = ['Jan','Fev','Mar','Abr','Mai','Jun','Jul','Ago','Set','Out','Nov','Dez'];
var grid = document.getElementById('heatmap');
var monthsBar = document.getElementById('hmMonths');
grid.innerHTML = ''; monthsBar.innerHTML = '';
var cursor = new Date(inicio);
var ultimoMes = -1;
while (cursor <= hoje) {
var col = document.createElement('div');
col.className = 'hm-col';
var mesDaColuna = cursor.getMonth();
// rótulo do mês quando muda no topo da coluna
var lbl = document.createElement('div');
lbl.style.width = '12px';
lbl.style.fontSize = '10px';
lbl.style.color = '#9ca3af';
if (mesDaColuna !== ultimoMes) { lbl.textContent = meses[mesDaColuna]; ultimoMes = mesDaColuna; }
else { lbl.innerHTML = '&nbsp;'; }
monthsBar.appendChild(lbl);
for (var dia = 0; dia < 7; dia++) {
var cell = document.createElement('div');
var iso = cursor.toISOString().split('T')[0];
if (cursor > hoje) { cell.className = 'hm-cell'; cell.style.visibility = 'hidden'; }
else {
var n = mapa[iso] || 0;
cell.className = 'hm-cell ' + nivel(n);
cell.title = iso.split('-').reverse().join('/') + ': ' + n + ' conversa' + (n === 1 ? '' : 's');
}
col.appendChild(cell);
cursor.setDate(cursor.getDate() + 1);
}
grid.appendChild(col);
}
document.getElementById('trafegoResumo').textContent =
totalGeral + ' conversa' + (totalGeral === 1 ? '' : 's') + ' iniciada' + (totalGeral === 1 ? '' : 's') + ' nos últimos 12 meses';
}
// ===== Presença + atualização periódica =====
function pingPresenca() {
fetch('/api/' + alias + '/dashboard/ping', {
method: 'POST', headers: { 'Authorization': 'Bearer ' + token }
}).catch(function(){});
}
if (tipoChat === 'G') {
pingPresenca();
carregarStats();
setInterval(pingPresenca, 45000);
setInterval(carregarStats, 30000);
} }
</script> </script>
<script src="/js/dark-mode.js"></script> <script src="/js/dark-mode.js"></script>
+2
View File
@@ -702,6 +702,7 @@ async function carregarConfig() {
'<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"><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"><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="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="cfgEnviarBoleto" ' + (cfg.enviarBoleto === 'S' ? 'checked' : '') + '> <label for="cfgEnviarBoleto">💳 Permitir enviar boleto na conversa (botão "Enviar boleto")</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"><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" 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 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>' +
@@ -739,6 +740,7 @@ window.salvarConfig = async function() {
saudacaoAtiva: document.getElementById('cfgSaudacao').checked ? 'S' : 'N', saudacaoAtiva: document.getElementById('cfgSaudacao').checked ? 'S' : 'N',
saudacaoMensagem: document.getElementById('cfgSaudacaoMsg').value, saudacaoMensagem: document.getElementById('cfgSaudacaoMsg').value,
enviarNomeUsuario: document.getElementById('cfgNomeUser').checked ? 'S' : 'N', enviarNomeUsuario: document.getElementById('cfgNomeUser').checked ? 'S' : 'N',
enviarBoleto: document.getElementById('cfgEnviarBoleto').checked ? 'S' : 'N',
triagemAtiva: document.getElementById('cfgTriagem').checked ? 'S' : 'N', triagemAtiva: document.getElementById('cfgTriagem').checked ? 'S' : 'N',
triagemMsgWelcome: document.getElementById('cfgTriagemWelcome').value, triagemMsgWelcome: document.getElementById('cfgTriagemWelcome').value,
triagemMsgAfter: document.getElementById('cfgTriagemAfter').value, triagemMsgAfter: document.getElementById('cfgTriagemAfter').value,
+3
View File
@@ -33,6 +33,9 @@ async function garantirEstrutura(alias) {
await tentar(alias, `ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_RESOLUCAO_VISUALIZAR CHAR(1) DEFAULT 'N'`); await tentar(alias, `ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_RESOLUCAO_VISUALIZAR CHAR(1) DEFAULT 'N'`);
await tentar(alias, `ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_RESOLUCAO_OBRIGATORIO CHAR(1) DEFAULT 'N'`); await tentar(alias, `ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_RESOLUCAO_OBRIGATORIO CHAR(1) DEFAULT 'N'`);
// Envio de boleto na conversa (checkbox em Configurações > Empresa)
await tentar(alias, `ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_ENVIAR_BOLETO CHAR(1) DEFAULT 'N'`);
await tentar(alias, `ALTER TABLE CHATC2_CONVERSAS ADD CON_MOTIVO_ID INTEGER`); await tentar(alias, `ALTER TABLE CHATC2_CONVERSAS ADD CON_MOTIVO_ID INTEGER`);
await tentar(alias, `ALTER TABLE CHATC2_CONVERSAS ADD CON_RESOLUCAO VARCHAR(4000)`); await tentar(alias, `ALTER TABLE CHATC2_CONVERSAS ADD CON_RESOLUCAO VARCHAR(4000)`);
+2
View File
@@ -17,6 +17,8 @@ router.post('/:alias/conversations/:id/assign', authenticateToken, ChatControlle
router.post('/:alias/conversations/:id/assign-team', authenticateToken, ChatController.assignTeam); router.post('/:alias/conversations/:id/assign-team', authenticateToken, ChatController.assignTeam);
router.post('/:alias/conversations/:id/labels', authenticateToken, ChatController.toggleLabel); router.post('/:alias/conversations/:id/labels', authenticateToken, ChatController.toggleLabel);
router.post('/:alias/conversations/:id/link-client', authenticateToken, ChatController.linkClient); router.post('/:alias/conversations/:id/link-client', authenticateToken, ChatController.linkClient);
router.get('/:alias/conversations/:id/boletos', authenticateToken, ChatController.getBoletos);
router.post('/:alias/conversations/:id/send-boleto', authenticateToken, ChatController.sendBoleto);
router.get('/:alias/media/:mediaId', ChatController.getMedia); router.get('/:alias/media/:mediaId', ChatController.getMedia);
module.exports = router; module.exports = router;
+10
View File
@@ -0,0 +1,10 @@
const express = require('express');
const router = express.Router();
const DashboardController = require('../controllers/dashboardController');
const authenticateToken = require('../middlewares/auth');
// Presença (qualquer usuário autenticado) e estatísticas (gerente).
router.post('/:alias/dashboard/ping', authenticateToken, DashboardController.ping);
router.get('/:alias/dashboard/stats', authenticateToken, DashboardController.stats);
module.exports = router;
+2
View File
@@ -7,6 +7,7 @@ const chatRoutes = require('./chatRoutes');
const configRoutes = require('./configRoutes'); const configRoutes = require('./configRoutes');
const menuRoutes = require('./menuRoutes'); const menuRoutes = require('./menuRoutes');
const databaseRoutes = require('./databaseRoutes'); const databaseRoutes = require('./databaseRoutes');
const dashboardRoutes = require('./dashboardRoutes');
const RoutesController = require('../controllers/routesController'); const RoutesController = require('../controllers/routesController');
const EvolutionController = require('../controllers/evolutionController'); const EvolutionController = require('../controllers/evolutionController');
@@ -34,6 +35,7 @@ router.use('/api', chatRoutes);
router.use('/api', configRoutes); router.use('/api', configRoutes);
router.use('/api', menuRoutes); router.use('/api', menuRoutes);
router.use('/api', databaseRoutes); router.use('/api', databaseRoutes);
router.use('/api', dashboardRoutes);
// Rotas do aplicativo (proteção aplicada internamente) // Rotas do aplicativo (proteção aplicada internamente)
router.use('/app', authRoutes); router.use('/app', authRoutes);