feat(agenda): janela IA-livre vs estendida (zona cinza) + fila da secretária humana
- Duplicar horário para todos os dias (botão na grade). - Janela do dentista com DOIS limites: "IA agenda livre" [inicio,fim] e "aceito confirmar também" (mais cedo/mais tarde) — colunas hora_inicio_flex/hora_fim_flex. - Zona cinza: quando o cliente pede um horário fora da janela livre, a Secretária IA NÃO agenda — registra um pedido (POST /pedido) e diz "vou confirmar e retorno". - Fila da secretária humana: tabela agenda_pedidos + endpoints (listar/confirmar/ recusar); tela PedidosPendentes (confirmar → vira agendamento real, com escolha do dentista); botão com BADGE de contagem na Agenda (poll a cada 30 min) para staff. Testado em dev: dentista configura flex; IA registra pedido de 8h30 e responde "vou verificar com a secretária"; humana confirma → agendamento criado. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -310,6 +310,36 @@ export function createAgendaBridge(pool) {
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /pedido ──────────────────────────────────────────────────────────────
|
||||
// A Secretária IA registra um pedido de horário FORA da janela livre (zona cinza:
|
||||
// mais cedo/mais tarde) para a SECRETÁRIA HUMANA confirmar. A IA não agenda esses.
|
||||
// Body: { clinica_id, data (YYYY-MM-DD), hora (HH:MM), hora_fim?, dentista_nome?,
|
||||
// nome_cliente, telefone?, procedimento? }
|
||||
router.post('/pedido', async (req, res) => {
|
||||
const b = req.body || {};
|
||||
if (!b.clinica_id || !b.data || !b.hora) return res.status(400).json({ error: 'clinica_id, data e hora obrigatórios' });
|
||||
try {
|
||||
// Dia totalmente fechado (feriado/fechamento) → não adianta criar pedido.
|
||||
const fer = await resolverFeriado(pool, b.clinica_id, b.data);
|
||||
if (fer && fer.fecha) return res.json({ ok: false, motivo: `Fechado nesse dia (${fer.nome}).` });
|
||||
// Resolve o dentista pelo nome (opcional).
|
||||
let dentistaId = null;
|
||||
if (b.dentista_nome) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id FROM dentistas WHERE clinica_id = $1 AND lower(nome) LIKE '%'||lower($2)||'%' LIMIT 1`,
|
||||
[b.clinica_id, b.dentista_nome]);
|
||||
dentistaId = rows[0]?.id || null;
|
||||
}
|
||||
const id = `ped_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||||
await pool.query(
|
||||
`INSERT INTO agenda_pedidos (id, clinica_id, dentista_id, data, hora_inicio, hora_fim, paciente_nome, paciente_telefone, procedimento, status)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'pendente')`,
|
||||
[id, b.clinica_id, dentistaId, b.data, hm(b.hora), b.hora_fim ? hm(b.hora_fim) : null,
|
||||
b.nome_cliente || null, (String(b.telefone || '').replace(/\D/g, '')) || null, b.procedimento || null]);
|
||||
res.json({ ok: true, pedido_id: id });
|
||||
} 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) => {
|
||||
|
||||
@@ -84,7 +84,9 @@ export function createAgendaConfig(pool) {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, dia_semana, to_char(hora_inicio,'HH24:MI') AS hora_inicio,
|
||||
to_char(hora_fim,'HH24:MI') AS hora_fim, ativo
|
||||
to_char(hora_fim,'HH24:MI') AS hora_fim,
|
||||
to_char(hora_inicio_flex,'HH24:MI') AS hora_inicio_flex,
|
||||
to_char(hora_fim_flex,'HH24:MI') AS hora_fim_flex, ativo
|
||||
FROM dentistas_horarios WHERE dentista_id = $1 ORDER BY dia_semana, hora_inicio`,
|
||||
[req.params.dentistaId]);
|
||||
res.json({ horarios: rows });
|
||||
@@ -109,9 +111,13 @@ export function createAgendaConfig(pool) {
|
||||
for (const h of linhas) {
|
||||
const dow = Number(h.dia_semana); const ini = hhmm(h.hora_inicio); const fim = hhmm(h.hora_fim);
|
||||
if (!(dow >= 0 && dow <= 6) || !ini || !fim || ini >= fim) continue;
|
||||
// Janela estendida (zona cinza): flex_inicio ≤ inicio e flex_fim ≥ fim.
|
||||
let fIni = hhmm(h.hora_inicio_flex), fFim = hhmm(h.hora_fim_flex);
|
||||
if (fIni && fIni >= ini) fIni = null; // só vale se for MAIS cedo
|
||||
if (fFim && fFim <= fim) fFim = null; // só vale se for MAIS tarde
|
||||
await client.query(
|
||||
`INSERT INTO dentistas_horarios (id, dentista_id, clinica_id, dia_semana, hora_inicio, hora_fim, ativo)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,1)`, [uid('dh'), dentistaId, dent.clinica_id, dow, ini, fim]);
|
||||
`INSERT INTO dentistas_horarios (id, dentista_id, clinica_id, dia_semana, hora_inicio, hora_fim, hora_inicio_flex, hora_fim_flex, ativo)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,1)`, [uid('dh'), dentistaId, dent.clinica_id, dow, ini, fim, fIni, fFim]);
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
res.json({ ok: true });
|
||||
@@ -210,6 +216,72 @@ export function createAgendaConfig(pool) {
|
||||
} 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;
|
||||
try { const { rows } = await pool.query('SELECT 1 FROM vinculos WHERE clinica_id = $1 AND usuario_id = $2 LIMIT 1', [clinicaId, userId]); return rows.length > 0; }
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
router.get('/clinica/:clinicaId/pedidos', async (req, res) => {
|
||||
if (!(await ehMembro(req.params.clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Sem acesso.' });
|
||||
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,
|
||||
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
|
||||
WHERE p.clinica_id = $1 AND p.status = $2 ORDER BY p.created_at DESC LIMIT 100`,
|
||||
[req.params.clinicaId, status]);
|
||||
res.json({ pedidos: rows, total: rows.length });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Confirma → cria o agendamento real e marca o pedido como confirmado.
|
||||
// Body: { dentista_id? (se o pedido não tinha), duracao_min? }
|
||||
router.post('/clinica/:clinicaId/pedidos/:id/confirmar', async (req, res) => {
|
||||
const clinicaId = req.params.clinicaId;
|
||||
if (!(await ehMembro(clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Sem acesso.' });
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const { rows } = await client.query('SELECT * FROM agenda_pedidos WHERE id = $1 AND clinica_id = $2 FOR UPDATE', [req.params.id, clinicaId]);
|
||||
const ped = rows[0];
|
||||
if (!ped) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'Pedido não encontrado.' }); }
|
||||
if (ped.status !== 'pendente') { await client.query('ROLLBACK'); return res.status(409).json({ error: 'Pedido já resolvido.' }); }
|
||||
const dentistaId = req.body?.dentista_id || ped.dentista_id;
|
||||
if (!dentistaId) { await client.query('ROLLBACK'); return res.status(400).json({ error: 'Escolha o dentista para confirmar.' }); }
|
||||
const dur = Math.max(10, Number(req.body?.duracao_min) || 30);
|
||||
const hi = String(ped.hora_inicio).slice(0, 5);
|
||||
const start = `${(ped.data instanceof Date ? ped.data.toISOString().slice(0, 10) : String(ped.data).slice(0, 10))} ${hi}:00`;
|
||||
const endD = new Date(`${start.replace(' ', 'T')}`); endD.setMinutes(endD.getMinutes() + dur);
|
||||
const end = `${start.slice(0, 10)} ${String(endD.getHours()).padStart(2, '0')}:${String(endD.getMinutes()).padStart(2, '0')}:00`;
|
||||
const { rows: dd } = await client.query('SELECT setor_id FROM dentistas WHERE id = $1', [dentistaId]);
|
||||
const agId = `ag_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||||
await client.query(
|
||||
`INSERT INTO agendamentos (id, clinica_id, pacientenome, pacientecelular, dentistaid, procedimento,
|
||||
start_time, end_time, status, setor_id, sala_id, created_by_nome, created_at, updated_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'agendado',$9,$10,'Secretária (confirmado)',NOW(),NOW())`,
|
||||
[agId, clinicaId, ped.paciente_nome || 'Cliente WhatsApp', ped.paciente_telefone || null, dentistaId,
|
||||
ped.procedimento || 'Consulta', start, end, dd[0]?.setor_id || null, ped.sala_id || null]);
|
||||
await client.query(`UPDATE agenda_pedidos SET status='confirmado', agendamento_id=$1, resolved_at=NOW(), resolved_by=$2 WHERE id=$3`,
|
||||
[agId, req.nwUser.userId, req.params.id]);
|
||||
await client.query('COMMIT');
|
||||
res.json({ ok: true, agendamento_id: agId });
|
||||
} catch (e) { try { await client.query('ROLLBACK'); } catch {} res.status(500).json({ error: e.message }); }
|
||||
finally { client.release(); }
|
||||
});
|
||||
|
||||
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 {
|
||||
await pool.query(`UPDATE agenda_pedidos SET status='recusado', 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 }); }
|
||||
});
|
||||
|
||||
// Lista dentistas da clínica (p/ a UI do dono escolher quem configurar / e o
|
||||
// dentista achar seu registro). Retorna também se o usuário logado é o dentista.
|
||||
router.get('/clinica/:clinicaId/dentistas', async (req, res) => {
|
||||
|
||||
@@ -49,6 +49,36 @@ export async function ensureAgendaSchema(pool) {
|
||||
// ── B (salas): agendamentos.sala_id — permite agendar num quarto alugado.
|
||||
// Aditivo/nullable: NÃO afeta o fluxo existente (clínica) quando nulo.
|
||||
await pool.query(`ALTER TABLE agendamentos ADD COLUMN IF NOT EXISTS sala_id varchar`);
|
||||
|
||||
// ── Janela ESTENDIDA do dentista (zona cinza): a IA agenda livre em
|
||||
// [hora_inicio, hora_fim]; entre a estendida e a livre exige confirmação
|
||||
// humana. Nulo = sem zona cinza (só a janela livre).
|
||||
await pool.query(`ALTER TABLE dentistas_horarios ADD COLUMN IF NOT EXISTS hora_inicio_flex time without time zone`);
|
||||
await pool.query(`ALTER TABLE dentistas_horarios ADD COLUMN IF NOT EXISTS hora_fim_flex time without time zone`);
|
||||
|
||||
// ── agenda_pedidos — pedidos da ZONA CINZA aguardando a secretária humana.
|
||||
// A IA cria (status 'pendente') e diz "vou confirmar"; a humana confirma
|
||||
// (vira agendamento) ou recusa.
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS agenda_pedidos (
|
||||
id varchar PRIMARY KEY,
|
||||
clinica_id varchar NOT NULL,
|
||||
dentista_id varchar,
|
||||
sala_id varchar,
|
||||
data date NOT NULL,
|
||||
hora_inicio time without time zone NOT NULL,
|
||||
hora_fim time without time zone,
|
||||
paciente_nome varchar,
|
||||
paciente_telefone varchar,
|
||||
procedimento varchar,
|
||||
origem varchar DEFAULT 'secretaria_ia',
|
||||
status varchar DEFAULT 'pendente', -- pendente | confirmado | recusado
|
||||
agendamento_id varchar, -- preenchido ao confirmar
|
||||
created_at timestamptz DEFAULT now(),
|
||||
resolved_at timestamptz,
|
||||
resolved_by varchar
|
||||
)`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_agenda_pedidos_clinica_status ON agenda_pedidos (clinica_id, status, created_at)`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
// Não derruba o boot do satélite se o banco estiver indisponível no momento.
|
||||
|
||||
Reference in New Issue
Block a user