FEAT: melhora painel admin, templates e fluxos de atendimento

- 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
This commit is contained in:
Rafael Alves Lopes 2026-05-26 09:08:08 -03:00
parent 4b0a4bb3e3
commit f690c6d652
14 changed files with 2206 additions and 183 deletions

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { BrandMark } from '../../../shared/components/BrandMark';
import { useViewport } from '../../../shared/hooks/useViewport';
@ -117,15 +117,22 @@ function applyPhoneMask(value, countryId) {
}
function requiresUnsupportedTemplateFields(template) {
const allowedFields = new Set(['nome', 'cliente']);
const placeholders = String(template?.content || '').matchAll(/\{([a-zA-Z0-9_]+)\}/g);
return Array.from(placeholders).some((match) => !allowedFields.has(String(match[1]).toLowerCase()));
const allowedFields = new Set(['nome', 'cliente', 'data', 'link', 'variavel']);
const placeholders = String(template?.content || '').matchAll(/\{([^{}]+)\}/g);
return Array.from(placeholders).some((match) => {
const key = String(match[1]).trim().toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '');
return !allowedFields.has(key);
});
}
function renderTemplatePreview(content, form) {
function renderTemplatePreview(content, form, variables) {
return String(content || '')
.replace(/\{nome\}/gi, form.name.trim() || 'cliente')
.replace(/\{cliente\}/gi, form.name.trim() || 'cliente');
.replace(/\{cliente\}/gi, form.name.trim() || 'cliente')
.replace(/\{data\}/gi, variables.date.trim() || '{data}')
.replace(/\{link\}/gi, variables.link.trim() || '{link}')
.replace(/\{variavel\}/gi, variables.custom.trim() || '{variavel}')
.replace(/\{variável\}/gi, variables.custom.trim() || '{variável}');
}
function formatLastContact(value) {
@ -186,7 +193,7 @@ async function startWhatsappAttendance(payload) {
return response.json();
}
export function NewAttendancePage() {
export function NewAttendancePage({ embedded = false }) {
const navigate = useNavigate();
const { isWideDesktop, isDesktop, isTablet, isMobile } = useViewport();
const currentUser = getCurrentUser();
@ -201,6 +208,7 @@ export function NewAttendancePage() {
const [selectedTemplateId, setSelectedTemplateId] = useState('');
const [selectedCountryId, setSelectedCountryId] = useState('br');
const [form, setForm] = useState({ phone: '', name: '', company: '', note: '' });
const [templateVariables, setTemplateVariables] = useState({ date: '', link: '', custom: '' });
const [isLoadingContacts, setIsLoadingContacts] = useState(false);
const [isStarting, setIsStarting] = useState(false);
const [error, setError] = useState('');
@ -314,7 +322,7 @@ export function NewAttendancePage() {
note: form.note,
userId: currentUserId,
});
await startWhatsappAttendance({
const startedAttendance = await startWhatsappAttendance({
to: saved.chat_id || chatId,
templateId: Number(selectedTemplateId),
userId: currentUserId,
@ -322,10 +330,14 @@ export function NewAttendancePage() {
variables: {
nome: form.name,
cliente: form.name,
data: templateVariables.date,
link: templateVariables.link,
variavel: templateVariables.custom,
'variável': templateVariables.custom,
},
});
setError('');
navigate(`/chat?chatId=${encodeURIComponent(saved.chat_id || chatId)}`);
navigate(`/chat?chatId=${encodeURIComponent(startedAttendance?.chatId || saved.chat_id || chatId)}`);
} catch (err) {
setError(err.message);
} finally {
@ -333,20 +345,20 @@ export function NewAttendancePage() {
}
}
return (
<main style={{ minHeight: '100vh', padding: '1.5rem' }}>
<section
style={{
width: 'min(1680px, calc(100vw - 3rem))',
margin: '0 auto',
background: 'var(--color-surface-strong)',
borderRadius: '32px',
boxShadow: 'var(--shadow-lg)',
padding: '1.5rem',
display: 'grid',
gap: '1.25rem',
}}
>
const content = (
<section
style={{
width: embedded ? '100%' : 'min(1680px, calc(100vw - 3rem))',
margin: embedded ? 0 : '0 auto',
background: 'var(--color-surface-strong)',
borderRadius: embedded ? 0 : '32px',
boxShadow: embedded ? 'none' : 'var(--shadow-lg)',
padding: embedded ? 0 : '1.5rem',
display: 'grid',
gap: '1.25rem',
}}
>
{!embedded ? (
<header
style={{
display: 'grid',
@ -384,6 +396,7 @@ export function NewAttendancePage() {
Voltar para home
</Link>
</header>
) : null}
<section
style={{
@ -547,17 +560,17 @@ export function NewAttendancePage() {
<div
style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'repeat(2, minmax(0, 1fr))',
gridTemplateColumns: isMobile ? '1fr' : 'repeat(3, minmax(0, 1fr))',
gap: '1rem',
}}
>
<label style={{ display: 'grid', gap: '0.45rem' }}>
<span style={{ fontWeight: 600 }}>Empresa</span>
<span style={{ fontWeight: 600 }}>Tag</span>
<input
type="text"
value={form.company}
onChange={(event) => setForm((current) => ({ ...current, company: event.target.value }))}
placeholder="Empresa ou conta vinculada"
placeholder="Tag ou conta vinculada"
style={{
border: '1px solid var(--color-border)',
borderRadius: '18px',
@ -591,21 +604,108 @@ export function NewAttendancePage() {
</label>
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'repeat(2, minmax(0, 1fr))',
gap: '1rem',
}}
>
<label style={{ display: 'grid', gap: '0.45rem' }}>
<span style={{ fontWeight: 600 }}>Data do template</span>
<input
type="text"
value={templateVariables.date}
onChange={(event) => setTemplateVariables((current) => ({ ...current, date: event.target.value }))}
placeholder="Ex: 26/05/2026"
style={{
border: '1px solid var(--color-border)',
borderRadius: '18px',
padding: '0.95rem 1rem',
background: '#fff',
outline: 'none',
}}
/>
</label>
<label style={{ display: 'grid', gap: '0.45rem' }}>
<span style={{ fontWeight: 600 }}>Link do template</span>
<input
type="text"
value={templateVariables.link}
onChange={(event) => setTemplateVariables((current) => ({ ...current, link: event.target.value }))}
placeholder="https://..."
style={{
border: '1px solid var(--color-border)',
borderRadius: '18px',
padding: '0.95rem 1rem',
background: '#fff',
outline: 'none',
}}
/>
</label>
<label style={{ display: 'grid', gap: '0.45rem' }}>
<span style={{ fontWeight: 600 }}>Variável do template</span>
<input
type="text"
value={templateVariables.custom}
onChange={(event) => setTemplateVariables((current) => ({ ...current, custom: event.target.value }))}
placeholder="Valor livre"
style={{
border: '1px solid var(--color-border)',
borderRadius: '18px',
padding: '0.95rem 1rem',
background: '#fff',
outline: 'none',
}}
/>
</label>
</div>
{selectedTemplate ? (
<div
style={{
border: '1px solid rgba(0, 164, 183, 0.24)',
borderRadius: '18px',
border: '1px solid rgba(0, 49, 80, 0.08)',
borderRadius: '22px',
padding: '1rem',
background: 'rgba(0, 164, 183, 0.06)',
color: 'var(--color-text)',
lineHeight: 1.5,
background: 'linear-gradient(180deg, #e8f3ee, #dcefe8)',
minHeight: 220,
display: 'grid',
alignContent: 'end',
}}
>
<strong style={{ display: 'block', color: 'var(--color-primary)', marginBottom: '0.35rem' }}>
Preview do template
</strong>
{renderTemplatePreview(selectedTemplate.content, form)}
<div style={{ display: 'grid', gap: '0.45rem' }}>
<strong style={{ display: 'block', color: 'var(--color-text-soft)', fontSize: '0.82rem' }}>
Preview WhatsApp
</strong>
<div
style={{
justifySelf: 'end',
maxWidth: '92%',
borderRadius: '16px 16px 4px 16px',
padding: '0.85rem 0.95rem',
background: '#d9fdd3',
color: '#1f2c33',
boxShadow: '0 6px 18px rgba(0, 49, 80, 0.08)',
whiteSpace: 'pre-wrap',
lineHeight: 1.45,
fontSize: '0.94rem',
}}
>
{renderTemplatePreview(selectedTemplate.content, form, templateVariables)}
<span
style={{
display: 'block',
marginTop: '0.5rem',
textAlign: 'right',
color: 'rgba(31, 44, 51, 0.58)',
fontSize: '0.72rem',
fontWeight: 700,
}}
>
10:42
</span>
</div>
</div>
</div>
) : null}
@ -668,7 +768,7 @@ export function NewAttendancePage() {
Número: {buildInternationalPhone(form.phone, selectedCountryId) ? `+${buildInternationalPhone(form.phone, selectedCountryId)}` : 'Não informado'}
</span>
<span style={{ color: 'rgba(255, 255, 255, 0.74)' }}>
Empresa: {form.company || 'Não informada'}
Tag: {form.company || 'Não informada'}
</span>
<span style={{ color: 'rgba(255, 255, 255, 0.74)' }}>
Origem: {selectedContactId ? 'Agenda' : 'Novo contato'}
@ -728,6 +828,17 @@ export function NewAttendancePage() {
</section>
</section>
</section>
);
if (embedded) {
return content;
}
return (
<main style={{ minHeight: '100vh', padding: '1.5rem' }}>
{content}
</main>
);
}

View File

@ -197,7 +197,7 @@ export function CallPage() {
color: 'rgba(255, 255, 255, 0.72)',
}}
>
Gravação mock: Habilitada
Gravação: Habilitada
</div>
<button
type="button"

View File

@ -247,7 +247,7 @@ export function ChatConversationList({
lineHeight: 1.45,
}}
>
Nenhuma conversa ativa na sua fila. Conversas em triagem do Omnino aparecem aqui depois de classificadas.
Nenhuma conversa ativa na sua fila. Conversas em triagem do Agente Virtual Sothis aparecem aqui depois de classificadas.
</div>
) : null}
</div>

View File

@ -313,6 +313,7 @@ export function ChatWindow({
onToggleTransfer,
onAssumeChat,
onReleaseChat,
onCloseChat,
canAssumeChat = false,
canReply = true,
assignmentLabel,
@ -327,7 +328,7 @@ export function ChatWindow({
id: '',
name: isPaused ? 'Atendimento pausado' : 'Nenhuma conversa ativa',
status: 'offline',
lastSeen: isPaused ? `Pausa em andamento: ${pauseDurationLabel}` : 'Aguardando fila do Omnino',
lastSeen: isPaused ? `Pausa em andamento: ${pauseDurationLabel}` : 'Aguardando fila do Agente Virtual Sothis',
};
useEffect(() => {
@ -469,20 +470,36 @@ export function ChatWindow({
</button>
) : null}
{canReply ? (
<button
type="button"
onClick={onReleaseChat}
style={{
border: '1px solid var(--color-border)',
borderRadius: '14px',
padding: '0.8rem 1rem',
background: '#fff',
color: 'var(--color-primary)',
fontWeight: 700,
}}
>
Sair do atendimento
</button>
<>
<button
type="button"
onClick={onReleaseChat}
style={{
border: '1px solid var(--color-border)',
borderRadius: '14px',
padding: '0.8rem 1rem',
background: '#fff',
color: 'var(--color-primary)',
fontWeight: 700,
}}
>
Sair do atendimento
</button>
<button
type="button"
onClick={onCloseChat}
style={{
border: 'none',
borderRadius: '14px',
padding: '0.8rem 1rem',
background: 'rgba(181, 31, 31, 0.1)',
color: 'var(--color-secondary)',
fontWeight: 800,
}}
>
Encerrar atendimento
</button>
</>
) : null}
<button
type="button"

View File

@ -672,6 +672,40 @@ export function useChat() {
}));
}
async function closeChat() {
if (!activeContactId?.includes('@')) return;
const confirmed = window.confirm('Tem certeza que deseja encerrar este atendimento?');
if (!confirmed) return;
try {
const response = await fetch(`${API_BASE_URL}/whatsapp/close`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chatId: activeContactId,
userId: currentUserId,
}),
});
if (!response.ok) throw new Error('Não foi possível encerrar o atendimento.');
setContacts((current) => current.filter((contact) => contact.id !== activeContactId));
setMessagesByContact((current) => {
const next = { ...current };
delete next[activeContactId];
return next;
});
setActiveContactId('');
setDraft('');
setAttachedFile(null);
setIsTransferOpen(false);
setApiError(null);
} catch (error) {
setApiError(error.message);
}
}
async function pauseAttendance() {
if (!currentUserId) return;
setIsPresenceLoading(true);
@ -867,6 +901,7 @@ export function useChat() {
hydrateMessageMedia,
assumeChat,
releaseChat,
closeChat,
canAssumeChat,
canReply,
assignmentLabel,

View File

@ -28,6 +28,7 @@ export function ChatPage() {
hydrateMessageMedia,
assumeChat,
releaseChat,
closeChat,
canAssumeChat,
canReply,
assignmentLabel,
@ -170,6 +171,7 @@ export function ChatPage() {
}}
onAssumeChat={assumeChat}
onReleaseChat={releaseChat}
onCloseChat={closeChat}
canAssumeChat={canAssumeChat}
canReply={canReply}
assignmentLabel={assignmentLabel}

View File

@ -0,0 +1,719 @@
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>
);
}

View File

@ -6,11 +6,12 @@ const navigationBySection = {
supervisor: [
{ id: 'dashboard', label: 'Home' },
{ id: 'templates', label: 'Templates' },
{ id: 'knowledge', label: 'Base de conhecimento IA' },
{ id: 'knowledge', label: 'Fluxo do Bot' },
{ id: 'ai-contents', label: 'Conteúdos da IA' },
{ id: 'audit', label: 'Auditoria' },
{ type: 'separator' },
{ id: 'attendance', label: 'Atendimento' },
{ id: 'new-attendance', label: 'Abrir Atendimento', path: '/new-attendance' },
{ id: 'new-attendance', label: 'Abrir Atendimento' },
{ id: 'mass-message', label: 'Disparo em Massa' },
{ id: 'contacts', label: 'Contatos' },
],
@ -20,12 +21,13 @@ const navigationBySection = {
{ type: 'separator' },
{ id: 'users-access', label: 'Usuários & Acessos' },
{ id: 'templates', label: 'Templates' },
{ id: 'knowledge', label: 'Base de conhecimento IA' },
{ id: 'knowledge', label: 'Fluxo do Bot' },
{ id: 'ai-contents', label: 'Conteúdos da IA' },
{ id: 'audit', label: 'Auditoria' },
{ id: 'channels', label: 'Canais' },
{ type: 'separator' },
{ id: 'attendance', label: 'Atendimento' },
{ id: 'new-attendance', label: 'Abrir Atendimento', path: '/new-attendance' },
{ id: 'new-attendance', label: 'Abrir Atendimento' },
{ id: 'mass-message', label: 'Disparo em Massa' },
{ id: 'contacts', label: 'Contatos' },
{ type: 'separator' },
@ -35,7 +37,7 @@ const navigationBySection = {
const actionLabelBySection = {
supervisor: '+ Redistribuir atendimento',
admin: 'Home',
admin: 'Abrir painel do atendente',
};
export function ManagementLayout({

View File

@ -0,0 +1,493 @@
import { useEffect, useMemo, useState } from 'react';
import { API_BASE_URL } from '../../../shared/services/apiConfig';
import { getCurrentUser } from '../../auth/services/sessionService';
import { listContactProfiles } from '../../chat/services/contactProfileService';
import { DataPanel } from './DataPanel';
import { listTemplates } from '../services/templateService';
const inputStyle = {
width: '100%',
border: '1px solid var(--color-border)',
borderRadius: 14,
padding: '0.85rem 0.9rem',
background: '#fff',
color: 'var(--color-text)',
fontWeight: 600,
};
function getUserId(user) {
const value = user?.databaseId || user?.id;
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : null;
}
function normalizePhoneToChatId(value) {
const digits = String(value || '').replace(/\D/g, '');
if (!digits) return '';
return `${digits}@c.us`;
}
function getPhoneFromChatId(chatId) {
return String(chatId || '').split('@')[0].replace(/\D/g, '');
}
function normalizeContact(contact) {
const phone = String(contact.phone || getPhoneFromChatId(contact.chat_id)).replace(/\D/g, '');
return {
id: contact.chat_id || normalizePhoneToChatId(phone),
name: contact.name || phone || 'Contato sem nome',
company: contact.company || '',
phone,
chatId: contact.chat_id || normalizePhoneToChatId(phone),
};
}
function renderPreview(content, variables) {
const name = variables?.nome || 'colaborador';
return String(content || '')
.replace(/\{nome\}/gi, name || 'colaborador')
.replace(/\{cliente\}/gi, name || 'colaborador')
.replace(/\{data\}/gi, variables?.data || '{data}')
.replace(/\{link\}/gi, variables?.link || '{link}')
.replace(/\{variavel\}/gi, variables?.variavel || '{variavel}')
.replace(/\{variável\}/gi, variables?.variavel || '{variável}');
}
async function startAttendance(payload) {
const response = await fetch(`${API_BASE_URL}/whatsapp/start-attendance`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error('Falha ao enviar disparo.');
}
return response.json();
}
export function MassMessagePanel({
areas,
managedAreaNames = [],
mode = 'admin',
isMobile = false,
}) {
const currentUserId = getUserId(getCurrentUser());
const isAdmin = mode === 'admin';
const visibleAreaNames = isAdmin ? [] : managedAreaNames;
const [templates, setTemplates] = useState([]);
const [contacts, setContacts] = useState([]);
const [selectedAreaId, setSelectedAreaId] = useState('');
const [selectedTemplateId, setSelectedTemplateId] = useState('');
const [defaultName, setDefaultName] = useState('colaborador');
const [templateDate, setTemplateDate] = useState('');
const [templateLink, setTemplateLink] = useState('');
const [templateCustomVariable, setTemplateCustomVariable] = useState('');
const [numbersText, setNumbersText] = useState('');
const [contactSearch, setContactSearch] = useState('');
const [selectedContactIds, setSelectedContactIds] = useState([]);
const [isSending, setIsSending] = useState(false);
const [results, setResults] = useState([]);
const [status, setStatus] = useState('');
useEffect(() => {
let isMounted = true;
listTemplates()
.then((data) => {
if (!isMounted) return;
const approved = Array.isArray(data)
? data.filter((template) => {
const isApproved = template.status === 'approved';
const isManaged = !visibleAreaNames.length || visibleAreaNames.includes(template.area_nome);
return isApproved && isManaged;
})
: [];
setTemplates(approved);
setSelectedTemplateId((current) => current || (approved[0]?.id ? String(approved[0].id) : ''));
})
.catch((error) => {
if (isMounted) setStatus(error.message);
});
return () => {
isMounted = false;
};
}, [visibleAreaNames.join('|')]);
useEffect(() => {
let isMounted = true;
listContactProfiles()
.then((data) => {
if (!isMounted) return;
setContacts(Array.isArray(data) ? data.map(normalizeContact).filter((contact) => contact.phone) : []);
})
.catch((error) => {
if (isMounted) setStatus(error.message);
});
return () => {
isMounted = false;
};
}, []);
const selectedTemplate = templates.find((template) => String(template.id) === String(selectedTemplateId));
const filteredTemplates = useMemo(() => {
if (!selectedAreaId) return templates;
return templates.filter((template) => String(template.area_id || '') === String(selectedAreaId));
}, [templates, selectedAreaId]);
const numbers = useMemo(
() =>
numbersText
.split(/\r?\n|,|;/)
.map((item) => item.trim())
.filter(Boolean),
[numbersText],
);
const selectedContacts = useMemo(
() => contacts.filter((contact) => selectedContactIds.includes(contact.id)),
[contacts, selectedContactIds],
);
const filteredContacts = useMemo(() => {
const search = contactSearch.trim().toLowerCase();
if (!search) return contacts;
return contacts.filter((contact) =>
`${contact.name} ${contact.company} ${contact.phone}`.toLowerCase().includes(search),
);
}, [contacts, contactSearch]);
const recipients = useMemo(() => {
const items = [];
const seen = new Set();
numbers.forEach((number) => {
const chatId = normalizePhoneToChatId(number);
if (!chatId || seen.has(chatId)) return;
seen.add(chatId);
items.push({
id: chatId,
number,
chatId,
name: defaultName,
});
});
return items;
}, [defaultName, numbers]);
useEffect(() => {
if (selectedTemplate && filteredTemplates.some((template) => String(template.id) === String(selectedTemplateId))) {
return;
}
setSelectedTemplateId(filteredTemplates[0]?.id ? String(filteredTemplates[0].id) : '');
}, [filteredTemplates, selectedTemplate, selectedTemplateId]);
function toggleContact(contact) {
const phone = String(contact.phone || '').replace(/\D/g, '');
if (!phone) return;
setSelectedContactIds((current) => {
const isSelected = current.includes(contact.id);
return isSelected ? current.filter((id) => id !== contact.id) : [...current, contact.id];
});
setNumbersText((current) => {
const currentNumbers = current
.split(/\r?\n|,|;/)
.map((item) => item.trim())
.filter(Boolean);
const exists = currentNumbers.some((item) => String(item).replace(/\D/g, '') === phone);
const nextNumbers = exists
? currentNumbers.filter((item) => String(item).replace(/\D/g, '') !== phone)
: [...currentNumbers, phone];
return nextNumbers.join('\n');
});
}
function clearSelectedContacts() {
const selectedPhones = new Set(selectedContacts.map((contact) => contact.phone));
setSelectedContactIds([]);
setNumbersText((current) =>
current
.split(/\r?\n|,|;/)
.map((item) => item.trim())
.filter(Boolean)
.filter((item) => !selectedPhones.has(String(item).replace(/\D/g, '')))
.join('\n'),
);
}
async function sendSelectedRecipients() {
if (!currentUserId) {
setStatus('Não foi possível identificar o usuário logado.');
return;
}
if (!selectedTemplateId) {
setStatus('Selecione um template aprovado.');
return;
}
if (!recipients.length) {
setStatus('Informe ao menos um número ou selecione contatos da agenda.');
return;
}
setIsSending(true);
setResults([]);
setStatus('');
const nextResults = [];
for (const recipient of recipients) {
if (!recipient.chatId) {
nextResults.push({ number: recipient.number, status: 'erro', detail: 'Número inválido' });
continue;
}
try {
await startAttendance({
to: recipient.chatId,
templateId: Number(selectedTemplateId),
userId: currentUserId,
areaId: selectedTemplate?.area_id || null,
variables: {
nome: defaultName,
cliente: defaultName,
data: templateDate,
link: templateLink,
variavel: templateCustomVariable,
'variável': templateCustomVariable,
},
});
nextResults.push({
number: recipient.number,
status: 'enviado',
detail: `${recipient.name || 'Contato'} - template enviado e atendimento iniciado`,
});
} catch (error) {
nextResults.push({ number: recipient.number, status: 'erro', detail: error.message });
}
setResults([...nextResults]);
}
setStatus(`Disparo finalizado: ${nextResults.filter((item) => item.status === 'enviado').length} enviados.`);
setIsSending(false);
}
return (
<DataPanel
title="Disparo em massa"
description="Envie templates aprovados para uma lista de colaboradores. Após o envio, a conversa aguarda resposta do cliente."
>
<div
style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 1fr) minmax(320px, 0.85fr)',
gap: '1rem',
}}
>
<div style={{ display: 'grid', gap: '0.85rem' }}>
<label style={{ display: 'grid', gap: '0.4rem' }}>
<span style={{ fontWeight: 700 }}>Especialidade</span>
<select value={selectedAreaId} onChange={(event) => setSelectedAreaId(event.target.value)} style={inputStyle}>
<option value="">Todas as especialidades</option>
{areas
.filter((area) => isAdmin || !managedAreaNames.length || managedAreaNames.includes(area.nome))
.map((area) => (
<option key={area.id} value={area.id}>
{area.nome}
</option>
))}
</select>
</label>
<label style={{ display: 'grid', gap: '0.4rem' }}>
<span style={{ fontWeight: 700 }}>Template aprovado</span>
<select value={selectedTemplateId} onChange={(event) => setSelectedTemplateId(event.target.value)} style={inputStyle}>
<option value="">Selecione</option>
{filteredTemplates.map((template) => (
<option key={template.id} value={template.id}>
{template.name}
</option>
))}
</select>
</label>
<label style={{ display: 'grid', gap: '0.4rem' }}>
<span style={{ fontWeight: 700 }}>Nome usado no preview</span>
<input value={defaultName} onChange={(event) => setDefaultName(event.target.value)} style={inputStyle} />
</label>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : 'repeat(3, minmax(0, 1fr))', gap: '0.85rem' }}>
<label style={{ display: 'grid', gap: '0.4rem' }}>
<span style={{ fontWeight: 700 }}>Data</span>
<input value={templateDate} onChange={(event) => setTemplateDate(event.target.value)} placeholder="Ex: 26/05/2026" style={inputStyle} />
</label>
<label style={{ display: 'grid', gap: '0.4rem' }}>
<span style={{ fontWeight: 700 }}>Link</span>
<input value={templateLink} onChange={(event) => setTemplateLink(event.target.value)} placeholder="https://..." style={inputStyle} />
</label>
<label style={{ display: 'grid', gap: '0.4rem' }}>
<span style={{ fontWeight: 700 }}>Variável</span>
<input value={templateCustomVariable} onChange={(event) => setTemplateCustomVariable(event.target.value)} placeholder="Valor livre" style={inputStyle} />
</label>
</div>
<label style={{ display: 'grid', gap: '0.4rem' }}>
<span style={{ fontWeight: 700 }}>Números manuais</span>
<textarea
rows={8}
value={numbersText}
onChange={(event) => setNumbersText(event.target.value)}
placeholder="5511999999999&#10;5511888888888"
style={{ ...inputStyle, resize: 'vertical', lineHeight: 1.5 }}
/>
</label>
<button
type="button"
onClick={sendSelectedRecipients}
disabled={isSending}
style={{
border: 'none',
borderRadius: 16,
padding: '0.95rem 1rem',
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
color: '#fff',
fontWeight: 800,
opacity: isSending ? 0.7 : 1,
}}
>
{isSending ? 'Enviando...' : `Enviar para ${recipients.length || 0} contato(s)`}
</button>
{status ? <span style={{ color: 'var(--color-primary)', fontWeight: 800 }}>{status}</span> : null}
</div>
<aside style={{ display: 'grid', gap: '0.85rem', alignContent: 'start' }}>
<article
style={{
border: '1px solid var(--color-border)',
borderRadius: 18,
padding: '1rem',
background: '#fff',
display: 'grid',
gap: '0.75rem',
}}
>
<div>
<strong style={{ display: 'block' }}>Agenda de contatos</strong>
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.9rem' }}>
Selecione contatos salvos para incluir no disparo.
</span>
</div>
<input
value={contactSearch}
onChange={(event) => setContactSearch(event.target.value)}
placeholder="Buscar por nome, empresa ou telefone"
style={inputStyle}
/>
<div style={{ display: 'grid', gap: '0.45rem', maxHeight: 260, overflowY: 'auto', paddingRight: '0.2rem' }}>
{filteredContacts.map((contact) => {
const isSelected = selectedContactIds.includes(contact.id);
return (
<button
key={contact.id}
type="button"
onClick={() => toggleContact(contact)}
style={{
border: '1px solid',
borderColor: isSelected ? 'rgba(0, 164, 183, 0.36)' : 'var(--color-border)',
borderRadius: 14,
padding: '0.7rem',
background: isSelected ? 'rgba(0, 164, 183, 0.08)' : '#fff',
textAlign: 'left',
display: 'grid',
gap: '0.2rem',
}}
>
<strong>{contact.name}</strong>
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.88rem' }}>
+{contact.phone}{contact.company ? ` · ${contact.company}` : ''}
</span>
</button>
);
})}
{!filteredContacts.length ? (
<span style={{ color: 'var(--color-text-soft)', fontWeight: 700 }}>
Nenhum contato encontrado na agenda.
</span>
) : null}
</div>
{selectedContacts.length ? (
<button
type="button"
onClick={clearSelectedContacts}
style={{
border: '1px solid var(--color-border)',
borderRadius: 14,
padding: '0.75rem',
background: '#fff',
color: 'var(--color-primary)',
fontWeight: 800,
}}
>
Limpar seleção ({selectedContacts.length})
</button>
) : null}
</article>
<article
style={{
border: '1px solid rgba(0, 164, 183, 0.24)',
borderRadius: 18,
padding: '1rem',
background: 'rgba(0, 164, 183, 0.06)',
lineHeight: 1.5,
}}
>
<strong style={{ display: 'block', color: 'var(--color-primary)', marginBottom: '0.45rem' }}>
Preview
</strong>
{selectedTemplate
? renderPreview(selectedTemplate.content, { nome: defaultName, data: templateDate, link: templateLink, variavel: templateCustomVariable })
: 'Selecione um template aprovado.'}
</article>
<article
style={{
border: '1px solid var(--color-border)',
borderRadius: 18,
padding: '1rem',
background: '#fff',
color: 'var(--color-text-soft)',
fontWeight: 700,
}}
>
Destinatários no campo: {numbers.length}.
</article>
<div style={{ display: 'grid', gap: '0.55rem', maxHeight: 320, overflowY: 'auto' }}>
{results.map((result) => (
<div
key={`${result.number}-${result.status}`}
style={{
border: '1px solid var(--color-border)',
borderRadius: 14,
padding: '0.75rem',
background: result.status === 'enviado' ? 'rgba(16,185,129,0.08)' : 'rgba(181,31,31,0.08)',
}}
>
<strong style={{ display: 'block' }}>{result.number}</strong>
<span style={{ color: 'var(--color-text-soft)' }}>{result.detail}</span>
</div>
))}
</div>
</aside>
</div>
</DataPanel>
);
}

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { DataPanel } from './DataPanel';
import {
approveTemplateByAdmin,
@ -41,6 +41,12 @@ const statusMeta = {
},
};
const templateCategories = [
{ id: 'UTILITY', label: 'UTILITY', detail: 'Confirmações, lembretes e atualizações', cost: 'Menor custo' },
{ id: 'MARKETING', label: 'MARKETING', detail: 'Promoções, ofertas e engajamento', cost: 'Maior custo' },
{ id: 'AUTHENTICATION', label: 'AUTHENTICATION', detail: 'OTP e códigos de verificação', cost: 'Menor custo' },
];
function getTemplateStatus(template) {
return statusMeta[template.status] || statusMeta.approved;
}
@ -50,9 +56,19 @@ function getRemainingMetaText(template) {
const submittedAt = new Date(template.meta_submitted_at).getTime();
const approvedAt = submittedAt + 15 * 60 * 1000;
const remainingMs = approvedAt - Date.now();
if (remainingMs <= 0) return 'Aprovação fake disponível ao atualizar.';
if (remainingMs <= 0) return 'Aprovação disponível ao atualizar.';
const minutes = Math.ceil(remainingMs / 60000);
return `Aprovação fake em aproximadamente ${minutes} min.`;
return `Aprovação em aproximadamente ${minutes} min.`;
}
function renderTemplatePreview(content) {
return String(content || 'Digite a mensagem do template...')
.replace(/\{nome\}/gi, 'Ana Paula')
.replace(/\{cliente\}/gi, 'Ana Paula')
.replace(/\{data\}/gi, '26/05/2026')
.replace(/\{link\}/gi, 'https://sothis.com.br/rh')
.replace(/\{variavel\}/gi, 'informação personalizada')
.replace(/\{variável\}/gi, 'informação personalizada');
}
export function TemplateManagementPanel({
@ -63,7 +79,7 @@ export function TemplateManagementPanel({
}) {
const [templates, setTemplates] = useState([]);
const [selectedArea, setSelectedArea] = useState('all');
const [form, setForm] = useState({ name: '', content: '', areaId: '' });
const [form, setForm] = useState({ name: '', content: '', areaId: '', category: 'UTILITY' });
const [statusMessage, setStatusMessage] = useState('');
const [isLoading, setIsLoading] = useState(true);
@ -110,10 +126,11 @@ export function TemplateManagementPanel({
await saveTemplate({
name,
content,
category: form.category,
areaId: Number(form.areaId) || null,
requestedByRole: isAdmin ? 'admin' : 'supervisor',
});
setForm({ name: '', content: '', areaId: '' });
setForm({ name: '', content: '', areaId: '', category: 'UTILITY' });
setStatusMessage(
isAdmin
? 'Template enviado para aprovação.'
@ -128,7 +145,7 @@ export function TemplateManagementPanel({
async function approveTemplate(templateId) {
try {
await approveTemplateByAdmin(templateId);
setStatusMessage('Template aprovado pelo admin e enviado para análise fake da Meta.');
setStatusMessage('Template aprovado pelo admin e enviado para análise da Meta.');
await loadTemplates();
} catch (error) {
setStatusMessage(error.message);
@ -175,55 +192,135 @@ export function TemplateManagementPanel({
title={isAdmin ? 'Templates WhatsApp' : 'Solicitar template'}
description={
isAdmin
? 'Crie templates e aprove solicitações de supervisores antes do envio fake para a Meta.'
? 'Crie templates e aprove solicitações de supervisores antes do envio para a Meta.'
: 'Templates enviados por supervisor passam primeiro pela aprovação do admin.'
}
>
<form onSubmit={submitTemplate} style={{ display: 'grid', gap: '0.85rem' }}>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 0.8fr) minmax(0, 0.7fr)', gap: '0.85rem' }}>
<input
type="text"
value={form.name}
onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))}
placeholder="Identificador do template"
style={fieldStyle}
/>
<select
value={form.areaId}
onChange={(event) => setForm((current) => ({ ...current, areaId: event.target.value }))}
style={fieldStyle}
>
<option value="">Sem especialidade</option>
{visibleAreas.map((area) => (
<option key={area.id} value={area.id}>
{area.nome}
</option>
<form onSubmit={submitTemplate} style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 1fr) minmax(300px, 0.55fr)', gap: '1rem', alignItems: 'start' }}>
<div style={{ display: 'grid', gap: '0.85rem' }}>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 0.8fr) minmax(0, 0.7fr) minmax(220px, 0.55fr)', gap: '0.85rem' }}>
<input
type="text"
value={form.name}
onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))}
placeholder="Identificador do template"
style={fieldStyle}
/>
<select
value={form.areaId}
onChange={(event) => setForm((current) => ({ ...current, areaId: event.target.value }))}
style={fieldStyle}
>
<option value="">Sem especialidade</option>
{visibleAreas.map((area) => (
<option key={area.id} value={area.id}>
{area.nome}
</option>
))}
</select>
<select
value={form.category}
onChange={(event) => setForm((current) => ({ ...current, category: event.target.value }))}
style={fieldStyle}
>
{templateCategories.map((category) => (
<option key={category.id} value={category.id}>
{category.label} - {category.cost}
</option>
))}
</select>
</div>
<div style={{ display: 'flex', gap: '0.45rem', flexWrap: 'wrap' }}>
{['{nome}', '{data}', '{link}', '{variavel}'].map((variable) => (
<button
key={variable}
type="button"
onClick={() => setForm((current) => ({ ...current, content: `${current.content}${current.content ? ' ' : ''}${variable}` }))}
style={{
border: '1px solid var(--color-border)',
borderRadius: 999,
padding: '0.45rem 0.7rem',
background: '#fff',
color: 'var(--color-primary)',
fontWeight: 800,
}}
>
Adicionar {variable}
</button>
))}
</select>
</div>
<textarea
value={form.content}
onChange={(event) => setForm((current) => ({ ...current, content: event.target.value }))}
placeholder="Mensagem do template. Ex: Olá, {nome}. Podemos seguir com seu atendimento por aqui?"
rows={6}
style={{ ...fieldStyle, resize: 'vertical', lineHeight: 1.5 }}
/>
<button
type="submit"
style={{
border: 'none',
borderRadius: 16,
padding: '0.9rem 1rem',
background: 'var(--color-primary)',
color: '#fff',
fontWeight: 800,
width: 'fit-content',
}}
>
{isAdmin ? 'Enviar para aprovação' : 'Enviar para admin'}
</button>
</div>
<textarea
value={form.content}
onChange={(event) => setForm((current) => ({ ...current, content: event.target.value }))}
placeholder="Mensagem do template. Ex: Olá, {nome}. Podemos seguir com seu atendimento por aqui?"
rows={4}
style={{ ...fieldStyle, resize: 'vertical', lineHeight: 1.5 }}
/>
<button
type="submit"
<aside
aria-label="Preview do template no WhatsApp"
style={{
border: 'none',
borderRadius: 16,
padding: '0.9rem 1rem',
background: 'var(--color-primary)',
color: '#fff',
fontWeight: 800,
width: 'fit-content',
borderRadius: 22,
padding: '1rem',
background: 'linear-gradient(180deg, #e8f3ee, #dcefe8)',
border: '1px solid rgba(0, 49, 80, 0.08)',
minHeight: 260,
display: 'grid',
alignContent: 'end',
}}
>
{isAdmin ? 'Enviar para aprovação' : 'Enviar para admin'}
</button>
<div style={{ display: 'grid', gap: '0.45rem' }}>
<span style={{ color: 'var(--color-text-soft)', fontWeight: 800, fontSize: '0.82rem' }}>
Preview WhatsApp
</span>
<div
style={{
justifySelf: 'end',
maxWidth: '92%',
borderRadius: '16px 16px 4px 16px',
padding: '0.85rem 0.95rem',
background: '#d9fdd3',
color: '#1f2c33',
boxShadow: '0 6px 18px rgba(0, 49, 80, 0.08)',
whiteSpace: 'pre-wrap',
lineHeight: 1.45,
fontSize: '0.94rem',
}}
>
{renderTemplatePreview(form.content)}
<span
style={{
display: 'block',
marginTop: '0.5rem',
textAlign: 'right',
color: 'rgba(31, 44, 51, 0.58)',
fontSize: '0.72rem',
fontWeight: 700,
}}
>
10:42
</span>
</div>
</div>
</aside>
</form>
{statusMessage ? (
@ -255,7 +352,7 @@ export function TemplateManagementPanel({
<div>
<strong style={{ display: 'block' }}>{template.name}</strong>
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.9rem' }}>
{template.area_nome || 'Sem especialidade'}
{template.area_nome || 'Sem especialidade'} · {template.category || 'UTILITY'}
</span>
</div>
<span
@ -345,3 +442,4 @@ export function TemplateManagementPanel({
</section>
);
}

View File

@ -5,28 +5,30 @@ import { ManagementTable } from '../components/ManagementTable';
import { MetricGrid } from '../components/MetricGrid';
import { OperationalDashboard } from '../components/OperationalDashboard';
import { TemplateManagementPanel } from '../components/TemplateManagementPanel';
import { aiContentRows, areaRows, userRows } from '../services/managementMocks';
import { KnowledgeBasePanel } from '../components/KnowledgeBasePanel';
import { MassMessagePanel } from '../components/MassMessagePanel';
import { NewAttendancePage } from '../../attendance/pages/NewAttendancePage';
import { AttendantOpsPanel } from '../../home/components/AttendantOpsPanel';
import { MessagesWorkspace } from '../../home/components/MessagesWorkspace';
import { useChat } from '../../chat/hooks/useChat';
import {
createAccessArea,
createAiContent,
deleteAccessArea,
deleteAiContent,
getAccessAreas,
getAccessOptions,
getAccessUsers,
getAdminOverview,
getAiContents,
getAttendantRanking,
getAuditLogs,
updateAccessArea,
updateUserAccess,
} from '../services/adminAccessService';
import { useViewport } from '../../../shared/hooks/useViewport';
import { getCurrentUserDisplay } from '../../auth/services/sessionService';
const contentColumns = [
{ key: 'title', label: 'Conteúdo' },
{ key: 'area', label: 'Especialidade' },
{ key: 'status', label: 'Status' },
{ key: 'updatedAt', label: 'Atualizado' },
];
const selectStyle = {
width: '100%',
border: '1px solid var(--color-border)',
@ -44,14 +46,6 @@ const compactSelectStyle = {
fontSize: '0.82rem',
};
const monthlyKpis = [
{ label: 'Total de Atendimentos', value: '1.284', detail: '+12% vs mês anterior' },
{ label: 'Tempo Médio de Atendimento', value: '8m 42s', detail: 'média mensal' },
{ label: 'Taxa de Satisfação', value: '91%', detail: 'avaliações positivas' },
{ label: 'Volume por Canal', value: 'W 982 · E 184 · S 118', detail: 'WhatsApp · Email · SMS' },
{ label: 'Atendentes Ativos', value: '14 de 17', detail: 'ativos no mês' },
];
const dailyAttendance = [28, 34, 42, 39, 51, 47, 58, 62, 55, 69, 73, 66, 71, 88, 79, 84, 91, 86, 94, 101, 97, 108, 112, 104, 118, 123, 116, 129, 134, 141];
const channelDistribution = [
{ label: 'WhatsApp', value: 982, color: '#2bb741' },
@ -59,35 +53,11 @@ const channelDistribution = [
{ label: 'SMS', value: 118, color: '#00a4b7' },
];
const attendantRanking = [
{ id: 1, name: 'Ana Camolesi', area: 'Suporte', closed: 186, avgTime: '7m 12s', satisfaction: '94%' },
{ id: 2, name: 'Rafael Lopes', area: 'Suporte', closed: 172, avgTime: '8m 01s', satisfaction: '92%' },
{ id: 3, name: 'Marina Alves', area: 'Financeiro', closed: 161, avgTime: '8m 44s', satisfaction: '91%' },
{ id: 4, name: 'Lucas Nunes', area: 'Comercial', closed: 148, avgTime: '9m 02s', satisfaction: '89%' },
{ id: 5, name: 'Camila Rocha', area: 'Comercial', closed: 139, avgTime: '7m 58s', satisfaction: '93%' },
{ id: 6, name: 'Joao Pedro', area: 'Financeiro', closed: 127, avgTime: '10m 11s', satisfaction: '88%' },
{ id: 7, name: 'Beatriz Lima', area: 'Suporte', closed: 121, avgTime: '8m 39s', satisfaction: '90%' },
{ id: 8, name: 'Roberto Pera', area: 'Financeiro', closed: 116, avgTime: '9m 21s', satisfaction: '87%' },
{ id: 9, name: 'Helena Costa', area: 'Comercial', closed: 109, avgTime: '8m 55s', satisfaction: '92%' },
{ id: 10, name: 'Pedro Santos', area: 'Suporte', closed: 103, avgTime: '9m 48s', satisfaction: '86%' },
];
const initialNotices = [
{ id: 'n1', text: 'Revisar atendimentos financeiros com SLA abaixo de 15 minutos.' },
{ id: 'n2', text: 'Templates de abertura ativa atualizados para WhatsApp.' },
];
function mapMockUsers() {
return userRows.map((user) => ({
id: user.id,
nome: user.name,
email: user.email,
perfilPrincipal: { id: user.role, nome: user.role },
areaPrincipal: { id: user.area, nome: user.area },
accessStatus: 'assigned',
}));
}
function formatMinutes(minutes) {
if (minutes === null || minutes === undefined || Number.isNaN(Number(minutes))) return 'Sem dados';
return `${Number(minutes)} min`;
@ -174,15 +144,22 @@ export function AdminPage() {
const [overview, setOverview] = useState(null);
const [notices, setNotices] = useState(initialNotices);
const [noticeDraft, setNoticeDraft] = useState('');
const [users, setUsers] = useState(mapMockUsers);
const [users, setUsers] = useState([]);
const [profiles, setProfiles] = useState([]);
const [areas, setAreas] = useState([]);
const [areaRowsState, setAreaRowsState] = useState(areaRows);
const [areaRowsState, setAreaRowsState] = useState([]);
const [attendantRankingRows, setAttendantRankingRows] = useState([]);
const [auditData, setAuditData] = useState({ page: 1, limit: 100, total: 0, items: [] });
const [aiContents, setAiContents] = useState([]);
const [aiContentForm, setAiContentForm] = useState({ title: '', areaId: '', notes: '', file: null });
const [userSearch, setUserSearch] = useState('');
const [newAreaName, setNewAreaName] = useState('');
const [isLoadingAccess, setIsLoadingAccess] = useState(true);
const [accessError, setAccessError] = useState('');
const [editingUser, setEditingUser] = useState(null);
const [editingArea, setEditingArea] = useState(null);
const [editAreaName, setEditAreaName] = useState('');
const [editAreaDescription, setEditAreaDescription] = useState('');
const [editUserProfileId, setEditUserProfileId] = useState('');
const [editUserSpecialties, setEditUserSpecialties] = useState([]);
const [specialtyToAdd, setSpecialtyToAdd] = useState('');
@ -192,11 +169,14 @@ export function AdminPage() {
async function loadAccessData() {
try {
const [options, accessUsers, accessAreas, adminOverview] = await Promise.all([
const [options, accessUsers, accessAreas, adminOverview, ranking, audit, contents] = await Promise.all([
getAccessOptions(),
getAccessUsers(),
getAccessAreas(),
getAdminOverview(),
getAttendantRanking(),
getAuditLogs(1, 100),
getAiContents(),
]);
if (!isMounted) {
@ -208,10 +188,13 @@ export function AdminPage() {
setUsers(accessUsers || []);
setAreaRowsState(accessAreas || []);
setOverview(adminOverview || null);
setAttendantRankingRows(Array.isArray(ranking) ? ranking : []);
setAuditData(audit || { page: 1, limit: 100, total: 0, items: [] });
setAiContents(Array.isArray(contents) ? contents : []);
setAccessError('');
} catch {
if (isMounted) {
setAccessError('Backend indisponivel. Exibindo dados demonstrativos.');
setAccessError('Backend indisponível. Verifique a conexão para carregar os dados administrativos.');
}
} finally {
if (isMounted) {
@ -227,6 +210,19 @@ export function AdminPage() {
};
}, []);
useEffect(() => {
let isMounted = true;
const area = areas.find((item) => item.nome === selectedAreaFilter);
getAttendantRanking(selectedAreaFilter === 'all' ? null : area?.id)
.then((ranking) => {
if (isMounted) setAttendantRankingRows(Array.isArray(ranking) ? ranking : []);
})
.catch(() => undefined);
return () => {
isMounted = false;
};
}, [selectedAreaFilter, areas]);
function openUserEditor(user) {
setEditingUser(user);
setEditUserProfileId(user.perfilPrincipal?.id ? String(user.perfilPrincipal.id) : '');
@ -333,6 +329,108 @@ export function AdminPage() {
}
}
function openAreaEditor(area) {
setEditingArea(area);
setEditAreaName(area.nome || '');
setEditAreaDescription(area.descricao || '');
}
function closeAreaEditor() {
setEditingArea(null);
setEditAreaName('');
setEditAreaDescription('');
}
async function submitAreaEditor() {
if (!editingArea) return;
const nome = editAreaName.trim();
if (!nome) return;
try {
await updateAccessArea(editingArea.id, {
nome,
descricao: editAreaDescription,
ativo: true,
});
await refreshAreas();
closeAreaEditor();
setAccessError('');
} catch {
setAccessError('Não foi possível editar a especialidade.');
}
}
async function handleDeleteArea(area) {
const confirmed = window.confirm(`Tem certeza que deseja excluir a especialidade "${area.nome}"?`);
if (!confirmed) return;
try {
await deleteAccessArea(area.id);
await refreshAreas();
setAccessError('');
} catch {
setAccessError('Não foi possível excluir a especialidade.');
}
}
function readFileAsBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = String(reader.result || '');
resolve(result.includes(',') ? result.split(',')[1] : result);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
async function submitAiContent(event) {
event.preventDefault();
const title = aiContentForm.title.trim();
if (!title || !aiContentForm.file) return;
try {
const contentBase64 = await readFileAsBase64(aiContentForm.file);
const contents = await createAiContent({
title,
areaId: aiContentForm.areaId ? Number(aiContentForm.areaId) : null,
notes: aiContentForm.notes,
filename: aiContentForm.file.name,
mimetype: aiContentForm.file.type || 'application/octet-stream',
fileSize: aiContentForm.file.size,
contentBase64,
});
setAiContents(Array.isArray(contents) ? contents : []);
setAiContentForm({ title: '', areaId: '', notes: '', file: null });
setAccessError('');
} catch {
setAccessError('Não foi possível adicionar o conteúdo da IA.');
}
}
async function removeAiContent(contentId) {
const confirmed = window.confirm('Tem certeza que deseja remover este conteúdo da IA?');
if (!confirmed) return;
try {
const contents = await deleteAiContent(contentId);
setAiContents(Array.isArray(contents) ? contents : []);
setAccessError('');
} catch {
setAccessError('Não foi possível remover o conteúdo da IA.');
}
}
async function goToAuditPage(nextPage) {
try {
const audit = await getAuditLogs(nextPage, 100);
setAuditData(audit || { page: nextPage, limit: 100, total: 0, items: [] });
} catch {
setAccessError('Não foi possível carregar a auditoria.');
}
}
const realMonthlyKpis = [
{
label: 'Total de Atendimentos',
@ -479,13 +577,47 @@ export function AdminPage() {
label: 'Status',
render: (row) => (row.ativo ? 'Ativa' : 'Inativa'),
},
{
key: 'actions',
label: 'Ações',
render: (row) => (
<div style={{ display: 'flex', gap: '0.45rem', flexWrap: 'wrap' }}>
<button
type="button"
onClick={() => openAreaEditor(row)}
style={{
border: 'none',
borderRadius: 12,
padding: '0.55rem 0.7rem',
background: 'var(--color-primary)',
color: '#fff',
fontWeight: 800,
}}
>
Editar
</button>
<button
type="button"
onClick={() => handleDeleteArea(row)}
style={{
border: 'none',
borderRadius: 12,
padding: '0.55rem 0.7rem',
background: 'rgba(181, 31, 31, 0.1)',
color: 'var(--color-secondary)',
fontWeight: 800,
}}
>
Excluir
</button>
</div>
),
},
],
[],
[isMobile],
);
const filteredRanking = selectedAreaFilter === 'all'
? attendantRanking
: attendantRanking.filter((row) => row.area === selectedAreaFilter);
const filteredRanking = attendantRankingRows;
const rankingColumns = [
{ key: 'name', label: 'Nome' },
@ -846,12 +978,244 @@ export function AdminPage() {
</button>
</div>
<ManagementTable columns={areaColumns} rows={areaRowsState} getRowId={(row) => row.id} isMobile={isMobile} />
{editingArea ? (
<div
role="dialog"
aria-modal="true"
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0, 49, 80, 0.28)',
display: 'grid',
placeItems: 'center',
padding: '1rem',
zIndex: 30,
}}
>
<div
style={{
width: 'min(520px, 100%)',
background: '#fff',
borderRadius: 24,
boxShadow: 'var(--shadow-lg)',
padding: '1.25rem',
display: 'grid',
gap: '1rem',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
<div>
<h2 style={{ margin: 0, fontSize: '1.2rem' }}>Editar especialidade</h2>
<p style={{ margin: '0.35rem 0 0', color: 'var(--color-text-soft)' }}>
Ajuste o nome exibido nas filas, templates e fluxo do bot.
</p>
</div>
<button
type="button"
onClick={closeAreaEditor}
style={{
border: 'none',
background: 'transparent',
color: 'var(--color-text-soft)',
fontWeight: 800,
}}
>
Fechar
</button>
</div>
<label style={{ display: 'grid', gap: '0.45rem' }}>
<span style={{ fontWeight: 800 }}>Nome</span>
<input
value={editAreaName}
onChange={(event) => setEditAreaName(event.target.value)}
style={selectStyle}
/>
</label>
<label style={{ display: 'grid', gap: '0.45rem' }}>
<span style={{ fontWeight: 800 }}>Descrição</span>
<textarea
rows={3}
value={editAreaDescription}
onChange={(event) => setEditAreaDescription(event.target.value)}
style={{ ...selectStyle, resize: 'vertical' }}
/>
</label>
<button
type="button"
onClick={submitAreaEditor}
style={{
border: 'none',
borderRadius: 16,
padding: '0.9rem 1rem',
background: 'var(--color-primary)',
color: '#fff',
fontWeight: 800,
}}
>
Salvar especialidade
</button>
</div>
</div>
) : null}
</div>
</DataPanel>
</div>
);
}
function renderAudit() {
const totalPages = Math.max(1, Math.ceil((auditData.total || 0) / 100));
const columns = [
{
key: 'created_at',
label: 'Data',
render: (row) => row.created_at ? new Date(row.created_at).toLocaleString('pt-BR') : '-',
},
{ key: 'actor', label: 'Origem' },
{ key: 'action', label: 'Ação' },
{ key: 'target_type', label: 'Tipo' },
{ key: 'details', label: 'Detalhe' },
];
return (
<DataPanel title="Auditoria" description="Eventos administrativos e operacionais consolidados. Exibição de 100 registros por página.">
<div style={{ display: 'grid', gap: '0.85rem' }}>
<ManagementTable columns={columns} rows={auditData.items || []} getRowId={(row) => row.id} isMobile={isMobile} />
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', alignItems: 'center', flexWrap: 'wrap' }}>
<span style={{ color: 'var(--color-text-soft)', fontWeight: 700 }}>
Página {auditData.page || 1} de {totalPages} · {auditData.total || 0} eventos
</span>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button
type="button"
disabled={(auditData.page || 1) <= 1}
onClick={() => goToAuditPage((auditData.page || 1) - 1)}
style={{ ...compactSelectStyle, opacity: (auditData.page || 1) <= 1 ? 0.55 : 1 }}
>
Anterior
</button>
<button
type="button"
disabled={(auditData.page || 1) >= totalPages}
onClick={() => goToAuditPage((auditData.page || 1) + 1)}
style={{ ...compactSelectStyle, opacity: (auditData.page || 1) >= totalPages ? 0.55 : 1 }}
>
Próxima
</button>
</div>
</div>
</div>
</DataPanel>
);
}
function renderAiContents() {
const columns = [
{ key: 'title', label: 'Conteúdo' },
{ key: 'area_nome', label: 'Especialidade', render: (row) => row.area_nome || 'Geral' },
{ key: 'filename', label: 'Arquivo' },
{ key: 'status', label: 'Status', render: () => 'Disponível para consulta' },
{
key: 'actions',
label: 'Ações',
render: (row) => (
<button
type="button"
onClick={() => removeAiContent(row.id)}
style={{
border: 'none',
borderRadius: 12,
padding: '0.55rem 0.7rem',
background: 'rgba(181, 31, 31, 0.1)',
color: 'var(--color-secondary)',
fontWeight: 800,
}}
>
Remover
</button>
),
},
];
return (
<section style={{ display: 'grid', gap: '1rem' }}>
<DataPanel
title="Conteúdos da IA"
description="A IA está em fase de testes. Os documentos adicionados aqui alimentam a base que será consultada para responder dúvidas de RH."
>
<form onSubmit={submitAiContent} style={{ display: 'grid', gap: '0.85rem' }}>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 1fr) minmax(220px, 0.45fr)', gap: '0.75rem' }}>
<input
value={aiContentForm.title}
onChange={(event) => setAiContentForm((current) => ({ ...current, title: event.target.value }))}
placeholder="Título do conteúdo. Ex: Política de férias"
style={selectStyle}
/>
<select
value={aiContentForm.areaId}
onChange={(event) => setAiContentForm((current) => ({ ...current, areaId: event.target.value }))}
style={selectStyle}
>
<option value="">Base geral</option>
{areas.map((area) => (
<option key={area.id} value={area.id}>{area.nome}</option>
))}
</select>
</div>
<textarea
rows={3}
value={aiContentForm.notes}
onChange={(event) => setAiContentForm((current) => ({ ...current, notes: event.target.value }))}
placeholder="Observações para curadoria, contexto ou restrições de uso."
style={{ ...selectStyle, resize: 'vertical' }}
/>
<input
type="file"
accept=".pdf,.txt,.doc,.docx,application/pdf,text/plain,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
onChange={(event) => setAiContentForm((current) => ({ ...current, file: event.target.files?.[0] || null }))}
style={selectStyle}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap', alignItems: 'center' }}>
<span style={{ color: 'var(--color-text-soft)', fontWeight: 700 }}>
Formatos aceitos: PDF, TXT, DOC e DOCX.
</span>
<button
type="submit"
style={{
border: 'none',
borderRadius: 16,
padding: '0.9rem 1rem',
background: 'var(--color-primary)',
color: '#fff',
fontWeight: 800,
}}
>
Adicionar conteúdo
</button>
</div>
</form>
</DataPanel>
<DataPanel title="Base disponível" description="Materiais cadastrados para consulta pela IA.">
<ManagementTable columns={columns} rows={aiContents} getRowId={(row) => row.id} isMobile={isMobile} />
</DataPanel>
<DataPanel title="Regras e travas" description="Diretrizes de segurança para a IA respeitar durante respostas ao colaborador.">
<div style={{ display: 'grid', gap: '0.65rem', color: 'var(--color-text-soft)', fontWeight: 700 }}>
<span>Não informar dados sensíveis sem validação do colaborador.</span>
<span>Direcionar casos de assédio, denúncia ou risco trabalhista para atendimento humano.</span>
<span>Não inventar políticas: responder apenas com base nos conteúdos cadastrados.</span>
<span>Quando houver dúvida ou conflito de informação, encaminhar para especialista.</span>
</div>
</DataPanel>
</section>
);
}
function renderPlaceholder(title, description) {
return (
<DataPanel title={title} description={description}>
@ -867,12 +1231,9 @@ export function AdminPage() {
today: <OperationalDashboard isDesktop={isDesktop} isMobile={isMobile} />,
'users-access': renderUsersAccess(),
templates: <TemplateManagementPanel areas={areas} mode="admin" isMobile={isMobile} />,
knowledge: (
<DataPanel title="Base de conhecimento IA" description="Entradas para alimentar a base de conhecimento.">
<ManagementTable columns={contentColumns} rows={aiContentRows} getRowId={(row) => row.id} isMobile={isMobile} />
</DataPanel>
),
audit: renderPlaceholder('Auditoria', 'Eventos administrativos e alterações sensíveis.'),
knowledge: <KnowledgeBasePanel areas={areas} mode="admin" isMobile={isMobile} />,
'ai-contents': renderAiContents(),
audit: renderAudit(),
channels: renderPlaceholder('Canais', 'Status e configurações dos canais conectados.'),
attendance: (
<AdminAttendanceWorkspace
@ -882,7 +1243,8 @@ export function AdminPage() {
isMobile={isMobile}
/>
),
'mass-message': renderPlaceholder('Disparo em massa', 'Fluxo de disparos por templates aprovados.'),
'new-attendance': <NewAttendancePage embedded />,
'mass-message': <MassMessagePanel areas={areas} mode="admin" isMobile={isMobile} />,
contacts: renderPlaceholder('Contatos', 'Agenda geral de contatos.'),
settings: renderPlaceholder('Configurações', 'Preferencias e parametros do ambiente.'),
};
@ -891,17 +1253,33 @@ export function AdminPage() {
? 'Home do Admin'
: activeAdminSection === 'attendance'
? 'Atendimento'
: activeAdminSection === 'new-attendance'
? 'Abrir Atendimento'
: activeAdminSection === 'today'
? 'Operação'
: 'Painel administrativo';
: activeAdminSection === 'audit'
? 'Auditoria'
: activeAdminSection === 'ai-contents'
? 'Conteúdos da IA'
: activeAdminSection === 'knowledge'
? 'Fluxo do Bot'
: 'Painel administrativo';
const pageSubtitle = activeAdminSection === 'home'
? 'Visão mensal consolidada por especialidade, canal e atendente.'
: activeAdminSection === 'attendance'
? 'Home operacional do atendente dentro do painel administrativo.'
? 'Operação de atendimento dentro do painel administrativo.'
: activeAdminSection === 'new-attendance'
? 'Inicie um contato ativo por WhatsApp usando mensagens pré-aprovadas.'
: activeAdminSection === 'today'
? 'Indicadores do dia, fila de espera e acompanhamento operacional do time.'
: 'Controle operacional e configurações administrativas.';
: activeAdminSection === 'audit'
? 'Logs administrativos e operacionais com paginação de 100 eventos.'
: activeAdminSection === 'ai-contents'
? 'Base de documentos que será consultada pela IA em fase de testes.'
: activeAdminSection === 'knowledge'
? 'Árvore de decisão configurável para roteamento do Agente Virtual Sothis.'
: 'Controle operacional e configurações administrativas.';
return (
<ManagementLayout

View File

@ -2,9 +2,10 @@ import { useEffect, useState } from 'react';
import { ManagementLayout } from '../components/ManagementLayout';
import { OperationalDashboard } from '../components/OperationalDashboard';
import { TemplateManagementPanel } from '../components/TemplateManagementPanel';
import { KnowledgeBasePanel } from '../components/KnowledgeBasePanel';
import { MassMessagePanel } from '../components/MassMessagePanel';
import { DataPanel } from '../components/DataPanel';
import { ManagementTable } from '../components/ManagementTable';
import { aiContentRows } from '../services/managementMocks';
import { NewAttendancePage } from '../../attendance/pages/NewAttendancePage';
import { getAccessOptions } from '../services/adminAccessService';
import { useViewport } from '../../../shared/hooks/useViewport';
import { getCurrentUser, getCurrentUserDisplay } from '../../auth/services/sessionService';
@ -50,23 +51,12 @@ export function SupervisorPage() {
return (
<DataPanel title={title} description={description}>
<div style={{ border: '1px solid var(--color-border)', borderRadius: 18, padding: '1rem', background: '#fff', color: 'var(--color-text-soft)', fontWeight: 700 }}>
Seção em preparação.
Secao em preparacao.
</div>
</DataPanel>
);
}
const contentColumns = [
{ key: 'title', label: 'Conteúdo' },
{ key: 'area', label: 'Especialidade' },
{ key: 'status', label: 'Status' },
{ key: 'updatedAt', label: 'Atualizado' },
];
const filteredKnowledgeRows = managedSpecialties.length
? aiContentRows.filter((row) => managedSpecialties.includes(row.area))
: aiContentRows;
const sectionContent = {
dashboard: <OperationalDashboard isDesktop={isDesktop} isMobile={isMobile} />,
templates: (
@ -78,11 +68,18 @@ export function SupervisorPage() {
/>
),
knowledge: (
<DataPanel title="Base de conhecimento" description="Conteúdos da IA para as especialidades supervisionadas.">
<ManagementTable columns={contentColumns} rows={filteredKnowledgeRows} getRowId={(row) => row.id} isMobile={isMobile} />
</DataPanel>
<KnowledgeBasePanel
areas={areas}
mode="supervisor"
managedAreaNames={managedSpecialties}
isMobile={isMobile}
/>
),
audit: renderPlaceholder('Auditoria', 'Eventos do time supervisionado serão consolidados aqui.'),
'ai-contents': renderPlaceholder(
'Conteúdos da IA',
'A IA está em fase de testes. O cadastro e a curadoria da base ficam centralizados no admin.',
),
audit: renderPlaceholder('Auditoria', 'Eventos do time supervisionado serao consolidados aqui.'),
attendance: (
<AdminAttendanceWorkspace
isWideDesktop={isWideDesktop}
@ -91,7 +88,15 @@ export function SupervisorPage() {
isMobile={isMobile}
/>
),
'mass-message': renderPlaceholder('Disparo em massa', 'Fluxo de disparos por templates aprovados.'),
'new-attendance': <NewAttendancePage embedded />,
'mass-message': (
<MassMessagePanel
areas={areas}
mode="supervisor"
managedAreaNames={managedSpecialties}
isMobile={isMobile}
/>
),
contacts: renderPlaceholder('Contatos', 'Agenda geral de contatos.'),
};

View File

@ -10,7 +10,14 @@ async function request(path, options = {}) {
});
if (!response.ok) {
throw new Error('Falha ao consultar acessos');
let message = 'Falha ao consultar acessos';
try {
const payload = await response.json();
message = payload?.message || payload?.error || message;
} catch {
// Mantem mensagem padrao.
}
throw new Error(message);
}
return response.json();
@ -24,6 +31,32 @@ export async function getAdminOverview() {
return request('/admin/access/overview');
}
export async function getAttendantRanking(areaId) {
const query = areaId ? `?areaId=${encodeURIComponent(areaId)}` : '';
return request(`/admin/access/ranking${query}`);
}
export async function getAuditLogs(page = 1, limit = 100) {
return request(`/admin/access/audit?page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`);
}
export async function getAiContents() {
return request('/admin/access/ai-contents');
}
export async function createAiContent(payload) {
return request('/admin/access/ai-contents', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export async function deleteAiContent(id) {
return request(`/admin/access/ai-contents/${id}`, {
method: 'DELETE',
});
}
export async function getAccessUsers() {
return request('/admin/access/users');
}
@ -52,3 +85,9 @@ export async function updateAccessArea(areaId, payload) {
body: JSON.stringify(payload),
});
}
export async function deleteAccessArea(areaId) {
return request(`/admin/access/areas/${areaId}`, {
method: 'DELETE',
});
}

View File

@ -0,0 +1,124 @@
import { API_BASE_URL } from '../../../shared/services/apiConfig';
async function request(path, options = {}) {
const response = await fetch(`${API_BASE_URL}${path}`, {
headers: {
'Content-Type': 'application/json',
...options.headers,
},
...options,
});
if (!response.ok) {
let message = 'Falha ao consultar base de conhecimento.';
try {
const payload = await response.json();
message = Array.isArray(payload?.message)
? payload.message.join(' ')
: payload?.message || payload?.error || message;
} catch {
// Mantem a mensagem padrao quando a API nao devolve JSON.
}
throw new Error(message);
}
return response.json();
}
export function listRoutingKeywords(areaId) {
const query = areaId ? `?areaId=${encodeURIComponent(areaId)}` : '';
return request(`/admin/knowledge/routing-keywords${query}`);
}
export function getBotFlow() {
return request('/admin/knowledge/bot-flow');
}
export function listBotFlowVersions() {
return request('/admin/knowledge/bot-flow/versions');
}
export function createBotFlowNode(payload) {
return request('/admin/knowledge/bot-flow/nodes', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function updateBotFlowNode(id, payload) {
return request(`/admin/knowledge/bot-flow/nodes/${id}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
}
export function deleteBotFlowNode(id) {
return request(`/admin/knowledge/bot-flow/nodes/${id}`, {
method: 'DELETE',
});
}
export function publishBotFlow() {
return request('/admin/knowledge/bot-flow/publish', {
method: 'POST',
});
}
export function getTriageFlow() {
return request('/admin/knowledge/triage-flow');
}
export function updateTriageFlow(payload) {
return request('/admin/knowledge/triage-flow', {
method: 'PUT',
body: JSON.stringify(payload),
});
}
export function createTriageAudience(payload) {
return request('/admin/knowledge/triage-flow/audiences', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function updateTriageAudience(id, payload) {
return request(`/admin/knowledge/triage-flow/audiences/${id}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
}
export function createTriageIntent(payload) {
return request('/admin/knowledge/triage-flow/intents', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function updateTriageIntent(id, payload) {
return request(`/admin/knowledge/triage-flow/intents/${id}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
}
export function createRoutingKeyword(payload) {
return request('/admin/knowledge/routing-keywords', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function updateRoutingKeyword(id, payload) {
return request(`/admin/knowledge/routing-keywords/${id}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
}
export function deleteRoutingKeyword(id) {
return request(`/admin/knowledge/routing-keywords/${id}`, {
method: 'DELETE',
});
}