From b253feb4aab4527d9a2bbd50d300db280eed3822 Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Tue, 7 Jul 2026 22:48:42 +0200 Subject: [PATCH] =?UTF-8?q?feat(comunicacao-interna):=20notifica=C3=A7?= =?UTF-8?q?=C3=A3o=20de=20n=C3=BAmero=20compartilhado=20+=20agenda=20de=20?= =?UTF-8?q?contatos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opção (b) — mesma pessoa/número servindo a papéis diferentes, roteando por situação: - GET /api/comunicacao/numero-lookup: ao digitar um número, avisa se ele já é de outra conta/papel da clínica (ex.: número na conta dentista aparece ao editar a de dono). Modal mostra banner "pode compartilhar — descreva as situações; a Secretária roteia". - Bridge GET /contatos: agenda de contatos internos (nome, papel, situações de cada número, janela atual) p/ a Secretária rotear por assunto. Não expõe o número cru. Verificado E2E: lookup como dono acha o nº na conta dentista; /contatos lista papel+situações. Co-Authored-By: Claude Opus 4.8 --- backend/newwhats/agenda-bridge.js | 34 +++++++++++++++++++ backend/server.js | 26 ++++++++++++++ .../components/ComunicacaoInternaModal.tsx | 22 +++++++++++- 3 files changed, 81 insertions(+), 1 deletion(-) diff --git a/backend/newwhats/agenda-bridge.js b/backend/newwhats/agenda-bridge.js index d263df0..3431221 100644 --- a/backend/newwhats/agenda-bridge.js +++ b/backend/newwhats/agenda-bridge.js @@ -472,6 +472,40 @@ export function createAgendaBridge(pool) { } catch (e) { res.status(500).json({ error: e.message }); } }); + // ── GET /contatos ───────────────────────────────────────────────────────────── + // "Agenda de contatos internos" p/ roteamento por SITUAÇÃO. Lista todas as pessoas da + // clínica (dono/dentista/funcionário) com o papel, as situações de cada número e se pode + // ser contatada agora. A Secretária escolhe QUEM acionar pelo ASSUNTO (encaixe→dentista; + // decisão/desconto/financeiro→dono). Não expõe o número cru (o envio é via /dentista-contato). + router.get('/contatos', async (req, res) => { + const clinicaId = String(req.query.clinica_id || ''); + if (!clinicaId) return res.status(400).json({ error: 'clinica_id obrigatório' }); + try { + const { rows } = await pool.query( + `SELECT pc.usuario_id, pc.numeros, v.role, u.nome + FROM pessoa_comunicacao pc + LEFT JOIN vinculos v ON v.usuario_id = pc.usuario_id AND v.clinica_id = pc.clinica_id + LEFT JOIN usuarios u ON u.id = pc.usuario_id + WHERE pc.clinica_id = $1`, [clinicaId]); + const now = new Date(); + const contatos = []; + for (const r of rows) { + const nums = Array.isArray(r.numeros) ? r.numeros : []; + for (const n of nums) { + if (String(n.numero || '').replace(/\D/g, '').length < 10) continue; + const agora = contatoPermitidoAgora([n], now); + const prox = agora ? null : proximaJanela([n], now); + contatos.push({ + nome: r.nome || null, papel: r.role || null, + descricao: n.descricao || null, situacoes: n.situacoes || null, + contato_agora: !!agora, proxima_janela: prox ? prox.label : null, + }); + } + } + res.json({ total: contatos.length, contatos }); + } catch (e) { res.status(500).json({ error: e.message }); } + }); + // ── POST /book ──────────────────────────────────────────────────────────────── // Body: { clinica_id, dentista_id, start, end?, paciente_nome, paciente_celular, procedimento? } router.post('/book', async (req, res) => { diff --git a/backend/server.js b/backend/server.js index 79d1e4b..c1f62f1 100644 --- a/backend/server.js +++ b/backend/server.js @@ -7585,6 +7585,32 @@ async function podeEditarComunicacao(reqUserId, alvoUserId, clinicaId) { return ['donoclinica', 'donoconsultorio', 'admin'].includes(rows[0]?.role || ''); } +// Lookup: este número já está cadastrado por OUTRA pessoa da clínica? (notificação de +// número compartilhado — a mesma pessoa pode ter contas/papéis diferentes). +// Definido ANTES de /:usuarioId para não ser capturado pela rota-parâmetro. +app.get('/api/comunicacao/numero-lookup', authGuard, async (req, res) => { + try { + const clinicaId = String(req.query.clinicaId || ''); + const tel = String(req.query.numero || '').replace(/\D/g, '').slice(-8); + if (!clinicaId || tel.length < 8) return res.json({ encontrados: [] }); + const { rows } = await pool.query( + `SELECT pc.usuario_id, pc.numeros, u.nome, v.role + FROM pessoa_comunicacao pc + LEFT JOIN usuarios u ON u.id = pc.usuario_id + LEFT JOIN vinculos v ON v.usuario_id = pc.usuario_id AND v.clinica_id = pc.clinica_id + WHERE pc.clinica_id = $1 AND pc.usuario_id <> $2`, + [clinicaId, req.authUser.userId]); + const encontrados = []; + for (const r of rows) { + const nums = Array.isArray(r.numeros) ? r.numeros : []; + if (nums.some((n) => String(n.numero || '').replace(/\D/g, '').slice(-8) === tel)) { + encontrados.push({ usuario_id: r.usuario_id, nome: r.nome, role: r.role }); + } + } + res.json({ encontrados }); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + app.get('/api/comunicacao/:usuarioId', authGuard, async (req, res) => { try { const alvo = req.params.usuarioId === 'me' ? req.authUser.userId : req.params.usuarioId; diff --git a/frontend/components/ComunicacaoInternaModal.tsx b/frontend/components/ComunicacaoInternaModal.tsx index fcee05b..fe48e66 100644 --- a/frontend/components/ComunicacaoInternaModal.tsx +++ b/frontend/components/ComunicacaoInternaModal.tsx @@ -106,6 +106,20 @@ export const ComunicacaoInternaModal: React.FC<{ const updNum = (i: number, patch: Partial) => setNumeros(ns => ns.map((n, k) => (k === i ? { ...n, ...patch } : n))); const addNum = () => setNumeros(ns => [...ns, { numero: '', descricao: '', situacoes: '', janelas: [{ dias: [1, 2, 3, 4, 5], inicio: '08:00', fim: '18:00' }] }]); + // Notificação de número compartilhado: ao sair do campo, verifica se o número já é de + // outra conta/papel da clínica (a mesma pessoa pode ser dono E dentista, por ex.). + const [avisos, setAvisos] = useState>({}); + const roleLabel = (r: string) => (({ dentista: 'dentista', donoclinica: 'dono', donoconsultorio: 'dono', funcionario: 'funcionário', admin: 'admin' } as Record)[r] || r || 'conta'); + const checkNumero = async (i: number, numero: string) => { + const tel = (numero || '').replace(/\D/g, ''); + if (tel.length < 10 || !clinicaId) { setAvisos(a => ({ ...a, [i]: [] })); return; } + try { + const r = await api(`/api/comunicacao/numero-lookup?clinicaId=${encodeURIComponent(clinicaId)}&numero=${encodeURIComponent(tel)}`); + const d = await r.json(); + setAvisos(a => ({ ...a, [i]: Array.isArray(d.encontrados) ? d.encontrados : [] })); + } catch { /* silencioso */ } + }; + // Completude (espelha o backend) — orienta o preenchimento e destrava o gate. const enderecoOk = !!(endereco.cidade && endereco.estado && (endereco.cep || endereco.logradouro)); const numerosOk = numeros.length >= 1 && numeros.every(n => (n.numero || '').replace(/\D/g, '').length >= 10 && (n.descricao || '').trim() && (n.janelas?.length ?? 0) >= 1); @@ -209,7 +223,7 @@ export const ComunicacaoInternaModal: React.FC<{
- updNum(i, { numero: e.target.value })} placeholder="(67) 99999-9999" /> + updNum(i, { numero: e.target.value })} onBlur={e => checkNumero(i, e.target.value)} placeholder="(67) 99999-9999" />
@@ -218,6 +232,12 @@ export const ComunicacaoInternaModal: React.FC<{
+ {(avisos[i]?.length ?? 0) > 0 && ( +
+ + Este número já está cadastrado por {avisos[i].map(x => `${x.nome} (${roleLabel(x.role)})`).join(', ')}. Pode compartilhar o mesmo número — descreva abaixo as situações deste papel; a Secretária identifica pelo assunto quem deve responder. +
+ )}