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
+84 -3
View File
@@ -8,10 +8,14 @@ import {
Sparkles, BookOpen, ShieldCheck, Calendar, AlertTriangle,
CheckCircle, Clock, User, RefreshCw, MoreVertical, Power, Cpu,
Phone, Shield, Star, Repeat, UserCheck, Building2, Stethoscope,
Maximize2, Save, HelpCircle, ArrowLeft,
Maximize2, Save, HelpCircle, ArrowLeft, ClipboardCheck,
} from 'lucide-react'
import { useSecretariaStore } from './store/secretariaStore'
import type { SecAgent, BrainNode, SecConversation, BrainNodeType, CalendarSlot, SecNumber, NumberRole } from './services/secretariaApi'
import { HybridBackend } from '../../services/backend.ts'
import { PendenciasDono } from '../../components/PendenciasDono.tsx'
import { PedidosPendentes } from '../../components/PedidosPendentes.tsx'
import { secPowerApi } from './services/secretariaApi'
// ─── Thin Nav ─────────────────────────────────────────────────────────────────
@@ -25,7 +29,7 @@ const NAV_ITEMS: { id: NavTab; icon: React.ElementType; label: string }[] = [
{ id: 'numbers', icon: Phone, label: 'Números' },
]
function ThinNav({ active, onChange, onHelp, onBack }: { active: NavTab; onChange: (t: NavTab) => void; onHelp: () => void; onBack?: () => void }) {
function ThinNav({ active, onChange, onHelp, onBack, onPendencias, pendenciasBadge, secOn, onTogglePower }: { active: NavTab; onChange: (t: NavTab) => void; onHelp: () => void; onBack?: () => void; onPendencias?: () => void; pendenciasBadge?: number; secOn?: boolean; onTogglePower?: () => void }) {
return (
<div className="w-[60px] h-full bg-white border-r border-gray-100 flex flex-col items-center py-4 shrink-0 z-30 gap-1">
{onBack && (
@@ -54,6 +58,34 @@ function ThinNav({ active, onChange, onHelp, onBack }: { active: NavTab; onChang
<div className="flex-1" />
{/* Desligar/ligar a Secretária — GLOBAL, todos os chats, sem timer de retorno.
Botão de segurança enquanto a IA amadurece; visível a todos. */}
{onTogglePower && (
<button
title={secOn ? 'Desligar a Secretária (todos os chats)' : 'Secretária DESLIGADA — clique para ligar'}
onClick={onTogglePower}
className={`relative w-10 h-10 rounded-xl flex items-center justify-center transition-all mb-1 ${
secOn ? 'text-emerald-600 hover:bg-emerald-50' : 'text-white bg-red-600 hover:bg-red-700 animate-pulse'
}`}
>
<Power size={20} />
</button>
)}
{/* Pendências do dono — checklist + config + fila (só o dono do workspace) */}
{onPendencias && (
<button
title="Pendências do workspace"
onClick={onPendencias}
className="relative w-10 h-10 rounded-xl flex items-center justify-center text-gray-500 hover:text-indigo-600 hover:bg-gray-50 transition-all mb-1"
>
<ClipboardCheck size={20} />
{!!pendenciasBadge && pendenciasBadge > 0 && (
<span className="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 px-1 bg-indigo-600 text-white text-[9px] font-black rounded-full flex items-center justify-center">{pendenciasBadge > 99 ? '99+' : pendenciasBadge}</span>
)}
</button>
)}
{/* Ajuda — modelos de como usar e configurar a secretária */}
<button
title="Ajuda — como usar"
@@ -1578,13 +1610,52 @@ export function SecretariaView(props: { onNavigate?: (view: string) => void } =
const [openNode, setOpenNode] = useState<BrainNode | null>(null)
const [showHelp, setShowHelp] = useState(false)
// Pendências do dono (checklist + config + fila) — só o dono do workspace
const clinicaId = (HybridBackend.getActiveWorkspace() as any)?.id as string | undefined
const ehDono = ['admin', 'donoclinica', 'donoconsultorio'].includes(HybridBackend.getCurrentRole?.() || '')
const [showPendencias, setShowPendencias] = useState(false)
const [showFila, setShowFila] = useState(false)
const [pendCount, setPendCount] = useState(0)
// Liga/desliga GLOBAL da Secretária (kill-switch auto_reply) — todos, sem timer
const [secOn, setSecOn] = useState(true)
const [powerBusy, setPowerBusy] = useState(false)
// Boot
useEffect(() => {
store.loadAgents()
store.loadCalendar()
store.loadNumbers()
secPowerApi.get().then((r) => setSecOn(r.enabled)).catch(() => {})
}, [])
const handleTogglePower = useCallback(async () => {
if (powerBusy) return
const proximo = !secOn
// Ao DESLIGAR (para todos os chats, sem retorno automático), confirma.
if (!proximo && !window.confirm('Desligar a Secretária para TODOS os chats? Ela para de responder e só volta quando você ligar de novo.')) return
setPowerBusy(true)
try { const r = await secPowerApi.set(proximo); setSecOn(r.enabled) }
catch { /* mantém estado atual */ } finally { setPowerBusy(false) }
}, [secOn, powerBusy])
// Contagem de pendências para o badge (dono) — atualiza ao abrir e a cada 5 min.
useEffect(() => {
if (!ehDono || !clinicaId) return
let vivo = true
const carregar = async () => {
try {
const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN')
const API = (import.meta as any).env?.VITE_API_URL || '/api'
const r = await fetch(`${API}/nw/agenda-config/clinica/${clinicaId}/pendencias`, { headers: token ? { Authorization: `Bearer ${token}` } : {} })
if (r.ok && vivo) { const d = await r.json(); setPendCount(d.total || 0) }
} catch { /* silencioso */ }
}
carregar()
const t = setInterval(carregar, 5 * 60 * 1000)
return () => { vivo = false; clearInterval(t) }
}, [ehDono, clinicaId, showPendencias, showFila])
// Sincroniza openNode quando o store atualiza o nó (ex: toggle active, node_model)
useEffect(() => {
if (!openNode) return
@@ -1637,7 +1708,17 @@ export function SecretariaView(props: { onNavigate?: (view: string) => void } =
<div className="flex h-full w-full bg-gray-50 overflow-hidden font-sans">
{/* Thin Nav */}
<ThinNav active={activeTab} onChange={handleTabChange} onHelp={() => setShowHelp(true)} onBack={() => props.onNavigate?.('dashboard')} />
<ThinNav active={activeTab} onChange={handleTabChange} onHelp={() => setShowHelp(true)} onBack={() => props.onNavigate?.('dashboard')}
onPendencias={ehDono ? () => setShowPendencias(true) : undefined} pendenciasBadge={pendCount}
secOn={secOn} onTogglePower={handleTogglePower} />
{ehDono && (
<>
<PendenciasDono isOpen={showPendencias} onClose={() => setShowPendencias(false)} clinicaId={clinicaId}
onOpenFila={() => { setShowPendencias(false); setShowFila(true) }} />
<PedidosPendentes isOpen={showFila} onClose={() => setShowFila(false)} clinicaId={clinicaId} />
</>
)}
{showHelp && (
<HelpModal
@@ -83,6 +83,13 @@ export interface CalendarSlot {
created_at: string
}
// ─── Power (liga/desliga global — kill-switch auto_reply) ──────────────────────
export const secPowerApi = {
get: () => api.get<{ enabled: boolean }>(`${BASE}/power`).then((r) => r.data),
set: (enabled: boolean) => api.post<{ enabled: boolean }>(`${BASE}/power`, { enabled }).then((r) => r.data),
}
// ─── Agents ───────────────────────────────────────────────────────────────────
export const secAgentApi = {