From 40bed9651553988e7e1a5ac16d317b1dc7c6f722 Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Mon, 6 Jul 2026 15:40:51 +0200 Subject: [PATCH] =?UTF-8?q?feat(newwhats):=20agenda=20inteligente=20?= =?UTF-8?q?=E2=80=94=20especialidades,=20situa=C3=A7=C3=B5es,=20d=C3=BAvid?= =?UTF-8?q?as,=20checklist=20do=20dono=20e=20pend=C3=AAncias?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/newwhats/agenda-bridge.js | 52 ++++++ backend/newwhats/agenda-config.js | 109 +++++++++++- backend/newwhats/agenda-schema.js | 30 ++++ backend/newwhats/checklist-defs.js | 78 +++++++++ frontend/components/HorariosConfig.tsx | 52 +++++- frontend/components/PedidosPendentes.tsx | 30 +++- frontend/components/PendenciasDono.tsx | 165 ++++++++++++++++++ frontend/views/newwhats/SecretariaView.tsx | 87 ++++++++- .../views/newwhats/services/secretariaApi.ts | 7 + 9 files changed, 597 insertions(+), 13 deletions(-) create mode 100644 backend/newwhats/checklist-defs.js create mode 100644 frontend/components/PendenciasDono.tsx diff --git a/backend/newwhats/agenda-bridge.js b/backend/newwhats/agenda-bridge.js index a7102dc..5f91b02 100644 --- a/backend/newwhats/agenda-bridge.js +++ b/backend/newwhats/agenda-bridge.js @@ -10,6 +10,7 @@ // POST /book — cria agendamento real (anti-overbooking + vínculo de paciente) import express from 'express'; import { getConfig } from './config.js'; +import { cautelasParaIA } from './checklist-defs.js'; import { resolverFeriado, feriadoNacionalNome } from './feriados.js'; const SLOT_MIN = 60; // duração/espaçamento padrão do slot (1h entre atendimentos) @@ -344,6 +345,57 @@ export function createAgendaBridge(pool) { } catch (e) { res.status(500).json({ error: e.message }); } }); + // ── POST /duvida ────────────────────────────────────────────────────────────── + // Encaminha uma DÚVIDA/caso incerto à secretária humana (sem agendar). Cria um + // agenda_pedidos tipo='duvida' com o resumo; entra na mesma fila de pendentes. + router.post('/duvida', async (req, res) => { + const b = req.body || {}; + if (!b.clinica_id || !b.resumo) return res.status(400).json({ error: 'clinica_id e resumo obrigatórios' }); + try { + const id = `ped_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`; + await pool.query( + `INSERT INTO agenda_pedidos (id, clinica_id, tipo, resumo, paciente_nome, paciente_telefone, status) + VALUES ($1,$2,'duvida',$3,$4,$5,'pendente')`, + [id, b.clinica_id, String(b.resumo).slice(0, 2000), + b.nome_cliente || null, (String(b.telefone || '').replace(/\D/g, '')) || null]); + res.json({ ok: true, pedido_id: id }); + } catch (e) { res.status(500).json({ error: e.message }); } + }); + + // ── GET /contexto ───────────────────────────────────────────────────────────── + // Contexto clínico p/ a Secretária: dentistas + especialidades + "situações" + // (regras da clínica). Query: clinica_id. + router.get('/contexto', 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: dentistas } = await pool.query( + `SELECT nome, especialidade, especialidades FROM dentistas WHERE clinica_id = $1 AND ativo IS DISTINCT FROM 0 ORDER BY nome`, [clinicaId]); + let situacoes = []; + try { + const { rows } = await pool.query(`SELECT texto FROM agenda_situacoes WHERE clinica_id = $1 AND ativo = 1 ORDER BY ordem, created_at`, [clinicaId]); + situacoes = rows.map((r) => r.texto); + } catch { /* tabela pode não existir ainda */ } + // Cautelas do checklist do dono (declaração × dados reais) → a IA não chuta. + let cautelas = []; + try { + const respostas = {}; + const { rows } = await pool.query(`SELECT chave, resposta FROM agenda_checklist WHERE clinica_id = $1`, [clinicaId]); + for (const r of rows) respostas[r.chave] = r.resposta == null ? null : Number(r.resposta); + const nPac = await pool.query(`SELECT COUNT(*) n FROM pacientes WHERE clinica_id = $1`, [clinicaId]).then((x) => Number(x.rows[0]?.n || 0)).catch(() => 0); + cautelas = cautelasParaIA(respostas, { dentistas: dentistas.length, pacientes: nPac }); + } catch { /* checklist novo/indisponível */ } + res.json({ + dentistas: dentistas.map((d) => ({ + nome: d.nome, + especialidades: Array.isArray(d.especialidades) ? d.especialidades : (d.especialidade ? [d.especialidade] : []), + })), + situacoes, + cautelas, + }); + } catch (e) { res.status(500).json({ error: e.message }); } + }); + // ── GET /dentista-contato ───────────────────────────────────────────────────── // Resolve o WhatsApp de um dentista da clínica (Parte A: a IA aciona o Dr). // Query: clinica_id (obrig.), nome? OU dentista_id? diff --git a/backend/newwhats/agenda-config.js b/backend/newwhats/agenda-config.js index b5ce0cd..197cf91 100644 --- a/backend/newwhats/agenda-config.js +++ b/backend/newwhats/agenda-config.js @@ -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 { diff --git a/backend/newwhats/agenda-schema.js b/backend/newwhats/agenda-schema.js index 9ebbc7f..c07ba0e 100644 --- a/backend/newwhats/agenda-schema.js +++ b/backend/newwhats/agenda-schema.js @@ -79,6 +79,36 @@ export async function ensureAgendaSchema(pool) { resolved_by varchar )`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_agenda_pedidos_clinica_status ON agenda_pedidos (clinica_id, status, created_at)`); + // Pedido também serve para DÚVIDAS (sem horário): tipo + resumo; data/hora nullable. + await pool.query(`ALTER TABLE agenda_pedidos ADD COLUMN IF NOT EXISTS tipo varchar DEFAULT 'horario'`); // horario | duvida + await pool.query(`ALTER TABLE agenda_pedidos ADD COLUMN IF NOT EXISTS resumo text`); + await pool.query(`ALTER TABLE agenda_pedidos ALTER COLUMN data DROP NOT NULL`).catch(() => {}); + await pool.query(`ALTER TABLE agenda_pedidos ALTER COLUMN hora_inicio DROP NOT NULL`).catch(() => {}); + + // ── agenda_situacoes — "área de situações": regras da clínica que a Secretária + // IA deve seguir (ex.: "plano X não cobre canal posterior — ofereça avaliação"). + await pool.query(` + CREATE TABLE IF NOT EXISTS agenda_situacoes ( + id varchar PRIMARY KEY, + clinica_id varchar NOT NULL, + texto text NOT NULL, + ativo smallint DEFAULT 1, + ordem integer DEFAULT 0, + created_at timestamptz DEFAULT now() + )`); + await pool.query(`CREATE INDEX IF NOT EXISTS idx_agenda_situacoes_clinica ON agenda_situacoes (clinica_id)`); + + // ── agenda_checklist — respostas Sim/Não do DONO sobre a clínica (ver + // checklist-defs.js). Conferidas contra o banco → geram pendências de config + // e deixam a Secretária cautelosa. resposta: 1=sim, 0=não, NULL=não respondido. + await pool.query(` + CREATE TABLE IF NOT EXISTS agenda_checklist ( + clinica_id varchar NOT NULL, + chave varchar NOT NULL, + resposta smallint, + updated_at timestamptz DEFAULT now(), + PRIMARY KEY (clinica_id, chave) + )`); return true; } catch (e) { // Não derruba o boot do satélite se o banco estiver indisponível no momento. diff --git a/backend/newwhats/checklist-defs.js b/backend/newwhats/checklist-defs.js new file mode 100644 index 0000000..073499e --- /dev/null +++ b/backend/newwhats/checklist-defs.js @@ -0,0 +1,78 @@ +// Checklist do DONO do workspace: perguntas Sim/Não sobre a clínica que a +// Secretária IA usa para saber quando PODE agendar sozinha e quando deve ter +// cautela. As respostas são conferidas contra o banco (dentistas/pacientes): +// quando a declaração do dono não bate com os dados, geramos uma PENDÊNCIA +// para ele configurar/cadastrar — e a IA fica cautelosa (encaminha ao humano). +// +// Fonte única: config (UI), consistência (pendências) e injeção no cérebro +// (cautelas) consomem esta mesma definição para nunca divergirem. + +export const CHECKLIST_QUESTOES = [ + { + chave: 'multi_dentista', + pergunta: 'A clínica tem mais de um dentista?', + ajuda: 'Se sim, a Secretária não pode assumir um único dentista nem "clínico geral" por padrão — precisa saber quem atende cada paciente.', + }, + { + chave: 'pacientes_cadastrados', + pergunta: 'Os pacientes já estão cadastrados no sistema (/pacientes)?', + ajuda: 'Sem os pacientes cadastrados, a Secretária não reconhece quem já é paciente e encaminha os retornos para a secretária humana confirmar.', + }, + { + chave: 'dentista_fixo', + pergunta: 'Cada paciente tem um dentista de referência (fixo)?', + ajuda: 'Se sim, um paciente de retorno deve voltar para o MESMO dentista — a IA não deve agendá-lo no clínico geral.', + }, +]; + +const sim = (r, k) => r[k] === 1; +const nao = (r, k) => r[k] === 0; + +// Pendências de CONFIG do dono: a declaração não bate com o banco. +// counts = { dentistas, pacientes }. +export function pendenciasDeConfig(respostas, counts) { + const r = respostas || {}; + const dentistas = Number(counts?.dentistas || 0); + const pacientes = Number(counts?.pacientes || 0); + const P = []; + + if (sim(r, 'multi_dentista') && dentistas <= 1) + P.push({ chave: 'multi_dentista', severidade: 'alta', titulo: 'Cadastre os demais dentistas', + detalhe: `Você indicou que a clínica tem mais de um dentista, mas há apenas ${dentistas} cadastrado. A Secretária não consegue direcionar cada paciente ao dentista certo — cadastre os outros dentistas.` }); + if (nao(r, 'multi_dentista') && dentistas > 1) + P.push({ chave: 'multi_dentista', severidade: 'media', titulo: 'Confirme quantos dentistas atendem', + detalhe: `Você indicou um único dentista, mas há ${dentistas} cadastrados no sistema. Ajuste a resposta ou os cadastros.` }); + + if (sim(r, 'pacientes_cadastrados') && pacientes === 0) + P.push({ chave: 'pacientes_cadastrados', severidade: 'alta', titulo: 'Cadastre os pacientes', + detalhe: 'Você indicou que os pacientes estão cadastrados, mas não há nenhum paciente no sistema. A Secretária não reconhece quem já é paciente.' }); + if (r['pacientes_cadastrados'] == null || nao(r, 'pacientes_cadastrados')) + P.push({ chave: 'pacientes_cadastrados', severidade: 'media', titulo: 'Complete o cadastro de pacientes', + detalhe: 'Enquanto os pacientes não estiverem cadastrados, a Secretária vai encaminhar todo retorno para a secretária humana confirmar (não agenda sozinha).' }); + + if (sim(r, 'dentista_fixo') && dentistas <= 1 && sim(r, 'multi_dentista')) + P.push({ chave: 'dentista_fixo', severidade: 'media', titulo: 'Cadastre os dentistas de referência', + detalhe: 'Você indicou que cada paciente tem um dentista fixo, mas o sistema ainda não tem os dentistas todos cadastrados para fazer esse vínculo.' }); + + return P; +} + +// Cautelas para injetar no cérebro da IA (texto direto no system prompt). +export function cautelasParaIA(respostas, counts) { + const r = respostas || {}; + const dentistas = Number(counts?.dentistas || 0); + const pacientes = Number(counts?.pacientes || 0); + const C = []; + + const multi = sim(r, 'multi_dentista') || dentistas > 1; + if (multi) + C.push('A clínica tem MAIS DE UM dentista: NUNCA assuma um dentista nem "clínico geral" por padrão para quem já é paciente. Descubra/pergunte qual dentista atende. Sem certeza → encaminhe ao humano.'); + if (sim(r, 'multi_dentista') && dentistas <= 1) + C.push('ATENÇÃO: o sistema ainda não tem todos os dentistas cadastrados. Para pacientes de retorno ou que pedem um dentista específico, encaminhe ao humano — NÃO agende no clínico geral.'); + if (sim(r, 'dentista_fixo')) + C.push('Cada paciente tem um dentista fixo: um retorno deve voltar ao MESMO dentista. Se você não sabe qual é (falta cadastro), encaminhe ao humano.'); + if (pacientes === 0 || nao(r, 'pacientes_cadastrados')) + C.push('O cadastro de pacientes está incompleto: você NÃO consegue confirmar histórico nem o dentista de quem diz já ser paciente. Nesses casos, encaminhe ao humano em vez de agendar.'); + + return C; +} diff --git a/frontend/components/HorariosConfig.tsx b/frontend/components/HorariosConfig.tsx index f02a3e8..4539afc 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, Copy } from 'lucide-react'; +import { X, Clock, CalendarOff, Plus, Trash2, Save, Loader2, User, Copy, AlertTriangle } from 'lucide-react'; const API = (import.meta as any).env?.VITE_API_URL || '/api'; const BASE = `${API}/nw/agenda-config`; @@ -88,8 +88,8 @@ const GradeSemanal: React.FC<{ faixas: Faixa[]; onChange: (f: Faixa[]) => void; ); }; -export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; clinicaId?: string; initialTab?: 'clinica' | 'dentistas'; soDentista?: boolean }> = ({ isOpen, onClose, clinicaId, initialTab, soDentista }) => { - const [aba, setAba] = useState<'clinica' | 'dentistas'>(initialTab || 'clinica'); +export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; clinicaId?: string; initialTab?: 'clinica' | 'dentistas' | 'situacoes'; soDentista?: boolean }> = ({ isOpen, onClose, clinicaId, initialTab, soDentista }) => { + const [aba, setAba] = useState<'clinica' | 'dentistas' | 'situacoes'>(initialTab || 'clinica'); useEffect(() => { if (isOpen && initialTab) setAba(initialTab); }, [isOpen, initialTab]); const [erro, setErro] = useState(null); const [ok, setOk] = useState(null); @@ -107,6 +107,9 @@ export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; cl const [folgas, setFolgas] = useState([]); const [novaFolga, setNovaFolga] = useState({ data_inicio: '', data_fim: '', motivo: '' }); + // Situações (regras que a Secretária IA segue) + const [situacoes, setSituacoes] = useState([]); + const flash = (setter: (v: string | null) => void, msg: string) => { setter(msg); setTimeout(() => setter(null), 3000); }; const carregarClinica = useCallback(async () => { @@ -131,7 +134,13 @@ export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; cl } catch (e: any) { flash(setErro, e.message); } }, [clinicaId, soDentista]); - useEffect(() => { if (isOpen) { carregarClinica(); carregarDentistas(); } }, [isOpen, carregarClinica, carregarDentistas]); + const carregarSituacoes = useCallback(async () => { + if (!clinicaId) return; + try { const s = await req(`/clinica/${clinicaId}/situacoes`); setSituacoes((s.situacoes || []).map((r: any) => r.texto)); } + catch (e: any) { flash(setErro, e.message); } + }, [clinicaId]); + + useEffect(() => { if (isOpen) { carregarClinica(); carregarDentistas(); if (!soDentista) carregarSituacoes(); } }, [isOpen, carregarClinica, carregarDentistas, carregarSituacoes, soDentista]); const carregarFolgas = useCallback((id: string) => { req(`/dentista/${id}/folgas`).then((f) => setFolgas(f.folgas || [])).catch(() => setFolgas([])); }, []); @@ -171,6 +180,11 @@ export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; cl try { await req(`/clinica/${clinicaId}/feriados/${id}`, { method: 'DELETE' }); carregarClinica(); } catch (e: any) { flash(setErro, e.message); } }; + const salvarSituacoes = async () => { + setSaving(true); setErro(null); + try { await req(`/clinica/${clinicaId}/situacoes`, { method: 'PUT', body: JSON.stringify({ situacoes: situacoes.map((s) => s.trim()).filter(Boolean) }) }); flash(setOk, 'Situações salvas!'); } + catch (e: any) { flash(setErro, e.message); } finally { setSaving(false); } + }; if (!isOpen) return null; @@ -191,10 +205,10 @@ export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; cl {/* Tabs */}
- {(soDentista ? (['dentistas'] as const) : (['clinica', 'dentistas'] as const)).map((t) => ( + {(soDentista ? (['dentistas'] as const) : (['clinica', 'dentistas', 'situacoes'] as const)).map((t) => ( ))}
@@ -244,7 +258,7 @@ export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; cl - ) : ( + ) : aba === 'dentistas' ? (
@@ -287,6 +301,30 @@ export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; cl )}
+ ) : ( +
+
+

Situações e regras da Secretária IA

+ +
+

+ Escreva regras que a Secretária IA deve seguir ao atender — uma por caixa. Ex.: "Pelo plano X não temos profissional para canal em dente posterior; não agende direto — ofereça uma avaliação para o dentista examinar melhor o dente." Quando uma situação exigir decisão humana, a IA encaminha o caso e pede para o paciente aguardar (aparece em Pedidos pendentes). +

+
+ {situacoes.map((s, i) => ( +
+