omnichannel-frontend/src/modules/management/components/KnowledgeBasePanel.jsx

720 lines
24 KiB
React
Raw Normal View History

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 WhatsAppPreview({ message }) {
return (
<div
style={{
background: '#e7f5ef',
borderRadius: 18,
padding: '0.85rem',
display: 'grid',
gap: '0.45rem',
}}
>
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.78rem', fontWeight: 800 }}>Preview WhatsApp</span>
<div
style={{
justifySelf: 'start',
maxWidth: 420,
borderRadius: '0 14px 14px 14px',
background: '#fff',
padding: '0.75rem 0.85rem',
boxShadow: '0 8px 22px rgba(0, 49, 80, 0.08)',
whiteSpace: 'pre-wrap',
lineHeight: 1.45,
}}
>
{message || 'Digite a mensagem para visualizar aqui.'}
</div>
</div>
);
}
function FlowNode({ node, areasById, onAdd, onEdit, onDelete }) {
const keywords = splitKeywords(node.keywords);
const isRoot = node.node_type === 'greeting';
const isAgent = node.node_type === 'agent';
const isClose = node.node_type === 'close';
return (
<div style={{ display: 'grid', justifyItems: 'center', gap: '0.8rem', minWidth: 260 }}>
<article
style={{
width: 280,
border: '1px solid var(--color-border)',
borderRadius: 18,
background: isRoot
? 'linear-gradient(180deg, #fff, rgba(0,164,183,0.09))'
: isAgent
? 'linear-gradient(180deg, #fff, rgba(50,96,179,0.09))'
: isClose
? 'linear-gradient(180deg, #fff, rgba(0,164,183,0.1))'
: '#fff',
boxShadow: '0 12px 28px rgba(0, 49, 80, 0.08)',
padding: '0.95rem',
display: 'grid',
gap: '0.7rem',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.7rem', alignItems: 'start' }}>
<div style={{ minWidth: 0 }}>
<span style={{ color: 'var(--color-primary)', fontSize: '0.74rem', fontWeight: 900, textTransform: 'uppercase' }}>
{nodeTypeLabel(node.node_type)}
</span>
<strong style={{ display: 'block', lineHeight: 1.25 }}>{node.title}</strong>
</div>
{!isAgent && !isClose && onAdd ? (
<button
type="button"
onClick={() => onAdd(node)}
title="Adicionar filho"
style={{
width: 34,
height: 34,
borderRadius: 12,
border: 'none',
background: 'var(--color-highlight)',
color: '#132534',
fontWeight: 900,
}}
>
+
</button>
) : null}
</div>
{isAgent ? (
<span style={{ color: 'var(--color-text-soft)', lineHeight: 1.35 }}>
Fila: {node.area_nome || areasById.get(Number(node.area_id))?.nome || 'não definida'}
</span>
) : isClose ? (
<span style={{ color: 'var(--color-text-soft)', lineHeight: 1.35, whiteSpace: 'pre-wrap' }}>
{node.message_text || 'Fecha o atendimento sem enviar para agente.'}
</span>
) : (
<span style={{ color: 'var(--color-text-soft)', whiteSpace: 'pre-wrap', lineHeight: 1.35 }}>
{node.message_text || 'Sem mensagem configurada.'}
</span>
)}
{!isRoot && keywords.length ? (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem' }}>
{keywords.slice(0, 8).map((keyword) => (
<span
key={keyword}
style={{
borderRadius: 999,
background: 'rgba(0,49,80,0.07)',
padding: '0.22rem 0.5rem',
fontSize: '0.75rem',
fontWeight: 800,
}}
>
{keyword}
</span>
))}
</div>
) : null}
<div style={{ display: 'flex', gap: '0.45rem', flexWrap: 'wrap' }}>
{onEdit ? (
<button type="button" onClick={() => onEdit(node)} style={{ ...ghostButton, padding: '0.55rem 0.7rem' }}>
Editar
</button>
) : null}
{!isRoot && onDelete ? (
<button
type="button"
onClick={() => onDelete(node)}
style={{
...ghostButton,
padding: '0.55rem 0.7rem',
color: 'var(--color-secondary)',
}}
>
Remover
</button>
) : null}
</div>
</article>
{node.children?.length ? (
<>
<div style={{ width: 2, height: 20, background: 'rgba(0,49,80,0.18)' }} />
<div
style={{
display: 'flex',
gap: '1rem',
alignItems: 'start',
justifyContent: 'center',
flexWrap: 'nowrap',
paddingTop: '0.2rem',
}}
>
{node.children.map((child) => (
<FlowNode
key={child.id}
node={child}
areasById={areasById}
onAdd={onAdd}
onEdit={onEdit}
onDelete={onDelete}
/>
))}
</div>
</>
) : null}
</div>
);
}
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 (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0, 20, 32, 0.42)',
display: 'grid',
placeItems: 'center',
zIndex: 50,
padding: '1rem',
}}
>
<section
style={{
width: 'min(760px, calc(100vw - 2rem))',
maxHeight: 'calc(100vh - 2rem)',
overflowY: 'auto',
borderRadius: 24,
background: '#fff',
boxShadow: 'var(--shadow-lg)',
padding: '1.25rem',
display: 'grid',
gap: '1rem',
}}
>
<header style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', alignItems: 'start' }}>
<div>
<h2 style={{ margin: 0, fontSize: '1.25rem' }}>
{isEdit ? 'Editar nó' : `Adicionar filho em ${parent?.title}`}
</h2>
<p style={{ margin: '0.35rem 0 0', color: 'var(--color-text-soft)' }}>
Configure a mensagem, as palavras que ativam o caminho e o destino quando for terminal.
</p>
</div>
<button type="button" onClick={onClose} style={ghostButton}>Fechar</button>
</header>
{canChooseType ? (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
{[
['question', 'Adicionar pergunta'],
['agent', 'Enviar para agente'],
['close', 'Encerrar pelo bot'],
].map(([type, label]) => (
<button
key={type}
type="button"
onClick={() => 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}
</button>
))}
</div>
) : null}
<div style={{ display: 'grid', gap: '0.75rem' }}>
<label style={{ display: 'grid', gap: '0.35rem' }}>
<span style={{ fontWeight: 800 }}>Título interno</span>
<input value={draft.title || ''} onChange={(event) => change('title', event.target.value)} style={fieldStyle} />
</label>
{!isRoot ? (
<label style={{ display: 'grid', gap: '0.35rem' }}>
<span style={{ fontWeight: 800 }}>Keywords que ativam este </span>
<input
value={draft.keywords || ''}
onChange={(event) => change('keywords', event.target.value)}
placeholder="Ex: 1, colaborador, ativo, funcionário"
style={fieldStyle}
/>
</label>
) : null}
{isAgent ? (
<label style={{ display: 'grid', gap: '0.35rem' }}>
<span style={{ fontWeight: 800 }}>Especialidade de destino</span>
<select value={draft.areaId || ''} onChange={(event) => change('areaId', event.target.value)} style={fieldStyle}>
<option value="">Selecione</option>
{areas.map((area) => (
<option key={area.id} value={area.id}>{area.nome}</option>
))}
</select>
</label>
) : isClose ? (
<>
<label style={{ display: 'grid', gap: '0.35rem' }}>
<span style={{ fontWeight: 800 }}>Mensagem de encerramento</span>
<textarea
rows={4}
value={draft.messageText || ''}
onChange={(event) => change('messageText', event.target.value)}
placeholder="Ex: Perfeito, vou encerrar por aqui. Se precisar de algo mais, é só chamar."
style={{ ...fieldStyle, resize: 'vertical' }}
/>
</label>
<WhatsAppPreview message={draft.messageText} />
</>
) : (
<>
<label style={{ display: 'grid', gap: '0.35rem' }}>
<span style={{ fontWeight: 800 }}>Mensagem enviada pelo bot</span>
<textarea
rows={5}
value={draft.messageText || ''}
onChange={(event) => change('messageText', event.target.value)}
style={{ ...fieldStyle, resize: 'vertical' }}
/>
</label>
<WhatsAppPreview message={draft.messageText} />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 140px minmax(180px, 0.5fr)', gap: '0.75rem' }}>
<label style={{ display: 'grid', gap: '0.35rem' }}>
<span style={{ fontWeight: 800 }}>Mensagem de fallback</span>
<input
value={draft.fallbackMessage || ''}
onChange={(event) => change('fallbackMessage', event.target.value)}
style={fieldStyle}
/>
</label>
<label style={{ display: 'grid', gap: '0.35rem' }}>
<span style={{ fontWeight: 800 }}>Tentativas</span>
<input
type="number"
min="1"
max="5"
value={draft.fallbackAttempts || 2}
onChange={(event) => change('fallbackAttempts', event.target.value)}
style={fieldStyle}
/>
</label>
<label style={{ display: 'grid', gap: '0.35rem' }}>
<span style={{ fontWeight: 800 }}>Fila de fallback</span>
<select
value={draft.fallbackAreaId || ''}
onChange={(event) => change('fallbackAreaId', event.target.value)}
style={fieldStyle}
>
<option value="">Herdar/Suporte</option>
{areas.map((area) => (
<option key={area.id} value={area.id}>{area.nome}</option>
))}
</select>
</label>
</div>
</>
)}
</div>
<footer style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.75rem' }}>
<button type="button" onClick={onClose} style={ghostButton}>Cancelar</button>
<button type="button" onClick={onSave} style={primaryButton}>Salvar</button>
</footer>
</section>
</div>
);
}
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]);
return (
<div style={{ display: 'grid', gap: '1rem' }}>
<DataPanel
title="Fluxo do Bot"
description="Monte a árvore de decisão do Agente Virtual Sothis. O fluxo só entra em produção depois de publicado."
>
<div style={{ display: 'grid', gap: '1rem' }}>
<div
style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 1fr) auto auto',
gap: '0.75rem',
alignItems: 'center',
}}
>
<div style={{ display: 'grid', gap: '0.25rem' }}>
<strong>{hasPublished ? `Publicado: versão ${flow.latestPublished.version_number}` : 'Nenhum fluxo publicado ainda'}</strong>
<span style={{ color: 'var(--color-text-soft)' }}>
Draft atual: edite livremente e publique apenas quando estiver consistente.
</span>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.6rem', fontWeight: 800 }}>
Zoom
<input
type="range"
min="0.65"
max="1.15"
step="0.05"
value={zoom}
onChange={(event) => setZoom(Number(event.target.value))}
/>
</label>
{canEdit ? (
<button
type="button"
onClick={publish}
disabled={isPublishing}
style={{
...primaryButton,
opacity: isPublishing ? 0.72 : 1,
cursor: isPublishing ? 'wait' : 'pointer',
}}
>
{isPublishing ? 'Publicando...' : 'Publicar fluxo'}
</button>
) : null}
</div>
{publishWarnings.length ? (
<div
style={{
border: '1px solid rgba(241,184,42,0.45)',
borderRadius: 16,
background: 'rgba(241,184,42,0.12)',
padding: '0.85rem 1rem',
color: 'var(--color-text)',
display: 'grid',
gap: '0.35rem',
}}
>
<strong>Antes de publicar</strong>
{publishWarnings.map((warning) => (
<span key={warning} style={{ color: 'var(--color-text-soft)', fontWeight: 700 }}>
{warning}
</span>
))}
</div>
) : null}
<div
style={{
border: '1px solid var(--color-border)',
borderRadius: 22,
background: 'linear-gradient(180deg, #fff, rgba(0,49,80,0.03))',
overflow: 'auto',
minHeight: 520,
padding: '1.25rem',
}}
>
<div
style={{
transform: `scale(${zoom})`,
transformOrigin: 'top center',
minWidth: 900,
minHeight: 480,
display: 'grid',
justifyContent: 'center',
alignContent: 'start',
}}
>
{root ? (
<FlowNode
node={root}
areasById={areasById}
onAdd={canEdit ? openAdd : null}
onEdit={canEdit ? openEdit : null}
onDelete={canEdit ? removeNode : null}
/>
) : (
<span style={{ color: 'var(--color-text-soft)', fontWeight: 800 }}>Carregando árvore...</span>
)}
</div>
</div>
{status ? (
<span
style={{
borderRadius: 14,
padding: '0.75rem 0.85rem',
background:
statusTone === 'error'
? 'rgba(181,31,31,0.08)'
: statusTone === 'success'
? 'rgba(0,164,183,0.09)'
: 'rgba(0,49,80,0.06)',
color: statusTone === 'error' ? 'var(--color-secondary)' : 'var(--color-primary)',
fontWeight: 800,
}}
>
{status}
</span>
) : null}
</div>
</DataPanel>
<DataPanel
title="Histórico de versões"
description="Cada publicação gera uma versão. Nesta primeira etapa o histórico é consultivo; restauração pode ser ligada na próxima rodada."
>
<div style={{ display: 'grid', gap: '0.55rem', maxHeight: 220, overflowY: 'auto' }}>
{versions.length ? versions.map((version) => (
<div
key={version.id}
style={{
border: '1px solid var(--color-border)',
borderRadius: 14,
padding: '0.75rem 0.85rem',
display: 'flex',
justifyContent: 'space-between',
gap: '1rem',
}}
>
<strong>Versão {version.version_number}</strong>
<span style={{ color: 'var(--color-text-soft)' }}>
{version.published_at ? new Date(version.published_at).toLocaleString('pt-BR') : 'Sem data'}
</span>
</div>
)) : (
<span style={{ color: 'var(--color-text-soft)' }}>Nenhuma versão publicada.</span>
)}
</div>
</DataPanel>
<NodeModal
mode={modalMode}
node={selectedNode}
parent={parentNode}
areas={areas}
draft={draft}
onDraftChange={setDraft}
onClose={closeModal}
onSave={saveNode}
/>
</div>
);
}