feat(newwhats): agenda inteligente — especialidades, situações, dúvidas, checklist do dono e pendências

Satélite da Secretária IA:
- /contexto (dentistas+especialidades+situações+cautelas) e /duvida (encaminhar
  ao humano sem agendar); agenda_pedidos ganha tipo=duvida + resumo.
- Situações da clínica: tabela agenda_situacoes + config (dono) + aba na UI.
- Checklist do dono (checklist-defs.js + agenda_checklist): perguntas Sim/Não
  conferidas contra o banco (dentistas/pacientes) → pendências de config +
  cautelas p/ a IA não chutar.
- Área de PENDÊNCIAS DO DONO (PendenciasDono.tsx): engloba config + fila
  operacional (horários/dúvidas); botão+badge no /wa-secretaria (só o dono).
- Fila humana (PedidosPendentes): cards de dúvida + resolver; /resolver.
- Botão DESLIGAR global da Secretária no ThinNav (secPowerApi, visível a todos).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-07-06 15:40:51 +02:00
parent f4c2c6861a
commit 40bed96515
9 changed files with 597 additions and 13 deletions
+108 -1
View File
@@ -7,6 +7,7 @@
import express from 'express';
import jwt from 'jsonwebtoken';
import { feriadosNacionais } from './feriados.js';
import { CHECKLIST_QUESTOES, pendenciasDeConfig } from './checklist-defs.js';
function authFromReq(req) {
try {
@@ -216,6 +217,100 @@ export function createAgendaConfig(pool) {
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── Situações / regras da clínica (dono) — a "área de situações" da Secretária ─
router.get('/clinica/:clinicaId/situacoes', async (req, res) => {
try {
const { rows } = await pool.query(`SELECT id, texto, ativo, ordem FROM agenda_situacoes WHERE clinica_id = $1 ORDER BY ordem, created_at`, [req.params.clinicaId]);
res.json({ situacoes: rows });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Substitui toda a lista. Body: { situacoes: [texto] }
router.put('/clinica/:clinicaId/situacoes', async (req, res) => {
const clinicaId = req.params.clinicaId;
if (!(await isDono(pool, clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Apenas o dono pode configurar as situações.' });
const linhas = Array.isArray(req.body?.situacoes) ? req.body.situacoes : [];
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('DELETE FROM agenda_situacoes WHERE clinica_id = $1', [clinicaId]);
let i = 0;
for (const s of linhas) {
const t = String(s || '').trim(); if (!t) continue;
await client.query(`INSERT INTO agenda_situacoes (id, clinica_id, texto, ativo, ordem) VALUES ($1,$2,$3,1,$4)`, [uid('sit'), clinicaId, t, i++]);
}
await client.query('COMMIT'); res.json({ ok: true });
} catch (e) { try { await client.query('ROLLBACK'); } catch {} res.status(500).json({ error: e.message }); }
finally { client.release(); }
});
// ── Checklist do DONO + pendências (só o dono do workspace) ───────────────────
// Conta dentistas/pacientes reais da clínica p/ conferir contra as respostas.
async function contagens(clinicaId) {
const q = async (sql) => { try { const { rows } = await pool.query(sql, [clinicaId]); return Number(rows[0]?.n || 0); } catch { return 0; } };
const dentistas = await q(`SELECT COUNT(*) n FROM dentistas WHERE clinica_id = $1 AND ativo IS DISTINCT FROM 0`);
const pacientes = await q(`SELECT COUNT(*) n FROM pacientes WHERE clinica_id = $1`);
return { dentistas, pacientes };
}
async function respostasChecklist(clinicaId) {
const map = {};
try { const { rows } = await pool.query('SELECT chave, resposta FROM agenda_checklist WHERE clinica_id = $1', [clinicaId]); for (const r of rows) map[r.chave] = r.resposta == null ? null : Number(r.resposta); } catch { /* tabela nova */ }
return map;
}
router.get('/clinica/:clinicaId/checklist', async (req, res) => {
const clinicaId = req.params.clinicaId;
if (!(await isDono(pool, clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Apenas o dono acessa o checklist.' });
try {
const map = await respostasChecklist(clinicaId);
res.json({ questoes: CHECKLIST_QUESTOES.map((q) => ({ ...q, resposta: map[q.chave] ?? null })) });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Salva respostas. Body: { respostas: { chave: 1|0|null } }
router.put('/clinica/:clinicaId/checklist', async (req, res) => {
const clinicaId = req.params.clinicaId;
if (!(await isDono(pool, clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Apenas o dono pode configurar o checklist.' });
const respostas = req.body?.respostas || {};
const client = await pool.connect();
try {
await client.query('BEGIN');
for (const q of CHECKLIST_QUESTOES) {
if (!(q.chave in respostas)) continue;
const v = respostas[q.chave];
const r = v === 1 || v === true ? 1 : v === 0 || v === false ? 0 : null;
await client.query(
`INSERT INTO agenda_checklist (clinica_id, chave, resposta, updated_at) VALUES ($1,$2,$3,now())
ON CONFLICT (clinica_id, chave) DO UPDATE SET resposta = EXCLUDED.resposta, updated_at = now()`,
[clinicaId, q.chave, r]);
}
await client.query('COMMIT'); res.json({ ok: true });
} catch (e) { try { await client.query('ROLLBACK'); } catch {} res.status(500).json({ error: e.message }); }
finally { client.release(); }
});
// Área de pendências do DONO — engloba TUDO: pendências de config (checklist ×
// banco) + a fila operacional (pedidos de horário + dúvidas aguardando humano).
router.get('/clinica/:clinicaId/pendencias', async (req, res) => {
const clinicaId = req.params.clinicaId;
if (!(await isDono(pool, clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Apenas o dono acessa as pendências.' });
try {
const counts = await contagens(clinicaId);
const respostas = await respostasChecklist(clinicaId);
const config = pendenciasDeConfig(respostas, counts);
let horarios = 0, duvidas = 0;
try {
const { rows } = await pool.query(
`SELECT COALESCE(tipo,'horario') tipo, COUNT(*) n FROM agenda_pedidos WHERE clinica_id = $1 AND status = 'pendente' GROUP BY 1`, [clinicaId]);
for (const r of rows) { if (r.tipo === 'duvida') duvidas = Number(r.n); else horarios = Number(r.n); }
} catch { /* tabela nova */ }
res.json({
contagens: counts,
config,
operacionais: { horarios, duvidas, total: horarios + duvidas },
total: config.length + horarios + duvidas,
});
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── Pedidos pendentes (zona cinza) — a secretária humana confirma/recusa ──────
async function ehMembro(clinicaId, userId) {
if (await isDono(pool, clinicaId, userId)) return true;
@@ -228,7 +323,8 @@ export function createAgendaConfig(pool) {
const status = req.query.status ? String(req.query.status) : 'pendente';
try {
const { rows } = await pool.query(
`SELECT p.id, to_char(p.data,'YYYY-MM-DD') AS data, to_char(p.hora_inicio,'HH24:MI') AS hora_inicio,
`SELECT p.id, COALESCE(p.tipo,'horario') AS tipo, p.resumo,
to_char(p.data,'YYYY-MM-DD') AS data, to_char(p.hora_inicio,'HH24:MI') AS hora_inicio,
to_char(p.hora_fim,'HH24:MI') AS hora_fim, p.paciente_nome, p.paciente_telefone, p.procedimento,
p.dentista_id, d.nome AS dentista_nome, p.status, p.created_at
FROM agenda_pedidos p LEFT JOIN dentistas d ON d.id = p.dentista_id
@@ -273,6 +369,17 @@ export function createAgendaConfig(pool) {
finally { client.release(); }
});
// Resolve uma DÚVIDA (tipo='duvida') sem criar agendamento — a humana já
// decidiu/atendeu (ou vai agendar manualmente uma avaliação).
router.post('/clinica/:clinicaId/pedidos/:id/resolver', async (req, res) => {
if (!(await ehMembro(req.params.clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Sem acesso.' });
try {
await pool.query(`UPDATE agenda_pedidos SET status='resolvido', resolved_at=NOW(), resolved_by=$1 WHERE id=$2 AND clinica_id=$3 AND status='pendente'`,
[req.nwUser.userId, req.params.id, req.params.clinicaId]);
res.json({ ok: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
router.post('/clinica/:clinicaId/pedidos/:id/recusar', async (req, res) => {
if (!(await ehMembro(req.params.clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Sem acesso.' });
try {