03711e6280
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>
1770 lines
82 KiB
TypeScript
1770 lines
82 KiB
TypeScript
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 nó:</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()}.
|
||
<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, 08h–18h. Sáb 08h–12h.
|
||
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 nó 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>
|
||
)
|
||
}
|