diff --git a/src/controllers/chatController.js b/src/controllers/chatController.js index e031f5f..ad15f0f 100644 --- a/src/controllers/chatController.js +++ b/src/controllers/chatController.js @@ -1,4 +1,5 @@ const db = require('../database'); +const { garantirEstrutura } = require('../resolucaoSetup'); const ffmpegPath = require('ffmpeg-static'); const { execFile } = require('child_process'); const path = require('path'); @@ -446,6 +447,8 @@ class ChatController { saudacaoEnviada: (row.CON_SAUDACAO_ENVIADA || 'N').trim(), csatEnviado: (row.CON_CSAT_ENVIADO || 'N').trim(), primeiraMsg: row.CON_PRIMEIRA_MSG, + motivoId: row.CON_MOTIVO_ID || null, + resolucao: row.CON_RESOLUCAO || null, cliente: clienteInfo, dependente: dependenteInfo, labels: labels.map(l => ({ id: l.ETI_CODIGO_ID, nome: (l.ETI_NOME || '').trim(), cor: (l.ETI_COR || '#667eea').trim() })), @@ -662,6 +665,23 @@ class ChatController { if (c.CON_EMPRESA_ID && !((req.user && req.user.empresas) || []).includes(c.CON_EMPRESA_ID)) 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() : ''; + + const flagsRows = await db.query(alias, + 'SELECT CFE_MOTIVO_VISUALIZAR, CFE_MOTIVO_OBRIGATORIO, CFE_RESOLUCAO_VISUALIZAR, CFE_RESOLUCAO_OBRIGATORIO FROM CHATC2_CONFIGURACOES_EMPRESA WHERE CFE_EMPRESA_ID = ?', + [c.CON_EMPRESA_ID]); + const fl = flagsRows[0] || {}; + const isS = (v) => String(v || 'N').trim() === 'S'; + if (isS(fl.CFE_MOTIVO_VISUALIZAR) && isS(fl.CFE_MOTIVO_OBRIGATORIO) && !motivoId) { + return res.status(400).json({ success: false, error: 'Selecione o motivo do atendimento para finalizar.' }); + } + if (isS(fl.CFE_RESOLUCAO_VISUALIZAR) && isS(fl.CFE_RESOLUCAO_OBRIGATORIO) && !resolucao) { + return res.status(400).json({ success: false, error: 'Preencha a resolução do atendimento para finalizar.' }); + } + // Busca nome do usuário que atendeu let usuarioNome = null; if (c.CON_USUARIO_ID) { @@ -692,9 +712,11 @@ class ChatController { CON_DT_FINAL = CURRENT_TIMESTAMP, CON_USUARIO_NOME = ?, CON_EQUIPE_NOME = ?, - CON_ETIQUETAS_DESC = ? + CON_ETIQUETAS_DESC = ?, + CON_MOTIVO_ID = ?, + CON_RESOLUCAO = ? WHERE CON_CODIGO_ID = ? - `, [usuarioNome, equipeNome, etiquetasDesc, id]); + `, [usuarioNome, equipeNome, etiquetasDesc, motivoId, resolucao || null, id]); // CSAT const config = await db.query(alias, diff --git a/src/controllers/configController.js b/src/controllers/configController.js index 5b5e25d..3bf74f5 100644 --- a/src/controllers/configController.js +++ b/src/controllers/configController.js @@ -1,5 +1,6 @@ const db = require('../database'); const { isGerente } = require('../middlewares/roles'); +const { garantirEstrutura } = require('../resolucaoSetup'); class ConfigController { // ==================== CHATC2_EQUIPES ==================== @@ -152,12 +153,16 @@ 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] ); + const flagsPadrao = { motivoVisualizar: 'N', motivoObrigatorio: 'N', resolucaoVisualizar: 'N', resolucaoObrigatorio: 'N' }; + if (config.length === 0) { - const cfg = { empresaId, fotoCelular: 'N', saudacaoAtiva: 'S', saudacaoMensagem: '', csatAtivo: 'N', csatMensagem: '', enviarNomeUsuario: 'N', triagemAtiva: 'N', triagemMsgWelcome: '', triagemMsgAfter: '', triagemBoletoNumero: '0', instanciaPadraoId: null }; + const cfg = Object.assign({ empresaId, fotoCelular: 'N', saudacaoAtiva: 'S', saudacaoMensagem: '', csatAtivo: 'N', csatMensagem: '', enviarNomeUsuario: 'N', triagemAtiva: 'N', triagemMsgWelcome: '', triagemMsgAfter: '', triagemBoletoNumero: '0', instanciaPadraoId: null }, flagsPadrao); res.json({ success: true, data: cfg }); } else { const c = config[0]; @@ -174,11 +179,84 @@ class ConfigController { triagemMsgWelcome: c.CFE_TRIAGEM_MSG_WELCOME || '', triagemMsgAfter: c.CFE_TRIAGEM_MSG_AFTER || '', triagemBoletoNumero: (c.CFE_TRIAGEM_BOLETO_NUMERO || '0').trim(), + // Fluxo de Resolução + motivoVisualizar: (c.CFE_MOTIVO_VISUALIZAR || 'N').trim(), + motivoObrigatorio: (c.CFE_MOTIVO_OBRIGATORIO || 'N').trim(), + resolucaoVisualizar: (c.CFE_RESOLUCAO_VISUALIZAR || 'N').trim(), + resolucaoObrigatorio: (c.CFE_RESOLUCAO_OBRIGATORIO || 'N').trim(), }}); } } catch (err) { res.status(500).json({ success: false, error: err.message }); } } + // ==================== FLUXO DE RESOLUÇÃO ==================== + /** Salva as 4 flags do Fluxo de Resolução (sem tocar nas demais configs). */ + static async saveResolucaoConfig(req, res) { + 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); + const rv = sn(req.body.resolucaoVisualizar), ro = sn(req.body.resolucaoObrigatorio); + + const exists = await db.query(alias, 'SELECT COUNT(*) AS T FROM CHATC2_CONFIGURACOES_EMPRESA WHERE CFE_EMPRESA_ID = ?', [empresaId]); + if (exists[0].T > 0) { + await db.execute(alias, + `UPDATE CHATC2_CONFIGURACOES_EMPRESA SET CFE_MOTIVO_VISUALIZAR = ?, CFE_MOTIVO_OBRIGATORIO = ?, + CFE_RESOLUCAO_VISUALIZAR = ?, CFE_RESOLUCAO_OBRIGATORIO = ? WHERE CFE_EMPRESA_ID = ?`, + [mv, mo, rv, ro, empresaId]); + } else { + await db.execute(alias, + `INSERT INTO CHATC2_CONFIGURACOES_EMPRESA (CFE_EMPRESA_ID, CFE_MOTIVO_VISUALIZAR, CFE_MOTIVO_OBRIGATORIO, CFE_RESOLUCAO_VISUALIZAR, CFE_RESOLUCAO_OBRIGATORIO) + VALUES (?, ?, ?, ?, ?)`, + [empresaId, mv, mo, rv, ro]); + } + res.json({ success: true }); + } catch (err) { res.status(500).json({ success: false, error: err.message }); } + } + + /** Lista os motivos de atendimento (leitura aberta — usada no chat). */ + 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" + WHERE MOT_EMPRESA_ID = ? AND MOT_SITUACAO = 'A' ORDER BY MOT_DESCRICAO`, [empresaId]); + res.json({ success: true, data: rows.map((m) => ({ id: m.MOT_CODIGO_ID, descricao: (m.MOT_DESCRICAO || '').trim() })) }); + } catch (err) { res.status(500).json({ success: false, error: err.message }); } + } + + static async createMotivo(req, res) { + 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]; + const maxId = await db.query(alias, 'SELECT MAX(MOT_CODIGO_ID) AS ID FROM "CHATC2_MOTIVOS_ATENDIMENTO"'); + const newId = (maxId[0]?.ID || 0) + 1; + await db.execute(alias, + `INSERT INTO "CHATC2_MOTIVOS_ATENDIMENTO" (MOT_CODIGO_ID, MOT_EMPRESA_ID, MOT_DESCRICAO, MOT_SITUACAO) + VALUES (?, ?, ?, 'A')`, [newId, empresaId, descricao]); + res.json({ success: true, data: { id: newId } }); + } catch (err) { res.status(500).json({ success: false, error: err.message }); } + } + + static async deleteMotivo(req, res) { + 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 }); } + } + static async saveCompanyConfig(req, res) { try { if (!(await isGerente(req))) return res.status(403).json({ success: false, error: 'Apenas gerentes podem alterar as configurações da empresa.' }); diff --git a/src/public/chat.html b/src/public/chat.html index 45c6dd7..9fd2ca9 100644 --- a/src/public/chat.html +++ b/src/public/chat.html @@ -685,7 +685,20 @@ window.darkModeIsDark=function(){return localStorage.getItem('chatc2_dark_mode')

📋 Informações

?
- + + + +
ATENDENTE
+ +
+

Carregando...

+
+
+ +

Resolução do Atendimento

+
+
+ + + @@ -725,6 +747,57 @@ window.salvarConfig = async function() { // O addEventListener do cfgTriagem é adicionado dentro do carregarConfig() após criar o HTML +// ===== FLUXO DE RESOLUÇÃO ===== +function escc(s){ return String(s == null ? '' : s).replace(/&/g,'&').replace(//g,'>'); } + +async function carregarResolucao() { + var md = await api('/motivos?empresaId=' + empresaId); + var div = document.getElementById('motivosList'); + if (div && md.success) { + div.innerHTML = (md.data && md.data.length) + ? md.data.map(function(m) { + return '
' + + '' + escc(m.descricao) + '' + + '
'; + }).join('') + : '

Nenhum motivo cadastrado.

'; + } + var cd = await api('/company/config?empresaId=' + empresaId); + if (cd.success) { + var s = function(id, v) { var el = document.getElementById(id); if (el) el.checked = v === 'S'; }; + s('flgMotivoVis', cd.data.motivoVisualizar); + s('flgMotivoObr', cd.data.motivoObrigatorio); + s('flgResolVis', cd.data.resolucaoVisualizar); + s('flgResolObr', cd.data.resolucaoObrigatorio); + } +} + +window.adicionarMotivo = async function() { + var inp = document.getElementById('novoMotivo'); + var d = (inp.value || '').trim(); + if (!d) return; + var r = await api('/motivos', { method: 'POST', body: JSON.stringify({ descricao: d, empresaId: empresaId }) }); + if (r.success) { inp.value = ''; carregarResolucao(); } else alert(r.error || 'Erro ao adicionar motivo'); +}; + +window.removerMotivo = async function(id) { + if (!confirm('Remover este motivo?')) return; + var r = await api('/motivos/' + id, { method: 'DELETE' }); + if (r.success) carregarResolucao(); else alert(r.error || 'Erro ao remover'); +}; + +window.salvarResolucao = async function() { + var body = { + empresaId: empresaId, + motivoVisualizar: document.getElementById('flgMotivoVis').checked ? 'S' : 'N', + motivoObrigatorio: document.getElementById('flgMotivoObr').checked ? 'S' : 'N', + resolucaoVisualizar: document.getElementById('flgResolVis').checked ? 'S' : 'N', + resolucaoObrigatorio: document.getElementById('flgResolObr').checked ? 'S' : 'N', + }; + var r = await api('/company/resolucao-config', { method: 'POST', body: JSON.stringify(body) }); + if (r.success) alert('Fluxo de Resolução salvo!'); else alert(r.error || 'Erro ao salvar'); +}; + // ===== CONEXÕES ===== async function carregarConexoes() { var data = await api('/evolution/instances?empresaId=' + empresaId); @@ -850,6 +923,7 @@ carregarMenus(); carregarEtiquetas(); carregarConexoes(); carregarConfig(); +carregarResolucao(); })(); diff --git a/src/resolucaoSetup.js b/src/resolucaoSetup.js new file mode 100644 index 0000000..a7d7d37 --- /dev/null +++ b/src/resolucaoSetup.js @@ -0,0 +1,42 @@ +/** + * Estrutura do "Fluxo de Resolução" (motivos + resolução do atendimento). + * Cria de forma idempotente, em Postgres ou 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'`); + + 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 }; diff --git a/src/routes/configRoutes.js b/src/routes/configRoutes.js index 8f08a34..cd76e09 100644 --- a/src/routes/configRoutes.js +++ b/src/routes/configRoutes.js @@ -27,6 +27,12 @@ router.delete('/:alias/labels/:id', ConfigController.deleteLabel); router.get('/:alias/company/config', ConfigController.getCompanyConfig); router.post('/:alias/company/config', ConfigController.saveCompanyConfig); +// Fluxo de Resolução (motivos + flags) +router.post('/:alias/company/resolucao-config', ConfigController.saveResolucaoConfig); +router.get('/:alias/motivos', ConfigController.listMotivos); +router.post('/:alias/motivos', ConfigController.createMotivo); +router.delete('/:alias/motivos/:id', ConfigController.deleteMotivo); + // Evolution API router.get('/:alias/evolution/instances', EvolutionController.listInstances); router.post('/:alias/evolution/connect', EvolutionController.connect);