FEAT: Ajustes no na pagina do Admin

This commit is contained in:
Rafael Alves Lopes 2026-05-27 09:31:25 -03:00
parent c4b846a079
commit 78b63e72c2
9 changed files with 914 additions and 250 deletions

View File

@ -0,0 +1,138 @@
import { useEffect, useMemo, useState } from 'react';
import { BrandMark } from '../../../shared/components/BrandMark';
import { useViewport } from '../../../shared/hooks/useViewport';
import { getCurrentUserDisplay } from '../../auth/services/sessionService';
import { listContactProfiles } from '../../chat/services/contactProfileService';
import { HomeSidebar } from '../../home/components/HomeSidebar';
import { sidebarItems } from '../../home/services/homeMocks';
import { NewAttendancePage } from './NewAttendancePage';
export function AgentNewAttendancePage() {
const { isDesktop, isMobile } = useViewport();
const userDisplay = getCurrentUserDisplay();
const [contactCount, setContactCount] = useState(0);
useEffect(() => {
let isMounted = true;
listContactProfiles()
.then((items) => {
if (isMounted) setContactCount(Array.isArray(items) ? items.length : 0);
})
.catch(() => {
if (isMounted) setContactCount(0);
});
return () => {
isMounted = false;
};
}, []);
const sidebarWithContactCount = useMemo(
() => sidebarItems.map((item) => (item.id === 'contacts' ? { ...item, count: contactCount } : item)),
[contactCount],
);
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.5rem',
}}
>
<div
style={{
display: 'grid',
gridTemplateColumns: isDesktop ? 'minmax(340px, 380px) minmax(0, 1fr)' : '1fr',
gap: '1.5rem',
alignItems: 'start',
}}
>
<div style={{ display: 'grid', gap: '1.25rem' }}>
<div
style={{
background: '#fff',
border: '1px solid var(--color-border)',
borderRadius: '28px',
padding: '1.5rem',
}}
>
<BrandMark size="lg" />
</div>
<HomeSidebar items={sidebarWithContactCount} activeItem="new-attendance" isMobile={!isDesktop} />
</div>
<div style={{ display: 'grid', gap: '1.25rem', minWidth: 0 }}>
<header
style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 1fr) auto',
gap: '1rem',
alignItems: 'center',
}}
>
<div
style={{
padding: '1.1rem 1.25rem',
borderRadius: '22px',
background: '#fff',
border: '1px solid var(--color-border)',
}}
>
<h1 style={{ margin: 0, fontSize: '1.65rem' }}>Abrir Atendimento</h1>
<p style={{ margin: '0.45rem 0 0', color: 'var(--color-text-soft)' }}>
Inicie um contato ativo por WhatsApp usando mensagens pré-aprovadas.
</p>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.9rem',
justifySelf: isMobile ? 'stretch' : 'end',
justifyContent: isMobile ? 'space-between' : 'flex-end',
padding: '0.85rem 1rem',
borderRadius: '22px',
background: '#fff',
border: '1px solid var(--color-border)',
}}
>
<div style={{ textAlign: 'right' }}>
<strong style={{ display: 'block' }}>{userDisplay.name}</strong>
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.92rem' }}>
Atendimento omnichannel
</span>
</div>
<div
aria-hidden="true"
style={{
width: 48,
height: 48,
borderRadius: '16px',
display: 'grid',
placeItems: 'center',
background: 'linear-gradient(135deg, var(--color-accent), var(--color-primary))',
color: '#fff',
fontWeight: 800,
}}
>
{userDisplay.initials}
</div>
</div>
</header>
<NewAttendancePage embedded />
</div>
</div>
</section>
</main>
);
}

View File

