diff --git a/backend/newwhats/agenda-bridge.js b/backend/newwhats/agenda-bridge.js index 67cfdc1..cd45678 100644 --- a/backend/newwhats/agenda-bridge.js +++ b/backend/newwhats/agenda-bridge.js @@ -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) => { diff --git a/backend/newwhats/agenda-config.js b/backend/newwhats/agenda-config.js index 8fc0e21..b5ce0cd 100644 --- a/backend/newwhats/agenda-config.js +++ b/backend/newwhats/agenda-config.js @@ -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) => { diff --git a/backend/newwhats/agenda-schema.js b/backend/newwhats/agenda-schema.js index 061b92e..9ebbc7f 100644 --- a/backend/newwhats/agenda-schema.js +++ b/backend/newwhats/agenda-schema.js @@ -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. diff --git a/frontend/components/HorariosConfig.tsx b/frontend/components/HorariosConfig.tsx index 198b2cc..f02a3e8 100644 --- a/frontend/components/HorariosConfig.tsx +++ b/frontend/components/HorariosConfig.tsx @@ -2,7 +2,7 @@ // horários dos dentistas. Consome /api/nw/agenda-config (o backend garante o // ownership: horário/feriado da clínica = dono; horário do dentista = ele ou dono). import React, { useState, useEffect, useCallback } from 'react'; -import { X, Clock, CalendarOff, Plus, Trash2, Save, Loader2, User } from 'lucide-react'; +import { X, Clock, CalendarOff, Plus, Trash2, Save, Loader2, User, Copy } from 'lucide-react'; const API = (import.meta as any).env?.VITE_API_URL || '/api'; const BASE = `${API}/nw/agenda-config`; @@ -20,13 +20,18 @@ async function req(path: string, opts: RequestInit = {}) { return data; } -interface Faixa { dia_semana: number; hora_inicio: string; hora_fim: string } +interface Faixa { dia_semana: number; hora_inicio: string; hora_fim: string; hora_inicio_flex?: string | null; hora_fim_flex?: string | null } interface Dentista { id: string; nome: string; usuario_id: string | null; eh_voce: boolean } // Editor de grade semanal (7 dias, cada um com 0..n faixas). Reusado p/ clínica e dentista. -const GradeSemanal: React.FC<{ faixas: Faixa[]; onChange: (f: Faixa[]) => void; disabled?: boolean }> = ({ faixas, onChange, disabled }) => { +const GradeSemanal: React.FC<{ faixas: Faixa[]; onChange: (f: Faixa[]) => void; disabled?: boolean; withFlex?: boolean }> = ({ faixas, onChange, disabled, withFlex }) => { const porDia = (d: number) => faixas.filter((f) => f.dia_semana === d); const setDia = (d: number, novas: Faixa[]) => onChange([...faixas.filter((f) => f.dia_semana !== d), ...novas]); + // Copia as faixas do dia `d` para TODOS os outros dias. + const duplicarParaTodos = (d: number) => { + const base = porDia(d).map((f) => ({ hora_inicio: f.hora_inicio, hora_fim: f.hora_fim })); + onChange(DIAS.flatMap((_, x) => base.map((f) => ({ dia_semana: x, ...f })))); + }; return (
Nenhum dentista.
} -Sem horário próprio, o dentista atende sempre que a clínica está aberta. Cada dentista (ou o dono) edita os seus.
+ {dentSel ?Nenhum dentista.
} +A IA agenda LIVRE na janela principal. Na janela "aceito confirmar" (mais cedo/mais tarde), ela não confirma sozinha — cria um pedido para a secretária humana decidir.
{dentSel && (Horários fora do padrão que a Secretária IA encaminhou
+Nenhum pedido pendente. 🎉
+ ) : pedidos.map((p) => ( +