feat(comunicacao-interna): notificação de número compartilhado + agenda de contatos

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 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-07-07 22:48:42 +02:00
parent eafc1578e3
commit b253feb4aa
3 changed files with 81 additions and 1 deletions
+34
View File
@@ -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) => {
+26
View File
@@ -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;
@@ -106,6 +106,20 @@ export const ComunicacaoInternaModal: React.FC<{
const updNum = (i: number, patch: Partial<Numero>) => 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<Record<number, { nome: string; role: string }[]>>({});
const roleLabel = (r: string) => (({ dentista: 'dentista', donoclinica: 'dono', donoconsultorio: 'dono', funcionario: 'funcionário', admin: 'admin' } as Record<string, string>)[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<{
<div className="flex-1 grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className={label}>Número</label>
<input className={input} value={n.numero} onChange={e => updNum(i, { numero: e.target.value })} placeholder="(67) 99999-9999" />
<input className={input} value={n.numero} onChange={e => updNum(i, { numero: e.target.value })} onBlur={e => checkNumero(i, e.target.value)} placeholder="(67) 99999-9999" />
</div>
<div>
<label className={label}>Descrição</label>
@@ -218,6 +232,12 @@ export const ComunicacaoInternaModal: React.FC<{
</div>
<button type="button" onClick={() => setNumeros(ns => ns.filter((_, k) => k !== i))} className="p-2 text-gray-300 hover:text-red-500 mt-5"><Trash2 size={16} /></button>
</div>
{(avisos[i]?.length ?? 0) > 0 && (
<div className="rounded-xl bg-amber-50 border border-amber-200 p-2.5 text-[11px] text-amber-700 font-medium flex gap-2">
<AlertTriangle size={14} className="shrink-0 mt-0.5 text-amber-500" />
<span>Este número está cadastrado por <b>{avisos[i].map(x => `${x.nome} (${roleLabel(x.role)})`).join(', ')}</b>. Pode compartilhar o mesmo número descreva abaixo as situações deste papel; a Secretária identifica pelo assunto quem deve responder.</span>
</div>
)}
<div>
<label className={label}>Situações em que a Secretária pode mandar msg neste número</label>
<textarea className={input} rows={2} value={n.situacoes} onChange={e => updNum(i, { situacoes: e.target.value })} placeholder="Ex.: só confirmações de encaixe e avisos urgentes; nunca assuntos pessoais." />