@ -140,11 +140,11 @@ export function ContactProfilePanel({ isOpen, contact, onClose, onSaved }) {
</label>
<label style={{ display: 'grid', gap: '0.45rem' }}>
<span style={{ fontWeight: 600 }}>Empresa</span>
<span style={{ fontWeight: 600 }}>Etiqueta de identificação</span>
<input
value={form.company}
onChange={(event) => setForm((current) => ({ ...current, company: event.target.value }))}
placeholder="Empresa ou conta vinculada"
placeholder="Ex: Departamento, vaga ou conta vinculada"
style={fieldStyle}
/>
</label>

View File

@ -56,7 +56,7 @@ function emptyDraft() {
};
}
export function ContactsPage() {
export function ContactsPanel({ embedded = false }) {
const { isDesktop, isMobile } = useViewport();
const currentUser = getCurrentUser();
const currentUserId = getUserId(currentUser);
@ -146,6 +146,202 @@ export function ContactsPage() {
}
}
const content = (
<section
style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'minmax(320px, 0.85fr) minmax(0, 1fr)',
gap: '1rem',
alignItems: 'start',
}}
>
<aside
style={{
border: '1px solid var(--color-border)',
borderRadius: 24,
padding: '1rem',
background: '#fff',
display: 'grid',
gap: '0.8rem',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.75rem', alignItems: 'center' }}>
<div>
<strong style={{ display: 'block' }}>Agenda</strong>
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.9rem' }}>
{contacts.length} contato(s)
</span>
</div>
<button
type="button"
onClick={startNewContact}
style={{
border: 'none',
borderRadius: 14,
padding: '0.7rem 0.85rem',
background: 'var(--color-highlight)',
color: '#132534',
fontWeight: 900,
}}
>
Novo
</button>
</div>
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Buscar contato"
style={inputStyle}
/>
<div style={{ display: 'grid', gap: '0.45rem', maxHeight: 560, overflowY: 'auto', paddingRight: '0.2rem' }}>
{filteredContacts.map((contact) => {
const isSelected = selectedChatId === contact.chatId;
return (
<button
key={contact.chatId}
type="button"
onClick={() => selectContact(contact)}
style={{
border: '1px solid',
borderColor: isSelected ? 'rgba(0, 164, 183, 0.36)' : 'var(--color-border)',
borderRadius: 16,
padding: '0.8rem',
background: isSelected ? 'rgba(0, 164, 183, 0.08)' : '#fff',
textAlign: 'left',
display: 'grid',
gap: '0.25rem',
}}
>
<strong>{contact.name}</strong>
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.88rem' }}>
WhatsApp: +{contact.whatsappPhone}
</span>
{contact.email ? (
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.84rem' }}>
{contact.email}
</span>
) : null}
</button>
);
})}
{!filteredContacts.length ? (
<span style={{ color: 'var(--color-text-soft)', fontWeight: 700 }}>
Nenhum contato encontrado.
</span>
) : null}
</div>
</aside>
<form
onSubmit={handleSave}
style={{
border: '1px solid var(--color-border)',
borderRadius: 24,
padding: '1.2rem',
background: '#fff',
display: 'grid',
gap: '0.9rem',
}}
>
<div>
<strong style={{ display: 'block', fontSize: '1.08rem' }}>
{selectedChatId ? 'Editar contato' : 'Novo contato'}
</strong>
<span style={{ color: 'var(--color-text-soft)' }}>
O WhatsApp é usado para vincular o contato à conversa.
</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : 'repeat(2, minmax(0, 1fr))', gap: '0.85rem' }}>
<label style={{ display: 'grid', gap: '0.4rem' }}>
<span style={{ fontWeight: 700 }}>Nome</span>
<input
value={draft.name}
onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))}
style={inputStyle}
/>
</label>
<label style={{ display: 'grid', gap: '0.4rem' }}>
<span style={{ fontWeight: 700 }}>Etiqueta de identificação</span>
<input
value={draft.tag}
onChange={(event) => setDraft((current) => ({ ...current, tag: event.target.value }))}
placeholder="Ex: Departamento, vaga ou conta vinculada"
style={inputStyle}
/>
</label>
<label style={{ display: 'grid', gap: '0.4rem' }}>
<span style={{ fontWeight: 700 }}>Número WhatsApp</span>
<input
value={draft.whatsappPhone}
onChange={(event) => setDraft((current) => ({ ...current, whatsappPhone: event.target.value }))}
placeholder="5511988267544"
style={inputStyle}
/>
</label>
<label style={{ display: 'grid', gap: '0.4rem' }}>
<span style={{ fontWeight: 700 }}>Ligação/SMS</span>
<input
value={draft.callSmsPhone}
onChange={(event) => setDraft((current) => ({ ...current, callSmsPhone: event.target.value }))}
placeholder="5511988267544"
style={inputStyle}
/>
</label>
<label style={{ display: 'grid', gap: '0.4rem' }}>
<span style={{ fontWeight: 700 }}>E-mail</span>
<input
type="email"
value={draft.email}
onChange={(event) => setDraft((current) => ({ ...current, email: event.target.value }))}
placeholder="nome@empresa.com"
style={inputStyle}
/>
</label>
</div>
<label style={{ display: 'grid', gap: '0.4rem' }}>
<span style={{ fontWeight: 700 }}>Observação</span>
<textarea
rows={5}
value={draft.note}
onChange={(event) => setDraft((current) => ({ ...current, note: event.target.value }))}
style={{ ...inputStyle, resize: 'vertical', lineHeight: 1.5 }}
/>
</label>
{status ? <span style={{ color: status.includes('sucesso') ? 'var(--color-primary)' : '#b42318', fontWeight: 800 }}>{status}</span> : null}
{isLoading ? <span style={{ color: 'var(--color-text-soft)', fontWeight: 700 }}>Carregando agenda...</span> : null}
<button
type="submit"
disabled={isSaving}
style={{
border: 'none',
borderRadius: 16,
padding: '0.95rem 1rem',
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
color: '#fff',
fontWeight: 900,
opacity: isSaving ? 0.7 : 1,
}}
>
{isSaving ? 'Salvando...' : 'Salvar contato'}
</button>
</form>
</section>
);
if (embedded) {
return content;
}
return (
<main style={{ minHeight: '100vh', padding: '1.5rem' }}>
<section
@ -201,7 +397,7 @@ export function ContactsPage() {
>
<h1 style={{ margin: 0, fontSize: '1.65rem' }}>Contatos</h1>
<p style={{ margin: '0.45rem 0 0', color: 'var(--color-text-soft)' }}>
Agenda geral com WhatsApp, telefone para ligação/SMS, e-mail, tag e observação.
Agenda geral com WhatsApp, telefone para ligação/SMS, e-mail, etiqueta e observação.
</p>
</div>
@ -242,198 +438,14 @@ export function ContactsPage() {
</div>
</header>
<section
style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'minmax(320px, 0.85fr) minmax(0, 1fr)',
gap: '1rem',
alignItems: 'start',
}}
>
<aside
style={{
border: '1px solid var(--color-border)',
borderRadius: 24,
padding: '1rem',
background: '#fff',
display: 'grid',
gap: '0.8rem',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.75rem', alignItems: 'center' }}>
<div>
<strong style={{ display: 'block' }}>Agenda</strong>
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.9rem' }}>
{contacts.length} contato(s)
</span>
</div>
<button
type="button"
onClick={startNewContact}
style={{
border: 'none',
borderRadius: 14,
padding: '0.7rem 0.85rem',
background: 'var(--color-highlight)',
color: '#132534',
fontWeight: 900,
}}
>
Novo
</button>
</div>
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Buscar contato"
style={inputStyle}
/>
<div style={{ display: 'grid', gap: '0.45rem', maxHeight: 560, overflowY: 'auto', paddingRight: '0.2rem' }}>
{filteredContacts.map((contact) => {
const isSelected = selectedChatId === contact.chatId;
return (
<button
key={contact.chatId}
type="button"
onClick={() => selectContact(contact)}
style={{
border: '1px solid',
borderColor: isSelected ? 'rgba(0, 164, 183, 0.36)' : 'var(--color-border)',
borderRadius: 16,
padding: '0.8rem',
background: isSelected ? 'rgba(0, 164, 183, 0.08)' : '#fff',
textAlign: 'left',
display: 'grid',
gap: '0.25rem',
}}
>
<strong>{contact.name}</strong>
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.88rem' }}>
WhatsApp: +{contact.whatsappPhone}
</span>
{contact.email ? (
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.84rem' }}>
{contact.email}
</span>
) : null}
</button>
);
})}
{!filteredContacts.length ? (
<span style={{ color: 'var(--color-text-soft)', fontWeight: 700 }}>
Nenhum contato encontrado.
</span>
) : null}
</div>
</aside>
<form
onSubmit={handleSave}
style={{
border: '1px solid var(--color-border)',
borderRadius: 24,
padding: '1.2rem',
background: '#fff',
display: 'grid',
gap: '0.9rem',
}}
>
<div>
<strong style={{ display: 'block', fontSize: '1.08rem' }}>
{selectedChatId ? 'Editar contato' : 'Novo contato'}
</strong>
<span style={{ color: 'var(--color-text-soft)' }}>
O WhatsApp é usado para vincular o contato à conversa.
</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : 'repeat(2, minmax(0, 1fr))', gap: '0.85rem' }}>
<label style={{ display: 'grid', gap: '0.4rem' }}>
<span style={{ fontWeight: 700 }}>Nome</span>
<input
value={draft.name}
onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))}
style={inputStyle}
/>
</label>
<label style={{ display: 'grid', gap: '0.4rem' }}>
<span style={{ fontWeight: 700 }}>Tag</span>
<input
value={draft.tag}
onChange={(event) => setDraft((current) => ({ ...current, tag: event.target.value }))}
placeholder="Ex: Analista de TI III"
style={inputStyle}
/>
</label>
<label style={{ display: 'grid', gap: '0.4rem' }}>
<span style={{ fontWeight: 700 }}>Número WhatsApp</span>
<input
value={draft.whatsappPhone}
onChange={(event) => setDraft((current) => ({ ...current, whatsappPhone: event.target.value }))}
placeholder="5511988267544"
style={inputStyle}
/>
</label>
<label style={{ display: 'grid', gap: '0.4rem' }}>
<span style={{ fontWeight: 700 }}>Ligação/SMS</span>
<input
value={draft.callSmsPhone}
onChange={(event) => setDraft((current) => ({ ...current, callSmsPhone: event.target.value }))}
placeholder="5511988267544"
style={inputStyle}
/>
</label>
<label style={{ display: 'grid', gap: '0.4rem' }}>
<span style={{ fontWeight: 700 }}>E-mail</span>
<input
type="email"
value={draft.email}
onChange={(event) => setDraft((current) => ({ ...current, email: event.target.value }))}
placeholder="nome@empresa.com"
style={inputStyle}
/>
</label>
</div>
<label style={{ display: 'grid', gap: '0.4rem' }}>
<span style={{ fontWeight: 700 }}>Observação</span>
<textarea
rows={5}
value={draft.note}
onChange={(event) => setDraft((current) => ({ ...current, note: event.target.value }))}
style={{ ...inputStyle, resize: 'vertical', lineHeight: 1.5 }}
/>
</label>
{status ? <span style={{ color: status.includes('sucesso') ? 'var(--color-primary)' : '#b42318', fontWeight: 800 }}>{status}</span> : null}
{isLoading ? <span style={{ color: 'var(--color-text-soft)', fontWeight: 700 }}>Carregando agenda...</span> : null}
<button
type="submit"
disabled={isSaving}
style={{
border: 'none',
borderRadius: 16,
padding: '0.95rem 1rem',
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
color: '#fff',
fontWeight: 900,
opacity: isSaving ? 0.7 : 1,
}}
>
{isSaving ? 'Salvando...' : 'Salvar contato'}
</button>
</form>
</section>
{content}
</div>
</div>
</section>
</main>
);
}
export function ContactsPage() {
return <ContactsPanel />;
}

