omnichannel-frontend/src/modules/management/components/MassMessagePanel.jsx

488 lines
17 KiB
React
Raw Normal View History

import { useEffect, useMemo, useState } from 'react';
import { apiRequest } from '../../../shared/services/apiClient';
import { getCurrentUser } from '../../auth/services/sessionService';
import { listContactProfiles } from '../../chat/services/contactProfileService';
import { DataPanel } from './DataPanel';
import { listTemplates } from '../services/templateService';
const inputStyle = {
width: '100%',
border: '1px solid var(--color-border)',
borderRadius: 10,
padding: '0.85rem 0.9rem',
background: '#fff',
color: 'var(--color-text)',
fontWeight: 600,
};
function getUserId(user) {
const value = user?.databaseId || user?.id;
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : null;
}
function normalizePhoneToChatId(value) {
const digits = String(value || '').replace(/\D/g, '');
if (!digits) return '';
return `${digits}@c.us`;
}
function getPhoneFromChatId(chatId) {
return String(chatId || '').split('@')[0].replace(/\D/g, '');
}
function normalizeContact(contact) {
const phone = String(contact.phone || getPhoneFromChatId(contact.chat_id)).replace(/\D/g, '');
return {
id: contact.chat_id || normalizePhoneToChatId(phone),
name: contact.name || phone || 'Contato sem nome',
company: contact.company || '',
phone,
chatId: contact.chat_id || normalizePhoneToChatId(phone),
};
}
function renderPreview(content, variables) {
const name = variables?.nome || 'colaborador';
return String(content || '')
.replace(/\{nome\}/gi, name || 'colaborador')
.replace(/\{cliente\}/gi, name || 'colaborador')
.replace(/\{data\}/gi, variables?.data || '{data}')
.replace(/\{link\}/gi, variables?.link || '{link}')
.replace(/\{variavel\}/gi, variables?.variavel || '{variavel}')
.replace(/\{variável\}/gi, variables?.variavel || '{variável}');
}
async function startAttendance(payload) {
return apiRequest('/whatsapp/start-attendance', {
method: 'POST',
body: JSON.stringify(payload),
fallbackMessage: 'Falha ao enviar disparo.',
});
}
export function MassMessagePanel({
areas,
managedAreaNames = [],
mode = 'admin',
isMobile = false,
}) {
const currentUserId = getUserId(getCurrentUser());
const isAdmin = mode === 'admin';
const visibleAreaNames = isAdmin ? [] : managedAreaNames;
const [templates, setTemplates] = useState([]);
const [contacts, setContacts] = useState([]);
const [selectedAreaId, setSelectedAreaId] = useState('');
const [selectedTemplateId, setSelectedTemplateId] = useState('');
const [defaultName, setDefaultName] = useState('colaborador');
const [templateDate, setTemplateDate] = useState('');
const [templateLink, setTemplateLink] = useState('');
const [templateCustomVariable, setTemplateCustomVariable] = useState('');
const [numbersText, setNumbersText] = useState('');
const [contactSearch, setContactSearch] = useState('');
const [selectedContactIds, setSelectedContactIds] = useState([]);
const [isSending, setIsSending] = useState(false);
const [results, setResults] = useState([]);
const [status, setStatus] = useState('');
useEffect(() => {
let isMounted = true;
listTemplates()
.then((data) => {
if (!isMounted) return;
const approved = Array.isArray(data)
? data.filter((template) => {
const isApproved = template.status === 'approved';
const isManaged = !visibleAreaNames.length || visibleAreaNames.includes(template.area_nome);
return isApproved && isManaged;
})
: [];
setTemplates(approved);
setSelectedTemplateId((current) => current || (approved[0]?.id ? String(approved[0].id) : ''));
})
.catch((error) => {
if (isMounted) setStatus(error.message);
});
return () => {
isMounted = false;
};
}, [visibleAreaNames.join('|')]);
useEffect(() => {
let isMounted = true;
listContactProfiles()
.then((data) => {
if (!isMounted) return;
setContacts(Array.isArray(data) ? data.map(normalizeContact).filter((contact) => contact.phone) : []);
})
.catch((error) => {
if (isMounted) setStatus(error.message);
});
return () => {
isMounted = false;
};
}, []);
const selectedTemplate = templates.find((template) => String(template.id) === String(selectedTemplateId));
const filteredTemplates = useMemo(() => {
if (!selectedAreaId) return templates;
return templates.filter((template) => String(template.area_id || '') === String(selectedAreaId));
}, [templates, selectedAreaId]);
const numbers = useMemo(
() =>
numbersText
.split(/\r?\n|,|;/)
.map((item) => item.trim())
.filter(Boolean),
[numbersText],
);
const selectedContacts = useMemo(
() => contacts.filter((contact) => selectedContactIds.includes(contact.id)),
[contacts, selectedContactIds],
);
const filteredContacts = useMemo(() => {
const search = contactSearch.trim().toLowerCase();
if (!search) return contacts;
return contacts.filter((contact) =>
`${contact.name} ${contact.company} ${contact.phone}`.toLowerCase().includes(search),
);
}, [contacts, contactSearch]);
const recipients = useMemo(() => {
const items = [];
const seen = new Set();
numbers.forEach((number) => {
const chatId = normalizePhoneToChatId(number);
if (!chatId || seen.has(chatId)) return;
seen.add(chatId);
items.push({
id: chatId,
number,
chatId,
name: defaultName,
});
});
return items;
}, [defaultName, numbers]);
useEffect(() => {
if (selectedTemplate && filteredTemplates.some((template) => String(template.id) === String(selectedTemplateId))) {
return;
}
setSelectedTemplateId(filteredTemplates[0]?.id ? String(filteredTemplates[0].id) : '');
}, [filteredTemplates, selectedTemplate, selectedTemplateId]);
function toggleContact(contact) {
const phone = String(contact.phone || '').replace(/\D/g, '');
if (!phone) return;
setSelectedContactIds((current) => {
const isSelected = current.includes(contact.id);
return isSelected ? current.filter((id) => id !== contact.id) : [...current, contact.id];
});
setNumbersText((current) => {
const currentNumbers = current
.split(/\r?\n|,|;/)
.map((item) => item.trim())
.filter(Boolean);
const exists = currentNumbers.some((item) => String(item).replace(/\D/g, '') === phone);
const nextNumbers = exists
? currentNumbers.filter((item) => String(item).replace(/\D/g, '') !== phone)
: [...currentNumbers, phone];
return nextNumbers.join('\n');
});
}
function clearSelectedContacts() {
const selectedPhones = new Set(selectedContacts.map((contact) => contact.phone));
setSelectedContactIds([]);
setNumbersText((current) =>
current
.split(/\r?\n|,|;/)
.map((item) => item.trim())
.filter(Boolean)
.filter((item) => !selectedPhones.has(String(item).replace(/\D/g, '')))
.join('\n'),
);
}
async function sendSelectedRecipients() {
if (!currentUserId) {
setStatus('Não foi possível identificar o usuário logado.');
return;
}
if (!selectedTemplateId) {
setStatus('Selecione um template aprovado.');
return;
}
if (!recipients.length) {
setStatus('Informe ao menos um número ou selecione contatos da agenda.');
return;
}
setIsSending(true);
setResults([]);
setStatus('');
const nextResults = [];
for (const recipient of recipients) {
if (!recipient.chatId) {
nextResults.push({ number: recipient.number, status: 'erro', detail: 'Número inválido' });
continue;
}
try {
await startAttendance({
to: recipient.chatId,
templateId: Number(selectedTemplateId),
userId: currentUserId,
areaId: selectedTemplate?.area_id || null,
variables: {
nome: defaultName,
cliente: defaultName,
data: templateDate,
link: templateLink,
variavel: templateCustomVariable,
'variável': templateCustomVariable,
},
});
nextResults.push({
number: recipient.number,
status: 'enviado',
detail: `${recipient.name || 'Contato'} - template enviado e atendimento iniciado`,
});
} catch (error) {
nextResults.push({ number: recipient.number, status: 'erro', detail: error.message });
}
setResults([...nextResults]);
}
setStatus(`Disparo finalizado: ${nextResults.filter((item) => item.status === 'enviado').length} enviados.`);
setIsSending(false);
}
return (
<DataPanel
title="Disparo em massa"
description="Envie templates aprovados para uma lista de colaboradores. Após o envio, a conversa aguarda resposta do cliente."
>
<div
style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 1fr) minmax(320px, 0.85fr)',
gap: '1rem',
}}
>
<div style={{ display: 'grid', gap: '0.85rem' }}>
<label style={{ display: 'grid', gap: '0.4rem' }}>
<span style={{ fontWeight: 700 }}>Especialidade</span>
<select value={selectedAreaId} onChange={(event) => setSelectedAreaId(event.target.value)} style={inputStyle}>
<option value="">Todas as especialidades</option>
{areas
.filter((area) => isAdmin || !managedAreaNames.length || managedAreaNames.includes(area.nome))
.map((area) => (
<option key={area.id} value={area.id}>
{area.nome}
</option>
))}
</select>
</label>
<label style={{ display: 'grid', gap: '0.4rem' }}>
<span style={{ fontWeight: 700 }}>Template aprovado</span>
<select value={selectedTemplateId} onChange={(event) => setSelectedTemplateId(event.target.value)} style={inputStyle}>
<option value="">Selecione</option>
{filteredTemplates.map((template) => (
<option key={template.id} value={template.id}>
{template.name}
</option>
))}
</select>
</label>
<label style={{ display: 'grid', gap: '0.4rem' }}>
<span style={{ fontWeight: 700 }}>Nome usado no preview</span>
<input value={defaultName} onChange={(event) => setDefaultName(event.target.value)} style={inputStyle} />
</label>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : 'repeat(3, minmax(0, 1fr))', gap: '0.85rem' }}>
<label style={{ display: 'grid', gap: '0.4rem' }}>
<span style={{ fontWeight: 700 }}>Data</span>
<input value={templateDate} onChange={(event) => setTemplateDate(event.target.value)} placeholder="Ex: 26/05/2026" style={inputStyle} />
</label>
<label style={{ display: 'grid', gap: '0.4rem' }}>
<span style={{ fontWeight: 700 }}>Link</span>
<input value={templateLink} onChange={(event) => setTemplateLink(event.target.value)} placeholder="https://..." style={inputStyle} />
</label>
<label style={{ display: 'grid', gap: '0.4rem' }}>
<span style={{ fontWeight: 700 }}>Variável</span>
<input value={templateCustomVariable} onChange={(event) => setTemplateCustomVariable(event.target.value)} placeholder="Valor livre" style={inputStyle} />
</label>
</div>
<label style={{ display: 'grid', gap: '0.4rem' }}>
<span style={{ fontWeight: 700 }}>Números manuais</span>
<textarea
rows={8}
value={numbersText}
onChange={(event) => setNumbersText(event.target.value)}
placeholder="5511999999999&#10;5511888888888"
style={{ ...inputStyle, resize: 'vertical', lineHeight: 1.5 }}
/>
</label>
<button
type="button"
onClick={sendSelectedRecipients}
disabled={isSending}
style={{
border: 'none',
borderRadius: 12,
padding: '0.95rem 1rem',
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
color: '#fff',
fontWeight: 800,
opacity: isSending ? 0.7 : 1,
}}
>
{isSending ? 'Enviando...' : `Enviar para ${recipients.length || 0} contato(s)`}
</button>
{status ? <span style={{ color: 'var(--color-primary)', fontWeight: 800 }}>{status}</span> : null}
</div>
<aside style={{ display: 'grid', gap: '0.85rem', alignContent: 'start' }}>
<article
style={{
border: '1px solid var(--color-border)',
borderRadius: 14,
padding: '1rem',
background: '#fff',
display: 'grid',
gap: '0.75rem',
}}
>
<div>
<strong style={{ display: 'block' }}>Agenda de contatos</strong>
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.9rem' }}>
Selecione contatos salvos para incluir no disparo.
</span>
</div>
<input
value={contactSearch}
onChange={(event) => setContactSearch(event.target.value)}
placeholder="Buscar por nome, empresa ou telefone"
style={inputStyle}
/>
<div style={{ display: 'grid', gap: '0.45rem', maxHeight: 260, overflowY: 'auto', paddingRight: '0.2rem' }}>
{filteredContacts.map((contact) => {
const isSelected = selectedContactIds.includes(contact.id);
return (
<button
key={contact.id}
type="button"
onClick={() => toggleContact(contact)}
style={{
border: '1px solid',
borderColor: isSelected ? 'rgba(0, 164, 183, 0.36)' : 'var(--color-border)',
borderRadius: 10,
padding: '0.7rem',
background: isSelected ? 'rgba(0, 164, 183, 0.08)' : '#fff',
textAlign: 'left',
display: 'grid',
gap: '0.2rem',
}}
>
<strong>{contact.name}</strong>
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.88rem' }}>
+{contact.phone}{contact.company ? ` · ${contact.company}` : ''}
</span>
</button>
);
})}
{!filteredContacts.length ? (
<span style={{ color: 'var(--color-text-soft)', fontWeight: 700 }}>
Nenhum contato encontrado na agenda.
</span>
) : null}
</div>
{selectedContacts.length ? (
<button
type="button"
onClick={clearSelectedContacts}
style={{
border: '1px solid var(--color-border)',
borderRadius: 10,
padding: '0.75rem',
background: '#fff',
color: 'var(--color-primary)',
fontWeight: 800,
}}
>
Limpar seleção ({selectedContacts.length})
</button>
) : null}
</article>
<article
style={{
border: '1px solid rgba(0, 164, 183, 0.24)',
borderRadius: 14,
padding: '1rem',
background: 'rgba(0, 164, 183, 0.06)',
lineHeight: 1.5,
}}
>
<strong style={{ display: 'block', color: 'var(--color-primary)', marginBottom: '0.45rem' }}>
Preview
</strong>
{selectedTemplate
? renderPreview(selectedTemplate.content, { nome: defaultName, data: templateDate, link: templateLink, variavel: templateCustomVariable })
: 'Selecione um template aprovado.'}
</article>
<article
style={{
border: '1px solid var(--color-border)',
borderRadius: 14,
padding: '1rem',
background: '#fff',
color: 'var(--color-text-soft)',
fontWeight: 700,
}}
>
Destinatários no campo: {numbers.length}.
</article>
<div style={{ display: 'grid', gap: '0.55rem', maxHeight: 320, overflowY: 'auto' }}>
{results.map((result) => (
<div
key={`${result.number}-${result.status}`}
style={{
border: '1px solid var(--color-border)',
borderRadius: 10,
padding: '0.75rem',
background: result.status === 'enviado' ? 'rgba(16,185,129,0.08)' : 'rgba(181,31,31,0.08)',
}}
>
<strong style={{ display: 'block' }}>{result.number}</strong>
<span style={{ color: 'var(--color-text-soft)' }}>{result.detail}</span>
</div>
))}
</div>
</aside>
</div>
</DataPanel>
);
}