2026-05-18 17:34:23 -03:00
|
|
|
import { useEffect, useMemo, useState } from 'react';
|
2026-05-26 12:12:33 -03:00
|
|
|
import { useNavigate } from 'react-router-dom';
|
2026-05-18 17:34:23 -03:00
|
|
|
import { DataPanel } from '../components/DataPanel';
|
|
|
|
|
import { ManagementLayout } from '../components/ManagementLayout';
|
|
|
|
|
import { ManagementTable } from '../components/ManagementTable';
|
|
|
|
|
import { MetricGrid } from '../components/MetricGrid';
|
2026-05-21 15:50:55 -03:00
|
|
|
import { OperationalDashboard } from '../components/OperationalDashboard';
|
2026-05-22 10:51:07 -03:00
|
|
|
import { TemplateManagementPanel } from '../components/TemplateManagementPanel';
|
2026-05-26 09:08:08 -03:00
|
|
|
import { KnowledgeBasePanel } from '../components/KnowledgeBasePanel';
|
|
|
|
|
import { MassMessagePanel } from '../components/MassMessagePanel';
|
|
|
|
|
import { NewAttendancePage } from '../../attendance/pages/NewAttendancePage';
|
2026-05-21 15:50:55 -03:00
|
|
|
import { AttendantOpsPanel } from '../../home/components/AttendantOpsPanel';
|
|
|
|
|
import { MessagesWorkspace } from '../../home/components/MessagesWorkspace';
|
|
|
|
|
import { useChat } from '../../chat/hooks/useChat';
|
2026-05-21 12:06:30 -03:00
|
|
|
import {
|
|
|
|
|
createAccessArea,
|
2026-05-26 09:08:08 -03:00
|
|
|
createAiContent,
|
|
|
|
|
deleteAccessArea,
|
|
|
|
|
deleteAiContent,
|
2026-05-21 12:06:30 -03:00
|
|
|
getAccessAreas,
|
|
|
|
|
getAccessOptions,
|
|
|
|
|
getAccessUsers,
|
|
|
|
|
getAdminOverview,
|
2026-05-26 09:08:08 -03:00
|
|
|
getAiContents,
|
|
|
|
|
getAttendantRanking,
|
|
|
|
|
getAuditLogs,
|
|
|
|
|
updateAccessArea,
|
2026-05-21 12:06:30 -03:00
|
|
|
updateUserAccess,
|
|
|
|
|
} from '../services/adminAccessService';
|
2026-05-18 17:34:23 -03:00
|
|
|
import { useViewport } from '../../../shared/hooks/useViewport';
|
2026-05-18 19:11:01 -03:00
|
|
|
import { getCurrentUserDisplay } from '../../auth/services/sessionService';
|
2026-05-18 17:34:23 -03:00
|
|
|
|
|
|
|
|
const selectStyle = {
|
|
|
|
|
width: '100%',
|
|
|
|
|
border: '1px solid var(--color-border)',
|
|
|
|
|
borderRadius: '14px',
|
|
|
|
|
padding: '0.75rem 0.85rem',
|
|
|
|
|
background: '#fff',
|
|
|
|
|
color: 'var(--color-text)',
|
|
|
|
|
fontWeight: 600,
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-21 15:50:55 -03:00
|
|
|
const compactSelectStyle = {
|
|
|
|
|
...selectStyle,
|
|
|
|
|
borderRadius: '10px',
|
|
|
|
|
padding: '0.45rem 0.55rem',
|
|
|
|
|
fontSize: '0.82rem',
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-21 12:06:30 -03:00
|
|
|
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' },
|
|
|
|
|
{ label: 'Email', value: 184, color: '#e5a22a' },
|
|
|
|
|
{ label: 'SMS', value: 118, color: '#00a4b7' },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const initialNotices = [
|
|
|
|
|
{ id: 'n1', text: 'Revisar atendimentos financeiros com SLA abaixo de 15 minutos.' },
|
|
|
|
|
{ id: 'n2', text: 'Templates de abertura ativa atualizados para WhatsApp.' },
|
|
|
|
|
];
|
|
|
|
|
|
2026-05-26 11:35:23 -03:00
|
|
|
const integrationCards = [
|
|
|
|
|
{
|
|
|
|
|
id: 'whatsapp',
|
|
|
|
|
group: 'Canal',
|
|
|
|
|
name: 'WhatsApp',
|
|
|
|
|
icon: 'WA',
|
|
|
|
|
color: '#20a45b',
|
2026-05-26 12:12:33 -03:00
|
|
|
configured: true,
|
2026-05-26 11:35:23 -03:00
|
|
|
description: 'Canal principal para atendimento, abertura ativa e continuidade das conversas no chat.',
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'sms',
|
|
|
|
|
group: 'Canal',
|
|
|
|
|
name: 'SMS',
|
|
|
|
|
icon: 'SM',
|
|
|
|
|
color: '#00a4b7',
|
2026-05-26 12:12:33 -03:00
|
|
|
configured: false,
|
2026-05-26 11:35:23 -03:00
|
|
|
description: 'Envio de comunicados curtos, confirmações e mensagens transacionais para contatos sem WhatsApp.',
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'email',
|
|
|
|
|
group: 'Canal',
|
|
|
|
|
name: 'Email',
|
|
|
|
|
icon: 'EM',
|
|
|
|
|
color: '#d8891c',
|
2026-05-26 12:12:33 -03:00
|
|
|
configured: false,
|
2026-05-26 11:35:23 -03:00
|
|
|
description: 'Recebimento e resposta de demandas por email dentro da fila omnichannel.',
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'sharepoint',
|
|
|
|
|
group: 'Integração',
|
|
|
|
|
name: 'SharePoint',
|
|
|
|
|
icon: 'SP',
|
|
|
|
|
color: '#036c70',
|
2026-05-26 12:12:33 -03:00
|
|
|
configured: false,
|
2026-05-26 11:35:23 -03:00
|
|
|
description: 'Permite que a IA visualize documentos autorizados para alimentar e manter a base de conhecimento.',
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'gupy',
|
|
|
|
|
group: 'Integração',
|
|
|
|
|
name: 'Gupy',
|
|
|
|
|
icon: 'GP',
|
|
|
|
|
color: '#7b4cc2',
|
2026-05-26 12:12:33 -03:00
|
|
|
configured: false,
|
2026-05-26 11:35:23 -03:00
|
|
|
description: 'Conecta vagas abertas e processos dos candidatos para enriquecer a base de conhecimento.',
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
2026-05-21 12:06:30 -03:00
|
|
|
function formatMinutes(minutes) {
|
|
|
|
|
if (minutes === null || minutes === undefined || Number.isNaN(Number(minutes))) return 'Sem dados';
|
|
|
|
|
return `${Number(minutes)} min`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-21 15:50:55 -03:00
|
|
|
function toHomeConversation(contact, messages = []) {
|
|
|
|
|
return {
|
|
|
|
|
id: contact.id,
|
|
|
|
|
name: contact.name,
|
|
|
|
|
channel: contact.channel || 'WhatsApp',
|
|
|
|
|
status: contact.status || 'online',
|
|
|
|
|
lastMessage: contact.preview || messages[messages.length - 1]?.text || '',
|
|
|
|
|
unread: contact.unread || 0,
|
|
|
|
|
time: contact.time || 'Agora',
|
|
|
|
|
lastSeen: contact.lastSeen,
|
|
|
|
|
messages: messages.map((message) => ({
|
|
|
|
|
id: message.id,
|
|
|
|
|
from: message.sender === 'agent' ? 'agent' : 'customer',
|
|
|
|
|
text: message.text || (message.hasMedia ? '[Mídia]' : ''),
|
|
|
|
|
timestamp: message.timestamp,
|
|
|
|
|
})),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 10:51:07 -03:00
|
|
|
export function AdminAttendanceWorkspace({ isWideDesktop, isDesktop, isTablet, isMobile }) {
|
2026-05-21 15:50:55 -03:00
|
|
|
const {
|
|
|
|
|
contacts,
|
|
|
|
|
activeContactId,
|
|
|
|
|
setActiveContactId,
|
|
|
|
|
messages,
|
|
|
|
|
sendMessage,
|
|
|
|
|
isLoadingChats,
|
|
|
|
|
} = useChat();
|
|
|
|
|
|
|
|
|
|
const conversations = contacts.map((contact) =>
|
|
|
|
|
toHomeConversation(contact, contact.id === activeContactId ? messages : []),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const safeConversationId =
|
|
|
|
|
conversations.find((conversation) => conversation.id === activeContactId)?.id ||
|
|
|
|
|
conversations[0]?.id;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<section style={{ display: 'grid', gap: '1rem' }}>
|
|
|
|
|
<AttendantOpsPanel activeChatsCount={conversations.length} />
|
|
|
|
|
|
|
|
|
|
{isLoadingChats ? (
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
border: '1px solid var(--color-border)',
|
|
|
|
|
borderRadius: 18,
|
|
|
|
|
padding: '0.85rem 1rem',
|
|
|
|
|
background: '#fff',
|
|
|
|
|
color: 'var(--color-text-soft)',
|
|
|
|
|
fontWeight: 700,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
Atualizando conversas do WhatsApp...
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
<MessagesWorkspace
|
|
|
|
|
conversations={conversations}
|
|
|
|
|
activeConversationId={safeConversationId}
|
|
|
|
|
onSelectConversation={setActiveContactId}
|
|
|
|
|
onSendSuggestedReply={async (conversationId, reply) => {
|
|
|
|
|
setActiveContactId(conversationId);
|
|
|
|
|
await sendMessage(reply, conversationId);
|
|
|
|
|
}}
|
|
|
|
|
isWideDesktop={isWideDesktop}
|
|
|
|
|
isDesktop={isDesktop}
|
|
|
|
|
isTablet={isTablet}
|
|
|
|
|
isMobile={isMobile}
|
|
|
|
|
/>
|
|
|
|
|
</section>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-18 17:34:23 -03:00
|
|
|
export function AdminPage() {
|
2026-05-26 12:12:33 -03:00
|
|
|
const navigate = useNavigate();
|
2026-05-21 15:50:55 -03:00
|
|
|
const { isWideDesktop, isDesktop, isTablet, isMobile } = useViewport();
|
2026-05-18 19:11:01 -03:00
|
|
|
const userDisplay = getCurrentUserDisplay();
|
2026-05-21 12:06:30 -03:00
|
|
|
const [activeAdminSection, setActiveAdminSection] = useState('home');
|
|
|
|
|
const [selectedAreaFilter, setSelectedAreaFilter] = useState('all');
|
|
|
|
|
const [overview, setOverview] = useState(null);
|
|
|
|
|
const [notices, setNotices] = useState(initialNotices);
|
|
|
|
|
const [noticeDraft, setNoticeDraft] = useState('');
|
2026-05-26 09:08:08 -03:00
|
|
|
const [users, setUsers] = useState([]);
|
2026-05-18 17:34:23 -03:00
|
|
|
const [profiles, setProfiles] = useState([]);
|
|
|
|
|
const [areas, setAreas] = useState([]);
|
2026-05-26 09:08:08 -03:00
|
|
|
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 });
|
2026-05-21 12:06:30 -03:00
|
|
|
const [userSearch, setUserSearch] = useState('');
|
|
|
|
|
const [newAreaName, setNewAreaName] = useState('');
|
2026-05-18 17:34:23 -03:00
|
|
|
const [isLoadingAccess, setIsLoadingAccess] = useState(true);
|
|
|
|
|
const [accessError, setAccessError] = useState('');
|
2026-05-21 15:50:55 -03:00
|
|
|
const [editingUser, setEditingUser] = useState(null);
|
2026-05-26 09:08:08 -03:00
|
|
|
const [editingArea, setEditingArea] = useState(null);
|
|
|
|
|
const [editAreaName, setEditAreaName] = useState('');
|
|
|
|
|
const [editAreaDescription, setEditAreaDescription] = useState('');
|
2026-05-21 15:50:55 -03:00
|
|
|
const [editUserProfileId, setEditUserProfileId] = useState('');
|
|
|
|
|
const [editUserSpecialties, setEditUserSpecialties] = useState([]);
|
|
|
|
|
const [specialtyToAdd, setSpecialtyToAdd] = useState('');
|
2026-05-26 11:35:23 -03:00
|
|
|
const [integrationStates, setIntegrationStates] = useState({
|
|
|
|
|
whatsapp: true,
|
|
|
|
|
sms: false,
|
|
|
|
|
email: false,
|
|
|
|
|
sharepoint: false,
|
|
|
|
|
gupy: false,
|
|
|
|
|
});
|
2026-05-26 12:12:33 -03:00
|
|
|
const [integrationNotice, setIntegrationNotice] = useState('');
|
|
|
|
|
const [configurationModal, setConfigurationModal] = useState(null);
|
2026-05-18 17:34:23 -03:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
let isMounted = true;
|
|
|
|
|
|
|
|
|
|
async function loadAccessData() {
|
|
|
|
|
try {
|
2026-05-26 09:08:08 -03:00
|
|
|
const [options, accessUsers, accessAreas, adminOverview, ranking, audit, contents] = await Promise.all([
|
2026-05-21 12:06:30 -03:00
|
|
|
getAccessOptions(),
|
|
|
|
|
getAccessUsers(),
|
|
|
|
|
getAccessAreas(),
|
|
|
|
|
getAdminOverview(),
|
2026-05-26 09:08:08 -03:00
|
|
|
getAttendantRanking(),
|
|
|
|
|
getAuditLogs(1, 100),
|
|
|
|
|
getAiContents(),
|
2026-05-21 12:06:30 -03:00
|
|
|
]);
|
2026-05-18 17:34:23 -03:00
|
|
|
|
|
|
|
|
if (!isMounted) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setProfiles(options.profiles || []);
|
|
|
|
|
setAreas(options.areas || []);
|
|
|
|
|
setUsers(accessUsers || []);
|
2026-05-21 12:06:30 -03:00
|
|
|
setAreaRowsState(accessAreas || []);
|
|
|
|
|
setOverview(adminOverview || null);
|
2026-05-26 09:08:08 -03:00
|
|
|
setAttendantRankingRows(Array.isArray(ranking) ? ranking : []);
|
|
|
|
|
setAuditData(audit || { page: 1, limit: 100, total: 0, items: [] });
|
|
|
|
|
setAiContents(Array.isArray(contents) ? contents : []);
|
2026-05-18 17:34:23 -03:00
|
|
|
setAccessError('');
|
|
|
|
|
} catch {
|
|
|
|
|
if (isMounted) {
|
2026-05-26 09:08:08 -03:00
|
|
|
setAccessError('Backend indisponível. Verifique a conexão para carregar os dados administrativos.');
|
2026-05-18 17:34:23 -03:00
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
if (isMounted) {
|
|
|
|
|
setIsLoadingAccess(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
loadAccessData();
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
isMounted = false;
|
|
|
|
|
};
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-05-26 09:08:08 -03:00
|
|
|
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]);
|
|
|
|
|
|
2026-05-21 15:50:55 -03:00
|
|
|
function openUserEditor(user) {
|
|
|
|
|
setEditingUser(user);
|
|
|
|
|
setEditUserProfileId(user.perfilPrincipal?.id ? String(user.perfilPrincipal.id) : '');
|
|
|
|
|
setEditUserSpecialties(Array.isArray(user.areas) ? user.areas : []);
|
|
|
|
|
setSpecialtyToAdd('');
|
|
|
|
|
}
|
2026-05-18 17:34:23 -03:00
|
|
|
|
2026-05-21 15:50:55 -03:00
|
|
|
function closeUserEditor() {
|
|
|
|
|
setEditingUser(null);
|
|
|
|
|
setEditUserProfileId('');
|
|
|
|
|
setEditUserSpecialties([]);
|
|
|
|
|
setSpecialtyToAdd('');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getProfileName(profileId) {
|
|
|
|
|
return profiles.find((profile) => profile.id === Number(profileId))?.nome || '';
|
|
|
|
|
}
|
2026-05-18 17:34:23 -03:00
|
|
|
|
2026-05-21 15:50:55 -03:00
|
|
|
async function saveUserAccess(user, nextProfileId, nextSpecialties) {
|
2026-05-18 17:34:23 -03:00
|
|
|
try {
|
2026-05-21 15:50:55 -03:00
|
|
|
const updatedUser = await updateUserAccess(user.id, {
|
|
|
|
|
perfilIds: nextProfileId ? [Number(nextProfileId)] : [],
|
|
|
|
|
especialidades: nextSpecialties.map((specialty, index) => ({
|
|
|
|
|
areaId: Number(specialty.id),
|
|
|
|
|
funcao: specialty.funcao || 'Agente',
|
|
|
|
|
principal: index === 0,
|
|
|
|
|
ativo: true,
|
|
|
|
|
})),
|
|
|
|
|
});
|
2026-05-18 17:34:23 -03:00
|
|
|
|
|
|
|
|
if (updatedUser) {
|
|
|
|
|
setUsers((current) =>
|
|
|
|
|
current.map((item) => (item.id === updatedUser.id ? updatedUser : item)),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setAccessError('');
|
2026-05-21 15:50:55 -03:00
|
|
|
await refreshAreas();
|
2026-05-18 17:34:23 -03:00
|
|
|
} catch {
|
2026-05-21 15:50:55 -03:00
|
|
|
setAccessError('Não foi possível salvar a atribuição. Confira o backend.');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleEditProfileChange(value) {
|
|
|
|
|
setEditUserProfileId(value);
|
|
|
|
|
if (getProfileName(value) === 'Admin') {
|
|
|
|
|
setEditUserSpecialties([]);
|
|
|
|
|
setSpecialtyToAdd('');
|
2026-05-18 17:34:23 -03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-21 15:50:55 -03:00
|
|
|
function addSpecialtyToEdit() {
|
|
|
|
|
const area = areas.find((item) => item.id === Number(specialtyToAdd));
|
|
|
|
|
if (!area) return;
|
|
|
|
|
|
|
|
|
|
setEditUserSpecialties((current) => {
|
|
|
|
|
if (current.some((specialty) => specialty.id === area.id)) return current;
|
|
|
|
|
return [
|
|
|
|
|
...current,
|
|
|
|
|
{ id: area.id, nome: area.nome, funcao: 'Agente', principal: current.length === 0 },
|
|
|
|
|
];
|
|
|
|
|
});
|
|
|
|
|
setSpecialtyToAdd('');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function removeSpecialtyFromEdit(areaId) {
|
|
|
|
|
setEditUserSpecialties((current) => current.filter((specialty) => specialty.id !== areaId));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateEditSpecialtyRole(areaId, role) {
|
|
|
|
|
setEditUserSpecialties((current) =>
|
|
|
|
|
current.map((specialty) =>
|
|
|
|
|
specialty.id === areaId ? { ...specialty, funcao: role } : specialty,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function submitUserEditor() {
|
|
|
|
|
if (!editingUser) return;
|
|
|
|
|
const isAdmin = getProfileName(editUserProfileId) === 'Admin';
|
|
|
|
|
await saveUserAccess(editingUser, editUserProfileId, isAdmin ? [] : editUserSpecialties);
|
|
|
|
|
closeUserEditor();
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-21 12:06:30 -03:00
|
|
|
async function refreshAreas() {
|
|
|
|
|
const [accessAreas, options] = await Promise.all([getAccessAreas(), getAccessOptions()]);
|
|
|
|
|
setAreaRowsState(accessAreas || []);
|
|
|
|
|
setAreas(options.areas || []);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleCreateArea() {
|
|
|
|
|
const nome = newAreaName.trim();
|
|
|
|
|
if (!nome) return;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await createAccessArea({
|
|
|
|
|
nome,
|
|
|
|
|
});
|
|
|
|
|
setNewAreaName('');
|
|
|
|
|
await refreshAreas();
|
|
|
|
|
setAccessError('');
|
|
|
|
|
} catch {
|
2026-05-21 15:50:55 -03:00
|
|
|
setAccessError('Não foi possível criar a especialidade.');
|
2026-05-21 12:06:30 -03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-26 09:08:08 -03:00
|
|
|
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.');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-21 12:06:30 -03:00
|
|
|
const realMonthlyKpis = [
|
|
|
|
|
{
|
|
|
|
|
label: 'Total de Atendimentos',
|
|
|
|
|
value: overview ? String(overview.totalAttendances) : '...',
|
|
|
|
|
detail: overview?.previousMonthVariation === null || overview?.previousMonthVariation === undefined
|
2026-05-21 15:50:55 -03:00
|
|
|
? 'sem base do mês anterior'
|
|
|
|
|
: `${overview.previousMonthVariation >= 0 ? '+' : ''}${overview.previousMonthVariation}% vs mês anterior`,
|
2026-05-21 12:06:30 -03:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
label: 'TMA',
|
|
|
|
|
value: formatMinutes(overview?.avgHandlingMinutes),
|
2026-05-21 15:50:55 -03:00
|
|
|
detail: overview?.avgHandlingMinutes === null ? 'aguardando histórico' : 'média mensal',
|
2026-05-21 12:06:30 -03:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
label: 'TME',
|
|
|
|
|
value: formatMinutes(overview?.avgFirstResponseMinutes),
|
2026-05-21 15:50:55 -03:00
|
|
|
detail: 'tempo médio de espera',
|
2026-05-21 12:06:30 -03:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
label: 'TMR',
|
|
|
|
|
value: 'Sem dados',
|
|
|
|
|
detail: 'requer eventos de resposta',
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
label: 'Atendentes Ativos',
|
|
|
|
|
value: overview ? `${overview.activeAttendants} de ${overview.totalActiveUsers}` : '...',
|
2026-05-21 15:50:55 -03:00
|
|
|
detail: 'ativos no mês',
|
2026-05-21 12:06:30 -03:00
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const filteredUsers = users.filter((user) => {
|
|
|
|
|
const search = userSearch.trim().toLowerCase();
|
|
|
|
|
if (!search) return true;
|
|
|
|
|
return `${user.nome} ${user.email || ''} ${user.perfilPrincipal?.nome || ''} ${user.areaPrincipal?.nome || ''}`
|
|
|
|
|
.toLowerCase()
|
|
|
|
|
.includes(search);
|
|
|
|
|
});
|
2026-05-21 15:50:55 -03:00
|
|
|
const availableSpecialtiesToAdd = areas.filter(
|
|
|
|
|
(area) => !editUserSpecialties.some((specialty) => specialty.id === area.id),
|
|
|
|
|
);
|
|
|
|
|
const isEditingAdmin = getProfileName(editUserProfileId) === 'Admin';
|
2026-05-21 12:06:30 -03:00
|
|
|
|
|
|
|
|
const channelDistributionData = overview
|
|
|
|
|
? [
|
|
|
|
|
{ label: 'WhatsApp', value: overview.channels?.whatsapp || 0, color: '#2bb741' },
|
|
|
|
|
{ label: 'Email', value: overview.channels?.email || 0, color: '#e5a22a' },
|
|
|
|
|
{ label: 'SMS', value: overview.channels?.sms || 0, color: '#00a4b7' },
|
|
|
|
|
]
|
|
|
|
|
: channelDistribution;
|
|
|
|
|
|
2026-05-18 17:34:23 -03:00
|
|
|
const userColumns = useMemo(
|
|
|
|
|
() => [
|
|
|
|
|
{
|
|
|
|
|
key: 'nome',
|
|
|
|
|
label: 'Usuario',
|
2026-05-21 15:50:55 -03:00
|
|
|
render: (row) => <strong>{row.nome}</strong>,
|
2026-05-18 17:34:23 -03:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
key: 'perfil',
|
|
|
|
|
label: 'Perfil',
|
2026-05-21 15:50:55 -03:00
|
|
|
render: (row) => <span>{row.perfilPrincipal?.nome || 'Sem perfil'}</span>,
|
2026-05-18 17:34:23 -03:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
key: 'status',
|
|
|
|
|
label: 'Status',
|
|
|
|
|
render: (row) => {
|
|
|
|
|
const isAssigned = row.accessStatus === 'assigned';
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<span
|
|
|
|
|
style={{
|
|
|
|
|
width: 'fit-content',
|
|
|
|
|
borderRadius: 999,
|
|
|
|
|
padding: '0.25rem 0.6rem',
|
|
|
|
|
background: isAssigned ? 'rgba(0, 164, 183, 0.1)' : 'rgba(229, 162, 42, 0.16)',
|
|
|
|
|
color: isAssigned ? 'var(--color-primary)' : '#8a5a00',
|
|
|
|
|
fontWeight: 700,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{isAssigned ? 'Atribuido' : 'Pendente'}
|
|
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-05-21 15:50:55 -03:00
|
|
|
{
|
|
|
|
|
key: 'actions',
|
|
|
|
|
label: 'Ações',
|
|
|
|
|
render: (row) => (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => openUserEditor(row)}
|
|
|
|
|
style={{
|
|
|
|
|
border: 'none',
|
|
|
|
|
borderRadius: 14,
|
|
|
|
|
padding: '0.7rem 0.9rem',
|
|
|
|
|
background: 'var(--color-primary)',
|
|
|
|
|
color: '#fff',
|
|
|
|
|
fontWeight: 800,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
Editar
|
|
|
|
|
</button>
|
|
|
|
|
),
|
|
|
|
|
},
|
2026-05-18 17:34:23 -03:00
|
|
|
],
|
2026-05-21 15:50:55 -03:00
|
|
|
[],
|
2026-05-18 17:34:23 -03:00
|
|
|
);
|
|
|
|
|
|
2026-05-21 12:06:30 -03:00
|
|
|
const areaColumns = useMemo(
|
|
|
|
|
() => [
|
2026-05-21 15:50:55 -03:00
|
|
|
{ key: 'nome', label: 'Especialidade' },
|
2026-05-21 12:06:30 -03:00
|
|
|
{
|
2026-05-21 15:50:55 -03:00
|
|
|
key: 'supervisores',
|
|
|
|
|
label: 'Supervisores',
|
|
|
|
|
render: (row) => {
|
|
|
|
|
const supervisors = Array.isArray(row.supervisores) ? row.supervisores : [];
|
|
|
|
|
|
|
|
|
|
return supervisors.length ? (
|
|
|
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem' }}>
|
|
|
|
|
{supervisors.map((supervisor) => (
|
|
|
|
|
<span
|
|
|
|
|
key={supervisor.id}
|
|
|
|
|
style={{
|
|
|
|
|
borderRadius: 999,
|
|
|
|
|
padding: '0.25rem 0.55rem',
|
|
|
|
|
background: 'rgba(0, 164, 183, 0.1)',
|
|
|
|
|
color: 'var(--color-primary)',
|
|
|
|
|
fontWeight: 800,
|
|
|
|
|
fontSize: '0.82rem',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{supervisor.nome}
|
|
|
|
|
</span>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<span style={{ color: 'var(--color-text-soft)', fontWeight: 700 }}>Sem supervisor</span>
|
|
|
|
|
);
|
|
|
|
|
},
|
2026-05-21 12:06:30 -03:00
|
|
|
},
|
2026-05-21 15:50:55 -03:00
|
|
|
{ key: 'members', label: 'Usuarios' },
|
2026-05-21 12:06:30 -03:00
|
|
|
{
|
|
|
|
|
key: 'status',
|
|
|
|
|
label: 'Status',
|
|
|
|
|
render: (row) => (row.ativo ? 'Ativa' : 'Inativa'),
|
|
|
|
|
},
|
2026-05-26 09:08:08 -03:00
|
|
|
{
|
|
|
|
|
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>
|
|
|
|
|
),
|
|
|
|
|
},
|
2026-05-21 12:06:30 -03:00
|
|
|
],
|
2026-05-26 09:08:08 -03:00
|
|
|
[isMobile],
|
2026-05-21 12:06:30 -03:00
|
|
|
);
|
2026-05-18 17:34:23 -03:00
|
|
|
|
2026-05-26 09:08:08 -03:00
|
|
|
const filteredRanking = attendantRankingRows;
|
2026-05-21 12:06:30 -03:00
|
|
|
|
|
|
|
|
const rankingColumns = [
|
|
|
|
|
{ key: 'name', label: 'Nome' },
|
2026-05-21 15:50:55 -03:00
|
|
|
{ key: 'area', label: 'Especialidade' },
|
2026-05-21 12:06:30 -03:00
|
|
|
{ key: 'closed', label: 'Atendimentos finalizados' },
|
2026-05-21 15:50:55 -03:00
|
|
|
{ key: 'avgTime', label: 'Tempo médio' },
|
|
|
|
|
{ key: 'satisfaction', label: 'Satisfação' },
|
2026-05-21 12:06:30 -03:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
function sendNotice() {
|
|
|
|
|
const text = noticeDraft.trim();
|
|
|
|
|
if (!text) return;
|
|
|
|
|
setNotices((current) => [{ id: `notice-${Date.now()}`, text }, ...current]);
|
|
|
|
|
setNoticeDraft('');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderLineChart() {
|
|
|
|
|
const maxValue = Math.max(...dailyAttendance);
|
|
|
|
|
const points = dailyAttendance
|
|
|
|
|
.map((value, index) => {
|
|
|
|
|
const x = (index / (dailyAttendance.length - 1)) * 100;
|
|
|
|
|
const y = 100 - (value / maxValue) * 86 - 7;
|
|
|
|
|
return `${x},${y}`;
|
|
|
|
|
})
|
|
|
|
|
.join(' ');
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<svg viewBox="0 0 100 100" preserveAspectRatio="none" style={{ width: '100%', height: 260 }}>
|
|
|
|
|
<polyline points={points} fill="none" stroke="var(--color-secondary)" strokeWidth="2.2" vectorEffect="non-scaling-stroke" />
|
|
|
|
|
</svg>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderDonutChart() {
|
|
|
|
|
const total = channelDistributionData.reduce((sum, item) => sum + item.value, 0) || 1;
|
|
|
|
|
let offset = 0;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '160px 1fr', gap: '1rem', alignItems: 'center' }}>
|
|
|
|
|
<svg viewBox="0 0 42 42" style={{ width: 160, height: 160 }}>
|
|
|
|
|
<circle cx="21" cy="21" r="15.9" fill="transparent" stroke="rgba(0,49,80,0.08)" strokeWidth="7" />
|
|
|
|
|
{channelDistributionData.map((item) => {
|
|
|
|
|
const dash = (item.value / total) * 100;
|
|
|
|
|
const circle = (
|
|
|
|
|
<circle
|
|
|
|
|
key={item.label}
|
|
|
|
|
cx="21"
|
|
|
|
|
cy="21"
|
|
|
|
|
r="15.9"
|
|
|
|
|
fill="transparent"
|
|
|
|
|
stroke={item.color}
|
|
|
|
|
strokeWidth="7"
|
|
|
|
|
strokeDasharray={`${dash} ${100 - dash}`}
|
|
|
|
|
strokeDashoffset={-offset}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
offset += dash;
|
|
|
|
|
return circle;
|
|
|
|
|
})}
|
|
|
|
|
</svg>
|
|
|
|
|
<div style={{ display: 'grid', gap: '0.65rem' }}>
|
|
|
|
|
{channelDistributionData.map((item) => (
|
|
|
|
|
<span key={item.label} style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
|
|
|
|
|
<span style={{ color: item.color, fontWeight: 800 }}>{item.label}</span>
|
|
|
|
|
<strong>{item.value}</strong>
|
|
|
|
|
</span>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderMonthlyHome() {
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap' }}>
|
|
|
|
|
<label style={{ display: 'grid', gap: '0.35rem', minWidth: isMobile ? '100%' : 260 }}>
|
2026-05-21 15:50:55 -03:00
|
|
|
<span style={{ fontWeight: 700 }}>Filtro por especialidade</span>
|
2026-05-21 12:06:30 -03:00
|
|
|
<select value={selectedAreaFilter} onChange={(event) => setSelectedAreaFilter(event.target.value)} style={selectStyle}>
|
2026-05-21 15:50:55 -03:00
|
|
|
<option value="all">Todas as especialidades</option>
|
2026-05-21 12:06:30 -03:00
|
|
|
{areas.map((area) => (
|
|
|
|
|
<option key={area.id} value={area.nome}>{area.nome}</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<MetricGrid metrics={realMonthlyKpis} minCardWidth="160px" />
|
|
|
|
|
|
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: isDesktop ? 'minmax(0, 1.85fr) minmax(300px, 1fr)' : '1fr', gap: '1rem' }}>
|
2026-05-21 15:50:55 -03:00
|
|
|
<DataPanel title="Atendimentos por dia" description="Volume diário do mês selecionado.">
|
2026-05-21 12:06:30 -03:00
|
|
|
{renderLineChart()}
|
|
|
|
|
</DataPanel>
|
2026-05-21 15:50:55 -03:00
|
|
|
<DataPanel title="Distribuição por canal" description="Participação mensal por canal.">
|
2026-05-21 12:06:30 -03:00
|
|
|
{renderDonutChart()}
|
|
|
|
|
</DataPanel>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<DataPanel title="Ranking de atendentes" description="Top 10 ordenado por atendimentos finalizados.">
|
|
|
|
|
<ManagementTable columns={rankingColumns} rows={filteredRanking.slice(0, 10)} getRowId={(row) => row.id} isMobile={isMobile} />
|
|
|
|
|
</DataPanel>
|
|
|
|
|
|
|
|
|
|
<DataPanel title="Painel de avisos" description="Comunicados enviados para os atendentes.">
|
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: isDesktop ? 'minmax(0, 1fr) minmax(320px, 0.8fr)' : '1fr', gap: '1rem' }}>
|
|
|
|
|
<div style={{ display: 'grid', gap: '0.75rem' }}>
|
|
|
|
|
{notices.map((notice) => (
|
|
|
|
|
<article key={notice.id} style={{ border: '1px solid var(--color-border)', borderRadius: 18, padding: '0.9rem 1rem', background: '#fff' }}>
|
|
|
|
|
{notice.text}
|
|
|
|
|
</article>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
<div style={{ display: 'grid', gap: '0.75rem', alignContent: 'start' }}>
|
|
|
|
|
<textarea
|
|
|
|
|
rows={5}
|
|
|
|
|
value={noticeDraft}
|
|
|
|
|
onChange={(event) => setNoticeDraft(event.target.value)}
|
|
|
|
|
placeholder="Digite um aviso para o time..."
|
|
|
|
|
style={{ ...selectStyle, resize: 'vertical', lineHeight: 1.45 }}
|
|
|
|
|
/>
|
|
|
|
|
<button type="button" onClick={sendNotice} style={{ border: 'none', borderRadius: 16, padding: '0.95rem 1rem', background: 'var(--color-primary)', color: '#fff', fontWeight: 800 }}>
|
|
|
|
|
Enviar aviso
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</DataPanel>
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderUsersAccess() {
|
|
|
|
|
return (
|
2026-05-18 17:34:23 -03:00
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
display: 'grid',
|
2026-05-21 15:50:55 -03:00
|
|
|
gridTemplateColumns: '1fr',
|
2026-05-18 17:34:23 -03:00
|
|
|
gap: '1rem',
|
|
|
|
|
alignItems: 'start',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<DataPanel
|
|
|
|
|
title="Usuarios e niveis de acesso"
|
|
|
|
|
description={
|
|
|
|
|
isLoadingAccess
|
2026-05-21 15:50:55 -03:00
|
|
|
? 'Carregando usuários do banco...'
|
|
|
|
|
: accessError || 'Gerencie perfil e especialidade principal dos usuários autenticados.'
|
2026-05-18 17:34:23 -03:00
|
|
|
}
|
2026-05-21 15:50:55 -03:00
|
|
|
actionLabel="Adicionar usuário"
|
2026-05-18 17:34:23 -03:00
|
|
|
>
|
2026-05-21 12:06:30 -03:00
|
|
|
<div style={{ display: 'grid', gap: '0.85rem' }}>
|
|
|
|
|
<input
|
|
|
|
|
type="search"
|
|
|
|
|
value={userSearch}
|
|
|
|
|
onChange={(event) => setUserSearch(event.target.value)}
|
2026-05-21 15:50:55 -03:00
|
|
|
placeholder="Buscar usuário por nome, email, perfil ou especialidade"
|
2026-05-21 12:06:30 -03:00
|
|
|
style={selectStyle}
|
|
|
|
|
/>
|
|
|
|
|
<div style={{ maxHeight: 470, overflowY: 'auto', paddingRight: '0.2rem' }}>
|
|
|
|
|
<ManagementTable columns={userColumns} rows={filteredUsers} getRowId={(row) => row.id} isMobile={isMobile} />
|
|
|
|
|
</div>
|
2026-05-21 15:50:55 -03:00
|
|
|
|
|
|
|
|
{editingUser ? (
|
|
|
|
|
<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(560px, 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 acesso</h2>
|
|
|
|
|
<p style={{ margin: '0.35rem 0 0', color: 'var(--color-text-soft)' }}>
|
|
|
|
|
{editingUser.nome} · {editingUser.email || 'Sem email'}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={closeUserEditor}
|
|
|
|
|
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 }}>Perfil global</span>
|
|
|
|
|
<select
|
|
|
|
|
value={editUserProfileId}
|
|
|
|
|
onChange={(event) => handleEditProfileChange(event.target.value)}
|
|
|
|
|
style={selectStyle}
|
|
|
|
|
>
|
|
|
|
|
<option value="">Sem perfil</option>
|
|
|
|
|
{profiles.map((profile) => (
|
|
|
|
|
<option key={profile.id} value={profile.id}>
|
|
|
|
|
{profile.nome}
|
|
|
|
|
</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
</label>
|
|
|
|
|
|
|
|
|
|
{isEditingAdmin ? (
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
border: '1px solid var(--color-border)',
|
|
|
|
|
borderRadius: 16,
|
|
|
|
|
padding: '0.85rem',
|
|
|
|
|
background: 'rgba(0, 164, 183, 0.08)',
|
|
|
|
|
color: 'var(--color-primary)',
|
|
|
|
|
fontWeight: 800,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
Admin tem acesso global. Especialidades não se aplicam para este perfil.
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div style={{ display: 'grid', gap: '0.75rem' }}>
|
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 1fr) auto', gap: '0.65rem' }}>
|
|
|
|
|
<select
|
|
|
|
|
value={specialtyToAdd}
|
|
|
|
|
onChange={(event) => setSpecialtyToAdd(event.target.value)}
|
|
|
|
|
style={selectStyle}
|
|
|
|
|
>
|
|
|
|
|
<option value="">Selecionar especialidade</option>
|
|
|
|
|
{availableSpecialtiesToAdd.map((area) => (
|
|
|
|
|
<option key={area.id} value={area.id}>
|
|
|
|
|
{area.nome}
|
|
|
|
|
</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={addSpecialtyToEdit}
|
|
|
|
|
disabled={!specialtyToAdd}
|
|
|
|
|
style={{
|
|
|
|
|
border: 'none',
|
|
|
|
|
borderRadius: 14,
|
|
|
|
|
padding: '0.75rem 0.95rem',
|
|
|
|
|
background: specialtyToAdd ? 'var(--color-primary)' : 'rgba(0, 49, 80, 0.12)',
|
|
|
|
|
color: specialtyToAdd ? '#fff' : 'var(--color-text-soft)',
|
|
|
|
|
fontWeight: 800,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
Adicionar
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div style={{ display: 'grid', gap: '0.55rem', maxHeight: 260, overflowY: 'auto', paddingRight: '0.2rem' }}>
|
|
|
|
|
{editUserSpecialties.length ? editUserSpecialties.map((specialty) => (
|
|
|
|
|
<div
|
|
|
|
|
key={specialty.id}
|
|
|
|
|
style={{
|
|
|
|
|
border: '1px solid var(--color-border)',
|
|
|
|
|
borderRadius: 16,
|
|
|
|
|
padding: '0.75rem',
|
|
|
|
|
display: 'grid',
|
|
|
|
|
gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 1fr) 140px auto',
|
|
|
|
|
gap: '0.65rem',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<strong>{specialty.nome}</strong>
|
|
|
|
|
<select
|
|
|
|
|
value={specialty.funcao || 'Agente'}
|
|
|
|
|
onChange={(event) => updateEditSpecialtyRole(specialty.id, event.target.value)}
|
|
|
|
|
style={compactSelectStyle}
|
|
|
|
|
>
|
|
|
|
|
<option value="Agente">Agente</option>
|
|
|
|
|
<option value="Supervisor">Supervisor</option>
|
|
|
|
|
</select>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => removeSpecialtyFromEdit(specialty.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>
|
|
|
|
|
</div>
|
|
|
|
|
)) : (
|
|
|
|
|
<span style={{ color: 'var(--color-text-soft)', fontWeight: 700 }}>
|
|
|
|
|
Nenhuma especialidade selecionada.
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={submitUserEditor}
|
|
|
|
|
style={{
|
|
|
|
|
border: 'none',
|
|
|
|
|
borderRadius: 16,
|
|
|
|
|
padding: '0.9rem 1rem',
|
|
|
|
|
background: 'var(--color-primary)',
|
|
|
|
|
color: '#fff',
|
|
|
|
|
fontWeight: 800,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
Salvar acesso
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
2026-05-21 12:06:30 -03:00
|
|
|
</div>
|
2026-05-18 17:34:23 -03:00
|
|
|
</DataPanel>
|
|
|
|
|
|
2026-05-21 15:50:55 -03:00
|
|
|
<DataPanel title="Especialidades" description="Especialidades operacionais. Supervisores são definidos em Usuários e níveis de acesso.">
|
2026-05-21 12:06:30 -03:00
|
|
|
<div style={{ display: 'grid', gap: '0.85rem' }}>
|
2026-05-21 15:50:55 -03:00
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 1fr) auto', gap: '0.75rem' }}>
|
2026-05-21 12:06:30 -03:00
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
value={newAreaName}
|
|
|
|
|
onChange={(event) => setNewAreaName(event.target.value)}
|
2026-05-21 15:50:55 -03:00
|
|
|
placeholder="Nome da nova especialidade"
|
2026-05-21 12:06:30 -03:00
|
|
|
style={selectStyle}
|
|
|
|
|
/>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={handleCreateArea}
|
|
|
|
|
style={{
|
|
|
|
|
border: 'none',
|
|
|
|
|
borderRadius: '14px',
|
|
|
|
|
padding: '0.75rem 0.95rem',
|
|
|
|
|
background: 'var(--color-primary)',
|
|
|
|
|
color: '#fff',
|
|
|
|
|
fontWeight: 800,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
Adicionar
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
<ManagementTable columns={areaColumns} rows={areaRowsState} getRowId={(row) => row.id} isMobile={isMobile} />
|
2026-05-26 09:08:08 -03:00
|
|
|
|
|
|
|
|
{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}
|
2026-05-21 12:06:30 -03:00
|
|
|
</div>
|
2026-05-18 17:34:23 -03:00
|
|
|
</DataPanel>
|
|
|
|
|
</div>
|
2026-05-21 12:06:30 -03:00
|
|
|
);
|
|
|
|
|
}
|
2026-05-18 17:34:23 -03:00
|
|
|
|
2026-05-26 09:08:08 -03:00
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-26 11:35:23 -03:00
|
|
|
function renderChannelsIntegrations() {
|
|
|
|
|
const activeCount = integrationCards.filter((item) => integrationStates[item.id]).length;
|
|
|
|
|
const channelCount = integrationCards.filter((item) => item.group === 'Canal' && integrationStates[item.id]).length;
|
|
|
|
|
const integrationCount = integrationCards.filter((item) => item.group === 'Integração' && integrationStates[item.id]).length;
|
|
|
|
|
|
2026-05-26 12:12:33 -03:00
|
|
|
function toggleIntegration(item) {
|
|
|
|
|
const isEnabled = Boolean(integrationStates[item.id]);
|
|
|
|
|
|
|
|
|
|
if (!isEnabled && !item.configured) {
|
|
|
|
|
setIntegrationNotice(`${item.name} ainda não pode ser habilitado porque precisa ser configurado primeiro.`);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setIntegrationNotice('');
|
|
|
|
|
setIntegrationStates((current) => ({
|
|
|
|
|
...current,
|
|
|
|
|
[item.id]: !current[item.id],
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-26 11:35:23 -03:00
|
|
|
return (
|
|
|
|
|
<section style={{ display: 'grid', gap: '1rem' }}>
|
|
|
|
|
<DataPanel
|
|
|
|
|
title="Canais e Integrações"
|
|
|
|
|
description="Controle quais canais ficam disponíveis e quais integrações alimentam a operação e a IA."
|
|
|
|
|
>
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
display: 'grid',
|
|
|
|
|
gridTemplateColumns: isMobile ? '1fr' : 'repeat(3, minmax(0, 1fr))',
|
|
|
|
|
gap: '0.8rem',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{[
|
|
|
|
|
{ label: 'Ativos', value: activeCount },
|
|
|
|
|
{ label: 'Canais habilitados', value: channelCount },
|
|
|
|
|
{ label: 'Integrações habilitadas', value: integrationCount },
|
|
|
|
|
].map((item) => (
|
|
|
|
|
<div
|
|
|
|
|
key={item.label}
|
|
|
|
|
style={{
|
|
|
|
|
border: '1px solid var(--color-border)',
|
|
|
|
|
borderRadius: 18,
|
|
|
|
|
padding: '1rem',
|
|
|
|
|
background: '#f8fbfc',
|
|
|
|
|
display: 'grid',
|
|
|
|
|
gap: '0.25rem',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<span style={{ color: 'var(--color-text-soft)', fontWeight: 700 }}>{item.label}</span>
|
|
|
|
|
<strong style={{ fontSize: '1.55rem' }}>{item.value}</strong>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
2026-05-26 12:12:33 -03:00
|
|
|
|
|
|
|
|
{integrationNotice ? (
|
|
|
|
|
<div
|
|
|
|
|
role="alert"
|
|
|
|
|
style={{
|
|
|
|
|
border: '1px solid rgba(216, 137, 28, 0.32)',
|
|
|
|
|
borderRadius: 16,
|
|
|
|
|
padding: '0.85rem 1rem',
|
|
|
|
|
background: 'rgba(216, 137, 28, 0.08)',
|
|
|
|
|
color: '#7a4a08',
|
|
|
|
|
fontWeight: 800,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{integrationNotice}
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
2026-05-26 11:35:23 -03:00
|
|
|
</DataPanel>
|
|
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
display: 'grid',
|
|
|
|
|
gridTemplateColumns: isMobile ? '1fr' : isTablet ? 'repeat(2, minmax(0, 1fr))' : 'repeat(3, minmax(0, 1fr))',
|
|
|
|
|
gap: '1rem',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{integrationCards.map((item) => {
|
|
|
|
|
const isEnabled = Boolean(integrationStates[item.id]);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<article
|
|
|
|
|
key={item.id}
|
|
|
|
|
style={{
|
|
|
|
|
border: `1px solid ${isEnabled ? `${item.color}55` : 'var(--color-border)'}`,
|
|
|
|
|
borderRadius: 22,
|
|
|
|
|
padding: '1.1rem',
|
|
|
|
|
background: isEnabled ? `${item.color}0f` : '#fff',
|
|
|
|
|
display: 'grid',
|
|
|
|
|
gap: '1rem',
|
|
|
|
|
minHeight: 260,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '0.85rem' }}>
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.8rem', minWidth: 0 }}>
|
|
|
|
|
<div
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
style={{
|
|
|
|
|
width: 52,
|
|
|
|
|
height: 52,
|
|
|
|
|
borderRadius: 16,
|
|
|
|
|
display: 'grid',
|
|
|
|
|
placeItems: 'center',
|
|
|
|
|
background: item.color,
|
|
|
|
|
color: '#fff',
|
|
|
|
|
fontWeight: 900,
|
|
|
|
|
letterSpacing: 0,
|
|
|
|
|
boxShadow: `0 12px 22px ${item.color}24`,
|
|
|
|
|
flex: '0 0 auto',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{item.icon}
|
|
|
|
|
</div>
|
|
|
|
|
<div style={{ minWidth: 0 }}>
|
|
|
|
|
<span
|
|
|
|
|
style={{
|
|
|
|
|
display: 'inline-flex',
|
|
|
|
|
borderRadius: 999,
|
|
|
|
|
padding: '0.18rem 0.55rem',
|
|
|
|
|
background: `${item.color}18`,
|
|
|
|
|
color: item.color,
|
|
|
|
|
fontSize: '0.78rem',
|
|
|
|
|
fontWeight: 800,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{item.group}
|
|
|
|
|
</span>
|
|
|
|
|
<strong style={{ display: 'block', marginTop: '0.35rem', fontSize: '1.15rem' }}>{item.name}</strong>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
aria-pressed={isEnabled}
|
2026-05-26 12:12:33 -03:00
|
|
|
onClick={() => toggleIntegration(item)}
|
2026-05-26 11:35:23 -03:00
|
|
|
style={{
|
|
|
|
|
border: 'none',
|
|
|
|
|
borderRadius: 999,
|
|
|
|
|
width: 54,
|
|
|
|
|
height: 30,
|
|
|
|
|
padding: 3,
|
|
|
|
|
background: isEnabled ? item.color : '#d6e0e5',
|
|
|
|
|
display: 'flex',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
justifyContent: isEnabled ? 'flex-end' : 'flex-start',
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
flex: '0 0 auto',
|
|
|
|
|
}}
|
|
|
|
|
title={isEnabled ? `Desabilitar ${item.name}` : `Habilitar ${item.name}`}
|
|
|
|
|
>
|
|
|
|
|
<span
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
style={{
|
|
|
|
|
width: 24,
|
|
|
|
|
height: 24,
|
|
|
|
|
borderRadius: '50%',
|
|
|
|
|
background: '#fff',
|
|
|
|
|
boxShadow: '0 3px 8px rgba(0, 0, 0, 0.18)',
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<p style={{ margin: 0, color: 'var(--color-text-soft)', lineHeight: 1.5, fontWeight: 650 }}>
|
|
|
|
|
{item.description}
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
marginTop: 'auto',
|
|
|
|
|
borderTop: '1px solid var(--color-border)',
|
|
|
|
|
paddingTop: '0.85rem',
|
|
|
|
|
display: 'flex',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
justifyContent: 'space-between',
|
|
|
|
|
gap: '0.75rem',
|
|
|
|
|
flexWrap: 'wrap',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<span style={{ color: isEnabled ? item.color : 'var(--color-text-soft)', fontWeight: 800 }}>
|
2026-05-26 12:12:33 -03:00
|
|
|
{isEnabled ? 'Habilitado' : item.configured ? 'Desabilitado' : 'Pendente de configuração'}
|
2026-05-26 11:35:23 -03:00
|
|
|
</span>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
2026-05-26 12:12:33 -03:00
|
|
|
onClick={() => {
|
|
|
|
|
if (item.id === 'whatsapp') {
|
|
|
|
|
navigate('/admin/whatsapp');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setConfigurationModal(item);
|
|
|
|
|
}}
|
2026-05-26 11:35:23 -03:00
|
|
|
style={{
|
|
|
|
|
border: `1px solid ${item.color}44`,
|
|
|
|
|
borderRadius: 14,
|
|
|
|
|
padding: '0.65rem 0.8rem',
|
|
|
|
|
background: '#fff',
|
|
|
|
|
color: item.color,
|
|
|
|
|
fontWeight: 800,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
Configurar
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</article>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
2026-05-26 12:12:33 -03:00
|
|
|
|
|
|
|
|
{configurationModal ? (
|
|
|
|
|
<div
|
|
|
|
|
role="dialog"
|
|
|
|
|
aria-modal="true"
|
|
|
|
|
aria-labelledby="integration-config-title"
|
|
|
|
|
style={{
|
|
|
|
|
position: 'fixed',
|
|
|
|
|
inset: 0,
|
|
|
|
|
background: 'rgba(0, 20, 32, 0.42)',
|
|
|
|
|
display: 'grid',
|
|
|
|
|
placeItems: 'center',
|
|
|
|
|
padding: '1rem',
|
|
|
|
|
zIndex: 50,
|
|
|
|
|
}}
|
|
|
|
|
onClick={() => setConfigurationModal(null)}
|
|
|
|
|
>
|
|
|
|
|
<div
|
|
|
|
|
onClick={(event) => event.stopPropagation()}
|
|
|
|
|
style={{
|
|
|
|
|
width: 'min(460px, 100%)',
|
|
|
|
|
borderRadius: 22,
|
|
|
|
|
border: '1px solid var(--color-border)',
|
|
|
|
|
background: '#fff',
|
|
|
|
|
padding: '1.25rem',
|
|
|
|
|
display: 'grid',
|
|
|
|
|
gap: '0.9rem',
|
|
|
|
|
boxShadow: 'var(--shadow-lg)',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.8rem' }}>
|
|
|
|
|
<div
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
style={{
|
|
|
|
|
width: 44,
|
|
|
|
|
height: 44,
|
|
|
|
|
borderRadius: 14,
|
|
|
|
|
display: 'grid',
|
|
|
|
|
placeItems: 'center',
|
|
|
|
|
background: configurationModal.color,
|
|
|
|
|
color: '#fff',
|
|
|
|
|
fontWeight: 900,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{configurationModal.icon}
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<strong id="integration-config-title" style={{ display: 'block', fontSize: '1.08rem' }}>
|
|
|
|
|
Configurar {configurationModal.name}
|
|
|
|
|
</strong>
|
|
|
|
|
<span style={{ color: 'var(--color-text-soft)', fontWeight: 700 }}>
|
|
|
|
|
{configurationModal.group}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<p style={{ margin: 0, color: 'var(--color-text-soft)', lineHeight: 1.5, fontWeight: 700 }}>
|
|
|
|
|
Esta configuração ainda está em construção. Assim que a integração estiver disponível, este espaço vai reunir credenciais, permissões e parâmetros de sincronização.
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setConfigurationModal(null)}
|
|
|
|
|
style={{
|
|
|
|
|
border: 'none',
|
|
|
|
|
borderRadius: 14,
|
|
|
|
|
padding: '0.75rem 0.95rem',
|
|
|
|
|
background: 'var(--color-primary)',
|
|
|
|
|
color: '#fff',
|
|
|
|
|
fontWeight: 800,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
Entendi
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
2026-05-26 11:35:23 -03:00
|
|
|
</section>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-21 12:06:30 -03:00
|
|
|
function renderPlaceholder(title, description) {
|
|
|
|
|
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 }}>
|
2026-05-21 15:50:55 -03:00
|
|
|
Seção em preparação.
|
2026-05-21 12:06:30 -03:00
|
|
|
</div>
|
|
|
|
|
</DataPanel>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const sectionContent = {
|
|
|
|
|
home: renderMonthlyHome(),
|
2026-05-21 15:50:55 -03:00
|
|
|
today: <OperationalDashboard isDesktop={isDesktop} isMobile={isMobile} />,
|
2026-05-21 12:06:30 -03:00
|
|
|
'users-access': renderUsersAccess(),
|
2026-05-22 10:51:07 -03:00
|
|
|
templates: <TemplateManagementPanel areas={areas} mode="admin" isMobile={isMobile} />,
|
2026-05-26 09:08:08 -03:00
|
|
|
knowledge: <KnowledgeBasePanel areas={areas} mode="admin" isMobile={isMobile} />,
|
|
|
|
|
'ai-contents': renderAiContents(),
|
|
|
|
|
audit: renderAudit(),
|
2026-05-26 11:35:23 -03:00
|
|
|
channels: renderChannelsIntegrations(),
|
2026-05-21 15:50:55 -03:00
|
|
|
attendance: (
|
|
|
|
|
<AdminAttendanceWorkspace
|
|
|
|
|
isWideDesktop={isWideDesktop}
|
|
|
|
|
isDesktop={isDesktop}
|
|
|
|
|
isTablet={isTablet}
|
|
|
|
|
isMobile={isMobile}
|
|
|
|
|
/>
|
|
|
|
|
),
|
2026-05-26 09:08:08 -03:00
|
|
|
'new-attendance': <NewAttendancePage embedded />,
|
|
|
|
|
'mass-message': <MassMessagePanel areas={areas} mode="admin" isMobile={isMobile} />,
|
2026-05-21 12:06:30 -03:00
|
|
|
contacts: renderPlaceholder('Contatos', 'Agenda geral de contatos.'),
|
|
|
|
|
settings: renderPlaceholder('Configurações', 'Preferencias e parametros do ambiente.'),
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-21 15:50:55 -03:00
|
|
|
const pageTitle = activeAdminSection === 'home'
|
|
|
|
|
? 'Home do Admin'
|
|
|
|
|
: activeAdminSection === 'attendance'
|
|
|
|
|
? 'Atendimento'
|
2026-05-26 09:08:08 -03:00
|
|
|
: activeAdminSection === 'new-attendance'
|
|
|
|
|
? 'Abrir Atendimento'
|
2026-05-21 15:50:55 -03:00
|
|
|
: activeAdminSection === 'today'
|
|
|
|
|
? 'Operação'
|
2026-05-26 09:08:08 -03:00
|
|
|
: activeAdminSection === 'audit'
|
|
|
|
|
? 'Auditoria'
|
2026-05-26 11:35:23 -03:00
|
|
|
: activeAdminSection === 'channels'
|
|
|
|
|
? 'Canais e Integração'
|
2026-05-26 09:08:08 -03:00
|
|
|
: activeAdminSection === 'ai-contents'
|
|
|
|
|
? 'Conteúdos da IA'
|
|
|
|
|
: activeAdminSection === 'knowledge'
|
|
|
|
|
? 'Fluxo do Bot'
|
|
|
|
|
: 'Painel administrativo';
|
2026-05-21 15:50:55 -03:00
|
|
|
|
|
|
|
|
const pageSubtitle = activeAdminSection === 'home'
|
|
|
|
|
? 'Visão mensal consolidada por especialidade, canal e atendente.'
|
|
|
|
|
: activeAdminSection === 'attendance'
|
2026-05-26 09:08:08 -03:00
|
|
|
? 'Operação de atendimento dentro do painel administrativo.'
|
|
|
|
|
: activeAdminSection === 'new-attendance'
|
|
|
|
|
? 'Inicie um contato ativo por WhatsApp usando mensagens pré-aprovadas.'
|
2026-05-21 15:50:55 -03:00
|
|
|
: activeAdminSection === 'today'
|
|
|
|
|
? 'Indicadores do dia, fila de espera e acompanhamento operacional do time.'
|
2026-05-26 09:08:08 -03:00
|
|
|
: activeAdminSection === 'audit'
|
|
|
|
|
? 'Logs administrativos e operacionais com paginação de 100 eventos.'
|
2026-05-26 11:35:23 -03:00
|
|
|
: activeAdminSection === 'channels'
|
|
|
|
|
? 'Canais de atendimento e integrações que alimentam a operação e a IA.'
|
2026-05-26 09:08:08 -03:00
|
|
|
: 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.';
|
2026-05-21 15:50:55 -03:00
|
|
|
|
2026-05-21 12:06:30 -03:00
|
|
|
return (
|
|
|
|
|
<ManagementLayout
|
2026-05-21 15:50:55 -03:00
|
|
|
title={pageTitle}
|
|
|
|
|
subtitle={pageSubtitle}
|
2026-05-21 12:06:30 -03:00
|
|
|
activeSection="admin"
|
|
|
|
|
profileLabel={userDisplay.name}
|
|
|
|
|
initials={userDisplay.initials}
|
|
|
|
|
isDesktop={isDesktop}
|
|
|
|
|
isMobile={isMobile}
|
|
|
|
|
activeNavItem={activeAdminSection}
|
|
|
|
|
onNavItemChange={setActiveAdminSection}
|
|
|
|
|
>
|
|
|
|
|
{sectionContent[activeAdminSection] || sectionContent.home}
|
2026-05-18 17:34:23 -03:00
|
|
|
</ManagementLayout>
|
|
|
|
|
);
|
|
|
|
|
}
|