View File

@ -16,7 +16,6 @@ const navigationBySection = {
{ id: 'contacts', label: 'Contatos' },
],
admin: [
{ id: 'home', label: 'Home' },
{ id: 'today', label: 'Operação' },
{ type: 'separator' },
{ id: 'users-access', label: 'Usuários & Acessos' },
@ -37,7 +36,7 @@ const navigationBySection = {
const actionLabelBySection = {
supervisor: '+ Redistribuir atendimento',
admin: 'Abrir painel do atendente',
admin: 'Home',
};
export function ManagementLayout({
@ -108,7 +107,14 @@ export function ManagementLayout({
>
<button
type="button"
onClick={() => navigate('/home')}
onClick={() => {
if (activeSection === 'admin') {
onNavItemChange?.('home');
return;
}
navigate('/home');
}}
style={{
border: 'none',
borderRadius: '20px',
@ -198,7 +204,7 @@ export function ManagementLayout({
borderRadius: '18px',
padding: '0.9rem 1rem',
background: 'transparent',
color: '#fff',
color: '#ef4444',
fontWeight: 700,
textAlign: 'left',
}}

View File

@ -135,22 +135,34 @@ function Badge({ children, tone }) {
);
}
function HourlyLineChart({ values }) {
function HourlyBarChart({ values }) {
const labels = ['08h', '09h', '10h', '11h', '12h', '13h', '14h', '15h', '16h', '17h', '18h'];
const maxValue = Math.max(...values, 1);
const points = values
.map((value, index) => {
const x = (index / (values.length - 1)) * 100;
const y = 100 - (value / maxValue) * 78 - 10;
return `${x},${y}`;
})
.join(' ');
return (
<div style={{ display: 'grid', gap: '0.75rem' }}>
<svg viewBox="0 0 100 100" preserveAspectRatio="none" style={{ width: '100%', height: 260 }}>
<polyline points={points} fill="none" stroke="var(--color-secondary)" strokeWidth="2.4" vectorEffect="non-scaling-stroke" />
</svg>
<div
style={{
height: 260,
display: 'grid',
gridTemplateColumns: `repeat(${values.length}, minmax(10px, 1fr))`,
gap: '0.65rem',
alignItems: 'end',
padding: '0.5rem 0 0',
}}
>
{values.map((value, index) => (
<div
key={`${value}-${index}`}
title={`${labels[index]}: ${value} atendimentos`}
style={{
height: `${Math.max(8, (value / maxValue) * 100)}%`,
borderRadius: '10px 10px 4px 4px',
background: 'linear-gradient(180deg, var(--color-secondary), rgba(181, 31, 31, 0.62))',
}}
/>
))}
</div>
<div style={{ display: 'grid', gridTemplateColumns: `repeat(${labels.length}, 1fr)`, gap: '0.25rem', color: 'var(--color-text-soft)', fontSize: '0.78rem', fontWeight: 700 }}>
{labels.map((label) => (
<span key={label} style={{ textAlign: 'center' }}>{label}</span>
@ -262,7 +274,7 @@ export function OperationalDashboard({ isDesktop, isMobile }) {
</div>
<DataPanel title="Atendimentos finalizados por hora" description="Volume do dia entre 08h e 18h.">
<HourlyLineChart values={data.hourly} />
<HourlyBarChart values={data.hourly} />
</DataPanel>
{assignmentTarget ? (

View File

@ -9,6 +9,7 @@ import { TemplateManagementPanel } from '../components/TemplateManagementPanel';
import { KnowledgeBasePanel } from '../components/KnowledgeBasePanel';
import { MassMessagePanel } from '../components/MassMessagePanel';
import { NewAttendancePage } from '../../attendance/pages/NewAttendancePage';
import { ContactsPanel } from '../../home/pages/ContactsPage';
import { AttendantOpsPanel } from '../../home/components/AttendantOpsPanel';
import { MessagesWorkspace } from '../../home/components/MessagesWorkspace';
import { useChat } from '../../chat/hooks/useChat';
@ -21,6 +22,7 @@ import {
getAccessOptions,
getAccessUsers,
getAdminOverview,
getAiContentFile,
getAiContents,
getAttendantRanking,
getAuditLogs,
@ -59,6 +61,13 @@ const initialNotices = [
{ id: 'n2', text: 'Templates de abertura ativa atualizados para WhatsApp.' },
];
const defaultAiGuardrails = [
'Não informar dados sensíveis sem validação do colaborador.',
'Direcionar casos de assédio, denúncia ou risco trabalhista para atendimento humano.',
'Não inventar políticas: responder apenas com base nos conteúdos cadastrados.',
'Quando houver dúvida ou conflito de informação, encaminhar para especialista.',
];
const integrationCards = [
{
id: 'whatsapp',
@ -107,6 +116,23 @@ const integrationCards = [
},
];
const authProviderCards = [
{
id: 'ldap',
name: 'LDAP / AD',
icon: 'AD',
color: '#003150',
description: 'Autenticação corporativa via Active Directory para usuários internos.',
},
{
id: 'microsoft',
name: 'Microsoft OAuth',
icon: 'MS',
color: '#2563eb',
description: 'Login com Microsoft Entra ID usando OAuth para contas corporativas.',
},
];
function formatMinutes(minutes) {
if (minutes === null || minutes === undefined || Number.isNaN(Number(minutes))) return 'Sem dados';
return `${Number(minutes)} min`;
@ -202,6 +228,9 @@ export function AdminPage() {
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 [aiGuardrails, setAiGuardrails] = useState(defaultAiGuardrails);
const [aiGuardrailDraft, setAiGuardrailDraft] = useState('');
const [isOpeningAiContentId, setIsOpeningAiContentId] = useState(null);
const [userSearch, setUserSearch] = useState('');
const [newAreaName, setNewAreaName] = useState('');
const [isLoadingAccess, setIsLoadingAccess] = useState(true);
@ -220,6 +249,11 @@ export function AdminPage() {
sharepoint: false,
gupy: false,
});
const [authProviderStates, setAuthProviderStates] = useState({
ldap: true,
microsoft: false,
});
const [authConfigModal, setAuthConfigModal] = useState(null);
const [integrationNotice, setIntegrationNotice] = useState('');
const [configurationModal, setConfigurationModal] = useState(null);
@ -444,6 +478,38 @@ export function AdminPage() {
});
}
function base64ToBlob(base64, mimetype = 'application/octet-stream') {
const binary = window.atob(base64 || '');
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return new Blob([bytes], { type: mimetype });
}
async function openAiContent(contentId) {
setIsOpeningAiContentId(contentId);
try {
const file = await getAiContentFile(contentId);
if (!file?.content_base64) throw new Error('Arquivo não disponível.');
const blob = base64ToBlob(file.content_base64, file.mimetype);
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = file.filename || `${file.title || 'conteudo-ia'}.txt`;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
setAccessError('');
} catch {
setAccessError('Não foi possível baixar o conteúdo da IA.');
} finally {
setIsOpeningAiContentId(null);
}
}
async function submitAiContent(event) {
event.preventDefault();
const title = aiContentForm.title.trim();
@ -468,6 +534,15 @@ export function AdminPage() {
}
}
function addAiGuardrail(event) {
event.preventDefault();
const text = aiGuardrailDraft.trim();
if (!text) return;
setAiGuardrails((current) => [...current, text]);
setAiGuardrailDraft('');
}
async function removeAiContent(contentId) {
const confirmed = window.confirm('Tem certeza que deseja remover este conteúdo da IA?');
if (!confirmed) return;
@ -693,20 +768,82 @@ export function AdminPage() {
setNoticeDraft('');
}
function renderLineChart() {
function renderBarChart() {
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(' ');
const today = new Date();
const currentYear = today.getFullYear();
const currentMonth = today.getMonth();
const monthLabel = today.toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' });
const dateLabels = dailyAttendance.map((_, index) => {
const date = new Date(currentYear, currentMonth, index + 1);
return date.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' });
});
const visibleDateLabels = [
{ index: 0, label: dateLabels[0] },
{ index: Math.floor(dateLabels.length / 2), label: dateLabels[Math.floor(dateLabels.length / 2)] },
{ index: dateLabels.length - 1, label: dateLabels[dateLabels.length - 1] },
];
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>
<div style={{ display: 'grid', gap: '0.65rem' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
gap: '1rem',
color: 'var(--color-text-soft)',
fontWeight: 800,
fontSize: '0.88rem',
}}
>
<span>{monthLabel}</span>
<span>{dateLabels[0]} a {dateLabels[dateLabels.length - 1]}</span>
</div>
<div
style={{
height: 240,
display: 'grid',
gridTemplateColumns: `repeat(${dailyAttendance.length}, minmax(4px, 1fr))`,
gap: '0.28rem',
alignItems: 'end',
padding: '0.5rem 0 0',
}}
>
{dailyAttendance.map((value, index) => (
<div
key={`${value}-${index}`}
title={`${dateLabels[index]}: ${value} atendimentos`}
style={{
height: `${Math.max(8, (value / maxValue) * 100)}%`,
borderRadius: '8px 8px 3px 3px',
background: 'linear-gradient(180deg, var(--color-secondary), rgba(181, 31, 31, 0.62))',
minWidth: 4,
}}
/>
))}
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: `repeat(${dailyAttendance.length}, minmax(4px, 1fr))`,
gap: '0.28rem',
color: 'var(--color-text-soft)',
fontSize: '0.78rem',
fontWeight: 800,
}}
>
{dailyAttendance.map((_, index) => {
const visibleLabel = visibleDateLabels.find((item) => item.index === index);
return (
<span key={index} style={{ textAlign: index === 0 ? 'left' : index === dailyAttendance.length - 1 ? 'right' : 'center' }}>
{visibleLabel?.label || ''}
</span>
);
})}
</div>
</div>
);
}
@ -768,7 +905,7 @@ export function AdminPage() {
<div style={{ display: 'grid', gridTemplateColumns: isDesktop ? 'minmax(0, 1.85fr) minmax(300px, 1fr)' : '1fr', gap: '1rem' }}>
<DataPanel title="Atendimentos por dia" description="Volume diário do mês selecionado.">
{renderLineChart()}
{renderBarChart()}
</DataPanel>
<DataPanel title="Distribuição por canal" description="Participação mensal por canal.">
{renderDonutChart()}
@ -1173,29 +1310,83 @@ export function AdminPage() {
}
function renderAiContents() {
function getFileType(row) {
const filename = String(row.filename || '');
const extension = filename.includes('.') ? filename.split('.').pop().toUpperCase() : '';
if (extension) return extension;
if (row.mimetype?.includes('pdf')) return 'PDF';
if (row.mimetype?.includes('word')) return 'DOC';
if (row.mimetype?.includes('text')) return 'TXT';
return 'Arquivo';
}
const columns = [
{ key: 'title', label: 'Conteúdo' },
{ key: 'area_nome', label: 'Especialidade', render: (row) => row.area_nome || 'Geral' },
{ key: 'filename', label: 'Arquivo' },
{
key: 'filename',
label: 'Arquivo',
render: (row) => (
<div style={{ display: 'grid', gap: '0.18rem', minWidth: 0 }}>
<button
type="button"
disabled={isOpeningAiContentId === row.id}
onClick={() => openAiContent(row.id)}
style={{
border: 'none',
padding: 0,
background: 'transparent',
color: '#2563eb',
fontWeight: 700,
textAlign: 'left',
cursor: 'pointer',
opacity: isOpeningAiContentId === row.id ? 0.6 : 1,
textDecoration: 'underline',
textUnderlineOffset: 3,
width: 'fit-content',
}}
title={row.filename || 'Baixar arquivo'}
>
Baixar documento
</button>
<span
style={{
color: 'var(--color-text-soft)',
fontSize: '0.78rem',
fontWeight: 700,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: 180,
}}
title={row.filename || getFileType(row)}
>
{getFileType(row)}{row.filename ? ` · ${row.filename}` : ''}
</span>
</div>
),
},
{ 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>
<div style={{ display: 'flex', gap: '0.45rem', flexWrap: 'wrap' }}>
<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>
</div>
),
},
];
@ -1264,11 +1455,66 @@ export function AdminPage() {
</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>
<form onSubmit={addAiGuardrail} style={{ display: 'grid', gap: '0.75rem' }}>
<textarea
rows={3}
value={aiGuardrailDraft}
onChange={(event) => setAiGuardrailDraft(event.target.value)}
placeholder="Adicionar regra ou trava. Ex: Não responder dúvidas trabalhistas sem encaminhar para especialista."
style={{ ...selectStyle, resize: 'vertical', lineHeight: 1.5 }}
/>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button
type="submit"
style={{
border: 'none',
borderRadius: 16,
padding: '0.85rem 1rem',
background: 'var(--color-primary)',
color: '#fff',
fontWeight: 800,
}}
>
Adicionar regra
</button>
</div>
</form>
<div style={{ display: 'grid', gap: '0.55rem' }}>
{aiGuardrails.map((rule, index) => (
<div
key={`${rule}-${index}`}
style={{
border: '1px solid var(--color-border)',
borderRadius: 16,
padding: '0.85rem 0.9rem',
background: '#fff',
display: 'flex',
justifyContent: 'space-between',
gap: '0.85rem',
alignItems: 'flex-start',
}}
>
<span style={{ color: 'var(--color-text-soft)', fontWeight: 700, lineHeight: 1.45 }}>
{rule}
</span>
<button
type="button"
onClick={() => setAiGuardrails((current) => current.filter((_, itemIndex) => itemIndex !== index))}
style={{
border: 'none',
borderRadius: 12,
padding: '0.45rem 0.65rem',
background: 'rgba(181, 31, 31, 0.1)',
color: 'var(--color-secondary)',
fontWeight: 800,
flex: '0 0 auto',
}}
>
Remover
</button>
</div>
))}
</div>
</DataPanel>
</section>
@ -1578,6 +1824,243 @@ export function AdminPage() {
);
}
function renderSettings() {
const enabledCount = authProviderCards.filter((item) => authProviderStates[item.id]).length;
return (
<section style={{ display: 'grid', gap: '1rem' }}>
<DataPanel
title="Configurações de Login"
description="Provedores de autenticação disponíveis para acesso ao ambiente."
>
<div
style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'repeat(2, minmax(0, 1fr))',
gap: '0.8rem',
}}
>
<div
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 }}>Provedores ativos</span>
<strong style={{ fontSize: '1.55rem' }}>{enabledCount}</strong>
</div>
<div
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 }}>Segurança</span>
<strong style={{ fontSize: '1.05rem' }}>Login corporativo</strong>
</div>
</div>
</DataPanel>
<div
style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'repeat(2, minmax(0, 1fr))',
gap: '1rem',
}}
>
{authProviderCards.map((item) => {
const isEnabled = Boolean(authProviderStates[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: 220,
}}
>
<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,
}}
>
{item.icon}
</div>
<div>
<span
style={{
display: 'inline-flex',
borderRadius: 999,
padding: '0.18rem 0.55rem',
background: `${item.color}18`,
color: item.color,
fontSize: '0.78rem',
fontWeight: 800,
}}
>
Login
</span>
<strong style={{ display: 'block', marginTop: '0.35rem', fontSize: '1.15rem' }}>{item.name}</strong>
</div>
</div>
<button
type="button"
aria-pressed={isEnabled}
onClick={() => {
setAuthProviderStates((current) => ({
...current,
[item.id]: !current[item.id],
}));
}}
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 ? `Desativar ${item.name}` : `Ativar ${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 }}>
{isEnabled ? 'Ativo' : 'Inativo'}
</span>
<button
type="button"
onClick={() => setAuthConfigModal(item)}
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>
{authConfigModal ? (
<div
role="dialog"
aria-modal="true"
aria-labelledby="auth-config-title"
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0, 20, 32, 0.42)',
display: 'grid',
placeItems: 'center',
padding: '1rem',
zIndex: 50,
}}
onClick={() => setAuthConfigModal(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)',
}}
>
<strong id="auth-config-title" style={{ display: 'block', fontSize: '1.08rem' }}>
Configurar {authConfigModal.name}
</strong>
<p style={{ margin: 0, color: 'var(--color-text-soft)', lineHeight: 1.5, fontWeight: 700 }}>
Configure os parâmetros, credenciais e regras do provedor de login para controlar o acesso ao ambiente.
</p>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button
type="button"
onClick={() => setAuthConfigModal(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}
</section>
);
}
const sectionContent = {
home: renderMonthlyHome(),
today: <OperationalDashboard isDesktop={isDesktop} isMobile={isMobile} />,
@ -1597,8 +2080,8 @@ export function AdminPage() {
),
'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.'),
contacts: <ContactsPanel embedded />,
settings: renderSettings(),
};
const pageTitle = activeAdminSection === 'home'
@ -1613,8 +2096,12 @@ export function AdminPage() {
? 'Auditoria'
: activeAdminSection === 'channels'
? 'Canais e Integração'
: activeAdminSection === 'ai-contents'
? 'Conteúdos da IA'
: activeAdminSection === 'contacts'
? 'Contatos'
: activeAdminSection === 'settings'
? 'Configurações'
: activeAdminSection === 'ai-contents'
? 'Conteúdos da IA'
: activeAdminSection === 'knowledge'
? 'Fluxo do Bot'
: 'Painel administrativo';
@ -1631,8 +2118,12 @@ export function AdminPage() {
? 'Logs administrativos e operacionais com paginação de 100 eventos.'
: activeAdminSection === 'channels'
? 'Canais de atendimento e integrações que alimentam a operação e a IA.'
: activeAdminSection === 'ai-contents'
? 'Base de documentos que será consultada pela IA em fase de testes.'
: activeAdminSection === 'contacts'
? 'Agenda geral com WhatsApp, telefone, e-mail, etiqueta e observação.'
: activeAdminSection === 'settings'
? 'Configurações de autenticação e acesso ao ambiente.'
: 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.';

View File

@ -6,6 +6,7 @@ import { KnowledgeBasePanel } from '../components/KnowledgeBasePanel';
import { MassMessagePanel } from '../components/MassMessagePanel';
import { DataPanel } from '../components/DataPanel';
import { NewAttendancePage } from '../../attendance/pages/NewAttendancePage';
import { ContactsPanel } from '../../home/pages/ContactsPage';
import { getAccessOptions } from '../services/adminAccessService';
import { useViewport } from '../../../shared/hooks/useViewport';
import { getCurrentUser, getCurrentUserDisplay } from '../../auth/services/sessionService';
@ -97,7 +98,7 @@ export function SupervisorPage() {
isMobile={isMobile}
/>
),
contacts: renderPlaceholder('Contatos', 'Agenda geral de contatos.'),
contacts: <ContactsPanel embedded />,
};
return (

View File

@ -44,6 +44,10 @@ export async function getAiContents() {
return request('/admin/access/ai-contents');
}
export async function getAiContentFile(id) {
return request(`/admin/access/ai-contents/${id}/file`);
}
export async function createAiContent(payload) {
return request('/admin/access/ai-contents', {
method: 'POST',

View File

@ -5,7 +5,7 @@ import { AgentMassMessagePage } from '../modules/home/pages/AgentMassMessagePage
import { ContactsPage } from '../modules/home/pages/ContactsPage';
import { ChatPage } from '../modules/chat/pages/ChatPage';
import { CallPage } from '../modules/call/pages/CallPage';
import { NewAttendancePage } from '../modules/attendance/pages/NewAttendancePage';
import { AgentNewAttendancePage } from '../modules/attendance/pages/AgentNewAttendancePage';
import { WhatsappAdminPage } from '../modules/management/pages/WhatsappAdminPage';
export const router = createBrowserRouter([
@ -31,7 +31,7 @@ export const router = createBrowserRouter([
},
{
path: '/new-attendance',
element: <NewAttendancePage />,
element: <AgentNewAttendancePage />,
},
{
path: '/mass-message',