FEAT: Adicionado a lista de contatos a partir do modulo de novo atendimento
This commit is contained in:
parent
b605a4c507
commit
eeab43d2a4
@ -2,7 +2,6 @@ export function RecentContactsList({
|
|||||||
contacts,
|
contacts,
|
||||||
activeContactId,
|
activeContactId,
|
||||||
onSelectContact,
|
onSelectContact,
|
||||||
selectedChannel,
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
@ -25,11 +24,6 @@ export function RecentContactsList({
|
|||||||
<div style={{ display: 'grid', gap: '0.75rem' }}>
|
<div style={{ display: 'grid', gap: '0.75rem' }}>
|
||||||
{contacts.map((contact) => {
|
{contacts.map((contact) => {
|
||||||
const isActive = contact.id === activeContactId;
|
const isActive = contact.id === activeContactId;
|
||||||
const isPreferred = selectedChannel === 'call'
|
|
||||||
? contact.channel === 'Ligacao'
|
|
||||||
: selectedChannel === 'sms'
|
|
||||||
? contact.channel === 'SMS'
|
|
||||||
: contact.channel === 'WhatsApp';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@ -49,24 +43,22 @@ export function RecentContactsList({
|
|||||||
>
|
>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
|
||||||
<strong>{contact.name}</strong>
|
<strong>{contact.name}</strong>
|
||||||
{isPreferred ? (
|
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
padding: '0.2rem 0.5rem',
|
padding: '0.2rem 0.5rem',
|
||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
background: 'rgba(0, 164, 183, 0.12)',
|
background: 'rgba(43, 183, 65, 0.12)',
|
||||||
color: 'var(--color-primary)',
|
color: '#25883a',
|
||||||
fontSize: '0.76rem',
|
fontSize: '0.76rem',
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Sugerido
|
Agenda
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
<span style={{ color: 'var(--color-text-soft)' }}>{contact.phone}</span>
|
<span style={{ color: 'var(--color-text-soft)' }}>{contact.phone}</span>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
|
||||||
<span style={{ color: 'var(--color-primary)', fontWeight: 700 }}>{contact.channel}</span>
|
<span style={{ color: 'var(--color-primary)', fontWeight: 700 }}>WhatsApp</span>
|
||||||
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.85rem' }}>
|
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.85rem' }}>
|
||||||
{contact.lastContact}
|
{contact.lastContact}
|
||||||
</span>
|
</span>
|
||||||
@ -74,6 +66,22 @@ export function RecentContactsList({
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{contacts.length === 0 ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: '18px',
|
||||||
|
padding: '1rem',
|
||||||
|
background: 'rgba(0, 49, 80, 0.04)',
|
||||||
|
color: 'var(--color-text-soft)',
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: 1.45,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Nenhum contato encontrado na agenda.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,42 +1,262 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { BrandMark } from '../../../shared/components/BrandMark';
|
import { BrandMark } from '../../../shared/components/BrandMark';
|
||||||
import { useViewport } from '../../../shared/hooks/useViewport';
|
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 { RecentContactsList } from '../components/RecentContactsList';
|
||||||
import {
|
import { attendanceChannels } from '../services/attendanceMocks';
|
||||||
attendanceAreas,
|
|
||||||
attendanceChannels,
|
const countryOptions = [
|
||||||
recentContacts,
|
{ id: 'br', label: 'Brasil', dialCode: '55', placeholder: '(11) 99999-9999' },
|
||||||
} from '../services/attendanceMocks';
|
{ 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() {
|
export function NewAttendancePage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { isWideDesktop, isDesktop, isTablet, isMobile } = useViewport();
|
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 [searchValue, setSearchValue] = useState('');
|
||||||
const [selectedChannelId, setSelectedChannelId] = useState('whatsapp');
|
const [selectedChannelId, setSelectedChannelId] = useState('whatsapp');
|
||||||
const [selectedArea, setSelectedArea] = useState('');
|
const [selectedContactId, setSelectedContactId] = useState('');
|
||||||
const [selectedContactId, setSelectedContactId] = useState(recentContacts[0].id);
|
const [selectedTemplateId, setSelectedTemplateId] = useState('');
|
||||||
const [customNumber, setCustomNumber] = 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 search = searchValue.trim().toLowerCase();
|
||||||
const filteredContacts = useMemo(() => {
|
const filteredContacts = useMemo(() => {
|
||||||
if (!search) {
|
if (!search) {
|
||||||
return recentContacts;
|
return contacts;
|
||||||
}
|
}
|
||||||
|
|
||||||
return recentContacts.filter((contact) => {
|
return contacts.filter((contact) => {
|
||||||
const haystack = `${contact.name} ${contact.phone} ${contact.channel}`.toLowerCase();
|
const haystack = `${contact.name} ${contact.phone} ${contact.rawPhone}`.toLowerCase();
|
||||||
return haystack.includes(search);
|
return haystack.includes(search);
|
||||||
});
|
});
|
||||||
}, [search]);
|
}, [contacts, search]);
|
||||||
|
|
||||||
const selectedContact =
|
|
||||||
filteredContacts.find((contact) => contact.id === selectedContactId) ||
|
|
||||||
recentContacts.find((contact) => contact.id === selectedContactId) ||
|
|
||||||
recentContacts[0];
|
|
||||||
|
|
||||||
const selectedChannel =
|
const selectedChannel =
|
||||||
attendanceChannels.find((channel) => channel.id === selectedChannelId) || attendanceChannels[0];
|
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
|
const gridTemplateColumns = isMobile
|
||||||
? '1fr'
|
? '1fr'
|
||||||
@ -46,8 +266,71 @@ export function NewAttendancePage() {
|
|||||||
? 'minmax(280px, 340px) minmax(0, 1fr)'
|
? 'minmax(280px, 340px) minmax(0, 1fr)'
|
||||||
: '1fr';
|
: '1fr';
|
||||||
|
|
||||||
function handleStartAttendance() {
|
function selectContact(contactId) {
|
||||||
navigate(selectedChannel.route);
|
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 (
|
return (
|
||||||
@ -112,9 +395,8 @@ export function NewAttendancePage() {
|
|||||||
>
|
>
|
||||||
<RecentContactsList
|
<RecentContactsList
|
||||||
contacts={filteredContacts}
|
contacts={filteredContacts}
|
||||||
activeContactId={selectedContact.id}
|
activeContactId={selectedContactId}
|
||||||
onSelectContact={setSelectedContactId}
|
onSelectContact={selectContact}
|
||||||
selectedChannel={selectedChannelId}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
@ -130,23 +412,16 @@ export function NewAttendancePage() {
|
|||||||
<div>
|
<div>
|
||||||
<strong style={{ display: 'block', fontSize: '1.18rem' }}>Novo atendimento</strong>
|
<strong style={{ display: 'block', fontSize: '1.18rem' }}>Novo atendimento</strong>
|
||||||
<p style={{ margin: '0.45rem 0 0', color: 'var(--color-text-soft)', lineHeight: 1.6 }}>
|
<p style={{ margin: '0.45rem 0 0', color: 'var(--color-text-soft)', lineHeight: 1.6 }}>
|
||||||
Escolha o contato, o canal e a area opcional antes de iniciar. O fluxo e mockado
|
Informe um contato de WhatsApp ou selecione alguem da agenda para iniciar o atendimento.
|
||||||
e leva voce direto para chat ou ligacao.
|
Para conversas novas, o primeiro envio usa uma mensagem pre-aprovada da Meta.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 1fr) auto',
|
|
||||||
gap: '0.85rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
value={searchValue}
|
value={searchValue}
|
||||||
onChange={(event) => setSearchValue(event.target.value)}
|
onChange={(event) => setSearchValue(event.target.value)}
|
||||||
placeholder="Buscar contato por nome ou numero"
|
placeholder="Buscar contato salvo por nome ou numero"
|
||||||
style={{
|
style={{
|
||||||
border: '1px solid var(--color-border)',
|
border: '1px solid var(--color-border)',
|
||||||
borderRadius: '18px',
|
borderRadius: '18px',
|
||||||
@ -155,21 +430,6 @@ export function NewAttendancePage() {
|
|||||||
outline: 'none',
|
outline: 'none',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setCustomNumber(selectedContact.phone)}
|
|
||||||
style={{
|
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
borderRadius: '18px',
|
|
||||||
padding: '0.95rem 1rem',
|
|
||||||
background: '#fff',
|
|
||||||
color: 'var(--color-primary)',
|
|
||||||
fontWeight: 700,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Novo numero
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@ -180,12 +440,16 @@ export function NewAttendancePage() {
|
|||||||
>
|
>
|
||||||
{attendanceChannels.map((channel) => {
|
{attendanceChannels.map((channel) => {
|
||||||
const isActive = channel.id === selectedChannelId;
|
const isActive = channel.id === selectedChannelId;
|
||||||
|
const isDisabled = Boolean(channel.disabled);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={channel.id}
|
key={channel.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSelectedChannelId(channel.id)}
|
onClick={() => {
|
||||||
|
if (!isDisabled) setSelectedChannelId(channel.id);
|
||||||
|
}}
|
||||||
|
disabled={isDisabled}
|
||||||
style={{
|
style={{
|
||||||
border: '1px solid',
|
border: '1px solid',
|
||||||
borderColor: isActive ? `${channel.accent}44` : 'var(--color-border)',
|
borderColor: isActive ? `${channel.accent}44` : 'var(--color-border)',
|
||||||
@ -195,15 +459,15 @@ export function NewAttendancePage() {
|
|||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gap: '0.45rem',
|
gap: '0.45rem',
|
||||||
|
opacity: isDisabled ? 0.58 : 1,
|
||||||
|
cursor: isDisabled ? 'not-allowed' : 'pointer',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<strong style={{ color: isActive ? channel.accent : 'var(--color-text)' }}>
|
<strong style={{ color: isActive ? channel.accent : 'var(--color-text)' }}>
|
||||||
{channel.label}
|
{channel.label}
|
||||||
</strong>
|
</strong>
|
||||||
<span style={{ color: 'var(--color-text-soft)' }}>
|
<span style={{ color: 'var(--color-text-soft)' }}>
|
||||||
{channel.id === 'call'
|
{isDisabled ? 'Canal em construcao.' : 'Inicia uma conversa pelo WhatsApp.'}
|
||||||
? 'Inicia uma ligacao mock em tela cheia.'
|
|
||||||
: 'Abre o fluxo de conversa em tempo real.'}
|
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@ -213,15 +477,22 @@ export function NewAttendancePage() {
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: isMobile ? '1fr' : 'repeat(2, minmax(0, 1fr))',
|
gridTemplateColumns: isMobile ? '1fr' : 'minmax(170px, 0.45fr) minmax(0, 1fr) minmax(0, 1fr)',
|
||||||
gap: '1rem',
|
gap: '1rem',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<label style={{ display: 'grid', gap: '0.45rem' }}>
|
<label style={{ display: 'grid', gap: '0.45rem' }}>
|
||||||
<span style={{ fontWeight: 600 }}>Area (opcional)</span>
|
<span style={{ fontWeight: 600 }}>Pais</span>
|
||||||
<select
|
<select
|
||||||
value={selectedArea}
|
value={selectedCountryId}
|
||||||
onChange={(event) => setSelectedArea(event.target.value)}
|
onChange={(event) => {
|
||||||
|
const nextCountryId = event.target.value;
|
||||||
|
setSelectedCountryId(nextCountryId);
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
phone: applyPhoneMask(current.phone, nextCountryId),
|
||||||
|
}));
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
border: '1px solid var(--color-border)',
|
border: '1px solid var(--color-border)',
|
||||||
borderRadius: '18px',
|
borderRadius: '18px',
|
||||||
@ -230,22 +501,38 @@ export function NewAttendancePage() {
|
|||||||
outline: 'none',
|
outline: 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="">Selecionar depois</option>
|
{countryOptions.map((country) => (
|
||||||
{attendanceAreas.map((area) => (
|
<option key={country.id} value={country.id}>
|
||||||
<option key={area} value={area}>
|
{country.label} +{country.dialCode}
|
||||||
{area}
|
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label style={{ display: 'grid', gap: '0.45rem' }}>
|
<label style={{ display: 'grid', gap: '0.45rem' }}>
|
||||||
<span style={{ fontWeight: 600 }}>Numero selecionado</span>
|
<span style={{ fontWeight: 600 }}>Numero do WhatsApp</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={customNumber || selectedContact.phone}
|
value={form.phone}
|
||||||
onChange={(event) => setCustomNumber(event.target.value)}
|
onChange={(event) => setForm((current) => ({ ...current, phone: applyPhoneMask(event.target.value, selectedCountryId) }))}
|
||||||
placeholder="+55 11 99999-9999"
|
placeholder={selectedCountry.placeholder}
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: '18px',
|
||||||
|
padding: '0.95rem 1rem',
|
||||||
|
background: '#fff',
|
||||||
|
outline: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={{ display: 'grid', gap: '0.45rem' }}>
|
||||||
|
<span style={{ fontWeight: 600 }}>Nome do cliente</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))}
|
||||||
|
placeholder="Nome para salvar na agenda"
|
||||||
style={{
|
style={{
|
||||||
border: '1px solid var(--color-border)',
|
border: '1px solid var(--color-border)',
|
||||||
borderRadius: '18px',
|
borderRadius: '18px',
|
||||||
@ -257,6 +544,92 @@ export function NewAttendancePage() {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: isMobile ? '1fr' : 'repeat(2, minmax(0, 1fr))',
|
||||||
|
gap: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label style={{ display: 'grid', gap: '0.45rem' }}>
|
||||||
|
<span style={{ fontWeight: 600 }}>Empresa</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.company}
|
||||||
|
onChange={(event) => setForm((current) => ({ ...current, company: event.target.value }))}
|
||||||
|
placeholder="Empresa ou conta vinculada"
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: '18px',
|
||||||
|
padding: '0.95rem 1rem',
|
||||||
|
background: '#fff',
|
||||||
|
outline: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={{ display: 'grid', gap: '0.45rem' }}>
|
||||||
|
<span style={{ fontWeight: 600 }}>Mensagem pre-aprovada</span>
|
||||||
|
<select
|
||||||
|
value={selectedTemplateId}
|
||||||
|
onChange={(event) => setSelectedTemplateId(event.target.value)}
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: '18px',
|
||||||
|
padding: '0.95rem 1rem',
|
||||||
|
background: '#fff',
|
||||||
|
outline: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Selecione um template</option>
|
||||||
|
{templates.map((template) => (
|
||||||
|
<option key={template.id} value={template.id}>
|
||||||
|
{template.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedTemplate ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: '1px solid rgba(0, 164, 183, 0.24)',
|
||||||
|
borderRadius: '18px',
|
||||||
|
padding: '1rem',
|
||||||
|
background: 'rgba(0, 164, 183, 0.06)',
|
||||||
|
color: 'var(--color-text)',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong style={{ display: 'block', color: 'var(--color-primary)', marginBottom: '0.35rem' }}>
|
||||||
|
Preview do template
|
||||||
|
</strong>
|
||||||
|
{renderTemplatePreview(selectedTemplate.content, form)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<label style={{ display: 'grid', gap: '0.45rem' }}>
|
||||||
|
<span style={{ fontWeight: 600 }}>Observacao</span>
|
||||||
|
<textarea
|
||||||
|
rows={5}
|
||||||
|
value={form.note}
|
||||||
|
onChange={(event) => setForm((current) => ({ ...current, note: event.target.value }))}
|
||||||
|
placeholder="Contexto inicial deste atendimento."
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: '18px',
|
||||||
|
padding: '0.95rem 1rem',
|
||||||
|
background: '#fff',
|
||||||
|
outline: 'none',
|
||||||
|
resize: 'vertical',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error ? <span style={{ color: '#b42318', fontWeight: 700 }}>{error}</span> : null}
|
||||||
|
{isLoadingContacts ? <span style={{ color: 'var(--color-text-soft)' }}>Carregando agenda...</span> : null}
|
||||||
|
|
||||||
<section
|
<section
|
||||||
style={{
|
style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
@ -285,17 +658,20 @@ export function NewAttendancePage() {
|
|||||||
fontSize: '0.84rem',
|
fontSize: '0.84rem',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Resumo do fluxo
|
Resumo
|
||||||
</span>
|
</span>
|
||||||
<strong style={{ fontSize: '1.25rem' }}>{selectedContact.name}</strong>
|
<strong style={{ fontSize: '1.25rem' }}>{form.name || 'Cliente sem nome'}</strong>
|
||||||
<span style={{ color: 'rgba(255, 255, 255, 0.74)' }}>
|
<span style={{ color: 'rgba(255, 255, 255, 0.74)' }}>
|
||||||
Canal escolhido: {selectedChannel.label}
|
Canal: {selectedChannel.label}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ color: 'rgba(255, 255, 255, 0.74)' }}>
|
<span style={{ color: 'rgba(255, 255, 255, 0.74)' }}>
|
||||||
Numero: {customNumber || selectedContact.phone}
|
Numero: {buildInternationalPhone(form.phone, selectedCountryId) ? `+${buildInternationalPhone(form.phone, selectedCountryId)}` : 'Nao informado'}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ color: 'rgba(255, 255, 255, 0.74)' }}>
|
<span style={{ color: 'rgba(255, 255, 255, 0.74)' }}>
|
||||||
Area: {selectedArea || 'Definir depois'}
|
Empresa: {form.company || 'Nao informada'}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: 'rgba(255, 255, 255, 0.74)' }}>
|
||||||
|
Origem: {selectedContactId ? 'Agenda' : 'Novo contato'}
|
||||||
</span>
|
</span>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
@ -311,13 +687,29 @@ export function NewAttendancePage() {
|
|||||||
>
|
>
|
||||||
<strong>Proxima rota</strong>
|
<strong>Proxima rota</strong>
|
||||||
<span style={{ color: 'var(--color-text-soft)' }}>
|
<span style={{ color: 'var(--color-text-soft)' }}>
|
||||||
{selectedChannel.route === '/call'
|
O contato sera salvo, o template sera enviado e a conversa abrira atribuida a voce no chat.
|
||||||
? 'Ao iniciar, voce vai para a tela de ligacao.'
|
|
||||||
: 'Ao iniciar, voce vai para a tela de chat.'}
|
|
||||||
</span>
|
</span>
|
||||||
|
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', marginTop: '0.4rem' }}>
|
||||||
|
{selectedContactId ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={clearSelection}
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: '18px',
|
||||||
|
padding: '1rem 1.1rem',
|
||||||
|
background: '#fff',
|
||||||
|
color: 'var(--color-primary)',
|
||||||
|
fontWeight: 800,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Limpar selecao
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleStartAttendance}
|
onClick={handleStartAttendance}
|
||||||
|
disabled={!canStartAttendance || isStarting}
|
||||||
style={{
|
style={{
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '18px',
|
borderRadius: '18px',
|
||||||
@ -325,11 +717,12 @@ export function NewAttendancePage() {
|
|||||||
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
|
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
fontWeight: 800,
|
fontWeight: 800,
|
||||||
marginTop: '0.4rem',
|
opacity: canStartAttendance && !isStarting ? 1 : 0.55,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Iniciar atendimento
|
{isStarting ? 'Iniciando...' : 'Iniciar atendimento'}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -1,38 +1,5 @@
|
|||||||
export const attendanceChannels = [
|
export const attendanceChannels = [
|
||||||
{ id: 'whatsapp', label: 'WhatsApp', route: '/chat', accent: '#2bb741' },
|
{ id: 'whatsapp', label: 'WhatsApp', route: '/chat', accent: '#2bb741' },
|
||||||
{ id: 'sms', label: 'SMS', route: '/chat', accent: '#00a4b7' },
|
{ id: 'sms', label: 'SMS', route: '/chat', accent: '#00a4b7', disabled: true },
|
||||||
{ id: 'call', label: 'Ligacao', route: '/call', accent: '#e5a22a' },
|
{ id: 'email', label: 'E-mail', route: '/chat', accent: '#e5a22a', disabled: true },
|
||||||
];
|
|
||||||
|
|
||||||
export const attendanceAreas = ['Suporte', 'Financeiro', 'Comercial'];
|
|
||||||
|
|
||||||
export const recentContacts = [
|
|
||||||
{
|
|
||||||
id: 'maria-souza',
|
|
||||||
name: 'Maria Souza',
|
|
||||||
phone: '+55 11 99888-7766',
|
|
||||||
channel: 'WhatsApp',
|
|
||||||
lastContact: 'Hoje, 09:42',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'empresa-alpha',
|
|
||||||
name: 'Empresa Alpha',
|
|
||||||
phone: '+55 11 4002-2020',
|
|
||||||
channel: 'Email',
|
|
||||||
lastContact: 'Ontem, 16:18',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'joao-pedro',
|
|
||||||
name: 'João Pedro',
|
|
||||||
phone: '+55 31 98877-1102',
|
|
||||||
channel: 'SMS',
|
|
||||||
lastContact: 'Hoje, 08:15',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'beatriz-lima',
|
|
||||||
name: 'Beatriz Lima',
|
|
||||||
phone: '+55 21 99701-4455',
|
|
||||||
channel: 'Ligação',
|
|
||||||
lastContact: 'Hoje, 07:51',
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|||||||
@ -88,6 +88,8 @@ function SavedContactLabel({ contact }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CHAT_LIST_HEIGHT = 'min(760px, calc(100vh - 160px))';
|
||||||
|
|
||||||
export function ChatConversationList({
|
export function ChatConversationList({
|
||||||
contacts,
|
contacts,
|
||||||
activeContactId,
|
activeContactId,
|
||||||
@ -105,9 +107,9 @@ export function ChatConversationList({
|
|||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateRows: 'auto minmax(0, 1fr)',
|
gridTemplateRows: 'auto minmax(0, 1fr)',
|
||||||
gap: '0.85rem',
|
gap: '0.85rem',
|
||||||
height: isMobile ? 'auto' : '100%',
|
height: isMobile ? 'auto' : CHAT_LIST_HEIGHT,
|
||||||
maxHeight: isMobile ? 'none' : '100%',
|
maxHeight: isMobile ? 'none' : CHAT_LIST_HEIGHT,
|
||||||
alignSelf: isMobile ? 'start' : 'stretch',
|
alignSelf: 'start',
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -274,7 +274,7 @@ export function ChatWindow({
|
|||||||
|
|
||||||
container.scrollTo({
|
container.scrollTo({
|
||||||
top: container.scrollHeight,
|
top: container.scrollHeight,
|
||||||
behavior: 'smooth',
|
behavior: 'auto',
|
||||||
});
|
});
|
||||||
}, [messages, isReplying]);
|
}, [messages, isReplying]);
|
||||||
|
|
||||||
@ -615,6 +615,8 @@ export function ChatWindow({
|
|||||||
? 'Aguardando conversa entrar em uma fila'
|
? 'Aguardando conversa entrar em uma fila'
|
||||||
: canReply
|
: canReply
|
||||||
? 'Escreva sua mensagem...'
|
? 'Escreva sua mensagem...'
|
||||||
|
: assignmentLabel?.includes('Aguardando resposta')
|
||||||
|
? 'Aguardando resposta do cliente'
|
||||||
: canAssumeChat
|
: canAssumeChat
|
||||||
? 'Assuma o atendimento para responder'
|
? 'Assuma o atendimento para responder'
|
||||||
: 'Atendimento bloqueado para resposta'
|
: 'Atendimento bloqueado para resposta'
|
||||||
|
|||||||
@ -154,6 +154,38 @@ function buildFallbackContacts() {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeComparableContact(contact) {
|
||||||
|
return {
|
||||||
|
id: contact.id,
|
||||||
|
name: contact.name,
|
||||||
|
preview: contact.preview,
|
||||||
|
time: contact.time,
|
||||||
|
unread: contact.unread,
|
||||||
|
area: contact.area,
|
||||||
|
areaId: contact.areaId,
|
||||||
|
lastSeen: contact.lastSeen,
|
||||||
|
lastMessageFromMe: contact.lastMessageFromMe,
|
||||||
|
assignmentStatus: contact.assignment?.status || null,
|
||||||
|
assignmentUserId: contact.assignment?.user_id || null,
|
||||||
|
assignmentAreaId: contact.assignment?.area_id || null,
|
||||||
|
transferNote: contact.assignment?.transfer_note || null,
|
||||||
|
awaitingCustomerReply: contact.assignment?.awaiting_customer_reply || false,
|
||||||
|
contactName: contact.contactProfile?.name || null,
|
||||||
|
contactCompany: contact.contactProfile?.company || null,
|
||||||
|
contactNote: contact.contactProfile?.note || null,
|
||||||
|
contactPhone: contact.contactProfile?.phone || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function areContactListsEqual(currentContacts, nextContacts) {
|
||||||
|
if (currentContacts.length !== nextContacts.length) return false;
|
||||||
|
return currentContacts.every((contact, index) => {
|
||||||
|
const currentComparable = normalizeComparableContact(contact);
|
||||||
|
const nextComparable = normalizeComparableContact(nextContacts[index]);
|
||||||
|
return JSON.stringify(currentComparable) === JSON.stringify(nextComparable);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function getUserId(user) {
|
function getUserId(user) {
|
||||||
const value = user?.databaseId || user?.id;
|
const value = user?.databaseId || user?.id;
|
||||||
const numeric = Number(value);
|
const numeric = Number(value);
|
||||||
@ -200,6 +232,7 @@ export function useChat() {
|
|||||||
const [isLoadingMessages, setIsLoadingMessages] = useState(false);
|
const [isLoadingMessages, setIsLoadingMessages] = useState(false);
|
||||||
const [apiError, setApiError] = useState(null);
|
const [apiError, setApiError] = useState(null);
|
||||||
const activeContactRef = useRef(activeContactId);
|
const activeContactRef = useRef(activeContactId);
|
||||||
|
const contactsRef = useRef(contacts);
|
||||||
|
|
||||||
const activeContact = useMemo(
|
const activeContact = useMemo(
|
||||||
() => {
|
() => {
|
||||||
@ -225,14 +258,17 @@ export function useChat() {
|
|||||||
const isAssignedToCurrentUser = Boolean(
|
const isAssignedToCurrentUser = Boolean(
|
||||||
activeAssignment?.user_id && currentUserId && Number(activeAssignment.user_id) === currentUserId,
|
activeAssignment?.user_id && currentUserId && Number(activeAssignment.user_id) === currentUserId,
|
||||||
);
|
);
|
||||||
|
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)),
|
(!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);
|
const canReply = Boolean(isAssignedToCurrentUser && !isWaitingCustomerReply);
|
||||||
const assignmentLabel = activeAssignment?.user_id
|
const assignmentLabel = activeAssignment?.user_id
|
||||||
? `Atendimento com ${activeAssignment.user_nome || 'outro atendente'}`
|
? isWaitingCustomerReply
|
||||||
|
? 'Aguardando resposta do cliente para liberar novas mensagens'
|
||||||
|
: `Atendimento com ${activeAssignment.user_nome || 'outro atendente'}`
|
||||||
: activeAssignment?.area_nome
|
: activeAssignment?.area_nome
|
||||||
? `Na fila de ${activeAssignment.area_nome}`
|
? `Na fila de ${activeAssignment.area_nome}`
|
||||||
: 'Sem fila definida';
|
: 'Sem fila definida';
|
||||||
@ -250,6 +286,10 @@ export function useChat() {
|
|||||||
activeContactRef.current = activeContactId;
|
activeContactRef.current = activeContactId;
|
||||||
}, [activeContactId]);
|
}, [activeContactId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
contactsRef.current = contacts;
|
||||||
|
}, [contacts]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
|
|
||||||
@ -282,10 +322,10 @@ export function useChat() {
|
|||||||
return currentUserAreas.includes(contact.assignment.area_nome);
|
return currentUserAreas.includes(contact.assignment.area_nome);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadChats() {
|
async function loadChats({ showLoading = false } = {}) {
|
||||||
if (whatsappStatus !== 'CONNECTED') {
|
if (whatsappStatus !== 'CONNECTED') {
|
||||||
const fallbackContacts = buildFallbackContacts();
|
const fallbackContacts = buildFallbackContacts();
|
||||||
setContacts(fallbackContacts);
|
setContacts((current) => (areContactListsEqual(current, fallbackContacts) ? current : fallbackContacts));
|
||||||
setActiveContactId((current) =>
|
setActiveContactId((current) =>
|
||||||
fallbackContacts.some((contact) => contact.id === current) ? current : fallbackContacts[0]?.id,
|
fallbackContacts.some((contact) => contact.id === current) ? current : fallbackContacts[0]?.id,
|
||||||
);
|
);
|
||||||
@ -293,7 +333,9 @@ export function useChat() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showLoading) {
|
||||||
setIsLoadingChats(true);
|
setIsLoadingChats(true);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE_URL}/whatsapp/chats`);
|
const response = await fetch(`${API_BASE_URL}/whatsapp/chats`);
|
||||||
if (!response.ok) throw new Error('Falha ao carregar chats do WhatsApp.');
|
if (!response.ok) throw new Error('Falha ao carregar chats do WhatsApp.');
|
||||||
@ -301,7 +343,7 @@ export function useChat() {
|
|||||||
if (!Array.isArray(data)) return;
|
if (!Array.isArray(data)) return;
|
||||||
|
|
||||||
const nextContacts = data.map(normalizeChat).filter(canSeeContact);
|
const nextContacts = data.map(normalizeChat).filter(canSeeContact);
|
||||||
setContacts(nextContacts);
|
setContacts((current) => (areContactListsEqual(current, nextContacts) ? current : nextContacts));
|
||||||
setActiveContactId((current) =>
|
setActiveContactId((current) =>
|
||||||
nextContacts.some((contact) => contact.id === current) ? current : nextContacts[0]?.id || '',
|
nextContacts.some((contact) => contact.id === current) ? current : nextContacts[0]?.id || '',
|
||||||
);
|
);
|
||||||
@ -318,7 +360,7 @@ export function useChat() {
|
|||||||
|
|
||||||
async function guardedLoadChats() {
|
async function guardedLoadChats() {
|
||||||
if (!isMounted) return;
|
if (!isMounted) return;
|
||||||
await loadChats();
|
await loadChats({ showLoading: contactsRef.current.length === 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
guardedLoadChats();
|
guardedLoadChats();
|
||||||
@ -426,7 +468,7 @@ export function useChat() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
clearIncomingMessage();
|
clearIncomingMessage();
|
||||||
window.setTimeout(loadChats, 1200);
|
window.setTimeout(() => loadChats({ showLoading: false }), 1200);
|
||||||
}, [incomingMessage, clearIncomingMessage]);
|
}, [incomingMessage, clearIncomingMessage]);
|
||||||
|
|
||||||
function updateContact(contactId, updater) {
|
function updateContact(contactId, updater) {
|
||||||
@ -558,11 +600,16 @@ export function useChat() {
|
|||||||
const targetIsAssignedToCurrentUser = Boolean(
|
const targetIsAssignedToCurrentUser = Boolean(
|
||||||
targetAssignment?.user_id && currentUserId && Number(targetAssignment.user_id) === currentUserId,
|
targetAssignment?.user_id && currentUserId && Number(targetAssignment.user_id) === currentUserId,
|
||||||
);
|
);
|
||||||
|
const targetIsWaitingCustomerReply = Boolean(targetAssignment?.awaiting_customer_reply);
|
||||||
try {
|
try {
|
||||||
if (!targetIsAssignedToCurrentUser) {
|
if (!targetIsAssignedToCurrentUser) {
|
||||||
setApiError('Assuma o atendimento antes de responder.');
|
setApiError('Assuma o atendimento antes de responder.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (targetIsWaitingCustomerReply) {
|
||||||
|
setApiError('Aguarde o cliente responder antes de enviar novas mensagens.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setApiError(error.message);
|
setApiError(error.message);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { Link, useSearchParams } from 'react-router-dom';
|
import { Link, useSearchParams } from 'react-router-dom';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { BrandMark } from '../../../shared/components/BrandMark';
|
import { BrandMark } from '../../../shared/components/BrandMark';
|
||||||
import { useViewport } from '../../../shared/hooks/useViewport';
|
import { useViewport } from '../../../shared/hooks/useViewport';
|
||||||
import { ChatConversationList } from '../components/ChatConversationList';
|
import { ChatConversationList } from '../components/ChatConversationList';
|
||||||
@ -10,7 +10,7 @@ import { useChat } from '../hooks/useChat';
|
|||||||
import { quickReplies } from '../services/chatMocks';
|
import { quickReplies } from '../services/chatMocks';
|
||||||
|
|
||||||
export function ChatPage() {
|
export function ChatPage() {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const { isWideDesktop, isDesktop, isTablet, isMobile } = useViewport();
|
const { isWideDesktop, isDesktop, isTablet, isMobile } = useViewport();
|
||||||
const {
|
const {
|
||||||
contacts,
|
contacts,
|
||||||
@ -49,13 +49,24 @@ export function ChatPage() {
|
|||||||
submitTransfer,
|
submitTransfer,
|
||||||
} = useChat();
|
} = useChat();
|
||||||
const requestedChatId = searchParams.get('chatId');
|
const requestedChatId = searchParams.get('chatId');
|
||||||
|
const handledRequestedChatIdRef = useRef('');
|
||||||
const [isContactPanelOpen, setIsContactPanelOpen] = useState(false);
|
const [isContactPanelOpen, setIsContactPanelOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!requestedChatId) return;
|
if (!requestedChatId) return;
|
||||||
|
if (handledRequestedChatIdRef.current === requestedChatId) return;
|
||||||
if (!contacts.some((contact) => contact.id === requestedChatId)) return;
|
if (!contacts.some((contact) => contact.id === requestedChatId)) return;
|
||||||
|
handledRequestedChatIdRef.current = requestedChatId;
|
||||||
setActiveContactId(requestedChatId);
|
setActiveContactId(requestedChatId);
|
||||||
}, [requestedChatId, contacts, setActiveContactId]);
|
setSearchParams({}, { replace: true });
|
||||||
|
}, [requestedChatId, contacts, setActiveContactId, setSearchParams]);
|
||||||
|
|
||||||
|
function selectContact(contactId) {
|
||||||
|
setActiveContactId(contactId);
|
||||||
|
if (requestedChatId) {
|
||||||
|
setSearchParams({}, { replace: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const gridTemplateColumns = isMobile
|
const gridTemplateColumns = isMobile
|
||||||
? '1fr'
|
? '1fr'
|
||||||
@ -128,7 +139,7 @@ export function ChatPage() {
|
|||||||
<ChatConversationList
|
<ChatConversationList
|
||||||
contacts={contacts}
|
contacts={contacts}
|
||||||
activeContactId={activeContactId}
|
activeContactId={activeContactId}
|
||||||
onSelectContact={setActiveContactId}
|
onSelectContact={selectContact}
|
||||||
onOpenContact={() => {
|
onOpenContact={() => {
|
||||||
setIsTransferOpen(false);
|
setIsTransferOpen(false);
|
||||||
setIsContactPanelOpen(true);
|
setIsContactPanelOpen(true);
|
||||||
|
|||||||
@ -5,7 +5,7 @@ export const chatContacts = [
|
|||||||
channel: 'WhatsApp',
|
channel: 'WhatsApp',
|
||||||
status: 'away',
|
status: 'away',
|
||||||
area: 'Suporte',
|
area: 'Suporte',
|
||||||
lastSeen: 'Ultima atividade as 09:42',
|
lastSeen: 'Última atividade as 09:42',
|
||||||
preview: 'Preciso atualizar o cadastro do meu pedido.',
|
preview: 'Preciso atualizar o cadastro do meu pedido.',
|
||||||
time: '09:42',
|
time: '09:42',
|
||||||
unread: 2,
|
unread: 2,
|
||||||
@ -22,7 +22,7 @@ export const chatContacts = [
|
|||||||
channel: 'SMS',
|
channel: 'SMS',
|
||||||
status: 'offline',
|
status: 'offline',
|
||||||
area: 'Financeiro',
|
area: 'Financeiro',
|
||||||
lastSeen: 'Ultima atividade as 08:15',
|
lastSeen: 'Última atividade as 08:15',
|
||||||
preview: 'Pode me ligar em 10 minutos?',
|
preview: 'Pode me ligar em 10 minutos?',
|
||||||
time: '08:15',
|
time: '08:15',
|
||||||
unread: 1,
|
unread: 1,
|
||||||
|
|||||||
@ -1,5 +1,11 @@
|
|||||||
import { API_BASE_URL } from '../../../shared/services/apiConfig';
|
import { API_BASE_URL } from '../../../shared/services/apiConfig';
|
||||||
|
|
||||||
|
export async function listContactProfiles() {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/contacts`);
|
||||||
|
if (!response.ok) throw new Error('Falha ao carregar agenda.');
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
export async function getContactProfile(chatId) {
|
export async function getContactProfile(chatId) {
|
||||||
const response = await fetch(`${API_BASE_URL}/contacts/${encodeURIComponent(chatId)}`);
|
const response = await fetch(`${API_BASE_URL}/contacts/${encodeURIComponent(chatId)}`);
|
||||||
if (!response.ok) throw new Error('Falha ao carregar contato.');
|
if (!response.ok) throw new Error('Falha ao carregar contato.');
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user