FEAT: Melhora distribuição e incrementa os templates
This commit is contained in:
parent
4d287faf28
commit
fbdbca7f20
@ -219,7 +219,7 @@ export function NewAttendancePage() {
|
||||
if (!isMounted) return;
|
||||
setContacts(Array.isArray(contactsData) ? contactsData.map(normalizeAgendaContact) : []);
|
||||
const supportedTemplates = Array.isArray(templatesData)
|
||||
? templatesData.filter((template) => !requiresUnsupportedTemplateFields(template))
|
||||
? templatesData.filter((template) => template.status === 'approved' && !requiresUnsupportedTemplateFields(template))
|
||||
: [];
|
||||
setTemplates(supportedTemplates);
|
||||
setSelectedTemplateId((current) => current || (supportedTemplates?.[0]?.id ? String(supportedTemplates[0].id) : ''));
|
||||
|
||||
@ -23,26 +23,69 @@ function ChannelBadge({ channel }) {
|
||||
);
|
||||
}
|
||||
|
||||
function LastMessageDot({ fromMe }) {
|
||||
const color = fromMe ? '#e5a22a' : '#00a4b7';
|
||||
const label = fromMe ? 'Última mensagem enviada pelo atendimento' : 'Última mensagem enviada pelo cliente';
|
||||
function AssignmentDot({ contact, currentUserId }) {
|
||||
const assignment = contact.assignment;
|
||||
const assignedUserId = assignment?.user_id ? Number(assignment.user_id) : null;
|
||||
const isQueued = assignment?.status === 'queued' && !assignedUserId;
|
||||
const isMine = assignedUserId && currentUserId && assignedUserId === Number(currentUserId);
|
||||
const meta = isQueued
|
||||
? {
|
||||
color: '#e5a22a',
|
||||
label: 'Chamado na fila da especialidade, ainda sem atribuição',
|
||||
}
|
||||
: isMine
|
||||
? {
|
||||
color: '#00a4b7',
|
||||
label: 'Chamado atribuído a mim',
|
||||
}
|
||||
: assignedUserId
|
||||
? {
|
||||
color: '#d62828',
|
||||
label: `Chamado atribuído a ${assignment?.user_nome || 'outra pessoa'}`,
|
||||
}
|
||||
: null;
|
||||
|
||||
if (!meta) return null;
|
||||
|
||||
return (
|
||||
<span
|
||||
title={label}
|
||||
aria-label={label}
|
||||
title={meta.label}
|
||||
aria-label={meta.label}
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 999,
|
||||
background: color,
|
||||
boxShadow: `0 0 0 3px ${color}22`,
|
||||
background: meta.color,
|
||||
boxShadow: `0 0 0 3px ${meta.color}22`,
|
||||
flex: '0 0 auto',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SpecialtyBadge({ contact }) {
|
||||
const specialty = contact.assignment?.area_nome || contact.area;
|
||||
if (!specialty || specialty === 'Sem fila') return null;
|
||||
|
||||
return (
|
||||
<span
|
||||
title={`Especialidade: ${specialty}`}
|
||||
style={{
|
||||
color: 'var(--color-primary)',
|
||||
flex: '0 0 auto',
|
||||
fontSize: '0.72rem',
|
||||
fontWeight: 800,
|
||||
lineHeight: 1,
|
||||
borderRadius: 999,
|
||||
padding: '0.2rem 0.5rem',
|
||||
background: 'rgba(0, 49, 80, 0.08)',
|
||||
}}
|
||||
>
|
||||
{specialty}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function UnreadBadge({ count }) {
|
||||
if (!count) return null;
|
||||
|
||||
@ -83,7 +126,7 @@ function SavedContactLabel({ contact }) {
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
•Salvo•
|
||||
•Salvo•
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@ -95,6 +138,7 @@ export function ChatConversationList({
|
||||
activeContactId,
|
||||
onSelectContact,
|
||||
onOpenContact,
|
||||
currentUserId,
|
||||
isMobile = false,
|
||||
}) {
|
||||
return (
|
||||
@ -158,7 +202,7 @@ export function ChatConversationList({
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.5rem', minWidth: 0 }}>
|
||||
<LastMessageDot fromMe={contact.lastMessageFromMe} />
|
||||
<AssignmentDot contact={contact} currentUserId={currentUserId} />
|
||||
<strong style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{contact.name}
|
||||
</strong>
|
||||
@ -170,6 +214,7 @@ export function ChatConversationList({
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.75rem' }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.45rem', minWidth: 0 }}>
|
||||
<ChannelBadge channel={contact.channel} />
|
||||
<SpecialtyBadge contact={contact} />
|
||||
<SavedContactLabel contact={contact} />
|
||||
</span>
|
||||
<UnreadBadge count={contact.unread} />
|
||||
|
||||
@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useWhatsappSocket } from '../../../shared/hooks/useWhatsappSocket';
|
||||
import { API_BASE_URL } from '../../../shared/services/apiConfig';
|
||||
import { getAccessOptions, getAccessUsers } from '../../management/services/adminAccessService';
|
||||
import { getCurrentUser } from '../../auth/services/sessionService';
|
||||
import { getCurrentUser, getCurrentUserProfile } from '../../auth/services/sessionService';
|
||||
import { chatContacts, transferAreas as fallbackTransferAreas } from '../services/chatMocks';
|
||||
|
||||
function buildInitialMessages() {
|
||||
@ -212,8 +212,10 @@ function getUserDisplayName(user) {
|
||||
|
||||
export function useChat() {
|
||||
const currentUser = getCurrentUser();
|
||||
const currentUserProfile = getCurrentUserProfile();
|
||||
const currentUserId = getUserId(currentUser);
|
||||
const currentUserAreas = getUserAreas(currentUser);
|
||||
const isAdminUser = currentUserProfile === 'admin';
|
||||
const { status: whatsappStatus, incomingMessage, clearIncomingMessage } = useWhatsappSocket();
|
||||
const [contacts, setContacts] = useState(buildFallbackContacts);
|
||||
const [activeContactId, setActiveContactId] = useState(chatContacts[0].id);
|
||||
@ -261,7 +263,7 @@ export function useChat() {
|
||||
const isWaitingCustomerReply = Boolean(activeAssignment?.awaiting_customer_reply);
|
||||
const isQueuedForUserArea = Boolean(
|
||||
activeAssignment?.status === 'queued' &&
|
||||
(!activeAssignment.area_nome || currentUserAreas.includes(activeAssignment.area_nome)),
|
||||
(isAdminUser || !activeAssignment.area_nome || currentUserAreas.includes(activeAssignment.area_nome)),
|
||||
);
|
||||
const canAssumeChat = Boolean(activeContact?.id?.includes('@') && currentUserId && isQueuedForUserArea);
|
||||
const canReply = Boolean(isAssignedToCurrentUser && !isWaitingCustomerReply);
|
||||
@ -314,6 +316,9 @@ export function useChat() {
|
||||
}, []);
|
||||
|
||||
function canSeeContact(contact) {
|
||||
if (isAdminUser) {
|
||||
return Boolean(contact.assignment && contact.assignment.status !== 'bot_triage');
|
||||
}
|
||||
if (!currentUserAreas.length) return false;
|
||||
if (!contact.assignment) return false;
|
||||
if (contact.assignment.status === 'bot_triage') return false;
|
||||
@ -369,7 +374,7 @@ export function useChat() {
|
||||
isMounted = false;
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, [currentUserId, currentUserAreas.join('|'), whatsappStatus]);
|
||||
}, [currentUserId, currentUserAreas.join('|'), isAdminUser, whatsappStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeContactId) return;
|
||||
@ -724,6 +729,7 @@ export function useChat() {
|
||||
}
|
||||
|
||||
return {
|
||||
currentUserId,
|
||||
contacts,
|
||||
activeContact,
|
||||
activeContactId,
|
||||
|
||||
@ -13,6 +13,7 @@ export function ChatPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { isWideDesktop, isDesktop, isTablet, isMobile } = useViewport();
|
||||
const {
|
||||
currentUserId,
|
||||
contacts,
|
||||
activeContact,
|
||||
activeContactId,
|
||||
@ -144,6 +145,7 @@ export function ChatPage() {
|
||||
setIsTransferOpen(false);
|
||||
setIsContactPanelOpen(true);
|
||||
}}
|
||||
currentUserId={currentUserId}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
|
||||
|
||||
@ -4,11 +4,15 @@ import { clearSession } from '../../auth/services/sessionService';
|
||||
|
||||
const navigationBySection = {
|
||||
supervisor: [
|
||||
{ id: 'dashboard', label: 'Dashboard', count: null },
|
||||
{ id: 'queues', label: 'Filas em tempo real', count: 42 },
|
||||
{ id: 'areas', label: 'Especialidades supervisionadas', count: 3 },
|
||||
{ id: 'agents', label: 'Agentes online', count: 18 },
|
||||
{ id: 'reports', label: 'Relatórios', count: null },
|
||||
{ id: 'dashboard', label: 'Home' },
|
||||
{ id: 'templates', label: 'Templates' },
|
||||
{ id: 'knowledge', label: 'Base de conhecimento IA' },
|
||||
{ id: 'audit', label: 'Auditoria' },
|
||||
{ type: 'separator' },
|
||||
{ id: 'attendance', label: 'Atendimento' },
|
||||
{ id: 'new-attendance', label: 'Abrir Atendimento', path: '/new-attendance' },
|
||||
{ id: 'mass-message', label: 'Disparo em Massa' },
|
||||
{ id: 'contacts', label: 'Contatos' },
|
||||
],
|
||||
admin: [
|
||||
{ id: 'home', label: 'Home' },
|
||||
@ -31,7 +35,7 @@ const navigationBySection = {
|
||||
|
||||
const actionLabelBySection = {
|
||||
supervisor: '+ Redistribuir atendimento',
|
||||
admin: '+ Nova configuração',
|
||||
admin: 'Home',
|
||||
};
|
||||
|
||||
export function ManagementLayout({
|
||||
@ -48,7 +52,7 @@ export function ManagementLayout({
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const navItems = navigationBySection[activeSection] || navigationBySection.supervisor;
|
||||
const actionLabel = actionLabelBySection[activeSection] || '+ Nova ação';
|
||||
const actionLabel = actionLabelBySection[activeSection] || 'Home';
|
||||
|
||||
function handleLogout() {
|
||||
clearSession();
|
||||
|
||||
347
src/modules/management/components/TemplateManagementPanel.jsx
Normal file
347
src/modules/management/components/TemplateManagementPanel.jsx
Normal file
@ -0,0 +1,347 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { DataPanel } from './DataPanel';
|
||||
import {
|
||||
approveTemplateByAdmin,
|
||||
deleteTemplate,
|
||||
listTemplates,
|
||||
rejectTemplateByAdmin,
|
||||
saveTemplate,
|
||||
} from '../services/templateService';
|
||||
|
||||
const fieldStyle = {
|
||||
width: '100%',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '14px',
|
||||
padding: '0.75rem 0.85rem',
|
||||
background: '#fff',
|
||||
color: 'var(--color-text)',
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
const statusMeta = {
|
||||
approved: {
|
||||
label: 'Aprovado pela Meta',
|
||||
background: 'rgba(34, 197, 94, 0.12)',
|
||||
color: '#15803d',
|
||||
},
|
||||
meta_review: {
|
||||
label: 'Em análise pela Meta',
|
||||
background: 'rgba(229, 162, 42, 0.16)',
|
||||
color: '#8a5a00',
|
||||
},
|
||||
admin_review: {
|
||||
label: 'Aguardando aprovação do admin',
|
||||
background: 'rgba(0, 49, 80, 0.1)',
|
||||
color: 'var(--color-primary)',
|
||||
},
|
||||
rejected: {
|
||||
label: 'Reprovado pelo admin',
|
||||
background: 'rgba(181, 31, 31, 0.1)',
|
||||
color: 'var(--color-secondary)',
|
||||
},
|
||||
};
|
||||
|
||||
function getTemplateStatus(template) {
|
||||
return statusMeta[template.status] || statusMeta.approved;
|
||||
}
|
||||
|
||||
function getRemainingMetaText(template) {
|
||||
if (template.status !== 'meta_review' || !template.meta_submitted_at) return '';
|
||||
const submittedAt = new Date(template.meta_submitted_at).getTime();
|
||||
const approvedAt = submittedAt + 15 * 60 * 1000;
|
||||
const remainingMs = approvedAt - Date.now();
|
||||
if (remainingMs <= 0) return 'Aprovação fake disponível ao atualizar.';
|
||||
const minutes = Math.ceil(remainingMs / 60000);
|
||||
return `Aprovação fake em aproximadamente ${minutes} min.`;
|
||||
}
|
||||
|
||||
export function TemplateManagementPanel({
|
||||
areas = [],
|
||||
mode = 'admin',
|
||||
managedAreaNames = [],
|
||||
isMobile = false,
|
||||
}) {
|
||||
const [templates, setTemplates] = useState([]);
|
||||
const [selectedArea, setSelectedArea] = useState('all');
|
||||
const [form, setForm] = useState({ name: '', content: '', areaId: '' });
|
||||
const [statusMessage, setStatusMessage] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const isAdmin = mode === 'admin';
|
||||
const visibleAreas = isAdmin
|
||||
? areas
|
||||
: areas.filter((area) => managedAreaNames.includes(area.nome));
|
||||
|
||||
const filteredTemplates = useMemo(() => {
|
||||
return templates.filter((template) => {
|
||||
const areaMatches = selectedArea === 'all' || String(template.area_id || '') === selectedArea;
|
||||
const supervisorAreaMatches =
|
||||
isAdmin ||
|
||||
!template.area_nome ||
|
||||
managedAreaNames.includes(template.area_nome);
|
||||
return areaMatches && supervisorAreaMatches;
|
||||
});
|
||||
}, [templates, selectedArea, isAdmin, managedAreaNames]);
|
||||
|
||||
async function loadTemplates() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await listTemplates();
|
||||
setTemplates(Array.isArray(data) ? data : []);
|
||||
setStatusMessage('');
|
||||
} catch (error) {
|
||||
setStatusMessage(error.message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadTemplates();
|
||||
}, []);
|
||||
|
||||
async function submitTemplate(event) {
|
||||
event.preventDefault();
|
||||
const name = form.name.trim();
|
||||
const content = form.content.trim();
|
||||
if (!name || !content) return;
|
||||
|
||||
try {
|
||||
await saveTemplate({
|
||||
name,
|
||||
content,
|
||||
areaId: Number(form.areaId) || null,
|
||||
requestedByRole: isAdmin ? 'admin' : 'supervisor',
|
||||
});
|
||||
setForm({ name: '', content: '', areaId: '' });
|
||||
setStatusMessage(
|
||||
isAdmin
|
||||
? 'Template enviado para aprovação.'
|
||||
: 'Template enviado para aprovação do admin.',
|
||||
);
|
||||
await loadTemplates();
|
||||
} catch (error) {
|
||||
setStatusMessage(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function approveTemplate(templateId) {
|
||||
try {
|
||||
await approveTemplateByAdmin(templateId);
|
||||
setStatusMessage('Template aprovado pelo admin e enviado para análise fake da Meta.');
|
||||
await loadTemplates();
|
||||
} catch (error) {
|
||||
setStatusMessage(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function rejectTemplate(templateId) {
|
||||
try {
|
||||
await rejectTemplateByAdmin(templateId);
|
||||
setStatusMessage('Template reprovado pelo admin.');
|
||||
await loadTemplates();
|
||||
} catch (error) {
|
||||
setStatusMessage(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeTemplate(templateId) {
|
||||
try {
|
||||
await deleteTemplate(templateId);
|
||||
setStatusMessage('Template excluído.');
|
||||
await loadTemplates();
|
||||
} catch (error) {
|
||||
setStatusMessage(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section style={{ display: 'grid', gap: '1rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<label style={{ display: 'grid', gap: '0.35rem', width: isMobile ? '100%' : 280 }}>
|
||||
<span style={{ fontWeight: 800 }}>Filtrar por especialidade</span>
|
||||
<select value={selectedArea} onChange={(event) => setSelectedArea(event.target.value)} style={fieldStyle}>
|
||||
<option value="all">Todas as especialidades</option>
|
||||
{visibleAreas.map((area) => (
|
||||
<option key={area.id} value={area.id}>
|
||||
{area.nome}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<DataPanel
|
||||
title={isAdmin ? 'Templates WhatsApp' : 'Solicitar template'}
|
||||
description={
|
||||
isAdmin
|
||||
? 'Crie templates e aprove solicitações de supervisores antes do envio fake para a Meta.'
|
||||
: 'Templates enviados por supervisor passam primeiro pela aprovação do admin.'
|
||||
}
|
||||
>
|
||||
<form onSubmit={submitTemplate} style={{ display: 'grid', gap: '0.85rem' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 0.8fr) minmax(0, 0.7fr)', gap: '0.85rem' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))}
|
||||
placeholder="Identificador do template"
|
||||
style={fieldStyle}
|
||||
/>
|
||||
<select
|
||||
value={form.areaId}
|
||||
onChange={(event) => setForm((current) => ({ ...current, areaId: event.target.value }))}
|
||||
style={fieldStyle}
|
||||
>
|
||||
<option value="">Sem especialidade</option>
|
||||
{visibleAreas.map((area) => (
|
||||
<option key={area.id} value={area.id}>
|
||||
{area.nome}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={form.content}
|
||||
onChange={(event) => setForm((current) => ({ ...current, content: event.target.value }))}
|
||||
placeholder="Mensagem do template. Ex: Olá, {nome}. Podemos seguir com seu atendimento por aqui?"
|
||||
rows={4}
|
||||
style={{ ...fieldStyle, resize: 'vertical', lineHeight: 1.5 }}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: 16,
|
||||
padding: '0.9rem 1rem',
|
||||
background: 'var(--color-primary)',
|
||||
color: '#fff',
|
||||
fontWeight: 800,
|
||||
width: 'fit-content',
|
||||
}}
|
||||
>
|
||||
{isAdmin ? 'Enviar para aprovação' : 'Enviar para admin'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{statusMessage ? (
|
||||
<div style={{ marginTop: '0.85rem', color: 'var(--color-primary)', fontWeight: 800 }}>
|
||||
{statusMessage}
|
||||
</div>
|
||||
) : null}
|
||||
</DataPanel>
|
||||
|
||||
<DataPanel title="Lista de templates" description={isLoading ? 'Carregando templates...' : 'Status do fluxo de aprovação.'}>
|
||||
<div style={{ display: 'grid', gap: '0.75rem', maxHeight: 520, overflowY: 'auto', paddingRight: '0.2rem' }}>
|
||||
{filteredTemplates.map((template) => {
|
||||
const status = getTemplateStatus(template);
|
||||
const remainingMetaText = getRemainingMetaText(template);
|
||||
|
||||
return (
|
||||
<article
|
||||
key={template.id}
|
||||
style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 18,
|
||||
padding: '1rem',
|
||||
background: '#fff',
|
||||
display: 'grid',
|
||||
gap: '0.65rem',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<strong style={{ display: 'block' }}>{template.name}</strong>
|
||||
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.9rem' }}>
|
||||
{template.area_nome || 'Sem especialidade'}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
width: 'fit-content',
|
||||
borderRadius: 999,
|
||||
padding: '0.3rem 0.65rem',
|
||||
background: status.background,
|
||||
color: status.color,
|
||||
fontWeight: 800,
|
||||
fontSize: '0.82rem',
|
||||
}}
|
||||
>
|
||||
{status.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p style={{ margin: 0, color: 'var(--color-text-soft)', lineHeight: 1.5 }}>
|
||||
{template.content}
|
||||
</p>
|
||||
|
||||
{remainingMetaText ? (
|
||||
<span style={{ color: '#8a5a00', fontWeight: 700 }}>{remainingMetaText}</span>
|
||||
) : null}
|
||||
|
||||
{isAdmin ? (
|
||||
<div style={{ display: 'flex', gap: '0.55rem', flexWrap: 'wrap' }}>
|
||||
{template.status === 'admin_review' ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => approveTemplate(template.id)}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: 14,
|
||||
padding: '0.75rem 0.9rem',
|
||||
background: 'var(--color-primary)',
|
||||
color: '#fff',
|
||||
fontWeight: 800,
|
||||
}}
|
||||
>
|
||||
Aprovar e enviar para Meta
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => rejectTemplate(template.id)}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: 14,
|
||||
padding: '0.75rem 0.9rem',
|
||||
background: 'rgba(181, 31, 31, 0.1)',
|
||||
color: 'var(--color-secondary)',
|
||||
fontWeight: 800,
|
||||
}}
|
||||
>
|
||||
Reprovar
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeTemplate(template.id)}
|
||||
style={{
|
||||
border: '1px solid rgba(181, 31, 31, 0.22)',
|
||||
borderRadius: 14,
|
||||
padding: '0.75rem 0.9rem',
|
||||
background: '#fff',
|
||||
color: 'var(--color-secondary)',
|
||||
fontWeight: 800,
|
||||
}}
|
||||
>
|
||||
Excluir
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
|
||||
{!filteredTemplates.length ? (
|
||||
<span style={{ color: 'var(--color-text-soft)', fontWeight: 700 }}>
|
||||
Nenhum template encontrado para o filtro atual.
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</DataPanel>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -4,6 +4,7 @@ import { ManagementLayout } from '../components/ManagementLayout';
|
||||
import { ManagementTable } from '../components/ManagementTable';
|
||||
import { MetricGrid } from '../components/MetricGrid';
|
||||
import { OperationalDashboard } from '../components/OperationalDashboard';
|
||||
import { TemplateManagementPanel } from '../components/TemplateManagementPanel';
|
||||
import { aiContentRows, areaRows, userRows } from '../services/managementMocks';
|
||||
import { AttendantOpsPanel } from '../../home/components/AttendantOpsPanel';
|
||||
import { MessagesWorkspace } from '../../home/components/MessagesWorkspace';
|
||||
@ -111,7 +112,7 @@ function toHomeConversation(contact, messages = []) {
|
||||
};
|
||||
}
|
||||
|
||||
function AdminAttendanceWorkspace({ isWideDesktop, isDesktop, isTablet, isMobile }) {
|
||||
export function AdminAttendanceWorkspace({ isWideDesktop, isDesktop, isTablet, isMobile }) {
|
||||
const {
|
||||
contacts,
|
||||
activeContactId,
|
||||
@ -865,7 +866,7 @@ export function AdminPage() {
|
||||
home: renderMonthlyHome(),
|
||||
today: <OperationalDashboard isDesktop={isDesktop} isMobile={isMobile} />,
|
||||
'users-access': renderUsersAccess(),
|
||||
templates: renderPlaceholder('Templates', 'Gestão de templates aprovados pela Meta.'),
|
||||
templates: <TemplateManagementPanel areas={areas} mode="admin" isMobile={isMobile} />,
|
||||
knowledge: (
|
||||
<DataPanel title="Base de conhecimento IA" description="Entradas para alimentar a base de conhecimento.">
|
||||
<ManagementTable columns={contentColumns} rows={aiContentRows} getRowId={(row) => row.id} isMobile={isMobile} />
|
||||
|
||||
@ -1,11 +1,99 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ManagementLayout } from '../components/ManagementLayout';
|
||||
import { OperationalDashboard } from '../components/OperationalDashboard';
|
||||
import { TemplateManagementPanel } from '../components/TemplateManagementPanel';
|
||||
import { DataPanel } from '../components/DataPanel';
|
||||
import { ManagementTable } from '../components/ManagementTable';
|
||||
import { aiContentRows } from '../services/managementMocks';
|
||||
import { getAccessOptions } from '../services/adminAccessService';
|
||||
import { useViewport } from '../../../shared/hooks/useViewport';
|
||||
import { getCurrentUserDisplay } from '../../auth/services/sessionService';
|
||||
import { getCurrentUser, getCurrentUserDisplay } from '../../auth/services/sessionService';
|
||||
import { AdminAttendanceWorkspace } from './AdminPage';
|
||||
|
||||
function getUserSpecialties(user) {
|
||||
const normalize = (area) => {
|
||||
if (!area) return null;
|
||||
if (typeof area === 'string') return area;
|
||||
return area.nome || area.name || null;
|
||||
};
|
||||
|
||||
const areas = Array.isArray(user?.areas) ? user.areas.map(normalize).filter(Boolean) : [];
|
||||
const primary = normalize(user?.areaPrincipal);
|
||||
return primary && !areas.includes(primary) ? [primary, ...areas] : areas;
|
||||
}
|
||||
|
||||
export function SupervisorPage() {
|
||||
const { isDesktop, isMobile } = useViewport();
|
||||
const { isWideDesktop, isDesktop, isTablet, isMobile } = useViewport();
|
||||
const userDisplay = getCurrentUserDisplay();
|
||||
const currentUser = getCurrentUser();
|
||||
const managedSpecialties = getUserSpecialties(currentUser);
|
||||
const [activeSection, setActiveSection] = useState('dashboard');
|
||||
const [areas, setAreas] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
getAccessOptions()
|
||||
.then((options) => {
|
||||
if (isMounted) setAreas(options.areas || []);
|
||||
})
|
||||
.catch(() => {
|
||||
if (isMounted) setAreas([]);
|
||||
});
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
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 }}>
|
||||
Seção em preparação.
|
||||
</div>
|
||||
</DataPanel>
|
||||
);
|
||||
}
|
||||
|
||||
const contentColumns = [
|
||||
{ key: 'title', label: 'Conteúdo' },
|
||||
{ key: 'area', label: 'Especialidade' },
|
||||
{ key: 'status', label: 'Status' },
|
||||
{ key: 'updatedAt', label: 'Atualizado' },
|
||||
];
|
||||
|
||||
const filteredKnowledgeRows = managedSpecialties.length
|
||||
? aiContentRows.filter((row) => managedSpecialties.includes(row.area))
|
||||
: aiContentRows;
|
||||
|
||||
const sectionContent = {
|
||||
dashboard: <OperationalDashboard isDesktop={isDesktop} isMobile={isMobile} />,
|
||||
templates: (
|
||||
<TemplateManagementPanel
|
||||
areas={areas}
|
||||
mode="supervisor"
|
||||
managedAreaNames={managedSpecialties}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
),
|
||||
knowledge: (
|
||||
<DataPanel title="Base de conhecimento" description="Conteúdos da IA para as especialidades supervisionadas.">
|
||||
<ManagementTable columns={contentColumns} rows={filteredKnowledgeRows} getRowId={(row) => row.id} isMobile={isMobile} />
|
||||
</DataPanel>
|
||||
),
|
||||
audit: renderPlaceholder('Auditoria', 'Eventos do time supervisionado serão consolidados aqui.'),
|
||||
attendance: (
|
||||
<AdminAttendanceWorkspace
|
||||
isWideDesktop={isWideDesktop}
|
||||
isDesktop={isDesktop}
|
||||
isTablet={isTablet}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
),
|
||||
'mass-message': renderPlaceholder('Disparo em massa', 'Fluxo de disparos por templates aprovados.'),
|
||||
contacts: renderPlaceholder('Contatos', 'Agenda geral de contatos.'),
|
||||
};
|
||||
|
||||
return (
|
||||
<ManagementLayout
|
||||
@ -16,8 +104,10 @@ export function SupervisorPage() {
|
||||
initials={userDisplay.initials}
|
||||
isDesktop={isDesktop}
|
||||
isMobile={isMobile}
|
||||
activeNavItem={activeSection}
|
||||
onNavItemChange={setActiveSection}
|
||||
>
|
||||
<OperationalDashboard isDesktop={isDesktop} isMobile={isMobile} />
|
||||
{sectionContent[activeSection] || sectionContent.dashboard}
|
||||
</ManagementLayout>
|
||||
);
|
||||
}
|
||||
|
||||
53
src/modules/management/services/templateService.js
Normal file
53
src/modules/management/services/templateService.js
Normal file
@ -0,0 +1,53 @@
|
||||
import { API_BASE_URL } from '../../../shared/services/apiConfig';
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Falha ao consultar templates.');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export function listTemplates() {
|
||||
return request('/whatsapp/templates');
|
||||
}
|
||||
|
||||
export function saveTemplate(payload) {
|
||||
return request('/whatsapp/templates', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateTemplate(id, payload) {
|
||||
return request(`/whatsapp/templates/update/${id}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function approveTemplateByAdmin(id) {
|
||||
return request(`/whatsapp/templates/approve-admin/${id}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export function rejectTemplateByAdmin(id) {
|
||||
return request(`/whatsapp/templates/reject-admin/${id}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteTemplate(id) {
|
||||
return request(`/whatsapp/templates/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user