import { useEffect, useMemo, useState } from 'react';
import { DataPanel } from './DataPanel';
import {
createBotFlowNode,
deleteBotFlowNode,
getBotFlow,
listBotFlowVersions,
publishBotFlow,
updateBotFlowNode,
} from '../services/knowledgeService';
const fieldStyle = {
width: '100%',
border: '1px solid var(--color-border)',
borderRadius: 14,
padding: '0.78rem 0.9rem',
background: '#fff',
color: 'var(--color-text)',
fontWeight: 600,
};
const primaryButton = {
border: 'none',
borderRadius: 14,
padding: '0.78rem 1rem',
background: 'var(--color-primary)',
color: '#fff',
fontWeight: 800,
};
const ghostButton = {
border: '1px solid var(--color-border)',
borderRadius: 14,
padding: '0.72rem 0.9rem',
background: '#fff',
color: 'var(--color-text)',
fontWeight: 800,
};
const emptyDraft = {
nodeType: 'question',
title: '',
messageText: '',
keywords: '',
fallbackMessage: '',
fallbackAttempts: 2,
fallbackAreaId: '',
areaId: '',
};
const closeDefaultMessage = 'Perfeito, vou encerrar por aqui. Se precisar de algo mais, é só chamar novamente.';
function nodeTypeLabel(type) {
if (type === 'greeting') return 'Saudação';
if (type === 'agent') return 'Enviar para agente';
if (type === 'close') return 'Encerrar pelo bot';
return 'Pergunta';
}
function splitKeywords(value) {
return String(value || '')
.split(',')
.map((keyword) => keyword.trim())
.filter(Boolean);
}
function collectPublishWarnings(node, warnings = []) {
if (!node) return warnings;
const children = node.children || [];
const isTerminal = node.node_type === 'agent' || node.node_type === 'close';
if (!isTerminal && children.length === 0) {
warnings.push(`"${node.title}" precisa ter pelo menos um filho.`);
}
if (node.node_type === 'agent' && !node.area_id) {
warnings.push(`"${node.title}" precisa de uma especialidade.`);
}
children.forEach((child) => collectPublishWarnings(child, warnings));
return warnings;
}
function getFlowNodeWidth(level) {
return level >= 2 ? 260 : 300;
}
function getFlowChildGap(level) {
return level >= 2 ? 56 : 40;
}
function getFlowSubtreeWidth(node, level = 0) {
if (!node) return getFlowNodeWidth(level);
const children = node.children || [];
const nodeWidth = getFlowNodeWidth(level);
const horizontalPadding = level >= 2 ? 56 : 72;
if (!children.length) {
return nodeWidth + horizontalPadding;
}
const gap = getFlowChildGap(level);
const childrenWidth =
children.reduce((total, child) => total + getFlowSubtreeWidth(child, level + 1), 0) +
Math.max(0, children.length - 1) * gap;
return Math.max(nodeWidth + horizontalPadding, childrenWidth);
}
function WhatsAppPreview({ message }) {
return (
Preview WhatsApp
{message || 'Digite a mensagem para visualizar aqui.'}
);
}
function FlowNode({ node, areasById, onAdd, onEdit, onDelete, level = 0, parentTitle = '' }) {
const keywords = splitKeywords(node.keywords);
const isRoot = node.node_type === 'greeting';
const isAgent = node.node_type === 'agent';
const isClose = node.node_type === 'close';
const isDeep = level >= 2;
const nodeWidth = getFlowNodeWidth(level);
const visibleKeywordLimit = isDeep ? 4 : 8;
const childGap = getFlowChildGap(level);
const subtreeWidth = getFlowSubtreeWidth(node, level);
const firstChildWidth = node.children?.length ? getFlowSubtreeWidth(node.children[0], level + 1) : 0;
const lastChildWidth = node.children?.length
? getFlowSubtreeWidth(node.children[node.children.length - 1], level + 1)
: 0;
const accentColor = isRoot
? 'var(--color-primary)'
: isAgent
? '#3260b3'
: isClose
? '#0f8f77'
: 'var(--color-highlight)';
const nodeMessage = node.message_text || (isAgent ? '' : 'Sem mensagem configurada.');
return (
{nodeTypeLabel(node.node_type)}
Nível {level + 1}
{node.title}
{!isRoot && parentTitle ? (
abaixo de: {parentTitle}
) : null}
{!isAgent && !isClose && onAdd ? (
onAdd(node)}
title="Adicionar filho"
style={{
width: 34,
height: 34,
borderRadius: 12,
border: 'none',
background: 'var(--color-highlight)',
color: '#132534',
fontWeight: 900,
}}
>
+
) : null}
{isAgent ? (
Fila: {node.area_nome || areasById.get(Number(node.area_id))?.nome || 'não definida'}
) : isClose ? (
{isDeep && nodeMessage.length > 96 ? `${nodeMessage.slice(0, 96)}...` : nodeMessage}
) : (
{isDeep && nodeMessage.length > 96 ? `${nodeMessage.slice(0, 96)}...` : nodeMessage}
)}
{!isRoot && keywords.length ? (
Respostas que chegam aqui
{keywords.slice(0, visibleKeywordLimit).map((keyword) => (
{keyword}
))}
{keywords.length > visibleKeywordLimit ? (
+{keywords.length - visibleKeywordLimit}
) : null}
) : null}
{onEdit ? (
onEdit(node)} style={{ ...ghostButton, padding: '0.55rem 0.7rem' }}>
Editar
) : null}
{!isRoot && onDelete ? (
onDelete(node)}
style={{
...ghostButton,
padding: '0.55rem 0.7rem',
color: 'var(--color-secondary)',
}}
>
Remover
) : null}
{node.children?.length ? (
<>
{node.children.length > 1 ? (
) : null}
{node.children.map((child) => {
const childWidth = getFlowSubtreeWidth(child, level + 1);
return (
);
})}
>
) : null}
);
}
function NodeModal({ mode, node, parent, areas, draft, onDraftChange, onClose, onSave }) {
if (!mode) return null;
const isEdit = mode === 'edit';
const isRoot = node?.node_type === 'greeting';
const isAgent = draft.nodeType === 'agent' || node?.node_type === 'agent';
const isClose = draft.nodeType === 'close' || node?.node_type === 'close';
const canChooseType = !isEdit;
function change(key, value) {
onDraftChange((current) => ({ ...current, [key]: value }));
}
return (
{canChooseType ? (
{[
['question', 'Adicionar pergunta'],
['agent', 'Enviar para agente'],
['close', 'Encerrar pelo bot'],
].map(([type, label]) => (
change('nodeType', type)}
style={{
border: `1px solid ${draft.nodeType === type ? 'var(--color-primary)' : 'var(--color-border)'}`,
borderRadius: 16,
padding: '0.9rem',
background: draft.nodeType === type ? 'rgba(0,164,183,0.08)' : '#fff',
color: 'var(--color-text)',
fontWeight: 900,
}}
>
{label}
))}
) : null}
);
}
export function KnowledgeBasePanel({ areas, mode = 'admin', isMobile = false }) {
const canEdit = mode === 'admin';
const [flow, setFlow] = useState(null);
const [versions, setVersions] = useState([]);
const [status, setStatus] = useState('');
const [statusTone, setStatusTone] = useState('info');
const [isPublishing, setIsPublishing] = useState(false);
const [zoom, setZoom] = useState(0.92);
const [modalMode, setModalMode] = useState(null);
const [selectedNode, setSelectedNode] = useState(null);
const [parentNode, setParentNode] = useState(null);
const [draft, setDraft] = useState(emptyDraft);
const areasById = useMemo(() => new Map(areas.map((area) => [Number(area.id), area])), [areas]);
async function load() {
try {
const [flowData, versionData] = await Promise.all([getBotFlow(), listBotFlowVersions()]);
setFlow(flowData);
setVersions(Array.isArray(versionData) ? versionData : []);
} catch (error) {
setStatus(error.message);
setStatusTone('error');
}
}
useEffect(() => {
load();
}, []);
function openAdd(node) {
setParentNode(node);
setSelectedNode(null);
setDraft({ ...emptyDraft });
setModalMode('add');
}
function openEdit(node) {
setSelectedNode(node);
setParentNode(null);
setDraft({
nodeType: node.node_type,
title: node.title || '',
messageText: node.message_text || '',
keywords: node.keywords || '',
fallbackMessage: node.fallback_message || '',
fallbackAttempts: node.fallback_attempts || 2,
fallbackAreaId: node.fallback_area_id || '',
areaId: node.area_id || '',
});
setModalMode('edit');
}
function closeModal() {
setModalMode(null);
setSelectedNode(null);
setParentNode(null);
setDraft(emptyDraft);
}
async function saveNode() {
try {
const messageText =
draft.nodeType === 'close' && !draft.messageText.trim()
? closeDefaultMessage
: draft.messageText;
const payload = {
nodeType: draft.nodeType,
title: draft.title,
messageText,
keywords: draft.keywords,
fallbackMessage: draft.fallbackMessage,
fallbackAttempts: Number(draft.fallbackAttempts || 2),
fallbackAreaId: draft.fallbackAreaId ? Number(draft.fallbackAreaId) : null,
areaId: draft.areaId ? Number(draft.areaId) : null,
};
if (modalMode === 'add') {
await createBotFlowNode({ ...payload, parentId: parentNode.id });
setStatus('Nó adicionado ao fluxo.');
setStatusTone('success');
} else {
await updateBotFlowNode(selectedNode.id, payload);
setStatus('Nó atualizado.');
setStatusTone('success');
}
closeModal();
await load();
} catch (error) {
setStatus(error.message);
setStatusTone('error');
}
}
async function removeNode(node) {
if (!window.confirm(`Remover "${node.title}" e todos os filhos?`)) return;
try {
await deleteBotFlowNode(node.id);
await load();
setStatus('Nó removido.');
setStatusTone('success');
} catch (error) {
setStatus(error.message);
setStatusTone('error');
}
}
async function publish() {
setIsPublishing(true);
setStatus('Publicando fluxo...');
setStatusTone('info');
try {
await publishBotFlow();
await load();
setStatus('Fluxo publicado. As novas conversas passam a usar esta árvore.');
setStatusTone('success');
} catch (error) {
setStatus(error.message);
setStatusTone('error');
} finally {
setIsPublishing(false);
}
}
const root = flow?.tree;
const hasPublished = Boolean(flow?.latestPublished);
const publishWarnings = useMemo(() => collectPublishWarnings(root).slice(0, 5), [root]);
const treeMinWidth = useMemo(() => Math.max(1100, getFlowSubtreeWidth(root)), [root]);
return (
{hasPublished ? `Publicado: versão ${flow.latestPublished.version_number}` : 'Nenhum fluxo publicado ainda'}
Draft atual: edite livremente e publique apenas quando estiver consistente.
Zoom
setZoom(Number(event.target.value))}
/>
{canEdit ? (
{isPublishing ? 'Publicando...' : 'Publicar fluxo'}
) : null}
{publishWarnings.length ? (
Antes de publicar
{publishWarnings.map((warning) => (
{warning}
))}
) : null}
{root ? (
) : (
Carregando árvore...
)}
{status ? (
{status}
) : null}
{versions.length ? versions.map((version) => (
Versão {version.version_number}
{version.published_at ? new Date(version.published_at).toLocaleString('pt-BR') : 'Sem data'}
)) : (
Nenhuma versão publicada.
)}
);
}