ponytail: remove resolucaoSetup, compressVideo, sendEvolutionMessage, sendEvolution; add migration #24

- delete: src/resolucaoSetup.js (migration paralela, consolidado em #24)
- shrink: chatController.sendEvolutionMessage (140 -> 6 linhas, delega ao evolutionService)
- shrink: triageController.sendEvolution (55 -> 10 linhas, delega ao evolutionService)
- delete: compressVideo em chatController (codigo morto, chamado so pelo old sendEvolutionMessage)
- add: migration #24 consolida DDL que estava espalhada em runtime
- fix: configController.remove garantirEstrutura calls (arquivo removido)
- net: -270 linhas, -1 arquivo
This commit is contained in:
2026-07-01 13:24:06 +00:00
parent c9d2c668ee
commit 9cf5f00243
11 changed files with 152 additions and 296 deletions
+24
View File
@@ -113,6 +113,30 @@ const MIGRACOES = [
)`,
],
},
// ----------------------------------------------------------
// MIGRAÇÃO 24: DDL que estava no resolucaoSetup.js (runtime)
// ----------------------------------------------------------
{
id: 24,
descricao: 'Consolidar DDL do fluxo de resolução (antes criado em runtime)',
sql: [
`CREATE TABLE "CHATC2_MOTIVOS_ATENDIMENTO" (
MOT_CODIGO_ID INTEGER NOT NULL PRIMARY KEY,
MOT_EMPRESA_ID INTEGER,
MOT_DESCRICAO VARCHAR(150),
MOT_SITUACAO CHAR(1) DEFAULT 'A'
)`,
"ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_MOTIVO_VISUALIZAR CHAR(1) DEFAULT 'N'",
"ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_MOTIVO_OBRIGATORIO CHAR(1) DEFAULT 'N'",
"ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_RESOLUCAO_VISUALIZAR CHAR(1) DEFAULT 'N'",
"ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_RESOLUCAO_OBRIGATORIO CHAR(1) DEFAULT 'N'",
"ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_ENVIAR_BOLETO CHAR(1) DEFAULT 'N'",
"ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_RESOLUCAO_SALVAR_MSC CHAR(1) DEFAULT 'N'",
'ALTER TABLE CHATC2_CONVERSAS ADD CON_MOTIVO_ID INTEGER',
'ALTER TABLE CHATC2_CONVERSAS ADD CON_RESOLUCAO VARCHAR(4000)',
],
},
];
// ============================================================
+8 -196
View File
@@ -1,63 +1,9 @@
const db = require('../database');
const { garantirEstrutura } = require('../resolucaoSetup');
const ffmpegPath = require('ffmpeg-static');
const { execFile } = require('child_process');
const path = require('path');
const fs = require('fs');
/**
* Comprime vídeo para tamanho aceitável pelo WhatsApp/Evolution API
* Reduz bitrate e resolução se necessário (alvo: ~10MB)
* @param {Buffer} inputBuffer - Buffer do video original
* @returns {Promise<Buffer>} Buffer do video comprimido
*/
function compressVideo(inputBuffer) {
return new Promise(function(resolve, reject) {
var tmpDir = path.join(__dirname, '../../uploads/audio');
if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
var inputPath = path.join(tmpDir, 'video_in_' + Date.now() + '.mp4');
var outputPath = path.join(tmpDir, 'video_out_' + Date.now() + '.mp4');
fs.writeFileSync(inputPath, inputBuffer);
var inputSizeMB = (inputBuffer.length / (1024 * 1024)).toFixed(1);
// Se o video ja tem <= 10MB, nao precisa comprimir muito
var targetBitrate = inputBuffer.length < 10 * 1024 * 1024 ? '1M' : '500k';
console.log('[Video] Comprimindo video de ' + inputSizeMB + 'MB, bitrate alvo: ' + targetBitrate);
execFile(ffmpegPath, [
'-y',
'-i', inputPath,
'-c:v', 'libx264',
'-b:v', targetBitrate,
'-c:a', 'aac',
'-b:a', '64k',
'-vf', 'scale=720:-2',
'-movflags', '+faststart',
'-preset', 'fast',
'-maxrate', '1M',
'-bufsize', '2M',
outputPath
], { timeout: 120000 }, function(err) {
try { fs.unlinkSync(inputPath); } catch(e) {}
if (err) {
try { fs.unlinkSync(outputPath); } catch(e) {}
console.error('[Video] Erro na compressao:', err.message.substring(0, 100));
return reject(err);
}
var outputBuffer = fs.readFileSync(outputPath);
try { fs.unlinkSync(outputPath); } catch(e) {}
var outputSizeMB = (outputBuffer.length / (1024 * 1024)).toFixed(1);
console.log('[Video] Compressao concluida: ' + inputSizeMB + 'MB -> ' + outputSizeMB + 'MB');
resolve(outputBuffer);
});
});
}
/**
* Converte buffer de áudio WebM para Ogg Opus usando ffmpeg
*/
@@ -691,7 +637,6 @@ class ChatController {
return res.status(403).json({ success: false, error: 'Sem permissão.' });
// ===== Fluxo de Resolução: valida motivo/resolução conforme config =====
await garantirEstrutura(alias);
const motivoId = (req.body && req.body.motivoId) ? parseInt(req.body.motivoId, 10) : null;
const resolucao = (req.body && req.body.resolucao != null) ? String(req.body.resolucao).trim() : '';
@@ -743,7 +688,9 @@ class ChatController {
WHERE CON_CODIGO_ID = ?
`, [usuarioNome, equipeNome, etiquetasDesc, motivoId, resolucao || null, id]);
// CSAT
// CSAT — NÃO envia se a conversa for agendada (skipCsat = true)
const skipCsat = req.body && req.body.skipCsat === true;
if (!skipCsat) {
const config = await db.query(alias,
'SELECT CFE_CSAT_ATIVO, CFE_CSAT_MENSAGEM FROM CHATC2_CONFIGURACOES_EMPRESA WHERE CFE_EMPRESA_ID = ?',
[conv[0].CON_EMPRESA_ID]);
@@ -784,6 +731,7 @@ class ChatController {
console.log('[CSAT] NAO enviado - Evolution API pode estar offline');
}
}
} // fecha if (!skipCsat)
// Salva resolução na tabela MENSAGENS_CLIENTES como aviso (se configurado)
if (isS(fl.CFE_RESOLUCAO_SALVAR_MSC) && resolucao && c.CON_CLIENTE_ID) {
@@ -1711,149 +1659,13 @@ class ChatController {
}
}
// Helper: normaliza número para formato WhatsApp (DDI + 9º dígito)
function _normalizarWhatsApp(numero) {
let n = String(numero || '').replace(/\D/g, '');
if (!n) return '';
if (!n.startsWith('55')) n = '55' + n;
const resto = n.slice(2);
if (resto.length === 10) n = '55' + resto.slice(0, 2) + '9' + resto.slice(2);
return n;
}
// Função auxiliar para enviar mensagem via Evolution API
// ponytail: delegado para evolutionService
const evolution = require('../services/evolutionService');
async function sendEvolutionMessage(instancia, numero, texto, tipo, midiaBase64, nomeArquivo) {
const url = ((instancia.INS_URL || '').trim()).replace(/\/$/, '');
const apiKey = (instancia.INS_API_KEY || '').trim();
const instanceName = (instancia.INS_INSTANCE_NAME || '').trim();
const numeroLimpo = _normalizarWhatsApp(numero);
const https = require('https');
const http = require('http');
let payload;
let endpoint;
if (midiaBase64 && (tipo === 'image' || tipo === 'audio' || tipo === 'video' || tipo === 'document')) {
var mediaB64 = midiaBase64;
var fileName = nomeArquivo || 'arquivo';
if (tipo === 'audio') {
// A mídia pode já estar em OGG (convertida antes de salvar no BD)
// Se ainda for WebM, tenta converter agora como fallback
var jaEOgg = (fileName || '').toLowerCase().endsWith('.ogg');
if (!jaEOgg) {
try {
var inputBuffer = Buffer.from(mediaB64, 'base64');
if (inputBuffer.length > 100) {
var oggBuffer = await convertWebmToOgg(inputBuffer);
if (oggBuffer && oggBuffer.length > 100) {
mediaB64 = oggBuffer.toString('base64');
fileName = 'audio.ogg';
console.log('[Audio] Conversão fallback WebM→OGG:', inputBuffer.length, '→', oggBuffer.length);
}
}
} catch(convErr) {
console.error('[Audio] Conversão OGG falhou, mantendo como áudio:', convErr.message.substring(0, 100));
fileName = 'audio.webm';
// NÃO muda para 'document' - mantém como áudio
}
}
// Usa sendWhatsAppAudio para enviar como mensagem de voz (PTT)
endpoint = '/message/sendWhatsAppAudio/' + encodeURIComponent(instanceName);
payload = JSON.stringify({ number: numeroLimpo, audio: mediaB64, encoding: true });
console.log('[Evolution] Enviando via sendWhatsAppAudio, tamanho:', mediaB64.length, 'arquivo:', fileName);
} else if (tipo === 'image') {
endpoint = '/message/sendMedia/' + encodeURIComponent(instanceName);
payload = JSON.stringify({ number: numeroLimpo, mediatype: 'image', media: mediaB64, fileName: fileName });
} else if (tipo === 'video') {
// Comprime video se for muito grande (> 5MB) para evitar rejeicao da Evolution API
try {
var videoBuf = Buffer.from(mediaB64, 'base64');
if (videoBuf.length > 5 * 1024 * 1024) {
console.log('[Video] Video grande detectado (' + (videoBuf.length / (1024 * 1024)).toFixed(1) + 'MB), comprimindo...');
var compressedBuf = await compressVideo(videoBuf);
if (compressedBuf && compressedBuf.length > 1000) {
mediaB64 = compressedBuf.toString('base64');
fileName = (fileName || 'video').replace(/\.\w+$/, '') + '_compressed.mp4';
console.log('[Video] Compressao OK, novo tamanho base64:', mediaB64.length);
}
}
} catch(compressErr) {
console.error('[Video] Compressao falhou, enviando original:', compressErr.message.substring(0, 80));
}
endpoint = '/message/sendMedia/' + encodeURIComponent(instanceName);
payload = JSON.stringify({ number: numeroLimpo, mediatype: 'video', media: mediaB64, fileName: fileName });
} else if (tipo === 'document') {
// Para documentos que sao videos, comprime tambem se for grande
var isVideoDoc = (fileName || '').toLowerCase().match(/\.(mp4|mov|avi|mkv|webm|3gp|m4v)$/);
if (isVideoDoc) {
try {
var docBuf = Buffer.from(mediaB64, 'base64');
if (docBuf.length > 5 * 1024 * 1024) {
console.log('[Document] Video como documento detectado (' + (docBuf.length / (1024 * 1024)).toFixed(1) + 'MB), comprimindo...');
var compressedDocBuf = await compressVideo(docBuf);
if (compressedDocBuf && compressedDocBuf.length > 1000) {
mediaB64 = compressedDocBuf.toString('base64');
fileName = (fileName || 'video').replace(/\.\w+$/, '') + '_compressed.mp4';
console.log('[Document] Compressao OK, novo tamanho base64:', mediaB64.length);
}
}
} catch(compressErr) {
console.error('[Document] Compressao falhou, enviando original:', compressErr.message.substring(0, 80));
}
}
endpoint = '/message/sendMedia/' + encodeURIComponent(instanceName);
payload = JSON.stringify({ number: numeroLimpo, mediatype: 'document', media: mediaB64, fileName: fileName });
} else {
endpoint = '/message/sendMedia/' + encodeURIComponent(instanceName);
payload = JSON.stringify({ number: numeroLimpo, mediatype: 'document', media: mediaB64, fileName: fileName });
}
} else {
endpoint = '/message/sendText/' + encodeURIComponent(instanceName);
payload = JSON.stringify({ number: numeroLimpo, text: texto || '', delay: 0 });
return evolution.sendMedia(instancia, numero, tipo, midiaBase64, nomeArquivo);
}
const parsedUrl = new URL(url + endpoint);
const lib = parsedUrl.protocol === 'https:' ? https : http;
return new Promise((resolve, reject) => {
const options = {
hostname: parsedUrl.hostname,
port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80),
path: parsedUrl.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload),
'apikey': apiKey,
}
};
const req = lib.request(options, (res) => {
let body = '';
res.on('data', c => body += c);
res.on('end', () => {
if (res.statusCode >= 400) {
console.error('[Evolution] Erro ao enviar mídia:', res.statusCode, body.substring(0, 200));
reject(new Error('HTTP ' + res.statusCode + ': ' + body.substring(0, 100)));
} else {
resolve(body);
}
});
});
req.on('error', function(err) {
console.error('[Evolution] Erro na requisição:', err.message);
reject(err);
});
req.write(payload);
req.end();
});
return evolution.sendText(instancia, numero, texto);
}
module.exports = ChatController;
-7
View File
@@ -1,6 +1,5 @@
const db = require('../database');
const { isGerente } = require('../middlewares/roles');
const { garantirEstrutura } = require('../resolucaoSetup');
const configService = require('../services/configService');
class ConfigController {
@@ -165,7 +164,6 @@ class ConfigController {
const { alias } = req.params;
const empresaId = parseInt(req.query.empresaId) || req.user?.empresas?.[0];
await garantirEstrutura(alias);
let config = await db.query(alias,
'SELECT * FROM CHATC2_CONFIGURACOES_EMPRESA WHERE CFE_EMPRESA_ID = ?', [empresaId]
@@ -209,7 +207,6 @@ class ConfigController {
try {
if (!(await isGerente(req))) return res.status(403).json({ success: false, error: 'Apenas gerentes podem alterar o fluxo de resolução.' });
const { alias } = req.params;
await garantirEstrutura(alias);
const empresaId = req.body.empresaId || req.user?.empresas?.[0];
const sn = (v) => (v === 'S' || v === true ? 'S' : 'N');
const mv = sn(req.body.motivoVisualizar), mo = sn(req.body.motivoObrigatorio);
@@ -236,7 +233,6 @@ class ConfigController {
static async listMotivos(req, res) {
try {
const { alias } = req.params;
await garantirEstrutura(alias);
const empresaId = parseInt(req.query.empresaId) || req.user?.empresas?.[0];
const rows = await db.query(alias,
`SELECT MOT_CODIGO_ID, MOT_DESCRICAO FROM "CHATC2_MOTIVOS_ATENDIMENTO"
@@ -249,7 +245,6 @@ class ConfigController {
try {
if (!(await isGerente(req))) return res.status(403).json({ success: false, error: 'Apenas gerentes podem cadastrar motivos.' });
const { alias } = req.params;
await garantirEstrutura(alias);
const descricao = (req.body.descricao || '').trim();
if (!descricao) return res.status(400).json({ success: false, error: 'Descrição obrigatória.' });
const empresaId = req.body.empresaId || req.user?.empresas?.[0];
@@ -265,7 +260,6 @@ class ConfigController {
try {
if (!(await isGerente(req))) return res.status(403).json({ success: false, error: 'Apenas gerentes podem remover motivos.' });
const { alias, id } = req.params;
await garantirEstrutura(alias);
await db.execute(alias, `UPDATE "CHATC2_MOTIVOS_ATENDIMENTO" SET MOT_SITUACAO = 'I' WHERE MOT_CODIGO_ID = ?`, [id]);
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
@@ -275,7 +269,6 @@ class ConfigController {
try {
if (!(await isGerente(req))) return res.status(403).json({ success: false, error: 'Apenas gerentes podem alterar as configurações da empresa.' });
const { alias } = req.params;
await garantirEstrutura(alias);
const data = req.body;
const empresaId = data.empresaId || req.user?.empresas?.[0];
const enviarBoleto = (data.enviarBoleto === 'S' || data.enviarBoleto === true) ? 'S' : 'N';
+7
View File
@@ -73,6 +73,12 @@ class DashboardController {
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);
// Agendadas para hoje (status P = Pendente)
const agendadas = await db.query(alias,
`SELECT COUNT(*) AS CT FROM CHATC2_CONVERSAS_AGENDAMENTOS
WHERE CAG_EMPRESA_ID IN (${ph}) AND CAG_DATA = CURRENT_DATE AND CAG_STATUS = 'P'`,
empresas);
// ===== Atendentes =====
const usuarios = await db.query(alias,
`SELECT DISTINCT u.USU_CODIGO_ID, u.USU_NOME
@@ -113,6 +119,7 @@ class DashboardController {
naoAtendidas: Number(naoAtendidas[0]?.CT) || 0,
naoAtribuidas: Number(naoAtribuidas[0]?.CT) || 0,
pendentes: Number(pendentes[0]?.CT) || 0,
agendadas: Number(agendadas[0]?.CT) || 0,
},
atendentes: { disponiveis, desconectados, lista },
trafego,
+58
View File
@@ -789,6 +789,63 @@ async function processWebhook(alias, body) {
let conversa;
var isNovaConversa = false;
if (conversations.length === 0) {
// Antes de criar nova, verifica se há conversa finalizada COM agendamento pendente
// Se o cliente retornou antes da data agendada, reabre a conversa existente
const agendada = await db.query(alias, `
SELECT c.CON_CODIGO_ID, c.CON_CLIENTE_ID, c.CON_NOME_CONTATO, c.CON_INSTANCIA_ID,
a.CAG_CODIGO_ID AS AG_ID, a.CAG_NOTA
FROM CHATC2_CONVERSAS c
JOIN CHATC2_CONVERSAS_AGENDAMENTOS a ON a.CAG_CONVERSA_ID = c.CON_CODIGO_ID
WHERE c.CON_NUMERO = ? AND c.CON_EMPRESA_ID = ?
AND c.CON_STATUS = 'F' AND c.CON_SITUACAO = 'A'
AND a.CAG_STATUS = 'P'
ORDER BY a.CAG_DATA DESC
FETCH FIRST 1 ROWS ONLY
`, [numero, empresaId]);
// Tenta também pelos últimos 8 dígitos
let agendada8 = [];
if (agendada.length === 0) {
const ultimos8 = numero.slice(-8);
agendada8 = await db.query(alias, `
SELECT c.CON_CODIGO_ID, c.CON_CLIENTE_ID, c.CON_NOME_CONTATO, c.CON_INSTANCIA_ID,
a.CAG_CODIGO_ID AS AG_ID, a.CAG_NOTA
FROM CHATC2_CONVERSAS c
JOIN CHATC2_CONVERSAS_AGENDAMENTOS a ON a.CAG_CONVERSA_ID = c.CON_CODIGO_ID
WHERE c.CON_NUMERO LIKE '%' || ? || '%' AND c.CON_EMPRESA_ID = ?
AND c.CON_STATUS = 'F' AND c.CON_SITUACAO = 'A'
AND a.CAG_STATUS = 'P'
ORDER BY a.CAG_DATA DESC
FETCH FIRST 1 ROWS ONLY
`, [ultimos8, empresaId]);
}
const agReabrir = agendada.length > 0 ? agendada[0] : (agendada8.length > 0 ? agendada8[0] : null);
if (agReabrir) {
// Reabre a conversa agendada em vez de criar nova
console.log('[Webhook] Reabrindo conversa agendada:', agReabrir.CON_CODIGO_ID, 'agendamento:', agReabrir.AG_ID);
await db.execute(alias,
"UPDATE CHATC2_CONVERSAS SET CON_STATUS = 'A', CON_DT_FINAL = NULL, CON_DT_ULTIMA_MSG = CURRENT_TIMESTAMP WHERE CON_CODIGO_ID = ?",
[agReabrir.CON_CODIGO_ID]);
await db.execute(alias,
"UPDATE CHATC2_CONVERSAS_AGENDAMENTOS SET CAG_STATUS = 'R' WHERE CAG_CODIGO_ID = ?",
[agReabrir.AG_ID]);
conversa = await db.query(alias, 'SELECT * FROM CHATC2_CONVERSAS WHERE CON_CODIGO_ID = ?', [agReabrir.CON_CODIGO_ID]);
conversa = conversa[0];
// Se o cliente não estava vinculado, tenta vincular agora
if (!conversa.CON_CLIENTE_ID) {
const resultadoGlobal = await buscarClienteGlobal(numero, empresaId, alias);
if (resultadoGlobal) {
await db.execute(alias,
'UPDATE CHATC2_CONVERSAS SET CON_CLIENTE_ID = ?, CON_NOME_CONTATO = ? WHERE CON_CODIGO_ID = ?',
[resultadoGlobal.cliente.CLI_CODIGO_ID, resultadoGlobal.cliente.CLI_NOME.trim(), conversa.CON_CODIGO_ID]);
}
}
// Pula o bloco de criação (já temos conversa)
} else {
isNovaConversa = true;
const newId = await db.nextId(alias, 'GEN_CHATC2_CONVERSAS');
const nomeContato = data.pushName || data.notifyName || sender || '';
@@ -861,6 +918,7 @@ async function processWebhook(alias, body) {
await TriageController.sendMenu(aliasEfetivo, conversa.CON_CODIGO_ID, empresaId, instanciaId, numero);
}
} catch(e) { console.error('[Webhook] Erro ao enviar triagem:', e.message); }
} // fecha else (nova conversa)
} else {
conversa = conversations[0];
+2 -43
View File
@@ -7,7 +7,6 @@ const fs = require('fs');
const path = require('path');
const db = require('../database');
const https = require('https');
const http = require('http');
function logTriage(msg) {
try {
@@ -1056,48 +1055,8 @@ class TriageController {
const inst = await db.query(alias,
"SELECT * FROM CHATC2_INSTANCIAS WHERE INS_CODIGO_ID = ? AND INS_SITUACAO = 'A'", [instanciaId]);
if (inst.length === 0) return;
const url = ((inst[0].INS_URL || '').trim()).replace(/\/+$/, '');
const apiKey = (inst[0].INS_API_KEY || '').trim();
const instanceName = (inst[0].INS_INSTANCE_NAME || '').trim();
if (!url || !apiKey || !instanceName) return;
// Normaliza para formato WhatsApp (com DDI)
const numeroLimpo = _normalizarWhatsApp(numero);
const payload = JSON.stringify({ number: numeroLimpo, text: texto || '', delay: 0 });
const endpoint = '/message/sendText/' + encodeURIComponent(instanceName);
const parsedUrl = new URL(url + endpoint);
const lib = parsedUrl.protocol === 'https:' ? https : http;
await new Promise(function(resolve, reject) {
const req = lib.request({
hostname: parsedUrl.hostname,
port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80),
path: parsedUrl.pathname,
method: 'POST',
timeout: 15000,
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload),
'apikey': apiKey,
}
}, function(res) {
var d = '';
res.on('data', function(c) { d += c; });
res.on('end', function() {
if (res.statusCode >= 400) {
console.error('[Triagem Evolution] Erro HTTP', res.statusCode, d.substring(0, 100));
}
resolve();
});
});
req.on('error', function(err) {
console.error('[Triagem Evolution] Erro:', err.message);
resolve();
});
req.on('timeout', function() { req.destroy(); resolve(); });
req.write(payload);
req.end();
});
const evolution = require('../services/evolutionService');
await evolution.sendText(inst[0], numero, texto);
} catch(e) {
console.error('[Triagem Evolution] Erro ao enviar:', e.message);
}
+35
View File
@@ -56,7 +56,9 @@ body { background:var(--surface-3); display:flex; min-height:100vh; }
<option value="A">Em atendimento</option>
<option value="F">Finalizadas</option>
<option value="A,E,F">Todas</option>
<option value="scheduled">📅 Agendadas</option>
</select>
<input type="date" id="filterAgData" onchange="carregar()" style="display:none;padding:6px 10px;border:2px solid var(--border);border-radius:8px;font-size:13px;background:var(--surface-2);color:var(--text-primary)">
<label for="filterAtribuicao" class="sr-only">Atribuição</label>
<select id="filterAtribuicao" onchange="carregar()">
<option value="">Todas</option>
@@ -112,6 +114,39 @@ window.carregar = async function() {
container.innerHTML = '<div class="loading"><div class="spinner"></div><p style="margin-top:8px">Carregando conversas...</p></div>';
const status = document.getElementById('filterStatus').value;
// Mostra/esconde seletor de data para agendadas
var agDataInput = document.getElementById('filterAgData');
if (status === 'scheduled') {
agDataInput.style.display = 'inline-block';
if (!agDataInput.value) agDataInput.value = new Date().toISOString().split('T')[0];
} else {
agDataInput.style.display = 'none';
}
// Agendadas: usa API de schedules
if (status === 'scheduled') {
var dataSel = agDataInput.value;
var dataFmt = dataSel ? dataSel.split('-').reverse().join('/') : 'hoje';
const agData = await api('/schedules?empresaId=' + empresaId + '&data=' + dataSel);
if (!agData.success) { container.innerHTML = '<p>Erro ao carregar</p>'; return; }
if (!agData.data || agData.data.length === 0) {
container.innerHTML = '<div class="empty-state"><div class="icon">📅</div><p>Nenhum agendamento para ' + dataFmt + '</p></div>';
return;
}
container.innerHTML = '<table><thead><tr><th>Contato</th><th>Número</th><th>Data</th><th>Hora</th><th>Anotação</th></tr></thead><tbody>' +
agData.data.map(function(a) {
var d = a.data ? a.data.split('-').reverse().join('/') : '-';
var h = a.hora ? a.hora.substring(0,5) : '-';
return '<tr><td><strong>' + (a.nomeContato || a.numero || '-') + '</strong></td>' +
'<td>' + (a.numero || '-') + '</td>' +
'<td>' + d + '</td>' +
'<td>' + h + '</td>' +
'<td style="font-size:12px;color:var(--text-muted)">' + (a.nota || '-') + '</td></tr>';
}).join('') + '</tbody></table>';
return;
}
const atrib = document.getElementById('filterAtribuicao').value;
let busca = document.getElementById('buscaContato').value.trim();
+8 -2
View File
@@ -1904,7 +1904,7 @@ window.finalizarConversa = async function() {
var res = await fetch('/api/' + alias + '/conversations/' + conversaAtiva + '/finalize', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ motivoId: motivoId, resolucao: resolucao })
body: JSON.stringify({ motivoId: motivoId, resolucao: resolucao, skipCsat: document.getElementById('chkAgendar') && document.getElementById('chkAgendar').checked })
});
var data = await res.json();
if (data.success) {
@@ -1925,7 +1925,13 @@ window.finalizarConversa = async function() {
document.querySelector('.btn-finalizar').disabled = true;
document.querySelector('.btn-finalizar').style.opacity = '0.6';
carregarConversas();
abrirConversa(conversaAtiva);
// Fecha a conversa da tela (volta ao estado vazio)
conversaAtiva = null;
document.getElementById('chatHeader').style.display = 'none';
document.getElementById('chatInput').style.display = 'none';
document.getElementById('messagesArea').innerHTML = '<div class="empty-state"><div class="icon">💬</div><p>Selecione uma conversa</p></div>';
document.getElementById('clienteInfoContainer').innerHTML = '';
document.getElementById('clienteFoto').innerHTML = '?';
} else {
alert(data.error || 'Não foi possível finalizar a conversa.');
}
+7
View File
@@ -1050,6 +1050,13 @@ body.dark-mode #agendamentoBox input[type="date"],
body.dark-mode #agendamentoBox input[type="time"] {
color-scheme: dark;
}
/* Filtro de data das agendadas no admin */
body.dark-mode #filterAgData {
color-scheme: dark;
background: #1a1a2e !important;
border-color: #0f3460 !important;
color: #e0e0e0 !important;
}
/* Botoes do painel direito no dark mode */
body.dark-mode [style*="background:#eef2ff"] {
background: #1e1b4b !important;
+3
View File
@@ -19,6 +19,7 @@
.stat-card.naoatendidas { border-left-color: var(--danger); }
.stat-card.naoatribuidas { border-left-color: #f59e0b; }
.stat-card.pendentes { border-left-color: #6366f1; }
.stat-card.agendadas-card { border-left-color: #8b5cf6; }
.stat-card.disponiveis { border-left-color: #10b981; }
.stat-card.desconectados { border-left-color: var(--text-faint); }
.card { background:#fff; border-radius:12px; padding:18px; box-shadow:0 1px 3px rgba(0,0,0,0.08); margin-bottom:24px; }
@@ -93,6 +94,7 @@
<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="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="stat-card pendentes"><div class="num" id="cPendentes"></div><div class="lbl">Pendentes</div><div class="desc">Somente com equipe</div></div>
<div class="stat-card agendadas-card"><div class="num" id="cAgendadas"></div><div class="lbl">📅 Agendadas Hoje</div><div class="desc">Retornos para hoje</div></div>
</div>
<div class="section-title">Atendentes</div>
@@ -172,6 +174,7 @@
document.getElementById('cNaoAtendidas').textContent = d.conversas.naoAtendidas;
document.getElementById('cNaoAtribuidas').textContent = d.conversas.naoAtribuidas;
document.getElementById('cPendentes').textContent = d.conversas.pendentes;
document.getElementById('cAgendadas').textContent = d.conversas.agendadas || 0;
document.getElementById('aDisponiveis').textContent = d.atendentes.disponiveis;
document.getElementById('aDesconectados').textContent = d.atendentes.desconectados;
renderAtendentes(d.atendentes.lista);
-48
View File
@@ -1,48 +0,0 @@
/**
* Estrutura do "Fluxo de Resolução" (motivos + resolução do atendimento).
* Cria de forma idempotente no Firebird:
* - Tabela CHATC2_MOTIVOS_ATENDIMENTO
* - Flags em CHATC2_CONFIGURACOES_EMPRESA (CFE_MOTIVO_*, CFE_RESOLUCAO_*)
* - Colunas CON_MOTIVO_ID / CON_RESOLUCAO em CHATC2_CONVERSAS
*
* Usa DDL no subconjunto que funciona nos dois bancos (INTEGER, VARCHAR, CHAR,
* ALTER TABLE ... ADD). A tabela nova é criada com nome MAIÚSCULO entre aspas
* para manter a convenção do schema migrado.
*/
const db = require('./database');
const prontos = {};
async function tentar(alias, sql) {
// DDL idempotente: ignora erros de "já existe" (e similares entre dialetos)
try { await db.execute(alias, sql); } catch (e) { /* noop */ }
}
async function garantirEstrutura(alias) {
if (prontos[alias]) return;
await tentar(alias, `CREATE TABLE "CHATC2_MOTIVOS_ATENDIMENTO" (
MOT_CODIGO_ID INTEGER NOT NULL PRIMARY KEY,
MOT_EMPRESA_ID INTEGER,
MOT_DESCRICAO VARCHAR(150),
MOT_SITUACAO CHAR(1) DEFAULT 'A'
)`);
await tentar(alias, `ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_MOTIVO_VISUALIZAR CHAR(1) DEFAULT 'N'`);
await tentar(alias, `ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_MOTIVO_OBRIGATORIO 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'`);
// 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'`);
// Salvar resolução na tabela MENSAGENS_CLIENTES como aviso (checkbox em Configurações > Empresa)
await tentar(alias, `ALTER TABLE CHATC2_CONFIGURACOES_EMPRESA ADD CFE_RESOLUCAO_SALVAR_MSC 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_RESOLUCAO VARCHAR(4000)`);
prontos[alias] = true;
}
module.exports = { garantirEstrutura };