Files
clube67_newwhats.local/newwhats.clube67.com/newwhats.local/frontend/pages/secretaria/index.tsx
T
VPS 4 Deploy Agent 7d191c0cf7 feat(secretaria): ícone de ajuda com modelos de uso e configuração
Adiciona botão de ajuda (HelpCircle) na barra da /secretaria que abre um modal
detalhado: como funciona (agente + cérebro) + modelos prontos para Persona,
Conhecimento, Regras, Agenda e Escalada (copie e adapte) + dicas de uso.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 06:50:43 +02:00

1710 lines
79 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,
} 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 }: { active: NavTab; onChange: (t: NavTab) => void; onHelp: () => void }) {
return (
<div className="w-[60px] h-full bg-[#0c0e14] border-r border-white/5 flex flex-col items-center py-4 shrink-0 z-30 gap-1">
{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-400'
: 'text-slate-600 hover:text-slate-400 hover:bg-white/5'
}`}
>
<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-slate-600 hover:text-brand-400 hover:bg-white/5 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-white/[0.04] 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-400" />
</div>
<span className="flex-1 text-sm font-semibold text-white truncate">
{selected?.name ?? 'Selecionar agente'}
</span>
<ChevronDown size={14} className={`text-slate-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-[#12151e] border border-white/10 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-slate-300 hover:bg-white/5'
}`}
>
<Bot size={14} className={selected?.id === a.id ? 'text-brand-400' : 'text-slate-500'} />
<span className="flex-1 truncate">{a.name}</span>
{selected?.id === a.id && <Check size={13} className="text-brand-400" />}
</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-slate-500', dot: 'bg-slate-600' },
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-slate-600 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-white/5 hover:bg-brand-500/20 text-slate-500 hover:text-brand-400 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-slate-600" />
</div>
) : conversations.length === 0 ? (
<p className="text-xs text-slate-700 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-white/[0.03]'
}`}
onClick={() => onSelect(c)}
>
<div className="w-8 h-8 rounded-full bg-white/5 flex items-center justify-center shrink-0">
<User size={14} className="text-slate-500" />
</div>
<div className="flex-1 min-w-0">
<p className={`text-sm font-semibold truncate ${isActive ? 'text-brand-300' : 'text-slate-300'}`}>
{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-slate-700 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-slate-600 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-slate-600 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-white/5 hover:bg-brand-500/20 text-slate-500 hover:text-brand-400 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-[#12151e] border border-white/10 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-slate-300 hover:bg-white/5 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-slate-600" />
</div>
) : nodes.length === 0 ? (
<p className="text-xs text-slate-700 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-white/8 bg-white/[0.02]'
: 'border-white/4 bg-white/[0.01] 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-white border-b border-white/20 focus:outline-none"
/>
) : (
<p className="text-xs font-bold text-slate-300 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-400' : 'text-slate-600 hover:text-brand-400'
}`}
>
<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-slate-600 hover:text-slate-400 transition-colors"
>
{node.active
? <ToggleRight size={16} className="text-brand-400" />
: <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-slate-600 hover:text-slate-400">
<X size={12} />
</button>
</>
) : (
<>
<button onClick={() => startEdit(node)} className="w-5 h-5 flex items-center justify-center text-slate-600 hover:text-slate-400 transition-colors">
<Pencil size={11} />
</button>
<button onClick={() => onDelete(node.id)} className="w-5 h-5 flex items-center justify-center text-slate-700 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-white/5 text-slate-600 border border-white/5 hover:text-slate-400'
}`}
>
<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-[#12151e] border border-white/10 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-slate-400 hover:bg-white/5'
}`}
>
<span className="w-4 h-4 flex items-center justify-center shrink-0">
{!node.node_model && <Check size={11} className="text-brand-400" />}
</span>
<span>Padrão do agente</span>
</button>
<div className="border-t border-white/5 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-slate-300 hover:bg-white/5'
}`}
>
<span className="w-4 h-4 flex items-center justify-center shrink-0">
{node.node_model === mdl.value && <Check size={11} className="text-brand-400" />}
</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-slate-400 bg-black/20 border-t border-white/5 px-3 py-2 focus:outline-none focus:bg-black/30 resize-none font-mono leading-relaxed"
/>
) : (
<p className="px-3 pb-2.5 text-[11px] text-slate-600 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-slate-500', dot: 'bg-slate-600' },
}
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-slate-600 uppercase tracking-widest">Agenda</span>
<button onClick={onCreate}
className="w-6 h-6 rounded-lg bg-white/5 hover:bg-brand-500/20 text-slate-500 hover:text-brand-400 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-slate-600" />
</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-slate-600 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-white/[0.02] border border-white/5 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-slate-300 truncate">{slot.title}</p>
<div className="flex items-center gap-1.5 mt-0.5">
<Clock size={9} className="text-slate-600" />
<span className="text-[10px] text-slate-600">
{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-slate-600 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-slate-600 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-slate-700 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-slate-600 uppercase tracking-widest">Agentes</span>
<button onClick={onCreate}
className="w-6 h-6 rounded-lg bg-white/5 hover:bg-brand-500/20 text-slate-500 hover:text-brand-400 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-slate-600" /></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-white/8 bg-white/[0.02]'}`}>
<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-400" />
</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-slate-300'}`}>{a.name}</p>
<p className="text-[10px] text-slate-600">{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-slate-600 hover:text-slate-400">
<Pencil size={11} />
</button>
<button onClick={() => onDelete(a.id)} className="w-5 h-5 flex items-center justify-center text-slate-700 hover:text-red-400">
<Trash2 size={11} />
</button>
</div>
</div>
{isEditing && (
<div className="px-3 pb-3 space-y-2 border-t border-white/5 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-slate-600 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-black/30 text-white border border-white/10 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-400 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-white/5 text-slate-400 rounded-lg hover:bg-white/10 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-400', 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, loading, onCreate, onUpdate, onDelete,
}: {
numbers: SecNumber[]
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 }>({ area: '', priority: 10, notes: '' })
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-slate-600'
}
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-slate-600 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-white/5 hover:bg-brand-500/20 text-slate-500 hover:text-brand-400 flex items-center justify-center transition-all"
>
<RefreshCw size={12} className={loadingInst ? 'animate-spin' : ''} />
</button>
</div>
<p className="text-[10px] text-slate-700 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-slate-600" />
</div>
) : instances.length === 0 ? (
<p className="text-xs text-slate-700 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-white/4 bg-white/[0.01] opacity-50'
: 'border-white/8 bg-white/[0.02]'
}`}
>
<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-white/5'}`}>
<Icon size={13} className={m?.color ?? 'text-slate-600'} />
</div>
<span className={`absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border border-[#0a0d15] ${statusDot(inst.status)}`} />
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<p className="text-xs font-bold text-slate-300 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-slate-700 italic">sem papel atribuído</span>
)}
{num?.area && <span className="text-[10px] text-slate-700">· {num.area}</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-white/5 text-slate-600 hover:text-slate-400'
}`}
>
{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 && (
<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-[#12151e] border border-white/10 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-white/5" />
</>
)}
{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-slate-300 hover:bg-white/5'
}`}
>
<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-slate-600 hover:text-slate-400"
>
{num.active ? <ToggleRight size={16} className="text-brand-400" /> : <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 ?? '' }) }}
className="w-5 h-5 flex items-center justify-center text-slate-700 hover:text-slate-400"
>
<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-slate-600"
>
<X size={12} />
</button>
</>
)}
</div>
</div>
{/* Inline edit: área + prioridade */}
{isEditing && (
<div className="px-3 pb-3 pt-1 border-t border-white/5 grid grid-cols-2 gap-2">
<div>
<p className="text-[10px] text-slate-600 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-black/30 text-white border border-white/10 rounded-lg px-2 py-1.5 focus:outline-none focus:border-brand-500/40"
/>
</div>
<div>
<p className="text-[10px] text-slate-600 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-black/30 text-white border border-white/10 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-white/5 bg-[#080b10] 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-white focus:outline-none border-b border-transparent focus:border-white/20 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(99,179,237,0.2)', color: '#63b3ed', background: 'rgba(99,179,237,0.08)' }
: { borderColor: 'rgba(255,255,255,0.05)', color: '#4a5568', 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-white/5 text-slate-600 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-slate-700 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-slate-600 hover:text-slate-300 hover:bg-white/5 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-white/5 bg-[#080b10]/50 flex items-center gap-2" ref={modelPickerRef}>
<Cpu size={11} className="text-slate-600" />
<span className="text-[10px] text-slate-600 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-white/5 text-slate-500 border border-white/5 hover:text-slate-400'
}`}
>
{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-[#12151e] border border-white/10 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-slate-400 hover:bg-white/5'
}`}
>
<span className="w-4 h-4 flex items-center justify-center shrink-0">
{!node.node_model && <Check size={11} className="text-brand-400" />}
</span>
Padrão do agente
</button>
<div className="border-t border-white/5 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-slate-300 hover:bg-white/5'
}`}
>
<span className="w-4 h-4 flex items-center justify-center shrink-0">
{node.node_model === mdl.value && <Check size={11} className="text-brand-400" />}
</span>
{mdl.label}
</button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
)}
{/* Editor */}
<div className="flex-1 flex flex-col overflow-hidden" style={{ background: 'linear-gradient(180deg, #080b10 0%, #0a0d15 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-slate-300 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-white/5 bg-[#080b10] shrink-0">
<span className="text-[10px] text-slate-700">{lineCount} linha{lineCount !== 1 ? 's' : ''}</span>
<span className="text-[10px] text-slate-700">{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-white/5 bg-[#080b10] shrink-0">
<div className="w-8 h-8 rounded-full bg-white/5 flex items-center justify-center">
<User size={15} className="text-slate-400" />
</div>
<div className="flex-1">
<p className="text-sm font-bold text-white">{conversation.contact_name}</p>
<p className="text-[10px] text-slate-600">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-slate-500 hover:text-slate-300 border border-white/5 hover:border-white/15 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, #080b10 0%, #0a0d15 100%)' }}
>
{loadingMessages ? (
<div className="flex items-center justify-center h-full">
<Loader2 size={24} className="animate-spin text-slate-600" />
</div>
) : messages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full gap-3 opacity-40">
<Brain size={32} className="text-slate-600" />
<p className="text-xs text-slate-600 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-400" />
</div>
)}
<div className={`max-w-[75%] rounded-2xl px-4 py-2.5 ${
isUser
? 'bg-brand-600 text-white rounded-br-sm'
: 'bg-[#12151e] border border-white/5 text-slate-200 rounded-bl-sm'
}`}>
<p className="text-sm leading-relaxed whitespace-pre-wrap">{msg.content}</p>
<p className={`text-[10px] mt-1 ${isUser ? 'text-white/50 text-right' : 'text-slate-600'}`}>
{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-400" />
</div>
<div className="bg-[#12151e] border border-white/5 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-white/5 bg-[#080b10] 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-white/5 border border-white/10 rounded-2xl px-4 py-2.5 text-sm text-white placeholder-slate-600 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-white/5 bg-[#080b10] shrink-0">
<p className="text-xs text-center text-slate-600">
Conversa {STATUS_META[conversation.status].label.toLowerCase()}. &nbsp;
<button onClick={() => onStatusChange('active')} className="text-brand-400 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-[#0f1117] border border-white/10 rounded-2xl p-6 w-80 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-base font-black text-white mb-4">Nova sessão de teste</h3>
<label className="text-xs text-slate-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-white/5 border border-white/10 rounded-xl px-3 py-2.5 text-sm text-white placeholder-slate-600 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-white/5 text-slate-400 text-sm rounded-xl hover:bg-white/10 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-[#0f1117] border border-white/10 rounded-2xl p-6 w-96 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-base font-black text-white 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-slate-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-white/5 border border-white/10 rounded-xl px-3 py-2 text-sm text-white placeholder-slate-600 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-white/5 text-slate-400 text-sm rounded-xl hover:bg-white/10 transition-all">
Cancelar
</button>
</div>
</motion.div>
</div>
)
}
// ─── Modal de Ajuda ─────────────────────────────────────────────────────────
const HELP_MODELOS: { titulo: string; icon: React.ElementType; cor: string; descricao: string; exemplo: string }[] = [
{
titulo: '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', 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', 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', 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', 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 }: { onClose: () => void }) {
return (
<div className="fixed inset-0 z-[100] bg-black/70 flex items-center justify-center p-4" onClick={onClose}>
<div
className="bg-[#0c0e14] border border-white/10 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-white/10 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-400" />
</div>
<h2 className="text-lg font-bold text-white">Como usar e configurar a Secretária</h2>
</div>
<button onClick={onClose} className="text-slate-500 hover:text-white"><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-400 uppercase tracking-wide mb-2">Como funciona</h3>
<p className="text-sm text-slate-300 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-400 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-white/10 bg-white/[0.02] p-4">
<div className="flex items-center gap-2 mb-1">
<Icon size={16} className={m.cor} />
<span className="text-sm font-semibold text-white">{m.titulo}</span>
</div>
<p className="text-xs text-slate-400 mb-2">{m.descricao}</p>
<pre className="text-xs text-slate-300 bg-black/40 rounded-lg p-3 whitespace-pre-wrap font-mono leading-relaxed">{m.exemplo}</pre>
</div>
)
})}
</div>
</section>
<section>
<h3 className="text-sm font-bold text-brand-400 uppercase tracking-wide mb-2">Dicas</h3>
<ul className="text-sm text-slate-300 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 default function SecretariaPage() {
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-screen w-full bg-[#080b10] overflow-hidden font-sans">
{/* Thin Nav */}
<ThinNav active={activeTab} onChange={handleTabChange} onHelp={() => setShowHelp(true)} />
{showHelp && <HelpModal onClose={() => setShowHelp(false)} />}
{/* Left Panel */}
<div className="w-[280px] h-full bg-[#0a0d15] border-r border-white/5 flex flex-col shrink-0">
{/* Agent selector */}
<div className="border-b border-white/5 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}
loading={store.loadingNumbers}
onCreate={(d) => store.createNumber(d)}
onUpdate={(id, d) => store.updateNumber(id, d)}
onDelete={(id) => store.deleteNumber(id)}
/>
)}
</div>
{/* Main Panel */}
<div className="flex-1 flex 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-slate-600" />
<div className="text-center">
<p className="text-sm font-bold text-slate-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-slate-700 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>
)
}