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;
|
if (!isMounted) return;
|
||||||
setContacts(Array.isArray(contactsData) ? contactsData.map(normalizeAgendaContact) : []);
|
setContacts(Array.isArray(contactsData) ? contactsData.map(normalizeAgendaContact) : []);
|
||||||
const supportedTemplates = Array.isArray(templatesData)
|
const supportedTemplates = Array.isArray(templatesData)
|
||||||
? templatesData.filter((template) => !requiresUnsupportedTemplateFields(template))
|
? templatesData.filter((template) => template.status === 'approved' && !requiresUnsupportedTemplateFields(template))
|
||||||
: [];
|
: [];
|
||||||
setTemplates(supportedTemplates);
|
setTemplates(supportedTemplates);
|
||||||
setSelectedTemplateId((current) => current || (supportedTemplates?.[0]?.id ? String(supportedTemplates[0].id) : ''));
|
setSelectedTemplateId((current) => current || (supportedTemplates?.[0]?.id ? String(supportedTemplates[0].id) : ''));
|
||||||
|
|||||||
@ -23,26 +23,69 @@ function ChannelBadge({ channel }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function LastMessageDot({ fromMe }) {
|
function AssignmentDot({ contact, currentUserId }) {
|
||||||
const color = fromMe ? '#e5a22a' : '#00a4b7';
|
const assignment = contact.assignment;
|
||||||
const label = fromMe ? 'Última mensagem enviada pelo atendimento' : 'Última mensagem enviada pelo cliente';
|
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 (
|
return (
|
||||||
<span
|
<span
|
||||||
title={label}
|
title={meta.label}
|
||||||
aria-label={label}
|
aria-label={meta.label}
|
||||||
style={{
|
style={{
|
||||||
width: 10,
|
width: 10,
|
||||||
height: 10,
|
height: 10,
|
||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
background: color,
|
background: meta.color,
|
||||||
boxShadow: `0 0 0 3px ${color}22`,
|
boxShadow: `0 0 0 3px ${meta.color}22`,
|
||||||
flex: '0 0 auto',
|
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 }) {
|
function UnreadBadge({ count }) {
|
||||||
if (!count) return null;
|
if (!count) return null;
|
||||||
|
|
||||||
@ -83,7 +126,7 @@ function SavedContactLabel({ contact }) {
|
|||||||
lineHeight: 1,
|
lineHeight: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
•Salvo•
|
•Salvo•
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -95,6 +138,7 @@ export function ChatConversationList({
|
|||||||
activeContactId,
|
activeContactId,
|
||||||
onSelectContact,
|
onSelectContact,
|
||||||
onOpenContact,
|
onOpenContact,
|
||||||
|
currentUserId,
|
||||||
isMobile = false,
|
isMobile = false,
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@ -158,7 +202,7 @@ export function ChatConversationList({
|
|||||||
>
|
>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
|
||||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.5rem', minWidth: 0 }}>
|
<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' }}>
|
<strong style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
{contact.name}
|
{contact.name}
|
||||||
</strong>
|
</strong>
|
||||||
@ -170,6 +214,7 @@ export function ChatConversationList({
|
|||||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.75rem' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.75rem' }}>
|
||||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.45rem', minWidth: 0 }}>
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.45rem', minWidth: 0 }}>
|
||||||
<ChannelBadge channel={contact.channel} />
|
<ChannelBadge channel={contact.channel} />
|
||||||
|
<SpecialtyBadge contact={contact} />
|
||||||
<SavedContactLabel contact={contact} />
|
<SavedContactLabel contact={contact} />
|
||||||
</span>
|
</span>
|
||||||
<UnreadBadge count={contact.unread} />
|
<UnreadBadge count={contact.unread} />
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { useWhatsappSocket } from '../../../shared/hooks/useWhatsappSocket';
|
import { useWhatsappSocket } from '../../../shared/hooks/useWhatsappSocket';
|
||||||
import { API_BASE_URL } from '../../../shared/services/apiConfig';
|
import { API_BASE_URL } from '../../../shared/services/apiConfig';
|
||||||
import { getAccessOptions, getAccessUsers } from '../../management/services/adminAccessService';
|
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';
|
import { chatContacts, transferAreas as fallbackTransferAreas } from '../services/chatMocks';
|
||||||
|
|
||||||
function buildInitialMessages() {
|
function buildInitialMessages() {
|
||||||
@ -212,8 +212,10 @@ function getUserDisplayName(user) {
|
|||||||
|
|
||||||
export function useChat() {
|
export function useChat() {
|
||||||
const currentUser = getCurrentUser();
|
const currentUser = getCurrentUser();
|
||||||
|
const currentUserProfile = getCurrentUserProfile();
|
||||||
const currentUserId = getUserId(currentUser);
|
const currentUserId = getUserId(currentUser);
|
||||||
const currentUserAreas = getUserAreas(currentUser);
|
const currentUserAreas = getUserAreas(currentUser);
|
||||||
|
const isAdminUser = currentUserProfile === 'admin';
|
||||||
const { status: whatsappStatus, incomingMessage, clearIncomingMessage } = useWhatsappSocket();
|
const { status: whatsappStatus, incomingMessage, clearIncomingMessage } = useWhatsappSocket();
|
||||||
const [contacts, setContacts] = useState(buildFallbackContacts);
|
const [contacts, setContacts] = useState(buildFallbackContacts);
|
||||||
const [activeContactId, setActiveContactId] = useState(chatContacts[0].id);
|
const [activeContactId, setActiveContactId] = useState(chatContacts[0].id);
|
||||||
@ -261,7 +263,7 @@ export function useChat() {
|
|||||||
const isWaitingCustomerReply = Boolean(activeAssignment?.awaiting_customer_reply);
|
const isWaitingCustomerReply = Boolean(activeAssignment?.awaiting_customer_reply);
|
||||||
const isQueuedForUserArea = Boolean(
|
const isQueuedForUserArea = Boolean(
|
||||||
activeAssignment?.status === 'queued' &&
|
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 canAssumeChat = Boolean(activeContact?.id?.includes('@') && currentUserId && isQueuedForUserArea);
|
||||||
const canReply = Boolean(isAssignedToCurrentUser && !isWaitingCustomerReply);
|
const canReply = Boolean(isAssignedToCurrentUser && !isWaitingCustomerReply);
|
||||||
@ -314,6 +316,9 @@ export function useChat() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function canSeeContact(contact) {
|
function canSeeContact(contact) {
|
||||||
|
if (isAdminUser) {
|
||||||
|
return Boolean(contact.assignment && contact.assignment.status !== 'bot_triage');
|
||||||
|
}
|
||||||
if (!currentUserAreas.length) return false;
|
if (!currentUserAreas.length) return false;
|
||||||
if (!contact.assignment) return false;
|
if (!contact.assignment) return false;
|
||||||
if (contact.assignment.status === 'bot_triage') return false;
|
if (contact.assignment.status === 'bot_triage') return false;
|
||||||
@ -369,7 +374,7 @@ export function useChat() {
|
|||||||
isMounted = false;
|
isMounted = false;
|
||||||
window.clearInterval(intervalId);
|
window.clearInterval(intervalId);
|
||||||
};
|
};
|
||||||
}, [currentUserId, currentUserAreas.join('|'), whatsappStatus]);
|
}, [currentUserId, currentUserAreas.join('|'), isAdminUser, whatsappStatus]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeContactId) return;
|
if (!activeContactId) return;
|
||||||
@ -724,6 +729,7 @@ export function useChat() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
currentUserId,
|
||||||
contacts,
|
contacts,
|
||||||
activeContact,
|
activeContact,
|
||||||
activeContactId,
|
activeContactId,
|
||||||
|
|||||||
@ -13,6 +13,7 @@ export function ChatPage() {
|
|||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const { isWideDesktop, isDesktop, isTablet, isMobile } = useViewport();
|
const { isWideDesktop, isDesktop, isTablet, isMobile } = useViewport();
|
||||||
const {
|
const {
|
||||||
|
currentUserId,
|
||||||
contacts,
|
contacts,
|
||||||
activeContact,
|
activeContact,
|
||||||
activeContactId,
|
activeContactId,
|
||||||
@ -144,6 +145,7 @@ export function ChatPage() {
|
|||||||
setIsTransferOpen(false);
|
setIsTransferOpen(false);
|
||||||
setIsContactPanelOpen(true);
|
setIsContactPanelOpen(true);
|
||||||
}}
|
}}
|
||||||
|
currentUserId={currentUserId}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -4,11 +4,15 @@ import { clearSession } from '../../auth/services/sessionService';
|
|||||||
|
|
||||||
const navigationBySection = {
|
const navigationBySection = {
|
||||||
supervisor: [
|
supervisor: [
|
||||||
{ id: 'dashboard', label: 'Dashboard', count: null },
|
{ id: 'dashboard', label: 'Home' },
|
||||||
{ id: 'queues', label: 'Filas em tempo real', count: 42 },
|
{ id: 'templates', label: 'Templates' },
|
||||||
{ id: 'areas', label: 'Especialidades supervisionadas', count: 3 },
|
{ id: 'knowledge', label: 'Base de conhecimento IA' },
|
||||||
{ id: 'agents', label: 'Agentes online', count: 18 },
|
{ id: 'audit', label: 'Auditoria' },
|
||||||
{ id: 'reports', label: 'Relatórios', count: null },
|
{ 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: [
|
admin: [
|
||||||
{ id: 'home', label: 'Home' },
|
{ id: 'home', label: 'Home' },
|
||||||
@ -31,7 +35,7 @@ const navigationBySection = {
|
|||||||
|
|
||||||
const actionLabelBySection = {
|
const actionLabelBySection = {
|
||||||
supervisor: '+ Redistribuir atendimento',
|
supervisor: '+ Redistribuir atendimento',
|
||||||
admin: '+ Nova configuração',
|
admin: 'Home',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ManagementLayout({
|
export function ManagementLayout({
|
||||||
@ -48,7 +52,7 @@ export function ManagementLayout({
|
|||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const navItems = navigationBySection[activeSection] || navigationBySection.supervisor;
|
const navItems = navigationBySection[activeSection] || navigationBySection.supervisor;
|
||||||
const actionLabel = actionLabelBySection[activeSection] || '+ Nova ação';
|
const actionLabel = actionLabelBySection[activeSection] || 'Home';
|
||||||
|
|
||||||
function handleLogout() {
|
function handleLogout() {
|
||||||
clearSession();
|
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 { ManagementTable } from '../components/ManagementTable';
|
||||||
import { MetricGrid } from '../components/MetricGrid';
|
import { MetricGrid } from '../components/MetricGrid';
|
||||||
import { OperationalDashboard } from '../components/OperationalDashboard';
|
import { OperationalDashboard } from '../components/OperationalDashboard';
|
||||||
|
import { TemplateManagementPanel } from '../components/TemplateManagementPanel';
|
||||||
import { aiContentRows, areaRows, userRows } from '../services/managementMocks';
|
import { aiContentRows, areaRows, userRows } from '../services/managementMocks';
|
||||||
import { AttendantOpsPanel } from '../../home/components/AttendantOpsPanel';
|
import { AttendantOpsPanel } from '../../home/components/AttendantOpsPanel';
|
||||||
import { MessagesWorkspace } from '../../home/components/MessagesWorkspace';
|
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 {
|
const {
|
||||||
contacts,
|
contacts,
|
||||||
activeContactId,
|
activeContactId,
|
||||||
@ -865,7 +866,7 @@ export function AdminPage() {
|
|||||||
home: renderMonthlyHome(),
|
home: renderMonthlyHome(),
|
||||||
today: <OperationalDashboard isDesktop={isDesktop} isMobile={isMobile} />,
|
today: <OperationalDashboard isDesktop={isDesktop} isMobile={isMobile} />,
|
||||||
'users-access': renderUsersAccess(),
|
'users-access': renderUsersAccess(),
|
||||||
templates: renderPlaceholder('Templates', 'Gestão de templates aprovados pela Meta.'),
|
templates: <TemplateManagementPanel areas={areas} mode="admin" isMobile={isMobile} />,
|
||||||
knowledge: (
|
knowledge: (
|
||||||
<DataPanel title="Base de conhecimento IA" description="Entradas para alimentar a base de conhecimento.">
|
<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} />
|
<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 { ManagementLayout } from '../components/ManagementLayout';
|
||||||
import { OperationalDashboard } from '../components/OperationalDashboard';
|
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 { 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() {
|
export function SupervisorPage() {
|
||||||
const { isDesktop, isMobile } = useViewport();
|
const { isWideDesktop, isDesktop, isTablet, isMobile } = useViewport();
|
||||||
const userDisplay = getCurrentUserDisplay();
|
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 (
|
return (
|
||||||
<ManagementLayout
|
<ManagementLayout
|
||||||
@ -16,8 +104,10 @@ export function SupervisorPage() {
|
|||||||
initials={userDisplay.initials}
|
initials={userDisplay.initials}
|
||||||
isDesktop={isDesktop}
|
isDesktop={isDesktop}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
|
activeNavItem={activeSection}
|
||||||
|
onNavItemChange={setActiveSection}
|
||||||
>
|
>
|
||||||
<OperationalDashboard isDesktop={isDesktop} isMobile={isMobile} />
|
{sectionContent[activeSection] || sectionContent.dashboard}
|
||||||
</ManagementLayout>
|
</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