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
+45 -7
View File
@@ -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<string | null>(null);
const [ok, setOk] = useState<string | null>(null);
@@ -107,6 +107,9 @@ export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; cl
const [folgas, setFolgas] = useState<any[]>([]);
const [novaFolga, setNovaFolga] = useState({ data_inicio: '', data_fim: '', motivo: '' });
// Situações (regras que a Secretária IA segue)
const [situacoes, setSituacoes] = useState<string[]>([]);
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 */}
<div className="flex gap-1 px-8 pt-4 border-b border-gray-50">
{(soDentista ? (['dentistas'] as const) : (['clinica', 'dentistas'] as const)).map((t) => (
{(soDentista ? (['dentistas'] as const) : (['clinica', 'dentistas', 'situacoes'] as const)).map((t) => (
<button key={t} onClick={() => setAba(t)}
className={`px-4 py-2.5 text-xs font-black uppercase tracking-wide rounded-t-xl transition-colors ${aba === t ? 'bg-teal-600 text-white' : 'text-gray-400 hover:text-gray-600'}`}>
{t === 'clinica' ? 'Clínica' : 'Dentistas'}
{t === 'clinica' ? 'Clínica' : t === 'dentistas' ? 'Dentistas' : 'Situações'}
</button>
))}
</div>
@@ -244,7 +258,7 @@ export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; cl
</details>
</section>
</div>
) : (
) : aba === 'dentistas' ? (
<div className="space-y-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
@@ -287,6 +301,30 @@ export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; cl
</section>
)}
</div>
) : (
<div className="space-y-5">
<div className="flex items-center justify-between">
<h3 className="text-sm font-black text-gray-800 uppercase tracking-tight flex items-center gap-2"><AlertTriangle size={16} className="text-teal-600" /> Situações e regras da Secretária IA</h3>
<button onClick={salvarSituacoes} disabled={saving} className="bg-teal-600 hover:bg-teal-700 text-white text-xs font-black uppercase tracking-wide px-4 py-2 rounded-xl flex items-center gap-2 disabled:opacity-50">
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />} Salvar
</button>
</div>
<p className="text-[11px] text-gray-500 leading-relaxed bg-amber-50 rounded-2xl p-4">
Escreva regras que a Secretária IA deve seguir ao atender uma por caixa. Ex.: <span className="italic">"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."</span> Quando uma situação exigir decisão humana, a IA encaminha o caso e pede para o paciente aguardar (aparece em Pedidos pendentes).
</p>
<div className="space-y-2">
{situacoes.map((s, i) => (
<div key={i} className="flex items-start gap-2">
<textarea value={s} rows={2} onChange={(e) => setSituacoes(situacoes.map((x, j) => j === i ? e.target.value : x))}
placeholder="Descreva a situação e o que a IA deve fazer…"
className="flex-1 bg-gray-50 border-2 border-transparent focus:border-teal-600 rounded-xl px-3 py-2 text-sm font-medium text-gray-800 outline-none resize-y" />
<button onClick={() => setSituacoes(situacoes.filter((_, j) => j !== i))} className="mt-2 text-gray-300 hover:text-red-600"><Trash2 size={16} /></button>
</div>
))}
<button onClick={() => setSituacoes([...situacoes, ''])} className="bg-gray-900 hover:bg-black text-white text-xs font-black uppercase px-4 py-2.5 rounded-xl flex items-center gap-1"><Plus size={14} /> Nova situação</button>
</div>
{situacoes.length === 0 && <p className="text-[11px] text-gray-400 italic">Nenhuma situação cadastrada. A IA seguirá apenas o roteiro padrão.</p>}
</div>
)}
</div>
</div>