- adiciona flow builder visual, conteúdos da IA e disparo em massa com agenda - melhora templates com categoria, variáveis e preview estilo WhatsApp - ajusta abrir atendimento dentro do painel, com preview, tag e variáveis - refina tela de chat com encerramento, status de fila e correções visuais
720 lines
24 KiB
JavaScript
720 lines
24 KiB
JavaScript
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 nó</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>
|
|
);
|
|
}
|