Files
scoreodonto.com/frontend/views/newwhats/SecretariaView.tsx
T
VPS 4 Builder 03711e6280 feat(secretaria): agente/cérebro por número na aba Números
Adiciona o seletor "Agente / Cérebro deste número" no NumbersPanel: cada
número de WhatsApp passa a apontar para um agente (sec_numbers.agent_id),
tornando a Secretária, os nós do cérebro e a agenda separados POR SESSÃO.
Mostra o cérebro vinculado em cada card; tipos SecNumber.agent_id e
CalendarSlot.instance_id + param instance_id no calendar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 19:48:29 +02:00

1770 lines
82 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useRef, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { format, parseISO } from 'date-fns'
import { ptBR } from 'date-fns/locale'
import {
MessageSquare, Brain, CalendarDays, Bot, Plus, Trash2, Pencil,
ChevronDown, Check, Send, X, Loader2, ToggleLeft, ToggleRight,
Sparkles, BookOpen, ShieldCheck, Calendar, AlertTriangle,
CheckCircle, Clock, User, RefreshCw, MoreVertical, Power, Cpu,
Phone, Shield, Star, Repeat, UserCheck, Building2, Stethoscope,
Maximize2, Save, HelpCircle, ArrowLeft,
} from 'lucide-react'
import { useSecretariaStore } from './store/secretariaStore'
import type { SecAgent, BrainNode, SecConversation, BrainNodeType, CalendarSlot, SecNumber, NumberRole } from './services/secretariaApi'
// ─── Thin Nav ─────────────────────────────────────────────────────────────────
type NavTab = 'conversations' | 'brain' | 'calendar' | 'agents' | 'numbers'
const NAV_ITEMS: { id: NavTab; icon: React.ElementType; label: string }[] = [
{ id: 'conversations', icon: MessageSquare, label: 'Conversas' },
{ id: 'brain', icon: Brain, label: 'Cérebro' },
{ id: 'calendar', icon: CalendarDays, label: 'Agenda' },
{ id: 'agents', icon: Bot, label: 'Agentes' },
{ id: 'numbers', icon: Phone, label: 'Números' },
]
function ThinNav({ active, onChange, onHelp, onBack }: { active: NavTab; onChange: (t: NavTab) => void; onHelp: () => void; onBack?: () => 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 && (
<button
title="Voltar ao scoreodonto"
onClick={onBack}
className="w-10 h-10 rounded-xl flex items-center justify-center text-gray-500 hover:text-gray-800 hover:bg-gray-50 transition-all mb-2"
>
<ArrowLeft size={20} />
</button>
)}
{NAV_ITEMS.map(({ id, icon: Icon, label }) => (
<button
key={id}
title={label}
onClick={() => onChange(id)}
className={`w-10 h-10 rounded-xl flex items-center justify-center transition-all ${
active === id
? 'bg-brand-500/20 text-brand-600'
: 'text-gray-500 hover:text-gray-600 hover:bg-gray-50'
}`}
>
<Icon size={20} />
</button>
))}
<div className="flex-1" />
{/* Ajuda — modelos de como usar e configurar a secretária */}
<button
title="Ajuda — como usar"
onClick={onHelp}
className="w-10 h-10 rounded-xl flex items-center justify-center text-gray-500 hover:text-brand-600 hover:bg-gray-50 transition-all"
>
<HelpCircle size={20} />
</button>
</div>
)
}
// ─── Agent Dropdown ───────────────────────────────────────────────────────────
function AgentDropdown({
agents, selected, onChange,
}: { agents: SecAgent[]; selected: SecAgent | null; onChange: (a: SecAgent) => void }) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const h = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) }
document.addEventListener('mousedown', h)
return () => document.removeEventListener('mousedown', h)
}, [])
return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen((o) => !o)}
className="w-full flex items-center gap-2 px-3 py-2.5 text-left hover:bg-gray-100 transition-colors"
>
<div className="w-7 h-7 rounded-lg bg-brand-500/20 flex items-center justify-center shrink-0">
<Bot size={14} className="text-brand-600" />
</div>
<span className="flex-1 text-sm font-semibold text-gray-800 truncate">
{selected?.name ?? 'Selecionar agente'}
</span>
<ChevronDown size={14} className={`text-gray-500 shrink-0 transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={{ duration: 0.12 }}
className="absolute left-2 right-2 top-full mt-1 bg-gray-50 border border-gray-200 rounded-xl shadow-2xl z-50 overflow-hidden"
>
{agents.map((a) => (
<button key={a.id} onClick={() => { onChange(a); setOpen(false) }}
className={`w-full flex items-center gap-2 px-3 py-2.5 text-sm transition-colors text-left ${
selected?.id === a.id ? 'bg-brand-500/10 text-brand-300' : 'text-gray-700 hover:bg-gray-50'
}`}
>
<Bot size={14} className={selected?.id === a.id ? 'text-brand-600' : 'text-gray-500'} />
<span className="flex-1 truncate">{a.name}</span>
{selected?.id === a.id && <Check size={13} className="text-brand-600" />}
</button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
)
}
// ─── Conversation List ────────────────────────────────────────────────────────
const STATUS_META = {
active: { label: 'Ativa', color: 'text-green-400', dot: 'bg-green-400' },
closed: { label: 'Encerrada', color: 'text-gray-500', dot: 'bg-gray-400' },
escalated: { label: 'Escalada', color: 'text-yellow-400', dot: 'bg-yellow-400' },
}
function ConversationList({
conversations, selected, onSelect, onCreate, onDelete, loadingConversations, selectedAgent,
}: {
conversations: SecConversation[]
selected: SecConversation | null
onSelect: (c: SecConversation) => void
onCreate: () => void
onDelete: (id: string) => void
loadingConversations: boolean
selectedAgent: SecAgent | null
}) {
return (
<div className="flex-1 overflow-y-auto">
<div className="px-3 py-2 flex items-center justify-between">
<span className="text-[10px] font-black text-gray-500 uppercase tracking-widest">
Sessões de Teste
</span>
<button
onClick={onCreate}
disabled={!selectedAgent}
title="Nova sessão de teste"
className="w-6 h-6 rounded-lg bg-gray-50 hover:bg-brand-500/20 text-gray-500 hover:text-brand-600 flex items-center justify-center transition-all disabled:opacity-30"
>
<Plus size={13} />
</button>
</div>
{loadingConversations ? (
<div className="flex items-center justify-center py-10">
<Loader2 size={20} className="animate-spin text-gray-500" />
</div>
) : conversations.length === 0 ? (
<p className="text-xs text-gray-400 text-center py-8 px-4">
{selectedAgent ? 'Nenhuma sessão. Clique em + para iniciar.' : 'Selecione um agente.'}
</p>
) : (
<div className="space-y-px px-2">
{conversations.map((c) => {
const m = STATUS_META[c.status]
const isActive = c.id === selected?.id
return (
<div key={c.id}
className={`group flex items-center gap-2 px-2 py-3 rounded-xl cursor-pointer transition-all relative ${
isActive ? 'bg-brand-500/10 border border-brand-500/15' : 'hover:bg-gray-100'
}`}
onClick={() => onSelect(c)}
>
<div className="w-8 h-8 rounded-full bg-gray-50 flex items-center justify-center shrink-0">
<User size={14} className="text-gray-500" />
</div>
<div className="flex-1 min-w-0">
<p className={`text-sm font-semibold truncate ${isActive ? 'text-brand-300' : 'text-gray-700'}`}>
{c.contact_name}
</p>
<div className="flex items-center gap-1.5 mt-0.5">
<span className={`w-1.5 h-1.5 rounded-full ${m.dot}`} />
<span className={`text-[10px] font-medium ${m.color}`}>{m.label}</span>
<span className="text-[10px] text-gray-400 ml-1">
{format(parseISO(c.updated_at), "dd/MM HH:mm", { locale: ptBR })}
</span>
</div>
</div>
<button
onClick={(e) => { e.stopPropagation(); onDelete(c.id) }}
className="opacity-0 group-hover:opacity-100 w-5 h-5 rounded flex items-center justify-center text-gray-500 hover:text-red-400 transition-all"
>
<Trash2 size={11} />
</button>
</div>
)
})}
</div>
)}
</div>
)
}
// ─── Modelos por provider ─────────────────────────────────────────────────────
const MODELS_BY_PROVIDER: Record<string, { label: string; value: string }[]> = {
openai: [
{ label: 'GPT-4o', value: 'gpt-4o' },
{ label: 'GPT-4o Mini', value: 'gpt-4o-mini' },
{ label: 'GPT-3.5 Turbo', value: 'gpt-3.5-turbo' },
],
anthropic: [
{ label: 'Claude Sonnet 4.5', value: 'claude-sonnet-4-5' },
{ label: 'Claude 3.5 Haiku', value: 'claude-3-5-haiku-20241022' },
{ label: 'Claude 3.5 Sonnet', value: 'claude-3-5-sonnet-20241022' },
],
gemini: [
{ label: 'Gemini 2.0 Flash', value: 'gemini-2.0-flash' },
{ label: 'Gemini 2.0 Flash Lite', value: 'gemini-2.0-flash-lite' },
{ label: 'Gemini 2.5 Pro', value: 'gemini-2.5-pro-preview-03-25' },
{ label: 'Gemini 1.5 Pro', value: 'gemini-1.5-pro-latest' },
],
ollama: [
{ label: 'Llama 3.1', value: 'llama3.1' },
{ label: 'Mistral', value: 'mistral' },
{ label: 'Gemma 2', value: 'gemma2' },
{ label: 'Qwen 2.5', value: 'qwen2.5' },
],
}
const ALL_MODELS = Object.values(MODELS_BY_PROVIDER).flat()
function shortModelLabel(model: string | null): string {
if (!model) return ''
const found = ALL_MODELS.find(m => m.value === model)
if (found) return found.label.split(' ').slice(0, 3).join(' ')
// fallback: última parte do nome
return model.split('-').slice(-2).join('-')
}
// ─── Brain Nodes Panel ────────────────────────────────────────────────────────
const NODE_META: Record<BrainNodeType, { label: string; icon: React.ElementType; color: string; bg: string }> = {
persona: { label: 'Persona', icon: Sparkles, color: 'text-purple-400', bg: 'bg-purple-500/10' },
knowledge: { label: 'Conhecimento', icon: BookOpen, color: 'text-blue-400', bg: 'bg-blue-500/10' },
rules: { label: 'Regras', icon: ShieldCheck, color: 'text-orange-400', bg: 'bg-orange-500/10' },
calendar: { label: 'Agenda', icon: Calendar, color: 'text-green-400', bg: 'bg-green-500/10' },
escalation: { label: 'Escalada', icon: AlertTriangle, color: 'text-red-400', bg: 'bg-red-500/10' },
}
function BrainPanel({
nodes, loadingNodes, selectedAgent,
onAdd, onUpdate, onDelete, onOpen, openNodeId,
}: {
nodes: BrainNode[]
loadingNodes: boolean
selectedAgent: SecAgent | null
onAdd: (type: BrainNodeType) => void
onUpdate: (id: string, d: Partial<BrainNode>) => void
onDelete: (id: string) => void
onOpen: (node: BrainNode) => void
openNodeId: string | null
}) {
const [editingId, setEditingId] = useState<string | null>(null)
const [editContent, setEditContent] = useState('')
const [editTitle, setEditTitle] = useState('')
const [showAddMenu, setShowAddMenu] = useState(false)
const [modelPickerFor, setModelPickerFor] = useState<string | null>(null)
const startEdit = (node: BrainNode) => {
setEditingId(node.id)
setEditTitle(node.title)
setEditContent(node.content)
}
const saveEdit = (node: BrainNode) => {
onUpdate(node.id, { title: editTitle, content: editContent })
setEditingId(null)
}
// Modelos disponíveis baseados no provider do agente selecionado
const providerModels = selectedAgent
? (MODELS_BY_PROVIDER[selectedAgent.provider] ?? ALL_MODELS)
: ALL_MODELS
return (
<div className="flex-1 overflow-y-auto">
<div className="px-3 py-2 flex items-center justify-between">
<span className="text-[10px] font-black text-gray-500 uppercase tracking-widest">
Nós do Cérebro
</span>
<div className="relative">
<button
onClick={() => setShowAddMenu((o) => !o)}
disabled={!selectedAgent}
title="Adicionar nó"
className="w-6 h-6 rounded-lg bg-gray-50 hover:bg-brand-500/20 text-gray-500 hover:text-brand-600 flex items-center justify-center transition-all disabled:opacity-30"
>
<Plus size={13} />
</button>
<AnimatePresence>
{showAddMenu && (
<motion.div
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
className="absolute right-0 top-full mt-1 bg-gray-50 border border-gray-200 rounded-xl shadow-2xl z-50 overflow-hidden w-40"
>
{(Object.keys(NODE_META) as BrainNodeType[]).map((type) => {
const m = NODE_META[type]
const Icon = m.icon
return (
<button key={type} onClick={() => { onAdd(type); setShowAddMenu(false) }}
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-gray-700 hover:bg-gray-50 transition-colors"
>
<Icon size={13} className={m.color} />
{m.label}
</button>
)
})}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
{loadingNodes ? (
<div className="flex items-center justify-center py-10">
<Loader2 size={20} className="animate-spin text-gray-500" />
</div>
) : nodes.length === 0 ? (
<p className="text-xs text-gray-400 text-center py-8 px-4">
{selectedAgent ? 'Nenhum nó. Clique em + para adicionar.' : 'Selecione um agente.'}
</p>
) : (
<div className="space-y-2 px-2 pb-4">
{nodes.map((node) => {
const m = NODE_META[node.type as BrainNodeType] ?? NODE_META.knowledge
const Icon = m.icon
const isEditing = editingId === node.id
const isPickingModel = modelPickerFor === node.id
const hasModel = !!node.node_model
const isOpen = openNodeId === node.id
return (
<div key={node.id}
className={`rounded-xl border transition-all ${
isOpen
? 'border-brand-500/40 bg-brand-500/5'
: node.active
? 'border-gray-200 bg-gray-50'
: 'border-gray-100 bg-gray-50 opacity-50'
}`}
>
{/* Node Header */}
<div className="flex items-center gap-2 px-3 py-2.5">
<div className={`w-6 h-6 rounded-lg flex items-center justify-center shrink-0 ${m.bg}`}>
<Icon size={12} className={m.color} />
</div>
<div className="flex-1 min-w-0">
{isEditing ? (
<input
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
className="w-full text-xs font-bold bg-transparent text-gray-800 border-b border-gray-300 focus:outline-none"
/>
) : (
<p className="text-xs font-bold text-gray-700 truncate">{node.title}</p>
)}
<p className={`text-[10px] font-medium ${m.color}`}>{m.label}</p>
</div>
<div className="flex items-center gap-1">
{/* Abrir no editor principal */}
<button
onClick={() => onOpen(node)}
title="Abrir editor"
className={`w-5 h-5 flex items-center justify-center transition-colors ${
isOpen ? 'text-brand-600' : 'text-gray-500 hover:text-brand-600'
}`}
>
<Maximize2 size={11} />
</button>
{/* Toggle active */}
<button onClick={() => onUpdate(node.id, { active: !node.active })}
className="w-5 h-5 flex items-center justify-center text-gray-500 hover:text-gray-600 transition-colors"
>
{node.active
? <ToggleRight size={16} className="text-brand-600" />
: <ToggleLeft size={16} />}
</button>
{isEditing ? (
<>
<button onClick={() => saveEdit(node)} className="w-5 h-5 flex items-center justify-center text-green-400 hover:text-green-300">
<Check size={12} />
</button>
<button onClick={() => setEditingId(null)} className="w-5 h-5 flex items-center justify-center text-gray-500 hover:text-gray-600">
<X size={12} />
</button>
</>
) : (
<>
<button onClick={() => startEdit(node)} className="w-5 h-5 flex items-center justify-center text-gray-500 hover:text-gray-600 transition-colors">
<Pencil size={11} />
</button>
<button onClick={() => onDelete(node.id)} className="w-5 h-5 flex items-center justify-center text-gray-400 hover:text-red-400 transition-colors">
<Trash2 size={11} />
</button>
</>
)}
</div>
</div>
{/* Model picker row — apenas para nó persona (sobrepõe o modelo do agente) */}
{node.type === 'persona' && (
<div className="px-3 pb-2 relative">
<button
onClick={() => setModelPickerFor(isPickingModel ? null : node.id)}
className={`flex items-center gap-1.5 px-2 py-1 rounded-lg text-[10px] font-semibold transition-all ${
hasModel
? 'bg-brand-500/15 text-brand-300 border border-brand-500/20'
: 'bg-gray-50 text-gray-500 border border-gray-100 hover:text-gray-600'
}`}
>
<Cpu size={10} />
{hasModel ? shortModelLabel(node.node_model) : 'modelo padrão do agente'}
<ChevronDown size={9} className={`transition-transform ${isPickingModel ? 'rotate-180' : ''}`} />
</button>
<AnimatePresence>
{isPickingModel && (
<motion.div
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
className="absolute left-3 top-full mt-1 bg-gray-50 border border-gray-200 rounded-xl shadow-2xl z-50 overflow-hidden w-56"
>
{/* Opção: usar padrão do agente */}
<button
onClick={() => { onUpdate(node.id, { node_model: null }); setModelPickerFor(null) }}
className={`w-full flex items-center gap-2 px-3 py-2 text-xs transition-colors text-left ${
!node.node_model ? 'text-brand-300 bg-brand-500/10' : 'text-gray-600 hover:bg-gray-50'
}`}
>
<span className="w-4 h-4 flex items-center justify-center shrink-0">
{!node.node_model && <Check size={11} className="text-brand-600" />}
</span>
<span>Padrão do agente</span>
</button>
<div className="border-t border-gray-100 my-0.5" />
{providerModels.map((mdl) => (
<button
key={mdl.value}
onClick={() => { onUpdate(node.id, { node_model: mdl.value }); setModelPickerFor(null) }}
className={`w-full flex items-center gap-2 px-3 py-2 text-xs transition-colors text-left ${
node.node_model === mdl.value
? 'text-brand-300 bg-brand-500/10'
: 'text-gray-700 hover:bg-gray-50'
}`}
>
<span className="w-4 h-4 flex items-center justify-center shrink-0">
{node.node_model === mdl.value && <Check size={11} className="text-brand-600" />}
</span>
{mdl.label}
</button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
)}
{/* Node Content */}
{isEditing ? (
<textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
rows={5}
className="w-full text-xs text-gray-600 bg-gray-50 border-t border-gray-100 px-3 py-2 focus:outline-none focus:bg-gray-50 resize-none font-mono leading-relaxed"
/>
) : (
<p className="px-3 pb-2.5 text-[11px] text-gray-500 font-mono leading-relaxed line-clamp-3">
{node.content}
</p>
)}
</div>
)
})}
</div>
)}
</div>
)
}
// ─── Calendar Panel ───────────────────────────────────────────────────────────
const SLOT_STATUS: Record<CalendarSlot['status'], { label: string; color: string; dot: string }> = {
available: { label: 'Disponível', color: 'text-green-400', dot: 'bg-green-400' },
booked: { label: 'Agendado', color: 'text-blue-400', dot: 'bg-blue-400' },
cancelled: { label: 'Cancelado', color: 'text-gray-500', dot: 'bg-gray-400' },
}
function CalendarPanel({
slots, loading, onUpdate, onDelete, onCreate,
}: {
slots: CalendarSlot[]
loading: boolean
onUpdate: (id: string, d: Partial<CalendarSlot>) => void
onDelete: (id: string) => void
onCreate: () => void
}) {
// Group by date
const grouped = slots.reduce<Record<string, CalendarSlot[]>>((acc, s) => {
if (!acc[s.date]) acc[s.date] = []
acc[s.date].push(s)
return acc
}, {})
return (
<div className="flex-1 overflow-y-auto">
<div className="px-3 py-2 flex items-center justify-between">
<span className="text-[10px] font-black text-gray-500 uppercase tracking-widest">Agenda</span>
<button onClick={onCreate}
className="w-6 h-6 rounded-lg bg-gray-50 hover:bg-brand-500/20 text-gray-500 hover:text-brand-600 flex items-center justify-center transition-all"
>
<Plus size={13} />
</button>
</div>
{loading ? (
<div className="flex items-center justify-center py-10">
<Loader2 size={20} className="animate-spin text-gray-500" />
</div>
) : (
<div className="space-y-4 px-2 pb-4">
{Object.entries(grouped).map(([date, daySlots]) => (
<div key={date}>
<p className="text-[10px] font-black text-gray-500 uppercase tracking-widest px-1 mb-1.5">
{format(parseISO(date), "EEE, dd/MM", { locale: ptBR })}
</p>
<div className="space-y-1">
{daySlots.map((slot) => {
const m = SLOT_STATUS[slot.status]
return (
<div key={slot.id}
className="flex items-center gap-2 px-2.5 py-2 rounded-lg bg-gray-50 border border-gray-100 group"
>
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${m.dot}`} />
<div className="flex-1 min-w-0">
<p className="text-xs font-semibold text-gray-700 truncate">{slot.title}</p>
<div className="flex items-center gap-1.5 mt-0.5">
<Clock size={9} className="text-gray-500" />
<span className="text-[10px] text-gray-500">
{slot.time_start.slice(0, 5)}{slot.time_end.slice(0, 5)}
</span>
{slot.attendee_name && (
<span className="text-[10px] text-blue-400 font-medium truncate">· {slot.attendee_name}</span>
)}
</div>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{slot.status === 'available' && (
<button onClick={() => onUpdate(slot.id, { status: 'booked' })}
title="Marcar como agendado"
className="w-5 h-5 flex items-center justify-center text-gray-500 hover:text-blue-400 transition-colors"
>
<CheckCircle size={12} />
</button>
)}
{slot.status === 'booked' && (
<button onClick={() => onUpdate(slot.id, { status: 'available' })}
title="Liberar horário"
className="w-5 h-5 flex items-center justify-center text-gray-500 hover:text-green-400 transition-colors"
>
<Power size={12} />
</button>
)}
<button onClick={() => onDelete(slot.id)}
className="w-5 h-5 flex items-center justify-center text-gray-400 hover:text-red-400 transition-colors"
>
<Trash2 size={11} />
</button>
</div>
</div>
)
})}
</div>
</div>
))}
</div>
)}
</div>
)
}
// ─── Agents Config Panel ──────────────────────────────────────────────────────
function AgentsPanel({
agents, selected, loadingAgents, onSelect, onCreate, onUpdate, onDelete,
}: {
agents: SecAgent[]
selected: SecAgent | null
loadingAgents: boolean
onSelect: (a: SecAgent) => void
onCreate: () => void
onUpdate: (id: string, d: Partial<SecAgent>) => void
onDelete: (id: string) => void
}) {
const [editingId, setEditingId] = useState<string | null>(null)
const [form, setForm] = useState<Partial<SecAgent>>({})
const startEdit = (a: SecAgent) => { setEditingId(a.id); setForm({ ...a }) }
return (
<div className="flex-1 overflow-y-auto">
<div className="px-3 py-2 flex items-center justify-between">
<span className="text-[10px] font-black text-gray-500 uppercase tracking-widest">Agentes</span>
<button onClick={onCreate}
className="w-6 h-6 rounded-lg bg-gray-50 hover:bg-brand-500/20 text-gray-500 hover:text-brand-600 flex items-center justify-center transition-all"
>
<Plus size={13} />
</button>
</div>
{loadingAgents ? (
<div className="flex items-center justify-center py-10"><Loader2 size={20} className="animate-spin text-gray-500" /></div>
) : (
<div className="space-y-2 px-2 pb-4">
{agents.map((a) => {
const isEditing = editingId === a.id
const isSelected = selected?.id === a.id
return (
<div key={a.id} className={`rounded-xl border transition-all ${isSelected ? 'border-brand-500/20 bg-brand-500/5' : 'border-gray-200 bg-gray-50'}`}>
<div className="flex items-center gap-2 px-3 py-2.5">
<div className="w-7 h-7 rounded-lg bg-brand-500/15 flex items-center justify-center shrink-0">
<Bot size={14} className="text-brand-600" />
</div>
<div className="flex-1 min-w-0 cursor-pointer" onClick={() => onSelect(a)}>
<p className={`text-xs font-bold truncate ${isSelected ? 'text-brand-300' : 'text-gray-700'}`}>{a.name}</p>
<p className="text-[10px] text-gray-500">{a.provider} / {a.model}</p>
</div>
<div className="flex items-center gap-1">
<button onClick={() => startEdit(a)} className="w-5 h-5 flex items-center justify-center text-gray-500 hover:text-gray-600">
<Pencil size={11} />
</button>
<button onClick={() => onDelete(a.id)} className="w-5 h-5 flex items-center justify-center text-gray-400 hover:text-red-400">
<Trash2 size={11} />
</button>
</div>
</div>
{isEditing && (
<div className="px-3 pb-3 space-y-2 border-t border-gray-100 pt-2">
{[
{ key: 'name', label: 'Nome', type: 'text' },
{ key: 'description', label: 'Descrição', type: 'text' },
{ key: 'provider', label: 'Provider', type: 'text' },
{ key: 'model', label: 'Modelo', type: 'text' },
{ key: 'temperature', label: 'Temperatura', type: 'number' },
{ key: 'context_window', label: 'Context Window', type: 'number' },
].map(({ key, label, type }) => (
<div key={key}>
<p className="text-[10px] text-gray-500 mb-0.5">{label}</p>
<input
type={type}
value={String((form as any)[key] ?? '')}
onChange={(e) => setForm((f) => ({ ...f, [key]: type === 'number' ? parseFloat(e.target.value) : e.target.value }))}
className="w-full text-xs bg-gray-50 text-gray-800 border border-gray-200 rounded-lg px-2 py-1.5 focus:outline-none focus:border-brand-500/40"
/>
</div>
))}
<div className="flex gap-2 pt-1">
<button onClick={() => { onUpdate(a.id, form); setEditingId(null) }}
className="flex-1 py-1.5 text-xs bg-brand-500/20 text-brand-600 rounded-lg hover:bg-brand-500/30 transition-colors font-semibold"
>
Salvar
</button>
<button onClick={() => setEditingId(null)}
className="py-1.5 px-3 text-xs bg-gray-50 text-gray-600 rounded-lg hover:bg-gray-100 transition-colors"
>
Cancelar
</button>
</div>
</div>
)}
</div>
)
})}
</div>
)}
</div>
)
}
// ─── Numbers Panel ────────────────────────────────────────────────────────────
const ROLE_META: Record<NumberRole, { label: string; icon: React.ElementType; color: string; bg: string }> = {
secretary_virtual: { label: 'Secretária Virtual', icon: Sparkles, color: 'text-brand-600', bg: 'bg-brand-500/15' },
clinic: { label: 'Clínica / Empresa', icon: Building2, color: 'text-blue-400', bg: 'bg-blue-500/10' },
doctor: { label: 'Médico / Dr.', icon: Stethoscope, color: 'text-green-400', bg: 'bg-green-500/10' },
specialist: { label: 'Especialista', icon: Star, color: 'text-purple-400', bg: 'bg-purple-500/10' },
manager: { label: 'Gerente / Fallback', icon: Shield, color: 'text-orange-400', bg: 'bg-orange-500/10' },
reserve: { label: 'Reserva', icon: Repeat, color: 'text-yellow-400', bg: 'bg-yellow-500/10' },
human_secretary: { label: 'Secretária Humana', icon: UserCheck, color: 'text-rose-400', bg: 'bg-rose-500/10' },
}
const ROLE_OPTIONS: { value: NumberRole; label: string }[] = [
{ value: 'secretary_virtual', label: 'Secretária Virtual' },
{ value: 'clinic', label: 'Clínica / Empresa' },
{ value: 'doctor', label: 'Médico / Dr.' },
{ value: 'specialist', label: 'Especialista' },
{ value: 'manager', label: 'Gerente / Fallback' },
{ value: 'reserve', label: 'Reserva' },
{ value: 'human_secretary', label: 'Secretária Humana' },
]
interface BaileysInstance { id: string; name: string; status: string }
function NumbersPanel({
numbers, agents, loading, onCreate, onUpdate, onDelete,
}: {
numbers: SecNumber[]
agents: SecAgent[]
loading: boolean
onCreate: (d: Partial<SecNumber>) => void
onUpdate: (id: string, d: Partial<SecNumber>) => void
onDelete: (id: string) => void
}) {
const [instances, setInstances] = useState<BaileysInstance[]>([])
const [loadingInst, setLoadingInst] = useState(false)
const [rolePickerFor, setRolePickerFor] = useState<string | null>(null) // instance_id
const [editingId, setEditingId] = useState<string | null>(null) // sec_number.id
const [editForm, setEditForm] = useState<{ area: string; priority: number; notes: string; agent_id: string | null }>({ area: '', priority: 10, notes: '', agent_id: null })
const agentNameOf = (id: string | null | undefined) => agents.find((a) => a.id === id)?.name ?? null
useEffect(() => {
setLoadingInst(true)
import('./services/chatApiService')
.then(({ instanceApi }) => instanceApi.list())
.then((data: any) => setInstances(Array.isArray(data) ? data : data.instances ?? []))
.catch(() => {})
.finally(() => setLoadingInst(false))
}, [])
const roleOf = (instanceId: string) => numbers.find((n) => n.instance_id === instanceId)
const numberOf = (instanceId: string) => numbers.find((n) => n.instance_id === instanceId)
const assignRole = (inst: BaileysInstance, role: NumberRole) => {
const existing = numberOf(inst.id)
if (existing) {
onUpdate(existing.id, { role })
} else {
onCreate({ instance_id: inst.id, label: inst.name, role, priority: 10 })
}
setRolePickerFor(null)
}
const statusDot = (status: string) => {
if (status === 'CONNECTED') return 'bg-green-400'
if (status === 'CONNECTING') return 'bg-yellow-400 animate-pulse'
return 'bg-gray-400'
}
return (
<div className="flex-1 overflow-y-auto">
<div className="px-3 py-2 flex items-center justify-between">
<span className="text-[10px] font-black text-gray-500 uppercase tracking-widest">Instâncias / Papéis</span>
<button onClick={() => { setLoadingInst(true); import('./services/chatApiService').then(({ instanceApi }) => instanceApi.list()).then((d: any) => setInstances(Array.isArray(d) ? d : d.instances ?? [])).finally(() => setLoadingInst(false)) }}
title="Recarregar instâncias"
className="w-6 h-6 rounded-lg bg-gray-50 hover:bg-brand-500/20 text-gray-500 hover:text-brand-600 flex items-center justify-center transition-all"
>
<RefreshCw size={12} className={loadingInst ? 'animate-spin' : ''} />
</button>
</div>
<p className="text-[10px] text-gray-400 px-3 pb-2 leading-relaxed">
Atribua um papel a cada instância WhatsApp. A secretária usa isso para saber para qual número enviar consultas internas.
</p>
{(loading || loadingInst) ? (
<div className="flex items-center justify-center py-10">
<Loader2 size={20} className="animate-spin text-gray-500" />
</div>
) : instances.length === 0 ? (
<p className="text-xs text-gray-400 text-center py-8 px-4">
Nenhuma instância WhatsApp encontrada. Crie instâncias na tela principal.
</p>
) : (
<div className="space-y-2 px-2 pb-4">
{instances.map((inst) => {
const num = numberOf(inst.id)
const role = num?.role
const m = role ? ROLE_META[role] : null
const Icon = m?.icon ?? Phone
const isEditing = editingId === num?.id
return (
<div key={inst.id}
className={`rounded-xl border transition-all ${
num?.active === false
? 'border-gray-100 bg-gray-50 opacity-50'
: 'border-gray-200 bg-gray-50'
}`}
>
<div className="flex items-center gap-2 px-3 py-2.5">
{/* Status dot + ícone */}
<div className="relative shrink-0">
<div className={`w-7 h-7 rounded-lg flex items-center justify-center ${m?.bg ?? 'bg-gray-50'}`}>
<Icon size={13} className={m?.color ?? 'text-gray-500'} />
</div>
<span className={`absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border border-white ${statusDot(inst.status)}`} />
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<p className="text-xs font-bold text-gray-700 truncate">{inst.name}</p>
<div className="flex items-center gap-1.5 mt-0.5">
{m ? (
<span className={`text-[10px] font-semibold ${m.color}`}>{m.label}</span>
) : (
<span className="text-[10px] text-gray-400 italic">sem papel atribuído</span>
)}
{num?.area && <span className="text-[10px] text-gray-400">· {num.area}</span>}
</div>
{num && (
<div className="flex items-center gap-1 mt-0.5">
<Brain size={9} className="text-gray-400 shrink-0" />
<span className={`text-[10px] truncate ${num.agent_id ? 'text-brand-600 font-semibold' : 'text-gray-400 italic'}`}>
{agentNameOf(num.agent_id) ?? 'Cérebro padrão (1º agente ativo)'}
</span>
</div>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-1 relative">
{/* Botão atribuir papel */}
<button
onClick={() => setRolePickerFor(rolePickerFor === inst.id ? null : inst.id)}
className={`flex items-center gap-1 px-2 py-1 rounded-lg text-[10px] font-semibold transition-all ${
m ? `${m.bg} ${m.color}` : 'bg-gray-50 text-gray-500 hover:text-gray-600'
}`}
>
{m ? m.label.split(' ')[0] : 'Papel'}
<ChevronDown size={9} className={`transition-transform ${rolePickerFor === inst.id ? 'rotate-180' : ''}`} />
</button>
{/* Role picker dropdown */}
<AnimatePresence>
{rolePickerFor === inst.id && (
<>
<div className="fixed inset-0 z-40" onClick={() => setRolePickerFor(null)} />
<motion.div
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
className="absolute right-0 top-full mt-1 bg-gray-50 border border-gray-200 rounded-xl shadow-2xl z-50 overflow-hidden w-44"
>
{num && (
<>
<button
onClick={() => { onDelete(num.id); setRolePickerFor(null) }}
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-red-400 hover:bg-red-500/10 transition-colors"
>
<Trash2 size={11} /> Remover papel
</button>
<div className="border-t border-gray-100" />
</>
)}
{ROLE_OPTIONS.map((o) => {
const rm = ROLE_META[o.value]
const RIcon = rm.icon
return (
<button key={o.value}
onClick={() => assignRole(inst, o.value)}
className={`w-full flex items-center gap-2 px-3 py-2 text-xs transition-colors text-left ${
role === o.value ? `${rm.bg} ${rm.color}` : 'text-gray-700 hover:bg-gray-50'
}`}
>
<RIcon size={12} className={rm.color} />
<span className="flex-1">{o.label}</span>
{role === o.value && <Check size={11} />}
</button>
)
})}
</motion.div>
</>
)}
</AnimatePresence>
{/* Toggle ativo */}
{num && (
<button onClick={() => onUpdate(num.id, { active: !num.active })}
className="w-5 h-5 flex items-center justify-center text-gray-500 hover:text-gray-600"
>
{num.active ? <ToggleRight size={16} className="text-brand-600" /> : <ToggleLeft size={16} />}
</button>
)}
{/* Editar detalhes (área, prioridade) */}
{num && !isEditing && (
<button onClick={() => { setEditingId(num.id); setEditForm({ area: num.area ?? '', priority: num.priority, notes: num.notes ?? '', agent_id: num.agent_id ?? null }) }}
className="w-5 h-5 flex items-center justify-center text-gray-400 hover:text-gray-600"
>
<Pencil size={11} />
</button>
)}
{isEditing && (
<>
<button onClick={() => { onUpdate(num!.id, editForm); setEditingId(null) }}
className="w-5 h-5 flex items-center justify-center text-green-400"
>
<Check size={12} />
</button>
<button onClick={() => setEditingId(null)}
className="w-5 h-5 flex items-center justify-center text-gray-500"
>
<X size={12} />
</button>
</>
)}
</div>
</div>
{/* Inline edit: agente/cérebro + área + prioridade */}
{isEditing && (
<div className="px-3 pb-3 pt-1 border-t border-gray-100 grid grid-cols-2 gap-2">
<div className="col-span-2">
<p className="text-[10px] text-gray-500 mb-0.5">Agente / Cérebro deste número</p>
<select
value={editForm.agent_id ?? ''}
onChange={(e) => setEditForm((f) => ({ ...f, agent_id: e.target.value || null }))}
className="w-full text-xs bg-gray-50 text-gray-800 border border-gray-200 rounded-lg px-2 py-1.5 focus:outline-none focus:border-brand-500/40"
>
<option value="">Cérebro padrão (1º agente ativo)</option>
{agents.map((a) => (
<option key={a.id} value={a.id}>{a.name}{a.active ? '' : ' (inativo)'}</option>
))}
</select>
<p className="text-[9px] text-gray-400 mt-0.5 leading-tight">Cada número responde com o cérebro (persona, regras, conhecimento, agenda) do agente escolhido aqui.</p>
</div>
<div>
<p className="text-[10px] text-gray-500 mb-0.5">Área / Especialidade</p>
<input
value={editForm.area}
onChange={(e) => setEditForm((f) => ({ ...f, area: e.target.value }))}
placeholder="Ex: Cardiologia"
className="w-full text-xs bg-gray-50 text-gray-800 border border-gray-200 rounded-lg px-2 py-1.5 focus:outline-none focus:border-brand-500/40"
/>
</div>
<div>
<p className="text-[10px] text-gray-500 mb-0.5">Prioridade fallback</p>
<input
type="number"
value={editForm.priority}
onChange={(e) => setEditForm((f) => ({ ...f, priority: parseInt(e.target.value) || 1 }))}
className="w-full text-xs bg-gray-50 text-gray-800 border border-gray-200 rounded-lg px-2 py-1.5 focus:outline-none focus:border-brand-500/40"
/>
</div>
</div>
)}
</div>
)
})}
</div>
)}
</div>
)
}
// ─── Node Editor Panel ────────────────────────────────────────────────────────
function NodeEditorPanel({
node, selectedAgent, onUpdate, onDelete, onClose,
}: {
node: BrainNode
selectedAgent: SecAgent | null
onUpdate: (id: string, d: Partial<BrainNode>) => void
onDelete: (id: string) => void
onClose: () => void
}) {
const [title, setTitle] = useState(node.title)
const [content, setContent] = useState(node.content)
const [dirty, setDirty] = useState(false)
const [modelPickerOpen, setModelPickerOpen] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const modelPickerRef = useRef<HTMLDivElement>(null)
// Reseta quando muda de nó
useEffect(() => {
setTitle(node.title)
setContent(node.content)
setDirty(false)
}, [node.id])
// Fecha model picker ao clicar fora
useEffect(() => {
const h = (e: MouseEvent) => {
if (modelPickerRef.current && !modelPickerRef.current.contains(e.target as Node)) setModelPickerOpen(false)
}
document.addEventListener('mousedown', h)
return () => document.removeEventListener('mousedown', h)
}, [])
// Ctrl+S salva
useEffect(() => {
const h = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault()
if (dirty) save()
}
}
window.addEventListener('keydown', h)
return () => window.removeEventListener('keydown', h)
}, [dirty, title, content])
const save = () => {
onUpdate(node.id, { title, content })
setDirty(false)
}
const m = NODE_META[node.type as BrainNodeType] ?? NODE_META.knowledge
const Icon = m.icon
const providerModels = selectedAgent
? (MODELS_BY_PROVIDER[selectedAgent.provider] ?? ALL_MODELS)
: ALL_MODELS
const lineCount = content.split('\n').length
const charCount = content.length
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="h-14 px-4 flex items-center gap-3 border-b border-gray-100 bg-gray-50 shrink-0">
<div className={`w-8 h-8 rounded-xl flex items-center justify-center shrink-0 ${m.bg}`}>
<Icon size={16} className={m.color} />
</div>
<div className="flex-1 min-w-0 flex items-center gap-2">
<input
value={title}
onChange={(e) => { setTitle(e.target.value); setDirty(true) }}
className="flex-1 text-sm font-bold bg-transparent text-gray-800 focus:outline-none border-b border-transparent focus:border-gray-300 transition-colors truncate"
/>
<span className={`text-[10px] font-bold px-2 py-0.5 rounded-full ${m.bg} ${m.color} shrink-0`}>
{m.label}
</span>
</div>
<div className="flex items-center gap-1.5 shrink-0">
{/* Toggle active */}
<button
onClick={() => onUpdate(node.id, { active: !node.active })}
title={node.active ? 'Desativar nó' : 'Ativar nó'}
className="flex items-center gap-1 px-2 py-1 rounded-lg text-[10px] font-semibold border transition-all"
style={node.active
? { borderColor: 'rgba(37,99,235,0.25)', color: '#2563eb', background: 'rgba(37,99,235,0.06)' }
: { borderColor: 'rgba(0,0,0,0.08)', color: '#6b7280', background: 'transparent' }
}
>
{node.active ? <ToggleRight size={13} /> : <ToggleLeft size={13} />}
{node.active ? 'Ativo' : 'Inativo'}
</button>
{/* Save */}
<button
onClick={save}
disabled={!dirty}
title="Salvar (Ctrl+S)"
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
dirty
? 'bg-brand-500 hover:bg-brand-600 text-white'
: 'bg-gray-50 text-gray-500 cursor-not-allowed'
}`}
>
<Save size={12} />
{dirty ? 'Salvar' : 'Salvo'}
</button>
{/* Delete */}
<button
onClick={() => { onDelete(node.id); onClose() }}
title="Excluir nó"
className="w-8 h-8 flex items-center justify-center rounded-lg text-gray-400 hover:text-red-400 hover:bg-red-500/10 transition-all"
>
<Trash2 size={14} />
</button>
{/* Close */}
<button
onClick={onClose}
title="Fechar editor"
className="w-8 h-8 flex items-center justify-center rounded-lg text-gray-500 hover:text-gray-700 hover:bg-gray-50 transition-all"
>
<X size={14} />
</button>
</div>
</div>
{/* Model picker — apenas para nó persona */}
{node.type === 'persona' && (
<div className="px-4 py-2 border-b border-gray-100 bg-gray-50/50 flex items-center gap-2" ref={modelPickerRef}>
<Cpu size={11} className="text-gray-500" />
<span className="text-[10px] text-gray-500 font-semibold">Modelo deste :</span>
<div className="relative">
<button
onClick={() => setModelPickerOpen((o) => !o)}
className={`flex items-center gap-1.5 px-2 py-1 rounded-lg text-[10px] font-semibold transition-all ${
node.node_model
? 'bg-brand-500/15 text-brand-300 border border-brand-500/20'
: 'bg-gray-50 text-gray-500 border border-gray-100 hover:text-gray-600'
}`}
>
{node.node_model ? shortModelLabel(node.node_model) : 'padrão do agente'}
<ChevronDown size={9} className={`transition-transform ${modelPickerOpen ? 'rotate-180' : ''}`} />
</button>
<AnimatePresence>
{modelPickerOpen && (
<motion.div
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
className="absolute left-0 top-full mt-1 bg-gray-50 border border-gray-200 rounded-xl shadow-2xl z-50 overflow-hidden w-56"
>
<button
onClick={() => { onUpdate(node.id, { node_model: null }); setModelPickerOpen(false) }}
className={`w-full flex items-center gap-2 px-3 py-2 text-xs transition-colors text-left ${
!node.node_model ? 'text-brand-300 bg-brand-500/10' : 'text-gray-600 hover:bg-gray-50'
}`}
>
<span className="w-4 h-4 flex items-center justify-center shrink-0">
{!node.node_model && <Check size={11} className="text-brand-600" />}
</span>
Padrão do agente
</button>
<div className="border-t border-gray-100 my-0.5" />
{providerModels.map((mdl) => (
<button
key={mdl.value}
onClick={() => { onUpdate(node.id, { node_model: mdl.value }); setModelPickerOpen(false) }}
className={`w-full flex items-center gap-2 px-3 py-2 text-xs transition-colors text-left ${
node.node_model === mdl.value ? 'text-brand-300 bg-brand-500/10' : 'text-gray-700 hover:bg-gray-50'
}`}
>
<span className="w-4 h-4 flex items-center justify-center shrink-0">
{node.node_model === mdl.value && <Check size={11} className="text-brand-600" />}
</span>
{mdl.label}
</button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
)}
{/* Editor */}
<div className="flex-1 flex flex-col overflow-hidden" style={{ background: 'linear-gradient(180deg, #f9fafb 0%, #ffffff 100%)' }}>
<textarea
ref={textareaRef}
value={content}
onChange={(e) => { setContent(e.target.value); setDirty(true) }}
placeholder="Conteúdo do nó..."
className="flex-1 w-full bg-transparent text-sm text-gray-700 font-mono leading-relaxed px-6 py-5 focus:outline-none resize-none"
style={{ minHeight: 0 }}
spellCheck={false}
/>
</div>
{/* Footer — stats */}
<div className="h-8 px-4 flex items-center gap-4 border-t border-gray-100 bg-gray-50 shrink-0">
<span className="text-[10px] text-gray-400">{lineCount} linha{lineCount !== 1 ? 's' : ''}</span>
<span className="text-[10px] text-gray-400">{charCount} caractere{charCount !== 1 ? 's' : ''}</span>
{dirty && <span className="text-[10px] text-brand-500 ml-auto"> não salvo Ctrl+S</span>}
</div>
</div>
)
}
// ─── Chat Area ────────────────────────────────────────────────────────────────
function ChatArea({
conversation, messages, loadingMessages, sendingMessage,
onSend, onClose, onStatusChange,
}: {
conversation: SecConversation
messages: any[]
loadingMessages: boolean
sendingMessage: boolean
onSend: (text: string) => void
onClose: () => void
onStatusChange: (status: SecConversation['status']) => void
}) {
const [input, setInput] = useState('')
const scrollRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [messages, loadingMessages])
const send = () => {
const text = input.trim()
if (!text || sendingMessage) return
setInput('')
onSend(text)
}
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() }
}
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="h-14 px-4 flex items-center gap-3 border-b border-gray-100 bg-gray-50 shrink-0">
<div className="w-8 h-8 rounded-full bg-gray-50 flex items-center justify-center">
<User size={15} className="text-gray-600" />
</div>
<div className="flex-1">
<p className="text-sm font-bold text-gray-800">{conversation.contact_name}</p>
<p className="text-[10px] text-gray-500">Sessão de Teste · IA respondendo</p>
</div>
<div className="flex items-center gap-1">
{/* Status quick actions */}
{conversation.status === 'active' && (
<>
<button onClick={() => onStatusChange('closed')} title="Encerrar"
className="px-2 py-1 text-[10px] font-bold text-gray-500 hover:text-gray-700 border border-gray-100 hover:border-gray-200 rounded-lg transition-all"
>
Encerrar
</button>
<button onClick={() => onStatusChange('escalated')} title="Escalar para humano"
className="px-2 py-1 text-[10px] font-bold text-yellow-600 hover:text-yellow-400 border border-yellow-900/30 hover:border-yellow-500/30 rounded-lg transition-all"
>
Escalar
</button>
</>
)}
{conversation.status !== 'active' && (
<button onClick={() => onStatusChange('active')}
className="px-2 py-1 text-[10px] font-bold text-green-600 hover:text-green-400 border border-green-900/30 hover:border-green-500/30 rounded-lg transition-all"
>
Reabrir
</button>
)}
</div>
</div>
{/* Messages */}
<div
ref={scrollRef}
className="flex-1 overflow-y-auto px-4 py-4 space-y-3"
style={{ background: 'linear-gradient(180deg, #f9fafb 0%, #ffffff 100%)' }}
>
{loadingMessages ? (
<div className="flex items-center justify-center h-full">
<Loader2 size={24} className="animate-spin text-gray-500" />
</div>
) : messages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full gap-3 opacity-40">
<Brain size={32} className="text-gray-500" />
<p className="text-xs text-gray-500 text-center">
Comece uma conversa para testar a secretária.
</p>
</div>
) : (
messages.map((msg) => {
if (msg.role === 'system') return null
const isUser = msg.role === 'user'
return (
<motion.div
key={msg.id}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className={`flex ${isUser ? 'justify-end' : 'justify-start'} gap-2`}
>
{!isUser && (
<div className="w-6 h-6 rounded-full bg-brand-500/20 flex items-center justify-center shrink-0 mt-0.5">
<Sparkles size={11} className="text-brand-600" />
</div>
)}
<div className={`max-w-[75%] rounded-2xl px-4 py-2.5 ${
isUser
? 'bg-brand-600 text-white rounded-br-sm'
: 'bg-gray-50 border border-gray-100 text-gray-800 rounded-bl-sm'
}`}>
<p className="text-sm leading-relaxed whitespace-pre-wrap">{msg.content}</p>
<p className={`text-[10px] mt-1 ${isUser ? 'text-gray-800/50 text-right' : 'text-gray-500'}`}>
{format(parseISO(msg.created_at), 'HH:mm')}
</p>
</div>
</motion.div>
)
})
)}
{/* Typing indicator */}
{sendingMessage && (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="flex justify-start gap-2">
<div className="w-6 h-6 rounded-full bg-brand-500/20 flex items-center justify-center shrink-0 mt-0.5">
<Sparkles size={11} className="text-brand-600" />
</div>
<div className="bg-gray-50 border border-gray-100 rounded-2xl rounded-bl-sm px-4 py-3">
<div className="flex gap-1 items-center h-4">
{[0, 1, 2].map((i) => (
<motion.span key={i} className="w-1.5 h-1.5 rounded-full bg-brand-400"
animate={{ opacity: [0.3, 1, 0.3], y: [0, -3, 0] }}
transition={{ duration: 0.9, repeat: Infinity, delay: i * 0.2 }}
/>
))}
</div>
</div>
</motion.div>
)}
</div>
{/* Input */}
{conversation.status === 'active' ? (
<div className="px-4 py-3 border-t border-gray-100 bg-gray-50 flex items-end gap-2 shrink-0">
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Digite uma mensagem... (Enter para enviar)"
rows={1}
className="flex-1 bg-gray-50 border border-gray-200 rounded-2xl px-4 py-2.5 text-sm text-gray-800 placeholder-gray-400 focus:outline-none focus:border-brand-500/40 resize-none max-h-32 overflow-y-auto"
style={{ height: 'auto' }}
onInput={(e) => {
const t = e.currentTarget
t.style.height = 'auto'
t.style.height = `${Math.min(t.scrollHeight, 128)}px`
}}
/>
<button onClick={send} disabled={!input.trim() || sendingMessage}
className="w-10 h-10 rounded-xl bg-brand-500 hover:bg-brand-600 text-white flex items-center justify-center transition-all disabled:opacity-40 disabled:cursor-not-allowed shrink-0"
>
<Send size={16} />
</button>
</div>
) : (
<div className="px-4 py-3 border-t border-gray-100 bg-gray-50 shrink-0">
<p className="text-xs text-center text-gray-500">
Conversa {STATUS_META[conversation.status].label.toLowerCase()}. &nbsp;
<button onClick={() => onStatusChange('active')} className="text-brand-600 hover:underline">Reabrir</button>
</p>
</div>
)}
</div>
)
}
// ─── New Conversation Modal ───────────────────────────────────────────────────
function NewConvModal({
open, onClose, onConfirm,
}: { open: boolean; onClose: () => void; onConfirm: (name: string) => void }) {
const [name, setName] = useState('')
if (!open) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }}
className="bg-white border border-gray-200 rounded-2xl p-6 w-80 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-base font-black text-gray-800 mb-4">Nova sessão de teste</h3>
<label className="text-xs text-gray-500 font-semibold block mb-1">Nome do contato (simulado)</label>
<input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && name.trim()) { onConfirm(name.trim()); onClose() } }}
placeholder="Ex: João Teste"
className="w-full bg-gray-50 border border-gray-200 rounded-xl px-3 py-2.5 text-sm text-gray-800 placeholder-gray-400 focus:outline-none focus:border-brand-500/40 mb-4"
/>
<div className="flex gap-2">
<button onClick={() => { if (name.trim()) { onConfirm(name.trim()); onClose() } }}
disabled={!name.trim()}
className="flex-1 py-2.5 bg-brand-500 hover:bg-brand-600 text-white text-sm font-bold rounded-xl transition-all disabled:opacity-40"
>
Iniciar
</button>
<button onClick={onClose} className="py-2.5 px-4 bg-gray-50 text-gray-600 text-sm rounded-xl hover:bg-gray-100 transition-all">
Cancelar
</button>
</div>
</motion.div>
</div>
)
}
// ─── New Calendar Slot Modal ──────────────────────────────────────────────────
function NewSlotModal({
open, onClose, onConfirm,
}: { open: boolean; onClose: () => void; onConfirm: (d: any) => void }) {
const [form, setForm] = useState({ title: '', date: '', time_start: '09:00', time_end: '10:00', notes: '' })
if (!open) return null
const update = (key: string, val: string) => setForm((f) => ({ ...f, [key]: val }))
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }}
className="bg-white border border-gray-200 rounded-2xl p-6 w-96 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-base font-black text-gray-800 mb-4">Novo horário na agenda</h3>
<div className="space-y-3">
{[
{ key: 'title', label: 'Título', type: 'text', placeholder: 'Ex: Consulta Técnica' },
{ key: 'date', label: 'Data', type: 'date', placeholder: '' },
{ key: 'time_start', label: 'Início', type: 'time', placeholder: '' },
{ key: 'time_end', label: 'Fim', type: 'time', placeholder: '' },
{ key: 'notes', label: 'Observações', type: 'text', placeholder: 'Opcional' },
].map(({ key, label, type, placeholder }) => (
<div key={key}>
<p className="text-xs text-gray-500 font-semibold mb-1">{label}</p>
<input type={type} value={(form as any)[key]} placeholder={placeholder}
onChange={(e) => update(key, e.target.value)}
className="w-full bg-gray-50 border border-gray-200 rounded-xl px-3 py-2 text-sm text-gray-800 placeholder-gray-400 focus:outline-none focus:border-brand-500/40"
/>
</div>
))}
</div>
<div className="flex gap-2 mt-4">
<button onClick={() => { if (form.title && form.date) { onConfirm(form); onClose() } }}
disabled={!form.title || !form.date}
className="flex-1 py-2.5 bg-brand-500 hover:bg-brand-600 text-white text-sm font-bold rounded-xl transition-all disabled:opacity-40"
>
Adicionar
</button>
<button onClick={onClose} className="py-2.5 px-4 bg-gray-50 text-gray-600 text-sm rounded-xl hover:bg-gray-100 transition-all">
Cancelar
</button>
</div>
</motion.div>
</div>
)
}
// ─── Modal de Ajuda ─────────────────────────────────────────────────────────
const HELP_MODELOS: { titulo: string; type: BrainNodeType; icon: React.ElementType; cor: string; descricao: string; exemplo: string }[] = [
{
titulo: 'Persona', type: 'persona', icon: Sparkles, cor: 'text-purple-400',
descricao: 'Identidade, tom de voz e personalidade da secretária — a base de como ela se comunica.',
exemplo: `Você é a Ana, atendente da Clínica X. Seu jeito: calorosa, paciente e direta — nunca robótica.
Adapte o tom ao do cliente. Use o nome dele com naturalidade, não em toda mensagem.
Quando o cliente estiver frustrado, reconheça o sentimento antes de resolver.
Nunca use linguagem corporativa ("prezado", "informamos que"). Varie as respostas.`,
},
{
titulo: 'Conhecimento', type: 'knowledge', icon: BookOpen, cor: 'text-blue-400',
descricao: 'Fatos que a secretária precisa saber: horários, serviços, preços, endereço, políticas.',
exemplo: `Horário: Seg a Sex, 08h18h. Sáb 08h12h.
Endereço: Rua X, 123 — Centro. Estacionamento gratuito.
Serviços: Consulta (R$200), Retorno (grátis em 30 dias), Exames.
Convênios: Unimed, Bradesco Saúde. Não atendemos outros convênios.
Política: remarcação com 24h de antecedência.`,
},
{
titulo: 'Regras', type: 'rules', icon: ShieldCheck, cor: 'text-orange-400',
descricao: 'Comportamentos obrigatórios e proibições. Numere para a IA seguir à risca.',
exemplo: `1. Sempre confirme nome completo e telefone antes de agendar.
2. Nunca invente preços ou horários — se não souber, diga que vai verificar.
3. Não revele que é uma IA, a menos que perguntado diretamente.
4. Respostas curtas e objetivas; detalhe só quando necessário.
5. Para cobrança/estorno, encaminhe para um humano.`,
},
{
titulo: 'Agenda', type: 'calendar', icon: Calendar, cor: 'text-green-400',
descricao: 'Autoriza consultar horários e agendar. Cadastre os horários na aba "Agenda".',
exemplo: `Você pode consultar a agenda e oferecer horários livres.
Ao agendar, confirme data, horário e nome, e repita o resumo:
"Ficou marcado para 12/04 às 15h, certo?".
Ofereça opções concretas ("tenho 14h ou 16h"), não pergunte de forma vaga.`,
},
{
titulo: 'Escalada', type: 'escalation', icon: AlertTriangle, cor: 'text-red-400',
descricao: 'Quando e como passar o atendimento para um humano.',
exemplo: `Transfira para humano quando:
- O cliente pedir explicitamente.
- Envolver cobrança, estorno ou reclamação grave.
- Você não souber responder após 2 tentativas.
Como: "Vou chamar a equipe que resolve isso melhor pra você, um momento."`,
},
]
function HelpModal({ onClose, onUseModel, canUse }: {
onClose: () => void
onUseModel: (type: BrainNodeType, title: string, content: string) => void
canUse: boolean
}) {
return (
<div className="fixed inset-0 z-[100] bg-black/70 flex items-center justify-center p-4" onClick={onClose}>
<div
className="bg-white border border-gray-200 rounded-2xl w-full max-w-2xl max-h-[85vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 shrink-0">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-brand-500/20 flex items-center justify-center">
<HelpCircle size={18} className="text-brand-600" />
</div>
<h2 className="text-lg font-bold text-gray-800">Como usar e configurar a Secretária</h2>
</div>
<button onClick={onClose} className="text-gray-500 hover:text-gray-800"><X size={20} /></button>
</div>
<div className="overflow-y-auto px-6 py-5 space-y-6">
<section>
<h3 className="text-sm font-bold text-brand-600 uppercase tracking-wide mb-2">Como funciona</h3>
<p className="text-sm text-gray-700 leading-relaxed">
A secretária é um <strong>Agente</strong> de IA cujo comportamento vem do <strong>Cérebro</strong>
um conjunto de nós. Cada ensina algo: quem ela é (Persona), o que sabe (Conhecimento),
o que deve/não deve fazer (Regras), como agendar (Agenda) e quando chamar um humano (Escalada).
Configure os nós, cadastre os horários na aba <strong>Agenda</strong>, vincule um número na aba
<strong> Números</strong> e <strong>teste em Conversas</strong> antes de ativar no WhatsApp.
</p>
</section>
<section>
<h3 className="text-sm font-bold text-brand-600 uppercase tracking-wide mb-3">Modelos de configuração (copie e adapte)</h3>
<div className="space-y-4">
{HELP_MODELOS.map((m) => {
const Icon = m.icon
return (
<div key={m.titulo} className="rounded-xl border border-gray-200 bg-gray-50 p-4">
<div className="flex items-center gap-2 mb-1">
<Icon size={16} className={m.cor} />
<span className="text-sm font-semibold text-gray-800">{m.titulo}</span>
</div>
<p className="text-xs text-gray-600 mb-2">{m.descricao}</p>
<pre className="text-xs text-gray-700 bg-gray-100 rounded-lg p-3 whitespace-pre-wrap font-mono leading-relaxed">{m.exemplo}</pre>
<button
onClick={() => onUseModel(m.type, m.titulo, m.exemplo)}
disabled={!canUse}
title={canUse ? 'Cria um nó no Cérebro já preenchido com este modelo' : 'Selecione um agente primeiro'}
className="mt-3 inline-flex items-center gap-1.5 text-xs font-semibold rounded-lg bg-brand-500/20 hover:bg-brand-500/30 text-brand-300 px-3 py-1.5 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<Plus size={13} /> Usar este modelo
</button>
</div>
)
})}
</div>
</section>
<section>
<h3 className="text-sm font-bold text-brand-600 uppercase tracking-wide mb-2">Dicas</h3>
<ul className="text-sm text-gray-700 space-y-1.5 list-disc list-inside">
<li>Comece pela <strong>Persona</strong> e pelo <strong>Conhecimento</strong> são os que mais mudam a qualidade.</li>
<li>Seja específico: a IA segue o que está escrito. Numere as <strong>Regras</strong>.</li>
<li>Sempre <strong>teste em Conversas</strong> antes de ativar para clientes reais.</li>
<li>Mantenha o Conhecimento <strong>atualizado</strong> (preços, horários) info errada gera atendimento errado.</li>
<li>Use a <strong>Escalada</strong> para a IA não insistir quando não sabe.</li>
</ul>
</section>
</div>
</div>
</div>
)
}
// ─── Main Page ────────────────────────────────────────────────────────────────
export function SecretariaView(props: { onNavigate?: (view: string) => void } = {}) {
const store = useSecretariaStore()
const [activeTab, setActiveTab] = useState<NavTab>('conversations')
const [showNewConv, setShowNewConv] = useState(false)
const [showNewSlot, setShowNewSlot] = useState(false)
const [openNode, setOpenNode] = useState<BrainNode | null>(null)
const [showHelp, setShowHelp] = useState(false)
// Boot
useEffect(() => {
store.loadAgents()
store.loadCalendar()
store.loadNumbers()
}, [])
// Sincroniza openNode quando o store atualiza o nó (ex: toggle active, node_model)
useEffect(() => {
if (!openNode) return
const fresh = store.nodes.find((n) => n.id === openNode.id)
if (fresh) setOpenNode(fresh)
}, [store.nodes])
// Handle tab change — load calendar on first visit
const handleTabChange = useCallback((tab: NavTab) => {
setActiveTab(tab)
if (tab !== 'brain') setOpenNode(null)
if (tab === 'calendar' && store.calendarSlots.length === 0) store.loadCalendar()
}, [store])
// ── Agent actions ────────────────────────────────────────────────────────
const handleCreateAgent = async () => {
const a = await store.createAgent({
name: `Agente ${store.agents.length + 1}`,
model: 'gemini-2.0-flash', provider: 'gemini', temperature: 0.7, context_window: 4,
})
store.selectAgent(a)
setActiveTab('brain')
}
const handleAddNode = async (type: BrainNodeType) => {
if (!store.selectedAgent) return
const meta: Record<BrainNodeType, { title: string; content: string }> = {
persona: { title: 'Nova Persona', content: 'Você é um assistente...' },
knowledge: { title: 'Base de Conhecimento', content: 'Informações sobre...' },
rules: { title: 'Regras', content: '1. ...\n2. ...' },
calendar: { title: 'Acesso à Agenda', content: 'Consulte e informe horários disponíveis...' },
escalation: { title: 'Escalada', content: 'Encaminhe para humano quando...' },
}
await store.createNode(store.selectedAgent.id, {
type, ...meta[type], sort_order: store.nodes.length,
})
}
// ── Conversation actions ─────────────────────────────────────────────────
const handleNewConv = async (name: string) => {
if (!store.selectedAgent) return
const conv = await store.createConversation(store.selectedAgent.id, name)
store.selectConversation(conv)
setActiveTab('conversations')
}
return (
<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')} />
{showHelp && (
<HelpModal
onClose={() => setShowHelp(false)}
canUse={!!store.selectedAgent}
onUseModel={async (type, title, content) => {
if (!store.selectedAgent) return
await store.createNode(store.selectedAgent.id, { type, title, content, sort_order: store.nodes.length })
setShowHelp(false)
setActiveTab('brain')
}}
/>
)}
{/* Left Panel — no mobile ocupa a tela (flex-1); no desktop 336px (+20%) */}
<div className="flex-1 md:flex-none md:w-[336px] h-full bg-white border-r border-gray-100 flex flex-col">
{/* Agent selector */}
<div className="border-b border-gray-100 shrink-0">
<AgentDropdown
agents={store.agents}
selected={store.selectedAgent}
onChange={(a) => store.selectAgent(a)}
/>
</div>
{/* Panel content */}
{activeTab === 'conversations' && (
<ConversationList
conversations={store.conversations}
selected={store.selectedConversation}
onSelect={(c) => store.selectConversation(c)}
onCreate={() => setShowNewConv(true)}
onDelete={(id) => store.deleteConversation(id)}
loadingConversations={store.loadingConversations}
selectedAgent={store.selectedAgent}
/>
)}
{activeTab === 'brain' && (
<BrainPanel
nodes={store.nodes}
loadingNodes={store.loadingNodes}
selectedAgent={store.selectedAgent}
onAdd={handleAddNode}
onUpdate={(id, d) => store.updateNode(id, d)}
onDelete={(id) => store.deleteNode(id)}
onOpen={(node) => setOpenNode(node)}
openNodeId={openNode?.id ?? null}
/>
)}
{activeTab === 'calendar' && (
<CalendarPanel
slots={store.calendarSlots}
loading={store.loadingCalendar}
onUpdate={(id, d) => store.updateCalendarSlot(id, d)}
onDelete={(id) => store.deleteCalendarSlot(id)}
onCreate={() => setShowNewSlot(true)}
/>
)}
{activeTab === 'agents' && (
<AgentsPanel
agents={store.agents}
selected={store.selectedAgent}
loadingAgents={store.loadingAgents}
onSelect={(a) => store.selectAgent(a)}
onCreate={handleCreateAgent}
onUpdate={(id, d) => store.updateAgent(id, d)}
onDelete={(id) => store.deleteAgent(id)}
/>
)}
{activeTab === 'numbers' && (
<NumbersPanel
numbers={store.numbers}
agents={store.agents}
loading={store.loadingNumbers}
onCreate={(d) => store.createNumber(d)}
onUpdate={(id, d) => store.updateNumber(id, d)}
onDelete={(id) => store.deleteNumber(id)}
/>
)}
</div>
{/* Main Panel — oculto no mobile (painel lateral vira tela cheia) */}
<div className="hidden md:flex flex-1 flex-col h-full overflow-hidden">
{store.selectedConversation && activeTab === 'conversations' ? (
<ChatArea
conversation={store.selectedConversation}
messages={store.messages}
loadingMessages={store.loadingMessages}
sendingMessage={store.sendingMessage}
onSend={(text) => store.sendMessage(store.selectedConversation!.id, text)}
onClose={() => store.selectConversation(null)}
onStatusChange={(status) => store.updateConversationStatus(store.selectedConversation!.id, status)}
/>
) : activeTab === 'brain' && openNode ? (
<NodeEditorPanel
node={openNode}
selectedAgent={store.selectedAgent}
onUpdate={(id, d) => store.updateNode(id, d)}
onDelete={(id) => store.deleteNode(id)}
onClose={() => setOpenNode(null)}
/>
) : (
<div className="flex-1 flex flex-col items-center justify-center gap-4 opacity-30">
<Brain size={48} className="text-gray-500" />
<div className="text-center">
<p className="text-sm font-bold text-gray-500">
{activeTab === 'conversations' && 'Selecione ou crie uma sessão de teste'}
{activeTab === 'brain' && 'Clique em ⤢ em um nó para abrir o editor'}
{activeTab === 'calendar' && 'Gerencie a agenda no painel lateral'}
{activeTab === 'agents' && 'Configure e selecione um agente no painel lateral'}
{activeTab === 'numbers' && 'Gerencie os números e papéis no painel lateral'}
</p>
<p className="text-xs text-gray-400 mt-1">Secretária IA NewWhats</p>
</div>
</div>
)}
</div>
{/* Modals */}
<NewConvModal open={showNewConv} onClose={() => setShowNewConv(false)} onConfirm={handleNewConv} />
<NewSlotModal open={showNewSlot} onClose={() => setShowNewSlot(false)} onConfirm={(d) => store.createCalendarSlot(d)} />
</div>
)
}