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:
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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
@@ -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';
|
||||||
|
|||||||
@@ -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
@@ -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 vê 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||||
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 = ' '; }
|
||||||
|
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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)`);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user