adição de agendamento de conversa

This commit is contained in:
2026-06-29 12:27:48 +00:00
parent 71778cbd24
commit c9d2c668ee
6 changed files with 336 additions and 2 deletions
+21
View File
@@ -92,6 +92,27 @@ const MIGRACOES = [
'ALTER TABLE CHATC2_EQUIPES ADD EQU_ETIQUETA_ID INTEGER',
],
},
// ----------------------------------------------------------
// MIGRAÇÃO 23: Agendamento de conversas
// ----------------------------------------------------------
{
id: 23,
descricao: 'Criar tabela CHATC2_CONVERSAS_AGENDAMENTOS (retorno agendado)',
sql: [
`CREATE TABLE CHATC2_CONVERSAS_AGENDAMENTOS (
CAG_CODIGO_ID INTEGER NOT NULL PRIMARY KEY,
CAG_CONVERSA_ID INTEGER NOT NULL,
CAG_EMPRESA_ID INTEGER,
CAG_DATA DATE NOT NULL,
CAG_HORA TIME,
CAG_NOTA VARCHAR(300),
CAG_STATUS CHAR(1) DEFAULT 'P',
CAG_USUARIO_ID INTEGER,
CAG_DT_CRIACAO TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`,
],
},
];
// ============================================================
+140
View File
@@ -1569,6 +1569,146 @@ class ChatController {
return texto; // fallback: retorna sem substituir
}
}
// ==================== AGENDAMENTOS ====================
/**
* Agenda retorno para uma conversa.
* POST /api/:alias/conversations/:id/schedule
*/
static async scheduleConversation(req, res) {
try {
const { alias, id } = req.params;
const { data, hora, nota } = req.body;
if (!data) return res.status(400).json({ success: false, error: 'Data é obrigatória.' });
const chk = await ChatController.checarConversaEmpresa(alias, id, req);
if (chk.status) return res.status(chk.status).json({ success: false, error: 'Conversa não encontrada ou sem permissão.' });
const newId = await db.nextId(alias, 'GEN_CHATC2_AGENDAMENTOS');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_AGENDAMENTOS (CAG_CODIGO_ID, CAG_CONVERSA_ID, CAG_EMPRESA_ID, CAG_DATA, CAG_HORA, CAG_NOTA, CAG_USUARIO_ID)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, [newId, id, chk.empresaId, data, hora || null, nota || null, req.user?.id]);
res.json({ success: true, data: { id: newId } });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
/**
* Lista agendamentos de uma data (ou hoje).
* GET /api/:alias/schedules?data=2026-12-25&empresaId=
*/
static async listSchedules(req, res) {
try {
const { alias } = req.params;
const empresaId = parseInt(req.query.empresaId) || req.user?.empresas?.[0];
const data = req.query.data || new Date().toISOString().split('T')[0];
if (!req.user?.empresas?.some(e => Number(e) === Number(empresaId))) {
return res.status(403).json({ success: false, error: 'Sem permissão.' });
}
const result = await db.query(alias, `
SELECT a.*, c.CON_NUMERO, c.CON_NOME_CONTATO, c.CON_CLIENTE_ID
FROM CHATC2_CONVERSAS_AGENDAMENTOS a
JOIN CHATC2_CONVERSAS c ON c.CON_CODIGO_ID = a.CAG_CONVERSA_ID
WHERE a.CAG_EMPRESA_ID = ? AND a.CAG_DATA = ? AND a.CAG_STATUS = 'P'
ORDER BY a.CAG_HORA NULLS LAST, a.CAG_DT_CRIACAO
`, [empresaId, data]);
res.json({
success: true,
data: result.map(r => ({
id: r.CAG_CODIGO_ID,
conversaId: r.CAG_CONVERSA_ID,
data: r.CAG_DATA,
hora: r.CAG_HORA,
nota: (r.CAG_NOTA || '').trim(),
numero: (r.CON_NUMERO || '').trim(),
nomeContato: (r.CON_NOME_CONTATO || '').trim(),
clienteId: r.CON_CLIENTE_ID,
status: (r.CAG_STATUS || '').trim(),
})),
count: result.length,
});
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
/**
* Cria nova conversa a partir de um agendamento.
* POST /api/:alias/schedules/:id/reopen
* Retorna a nova conversa e o texto sugerido — NÃO envia automaticamente.
*/
static async reopenSchedule(req, res) {
try {
const { alias, id } = req.params;
const ag = await db.query(alias,
`SELECT a.*, c.CON_NUMERO, c.CON_NOME_CONTATO, c.CON_CLIENTE_ID, c.CON_EMPRESA_ID, c.CON_INSTANCIA_ID
FROM CHATC2_CONVERSAS_AGENDAMENTOS a
JOIN CHATC2_CONVERSAS c ON c.CON_CODIGO_ID = a.CAG_CONVERSA_ID
WHERE a.CAG_CODIGO_ID = ?`, [id]);
if (ag.length === 0) return res.status(404).json({ success: false, error: 'Agendamento não encontrado.' });
const a = ag[0];
const numeroLimpo = (a.CON_NUMERO || '').replace(/\D/g, '').substring(0, 15);
const nomeContato = (a.CON_NOME_CONTATO || a.CON_NUMERO || '').trim();
const nota = (a.CAG_NOTA || '').trim();
let clienteId = a.CON_CLIENTE_ID || null;
// Se não tem cliente vinculado, busca pelo número
if (!clienteId) {
const variantes = [numeroLimpo];
if (numeroLimpo.startsWith('55')) variantes.push(numeroLimpo.substring(2));
variantes.push(numeroLimpo.slice(-8));
for (const termo of variantes) {
const cli = await db.query(alias, `
SELECT CLI_CODIGO_ID, CLI_NOME FROM CLIENTES
WHERE CLI_EMPRESA_ID = ?
AND (REPLACE(REPLACE(REPLACE(COALESCE(CLI_CELULAR,''),'-',''),'(',''),')','') LIKE '%' || ? || '%'
OR REPLACE(REPLACE(REPLACE(COALESCE(CLI_FONE1,''),'-',''),'(',''),')','') LIKE '%' || ? || '%'
OR REPLACE(REPLACE(REPLACE(COALESCE(CLI_FONE2,''),'-',''),'(',''),')','') LIKE '%' || ? || '%')
FETCH FIRST 1 ROWS ONLY
`, [a.CAG_EMPRESA_ID, termo, termo, termo]);
if (cli.length > 0) { clienteId = cli[0].CLI_CODIGO_ID; break; }
}
if (!clienteId) {
for (const termo of variantes) {
const dep = await db.query(alias, `
SELECT DEPC_CLIENTE_ID FROM DEPENDENTES_CLI
WHERE DEPC_EMPRESA_ID = ? AND DEPC_SITUACAO = 'A'
AND REPLACE(REPLACE(REPLACE(COALESCE(DEPC_TELEFONE,''),'-',''),'(',''),')','') LIKE '%' || ? || '%'
FETCH FIRST 1 ROWS ONLY
`, [a.CAG_EMPRESA_ID, termo]);
if (dep.length > 0) { clienteId = dep[0].DEPC_CLIENTE_ID; break; }
}
}
}
// Cria NOVA conversa
const newId = await db.nextId(alias, 'GEN_CHATC2_CONVERSAS');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS (CON_CODIGO_ID, CON_EMPRESA_ID, CON_INSTANCIA_ID, CON_NUMERO,
CON_NOME_CONTATO, CON_CLIENTE_ID, CON_STATUS, CON_SITUACAO, CON_PRIMEIRA_MSG, CON_DT_ULTIMA_MSG)
VALUES (?, ?, ?, ?, ?, ?, 'A', 'A', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
`, [newId, a.CAG_EMPRESA_ID, a.CON_INSTANCIA_ID, numeroLimpo, nomeContato, clienteId]);
// Monta texto sugerido (placeholders serão processados no frontend ao enviar)
const textoSugerido = nota
? '📅 *Retorno agendado*\n' + nota + '\n\nOlá [CLIENTE]! Conforme combinado, estou retornando o contato. Em que posso ajudar?'
: '👋 Olá [CLIENTE]! Conforme combinado, estou retornando o contato. Em que posso ajudar?';
// Marca agendamento como retomado
await db.execute(alias,
"UPDATE CHATC2_CONVERSAS_AGENDAMENTOS SET CAG_STATUS = 'R' WHERE CAG_CODIGO_ID = ?", [id]);
res.json({ success: true, conversaId: newId, textoSugerido: textoSugerido });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
}
// Helper: normaliza número para formato WhatsApp (DDI + 9º dígito)
+1
View File
@@ -97,6 +97,7 @@ const GENERATORS = [
'GEN_CHATC2_MENUS_EMPRESA',
'GEN_CHATC2_CSAT_AVALIACOES',
'GEN_CHATC2_MOTIVOS_ATENDIMENTO',
'GEN_CHATC2_AGENDAMENTOS',
];
async function nextId(alias, generatorName) {
+162 -2
View File
@@ -825,9 +825,31 @@ body.dark-mode .msg.enviando { opacity: 0.4; }
<!-- Modal confirmar finalização -->
<div class="modal-overlay" id="modalConfirmarFinalizar">
<div class="modal">
<div class="modal" style="max-width:420px">
<h3>⚠️ Finalizar conversa</h3>
<p style="font-size:13px;color:#6b7280;margin-bottom:16px">Tem certeza que deseja finalizar este atendimento? A conversa será movida para finalizadas.</p>
<p style="font-size:13px;color:#6b7280;margin-bottom:12px">Tem certeza que deseja finalizar este atendimento?</p>
<!-- Agendamento -->
<div id="agendamentoBox" style="margin-bottom:14px;padding:14px;background:var(--surface-2);border-radius:8px;border:1px solid var(--border)">
<label style="display:flex;align-items:center;gap:8px;font-size:13px;font-weight:600;color:var(--text-secondary);cursor:pointer;margin-bottom:0">
<input type="checkbox" id="chkAgendar" onchange="toggleAgendamento()" style="accent-color:var(--primary);width:16px;height:16px"> 📅 Agendar retorno
</label>
<div id="agendamentoCampos" style="display:none;margin-top:12px">
<div style="display:flex;gap:8px;margin-bottom:8px">
<div style="flex:1">
<label style="font-size:11px;font-weight:600;color:var(--text-muted);display:block;margin-bottom:4px;text-transform:uppercase;letter-spacing:0.04em">Data</label>
<input type="date" id="agData" style="width:100%;padding:8px 10px;border:2px solid var(--border);border-radius:8px;font-size:13px;outline:none;background:var(--surface);color:var(--text-primary)">
</div>
<div style="width:90px">
<label style="font-size:11px;font-weight:600;color:var(--text-muted);display:block;margin-bottom:4px;text-transform:uppercase;letter-spacing:0.04em">Hora</label>
<input type="time" id="agHora" style="width:100%;padding:8px 10px;border:2px solid var(--border);border-radius:8px;font-size:13px;outline:none;background:var(--surface);color:var(--text-primary)">
</div>
</div>
<label style="font-size:11px;font-weight:600;color:var(--text-muted);display:block;margin-bottom:4px;text-transform:uppercase;letter-spacing:0.04em">Anotação</label>
<textarea id="agNota" rows="2" placeholder="Ex: Confirmar pagamento do boleto..." style="width:100%;padding:8px 10px;border:2px solid var(--border);border-radius:8px;font-size:13px;font-family:inherit;resize:vertical;outline:none;background:var(--surface);color:var(--text-primary)"></textarea>
</div>
</div>
<div class="btn-group">
<button onclick="fecharModal('modalConfirmarFinalizar')">Cancelar</button>
<button class="btn-primary" onclick="fecharModal('modalConfirmarFinalizar');finalizarConversa()">Finalizar</button>
@@ -835,6 +857,23 @@ body.dark-mode .msg.enviando { opacity: 0.4; }
</div>
</div>
<!-- Modal retorno agendado -->
<div class="modal-overlay" id="modalRetorno">
<div class="modal" style="max-width:460px">
<h3>📅 Retorno agendado</h3>
<p style="font-size:13px;color:var(--text-muted);margin-bottom:14px">Conversa criada. Revise o texto antes de enviar:</p>
<input type="hidden" id="retornoConversaId">
<div class="form-group">
<textarea id="retornoTextoSugerido" rows="4" style="width:100%;padding:10px 14px;border:2px solid var(--border);border-radius:8px;font-size:14px;font-family:inherit;resize:vertical;outline:none;background:var(--surface-2);color:var(--text-primary)"></textarea>
</div>
<p style="font-size:11px;color:var(--text-faint);margin-bottom:16px">Use [CLIENTE], [CONTATO], [EMPRESAF] para dados automáticos.</p>
<div class="btn-group">
<button onclick="document.getElementById('retornoConversaId').value='';fecharModal('modalRetorno')">Cancelar</button>
<button class="btn-primary" onclick="confirmarRetorno()">✅ Abrir conversa</button>
</div>
</div>
</div>
<!-- Modal associar cliente -->
<div class="modal-overlay" id="modalAssociar">
<div class="modal">
@@ -881,6 +920,7 @@ body.dark-mode .msg.enviando { opacity: 0.4; }
<a href="#" onclick="mudarFiltro('unassigned',this)">Sem atend. (0)</a>
<a href="#" id="tabTodasConvs" onclick="mudarFiltro('all',this)" class="admin-only-nav">Todas</a>
<a href="#" onclick="mudarFiltro('equipe',this)" id="tabEquipeConvs">Equipe (0)</a>
<a href="#" onclick="mudarFiltro('scheduled',this)" id="tabAgendadas">📅 Agendadas (0)</a>
</div>
<div class="search-box">
<input type="text" id="searchConv" placeholder="🔍 Buscar conversas..." onkeyup="carregarConversas()">
@@ -1080,6 +1120,11 @@ document.querySelectorAll('.sidebar-left .nav-tabs a').forEach(function(a) {
// ===== CARREGAR CONVERSAS =====
async function carregarConversas() {
// Aba Agendadas: carrega da API de agendamentos
if (filtroAtual === 'scheduled') {
return carregarAgendadas();
}
const busca = document.getElementById('searchConv').value;
var statusFilter = document.getElementById('chkFinalizadas').checked ? 'A,E,F' : 'A,E';
try {
@@ -1818,6 +1863,22 @@ dropArea.addEventListener('drop', function(e) {
});
// ===== FINALIZAR =====
window.toggleAgendamento = function() {
var show = document.getElementById('chkAgendar').checked;
document.getElementById('agendamentoCampos').style.display = show ? 'block' : 'none';
if (show) {
if (!document.getElementById('agData').value) {
document.getElementById('agData').value = new Date().toISOString().split('T')[0];
}
// Preenche anotação com o texto da resolução (se existir)
var txtResolucao = document.getElementById('campoResolucao');
var nota = document.getElementById('agNota');
if (txtResolucao && nota && !nota.value) {
nota.value = (txtResolucao.value || '').trim();
}
}
};
window.finalizarConversa = async function() {
if (!conversaAtiva) return;
@@ -1847,6 +1908,19 @@ window.finalizarConversa = async function() {
});
var data = await res.json();
if (data.success) {
// Agenda retorno se checkbox marcado
if (document.getElementById('chkAgendar') && document.getElementById('chkAgendar').checked) {
var agData = document.getElementById('agData').value;
var agHora = document.getElementById('agHora').value || null;
var agNota = document.getElementById('agNota').value.trim() || null;
if (agData) {
fetch('/api/' + alias + '/conversations/' + conversaAtiva + '/schedule', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ data: agData, hora: agHora, nota: agNota })
}).catch(function(){});
}
}
document.querySelector('.btn-finalizar').textContent = '✅ Finalizado';
document.querySelector('.btn-finalizar').disabled = true;
document.querySelector('.btn-finalizar').style.opacity = '0.6';
@@ -2116,9 +2190,95 @@ window.mudarFiltro = function(filtro, el) {
document.querySelectorAll('.nav-tabs a').forEach(function(a) { a.classList.remove('active'); });
el.classList.add('active');
filtroAtual = filtro;
// Mostra/esconde busca e checkbox de finalizadas (não fazem sentido para agendadas)
var searchBox = document.querySelector('.search-box');
if (filtro === 'scheduled') {
if (searchBox) searchBox.style.display = 'none';
} else {
if (searchBox) searchBox.style.display = '';
}
carregarConversas();
};
// ===== AGENDADAS =====
async function carregarAgendadas() {
var dataSel = document.getElementById('agendadasData');
var data = dataSel ? dataSel.value : new Date().toISOString().split('T')[0];
var list = document.getElementById('conversationsList');
list.innerHTML = '<div style="padding:24px;text-align:center;color:rgba(255,255,255,0.4);font-size:13px">Carregando agendamentos...</div>';
try {
var res = await fetch('/api/' + alias + '/schedules?empresaId=' + empresaId + '&data=' + data, {
headers: { 'Authorization': 'Bearer ' + token }
});
var d = await res.json();
if (!d.success) return;
var dataFmt = data.split('-').reverse().join('/');
var html = '<div style="padding:10px 12px;border-bottom:1px solid rgba(255,255,255,0.06);display:flex;align-items:center;gap:8px">' +
'<span style="font-size:13px;color:rgba(255,255,255,0.7)">📅</span>' +
'<input type="date" id="agendadasData" value="' + data + '" onchange="carregarAgendadas()" style="flex:1;padding:6px 8px;border:1px solid rgba(255,255,255,0.15);border-radius:6px;font-size:12px;background:rgba(255,255,255,0.06);color:#fff;outline:none">' +
'<span style="font-size:12px;color:rgba(255,255,255,0.5)">' + (d.count || 0) + ' agend.</span></div>';
if (!d.data || d.data.length === 0) {
html += '<div style="padding:24px;text-align:center;color:rgba(255,255,255,0.35);font-size:13px">Nenhum agendamento para ' + dataFmt + '</div>';
} else {
d.data.forEach(function(a) {
var nome = a.nomeContato || a.numero || '-';
var cor = avatarColor(nome);
var ini = avatarInitials(nome);
var hora = a.hora ? a.hora.substring(0,5) : '';
html += '<div class="conv-item" style="cursor:pointer" onclick="reabrirAgendamento(' + a.id + ',' + a.conversaId + ')">' +
'<div class="conv-avatar" style="background:' + cor + '">' + ini + '</div>' +
'<div class="conv-body">' +
'<div class="conv-nome">' + nome + (hora ? ' <span style="font-size:10px;opacity:0.6">🕐 ' + hora + '</span>' : '') + '</div>' +
'<div class="conv-ultima" style="font-size:11px">' + (a.nota || 'Sem anotação') + '</div>' +
'</div>' +
'</div>';
});
}
list.innerHTML = html;
} catch(e) {}
}
window.reabrirAgendamento = async function(agId, convId) {
try {
var res = await fetch('/api/' + alias + '/schedules/' + agId + '/reopen', {
method: 'POST', headers: { 'Authorization': 'Bearer ' + token }
});
var d = await res.json();
if (d.success) {
// Mostra modal com o texto sugerido
document.getElementById('retornoTextoSugerido').value = d.textoSugerido || '';
document.getElementById('retornoConversaId').value = d.conversaId;
document.getElementById('modalRetorno').classList.add('show');
}
} catch(e) {}
};
window.confirmarRetorno = function() {
var convId = document.getElementById('retornoConversaId').value;
var texto = document.getElementById('retornoTextoSugerido').value.trim();
fecharModal('modalRetorno');
if (!convId) return;
// Abre a conversa e preenche o input
mudarFiltro('mine', document.querySelector('.nav-tabs a'));
carregarConversas();
setTimeout(function() {
abrirConversa(parseInt(convId));
// Aguarda o chat carregar e preenche o input
setTimeout(function() {
var input = document.getElementById('msgInput');
if (input && texto) {
input.value = texto;
input.style.height = 'auto';
input.style.height = input.scrollHeight + 'px';
input.focus();
}
}, 500);
}, 300);
};
// Mostra
// ===== SELETOR DE EMPRESA (usuário com acesso a mais de uma) =====
+9
View File
@@ -1041,6 +1041,15 @@ body.dark-mode .tabbar {
border-color: #0f3460;
box-shadow: 0 -2px 12px rgba(0,0,0,0.3);
}
/* Agendamento box no dark mode */
body.dark-mode #agendamentoBox {
background: #1a1a2e !important;
border-color: #0f3460 !important;
}
body.dark-mode #agendamentoBox input[type="date"],
body.dark-mode #agendamentoBox input[type="time"] {
color-scheme: dark;
}
/* Botoes do painel direito no dark mode */
body.dark-mode [style*="background:#eef2ff"] {
background: #1e1b4b !important;
+3
View File
@@ -19,6 +19,9 @@ router.post('/:alias/conversations/:id/labels', authenticateToken, ChatControlle
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.post('/:alias/conversations/:id/schedule', authenticateToken, ChatController.scheduleConversation);
router.get('/:alias/schedules', authenticateToken, ChatController.listSchedules);
router.post('/:alias/schedules/:id/reopen', authenticateToken, ChatController.reopenSchedule);
router.get('/:alias/search', authenticateToken, ChatController.search);
router.get('/:alias/media/:mediaId', ChatController.getMedia);