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:
parent
4b0a4bb3e3
commit
f690c6d652
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}
|
||||
|
||||
719
src/modules/management/components/KnowledgeBasePanel.jsx
Normal file
719
src/modules/management/components/KnowledgeBasePanel.jsx
Normal 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 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>
|
||||
);
|
||||
}
|
||||
@ -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({
|
||||
|
||||
493
src/modules/management/components/MassMessagePanel.jsx
Normal file
493
src/modules/management/components/MassMessagePanel.jsx
Normal 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 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.'),
|
||||
};
|
||||
|
||||
|
||||
@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
124
src/modules/management/services/knowledgeService.js
Normal file
124
src/modules/management/services/knowledgeService.js
Normal 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',
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user