From eeab43d2a4203065637707a7bdab90e34fe0d625 Mon Sep 17 00:00:00 2001 From: Rafael Lopes Date: Wed, 20 May 2026 13:56:04 -0300 Subject: [PATCH] FEAT: Adicionado a lista de contatos a partir do modulo de novo atendimento --- .../components/RecentContactsList.jsx | 50 +- .../attendance/pages/NewAttendancePage.jsx | 587 +++++++++++++++--- .../attendance/services/attendanceMocks.js | 37 +- .../chat/components/ChatConversationList.jsx | 8 +- src/modules/chat/components/ChatWindow.jsx | 4 +- src/modules/chat/hooks/useChat.js | 63 +- src/modules/chat/pages/ChatPage.jsx | 19 +- src/modules/chat/services/chatMocks.js | 4 +- .../chat/services/contactProfileService.js | 6 + 9 files changed, 607 insertions(+), 171 deletions(-) diff --git a/src/modules/attendance/components/RecentContactsList.jsx b/src/modules/attendance/components/RecentContactsList.jsx index 9d8bdf9..7024c23 100644 --- a/src/modules/attendance/components/RecentContactsList.jsx +++ b/src/modules/attendance/components/RecentContactsList.jsx @@ -2,7 +2,6 @@ export function RecentContactsList({ contacts, activeContactId, onSelectContact, - selectedChannel, }) { return ( ); diff --git a/src/modules/attendance/pages/NewAttendancePage.jsx b/src/modules/attendance/pages/NewAttendancePage.jsx index d92b554..dde42ed 100644 --- a/src/modules/attendance/pages/NewAttendancePage.jsx +++ b/src/modules/attendance/pages/NewAttendancePage.jsx @@ -1,42 +1,262 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { BrandMark } from '../../../shared/components/BrandMark'; import { useViewport } from '../../../shared/hooks/useViewport'; +import { API_BASE_URL } from '../../../shared/services/apiConfig'; +import { getCurrentUser } from '../../auth/services/sessionService'; +import { listContactProfiles, saveContactProfile } from '../../chat/services/contactProfileService'; +import { getAccessOptions } from '../../management/services/adminAccessService'; import { RecentContactsList } from '../components/RecentContactsList'; -import { - attendanceAreas, - attendanceChannels, - recentContacts, -} from '../services/attendanceMocks'; +import { attendanceChannels } from '../services/attendanceMocks'; + +const countryOptions = [ + { id: 'br', label: 'Brasil', dialCode: '55', placeholder: '(11) 99999-9999' }, + { id: 'us', label: 'EUA', dialCode: '1', placeholder: '(212) 555-0199' }, + { id: 'ar', label: 'Argentina', dialCode: '54', placeholder: '11 1234-5678' }, + { id: 'cl', label: 'Chile', dialCode: '56', placeholder: '9 1234 5678' }, + { id: 'mx', label: 'Mexico', dialCode: '52', placeholder: '55 1234 5678' }, +]; + +function getUserId(user) { + const value = user?.databaseId || user?.id; + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : null; +} + +function normalizePhone(phone) { + return String(phone || '').replace(/\D/g, ''); +} + +function getCountryById(countryId) { + return countryOptions.find((country) => country.id === countryId) || countryOptions[0]; +} + +function inferCountryId(phone) { + const digits = normalizePhone(phone); + const matchedCountry = countryOptions.find((country) => digits.startsWith(country.dialCode)); + return matchedCountry?.id || 'br'; +} + +function stripCountryCode(phone, countryId) { + const digits = normalizePhone(phone); + const country = getCountryById(countryId); + return digits.startsWith(country.dialCode) ? digits.slice(country.dialCode.length) : digits; +} + +function buildInternationalPhone(phone, countryId) { + const country = getCountryById(countryId); + const nationalDigits = stripCountryCode(phone, country.id); + return nationalDigits ? `${country.dialCode}${nationalDigits}` : ''; +} + +function formatPhone(phone, countryId = inferCountryId(phone)) { + const country = getCountryById(countryId); + const digits = stripCountryCode(phone, country.id); + if (!digits) return ''; + + if (country.id === 'br') { + if (digits.length >= 11) { + return `(${digits.slice(0, 2)}) ${digits.slice(2, 7)}-${digits.slice(7, 11)}`; + } + if (digits.length >= 10) { + return `(${digits.slice(0, 2)}) ${digits.slice(2, 6)}-${digits.slice(6, 10)}`; + } + if (digits.length > 2) { + return `(${digits.slice(0, 2)}) ${digits.slice(2)}`; + } + return digits; + } + + if (country.id === 'us') { + if (digits.length >= 10) { + return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6, 10)}`; + } + if (digits.length > 3) { + return `(${digits.slice(0, 3)}) ${digits.slice(3)}`; + } + return digits; + } + + if (country.id === 'ar') { + if (digits.length >= 10) { + return `${digits.slice(0, 2)} ${digits.slice(2, 6)}-${digits.slice(6, 10)}`; + } + if (digits.length > 2) { + return `${digits.slice(0, 2)} ${digits.slice(2)}`; + } + return digits; + } + + if (country.id === 'cl') { + if (digits.length >= 9) { + return `${digits.slice(0, 1)} ${digits.slice(1, 5)} ${digits.slice(5, 9)}`; + } + if (digits.length > 1) { + return `${digits.slice(0, 1)} ${digits.slice(1)}`; + } + return digits; + } + + if (country.id === 'mx') { + if (digits.length >= 10) { + return `${digits.slice(0, 2)} ${digits.slice(2, 6)} ${digits.slice(6, 10)}`; + } + if (digits.length > 2) { + return `${digits.slice(0, 2)} ${digits.slice(2)}`; + } + return digits; + } + + return digits; +} + +function applyPhoneMask(value, countryId) { + const digits = stripCountryCode(value, countryId); + if (!digits) return ''; + return formatPhone(digits, countryId); +} + +function requiresUnsupportedTemplateFields(template) { + const allowedFields = new Set(['nome', 'cliente']); + const placeholders = String(template?.content || '').matchAll(/\{([a-zA-Z0-9_]+)\}/g); + return Array.from(placeholders).some((match) => !allowedFields.has(String(match[1]).toLowerCase())); +} + +function renderTemplatePreview(content, form) { + return String(content || '') + .replace(/\{nome\}/gi, form.name.trim() || 'cliente') + .replace(/\{cliente\}/gi, form.name.trim() || 'cliente'); +} + +function formatLastContact(value) { + if (!value) return 'Sem data'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return 'Sem data'; + return date.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' }); +} + +function buildChatId(phone, countryId) { + const digits = buildInternationalPhone(phone, countryId); + if (!digits) return ''; + return `${digits}@c.us`; +} + +function normalizeAgendaContact(contact) { + const phone = contact.phone || ''; + return { + id: contact.chat_id, + chatId: contact.chat_id, + name: contact.name || phone || 'Contato sem nome', + phone: formatPhone(phone), + rawPhone: phone, + countryId: inferCountryId(phone), + company: contact.company || '', + note: contact.note || '', + lastContact: formatLastContact(contact.updated_at || contact.created_at), + }; +} + +function getUserAreas(user) { + const normalizeArea = (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(normalizeArea).filter(Boolean); + const primaryArea = normalizeArea(user?.areaPrincipal); + if (primaryArea && !areas.includes(primaryArea)) { + return [primaryArea, ...areas]; + } + return areas; +} + +async function listWhatsappTemplates() { + const response = await fetch(`${API_BASE_URL}/whatsapp/templates`); + if (!response.ok) throw new Error('Falha ao carregar templates do WhatsApp.'); + return response.json(); +} + +async function startWhatsappAttendance(payload) { + const response = await fetch(`${API_BASE_URL}/whatsapp/start-attendance`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!response.ok) throw new Error('Falha ao iniciar atendimento pelo WhatsApp.'); + return response.json(); +} export function NewAttendancePage() { const navigate = useNavigate(); const { isWideDesktop, isDesktop, isTablet, isMobile } = useViewport(); + const currentUser = getCurrentUser(); + const currentUserId = getUserId(currentUser); + const currentUserAreas = getUserAreas(currentUser); + const [contacts, setContacts] = useState([]); + const [templates, setTemplates] = useState([]); + const [areaOptions, setAreaOptions] = useState([]); const [searchValue, setSearchValue] = useState(''); const [selectedChannelId, setSelectedChannelId] = useState('whatsapp'); - const [selectedArea, setSelectedArea] = useState(''); - const [selectedContactId, setSelectedContactId] = useState(recentContacts[0].id); - const [customNumber, setCustomNumber] = useState(''); + const [selectedContactId, setSelectedContactId] = useState(''); + const [selectedTemplateId, setSelectedTemplateId] = useState(''); + const [selectedCountryId, setSelectedCountryId] = useState('br'); + const [form, setForm] = useState({ phone: '', name: '', company: '', note: '' }); + const [isLoadingContacts, setIsLoadingContacts] = useState(false); + const [isStarting, setIsStarting] = useState(false); + const [error, setError] = useState(''); + + useEffect(() => { + let isMounted = true; + + async function loadContacts() { + setIsLoadingContacts(true); + try { + const [contactsData, templatesData, accessOptions] = await Promise.all([ + listContactProfiles(), + listWhatsappTemplates(), + getAccessOptions(), + ]); + if (!isMounted) return; + setContacts(Array.isArray(contactsData) ? contactsData.map(normalizeAgendaContact) : []); + const supportedTemplates = Array.isArray(templatesData) + ? templatesData.filter((template) => !requiresUnsupportedTemplateFields(template)) + : []; + setTemplates(supportedTemplates); + setSelectedTemplateId((current) => current || (supportedTemplates?.[0]?.id ? String(supportedTemplates[0].id) : '')); + setAreaOptions(accessOptions.areas || []); + setError(''); + } catch (err) { + if (isMounted) setError(err.message); + } finally { + if (isMounted) setIsLoadingContacts(false); + } + } + + loadContacts(); + return () => { + isMounted = false; + }; + }, []); const search = searchValue.trim().toLowerCase(); const filteredContacts = useMemo(() => { if (!search) { - return recentContacts; + return contacts; } - return recentContacts.filter((contact) => { - const haystack = `${contact.name} ${contact.phone} ${contact.channel}`.toLowerCase(); + return contacts.filter((contact) => { + const haystack = `${contact.name} ${contact.phone} ${contact.rawPhone}`.toLowerCase(); return haystack.includes(search); }); - }, [search]); - - const selectedContact = - filteredContacts.find((contact) => contact.id === selectedContactId) || - recentContacts.find((contact) => contact.id === selectedContactId) || - recentContacts[0]; + }, [contacts, search]); const selectedChannel = attendanceChannels.find((channel) => channel.id === selectedChannelId) || attendanceChannels[0]; + const selectedCountry = getCountryById(selectedCountryId); + const selectedTemplate = templates.find((template) => String(template.id) === String(selectedTemplateId)); + const primaryArea = areaOptions.find((area) => currentUserAreas.includes(area.nome)); + const isWhatsapp = selectedChannel.id === 'whatsapp'; + const canStartAttendance = isWhatsapp && Boolean(buildInternationalPhone(form.phone, selectedCountryId)) && Boolean(selectedTemplateId); const gridTemplateColumns = isMobile ? '1fr' @@ -46,8 +266,71 @@ export function NewAttendancePage() { ? 'minmax(280px, 340px) minmax(0, 1fr)' : '1fr'; - function handleStartAttendance() { - navigate(selectedChannel.route); + function selectContact(contactId) { + const contact = contacts.find((item) => item.id === contactId); + if (!contact) return; + + const contactCountryId = contact.countryId || inferCountryId(contact.rawPhone); + setSelectedContactId(contactId); + setSelectedCountryId(contactCountryId); + setForm({ + phone: applyPhoneMask(contact.rawPhone || contact.phone || '', contactCountryId), + name: contact.name || '', + company: contact.company || '', + note: contact.note || '', + }); + } + + function clearSelection() { + setSelectedContactId(''); + setSelectedCountryId('br'); + setForm({ phone: '', name: '', company: '', note: '' }); + } + + async function handleStartAttendance() { + if (!canStartAttendance) { + setError('Informe um numero de WhatsApp para iniciar o atendimento.'); + return; + } + + if (!selectedTemplateId) { + setError('Selecione uma mensagem pre-aprovada para iniciar o atendimento.'); + return; + } + + if (!currentUserId) { + setError('Nao foi possivel identificar o usuario logado.'); + return; + } + + setIsStarting(true); + try { + const fullPhone = buildInternationalPhone(form.phone, selectedCountryId); + const chatId = selectedContactId || buildChatId(form.phone, selectedCountryId); + const saved = await saveContactProfile(chatId, { + phone: fullPhone, + name: form.name, + company: form.company, + note: form.note, + userId: currentUserId, + }); + await startWhatsappAttendance({ + to: saved.chat_id || chatId, + templateId: Number(selectedTemplateId), + userId: currentUserId, + areaId: primaryArea?.id || null, + variables: { + nome: form.name, + cliente: form.name, + }, + }); + setError(''); + navigate(`/chat?chatId=${encodeURIComponent(saved.chat_id || chatId)}`); + } catch (err) { + setError(err.message); + } finally { + setIsStarting(false); + } } return ( @@ -112,9 +395,8 @@ export function NewAttendancePage() { >
Novo atendimento

- Escolha o contato, o canal e a area opcional antes de iniciar. O fluxo e mockado - e leva voce direto para chat ou ligacao. + Informe um contato de WhatsApp ou selecione alguem da agenda para iniciar o atendimento. + Para conversas novas, o primeiro envio usa uma mensagem pre-aprovada da Meta.

-
setSearchValue(event.target.value)} + placeholder="Buscar contato salvo por nome ou numero" style={{ - display: 'grid', - gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 1fr) auto', - gap: '0.85rem', + border: '1px solid var(--color-border)', + borderRadius: '18px', + padding: '0.95rem 1rem', + background: '#fff', + outline: 'none', }} - > - setSearchValue(event.target.value)} - placeholder="Buscar contato por nome ou numero" - style={{ - border: '1px solid var(--color-border)', - borderRadius: '18px', - padding: '0.95rem 1rem', - background: '#fff', - outline: 'none', - }} - /> - -
+ />
{attendanceChannels.map((channel) => { const isActive = channel.id === selectedChannelId; + const isDisabled = Boolean(channel.disabled); return ( ); @@ -213,15 +477,22 @@ export function NewAttendancePage() {
+ +
+
+ + + +
+ + {selectedTemplate ? ( +
+ + Preview do template + + {renderTemplatePreview(selectedTemplate.content, form)} +
+ ) : null} + +