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:
@@ -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) => {
|
||||
|
||||
@@ -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 já 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." />
|
||||
|
||||
Reference in New Issue
Block a user