FEAT: Adicionado a lista de contatos a partir do modulo de novo atendimento

This commit is contained in:
Rafael Alves Lopes 2026-05-20 13:56:04 -03:00
parent b605a4c507
commit eeab43d2a4
9 changed files with 607 additions and 171 deletions

View File

@ -2,7 +2,6 @@ export function RecentContactsList({
contacts,
activeContactId,
onSelectContact,
selectedChannel,
}) {
return (
<aside
@ -25,11 +24,6 @@ export function RecentContactsList({
<div style={{ display: 'grid', gap: '0.75rem' }}>
{contacts.map((contact) => {
const isActive = contact.id === activeContactId;
const isPreferred = selectedChannel === 'call'
? contact.channel === 'Ligacao'
: selectedChannel === 'sms'
? contact.channel === 'SMS'
: contact.channel === 'WhatsApp';
return (
<button
@ -49,24 +43,22 @@ export function RecentContactsList({
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
<strong>{contact.name}</strong>
{isPreferred ? (
<span
style={{
padding: '0.2rem 0.5rem',
borderRadius: 999,
background: 'rgba(0, 164, 183, 0.12)',
color: 'var(--color-primary)',
fontSize: '0.76rem',
fontWeight: 700,
}}
>
Sugerido
</span>
) : null}
<span
style={{
padding: '0.2rem 0.5rem',
borderRadius: 999,
background: 'rgba(43, 183, 65, 0.12)',
color: '#25883a',
fontSize: '0.76rem',
fontWeight: 700,
}}
>
Agenda
</span>
</div>
<span style={{ color: 'var(--color-text-soft)' }}>{contact.phone}</span>
<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' }}>
{contact.lastContact}
</span>
@ -74,6 +66,22 @@ export function RecentContactsList({
</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>
</aside>
);

View File

@ -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() {
>
<RecentContactsList
contacts={filteredContacts}
activeContactId={selectedContact.id}
onSelectContact={setSelectedContactId}
selectedChannel={selectedChannelId}
activeContactId={selectedContactId}
onSelectContact={selectContact}
/>
<section
@ -130,46 +412,24 @@ export function NewAttendancePage() {
<div>
<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 }}>
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.
</p>
</div>
<div
<input
type="search"
value={searchValue}
onChange={(event) => 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',
}}
>
<input
type="search"
value={searchValue}
onChange={(event) => 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',
}}
/>
<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
style={{
@ -180,12 +440,16 @@ export function NewAttendancePage() {
>
{attendanceChannels.map((channel) => {
const isActive = channel.id === selectedChannelId;
const isDisabled = Boolean(channel.disabled);
return (
<button
key={channel.id}
type="button"
onClick={() => setSelectedChannelId(channel.id)}
onClick={() => {
if (!isDisabled) setSelectedChannelId(channel.id);
}}
disabled={isDisabled}
style={{
border: '1px solid',
borderColor: isActive ? `${channel.accent}44` : 'var(--color-border)',
@ -195,15 +459,15 @@ export function NewAttendancePage() {
textAlign: 'left',
display: 'grid',
gap: '0.45rem',
opacity: isDisabled ? 0.58 : 1,
cursor: isDisabled ? 'not-allowed' : 'pointer',
}}
>
<strong style={{ color: isActive ? channel.accent : 'var(--color-text)' }}>
{channel.label}
</strong>
<span style={{ color: 'var(--color-text-soft)' }}>
{channel.id === 'call'
? 'Inicia uma ligacao mock em tela cheia.'
: 'Abre o fluxo de conversa em tempo real.'}
{isDisabled ? 'Canal em construcao.' : 'Inicia uma conversa pelo WhatsApp.'}
</span>
</button>
);
@ -213,15 +477,22 @@ export function NewAttendancePage() {
<div
style={{
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',
}}
>
<label style={{ display: 'grid', gap: '0.45rem' }}>
<span style={{ fontWeight: 600 }}>Area (opcional)</span>
<span style={{ fontWeight: 600 }}>Pais</span>
<select
value={selectedArea}
onChange={(event) => setSelectedArea(event.target.value)}
value={selectedCountryId}
onChange={(event) => {
const nextCountryId = event.target.value;
setSelectedCountryId(nextCountryId);
setForm((current) => ({
...current,
phone: applyPhoneMask(current.phone, nextCountryId),
}));
}}
style={{
border: '1px solid var(--color-border)',
borderRadius: '18px',
@ -230,22 +501,38 @@ export function NewAttendancePage() {
outline: 'none',
}}
>
<option value="">Selecionar depois</option>
{attendanceAreas.map((area) => (
<option key={area} value={area}>
{area}
{countryOptions.map((country) => (
<option key={country.id} value={country.id}>
{country.label} +{country.dialCode}
</option>
))}
</select>
</label>
<label style={{ display: 'grid', gap: '0.45rem' }}>
<span style={{ fontWeight: 600 }}>Numero selecionado</span>
<span style={{ fontWeight: 600 }}>Numero do WhatsApp</span>
<input
type="text"
value={customNumber || selectedContact.phone}
onChange={(event) => setCustomNumber(event.target.value)}
placeholder="+55 11 99999-9999"
value={form.phone}
onChange={(event) => setForm((current) => ({ ...current, phone: applyPhoneMask(event.target.value, selectedCountryId) }))}
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={{
border: '1px solid var(--color-border)',
borderRadius: '18px',
@ -257,6 +544,92 @@ export function NewAttendancePage() {
</label>
</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
style={{
display: 'grid',
@ -285,17 +658,20 @@ export function NewAttendancePage() {
fontSize: '0.84rem',
}}
>
Resumo do fluxo
Resumo
</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)' }}>
Canal escolhido: {selectedChannel.label}
Canal: {selectedChannel.label}
</span>
<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 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>
</article>
@ -311,25 +687,42 @@ export function NewAttendancePage() {
>
<strong>Proxima rota</strong>
<span style={{ color: 'var(--color-text-soft)' }}>
{selectedChannel.route === '/call'
? 'Ao iniciar, voce vai para a tela de ligacao.'
: 'Ao iniciar, voce vai para a tela de chat.'}
O contato sera salvo, o template sera enviado e a conversa abrira atribuida a voce no chat.
</span>
<button
type="button"
onClick={handleStartAttendance}
style={{
border: 'none',
borderRadius: '18px',
padding: '1rem 1.1rem',
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
color: '#fff',
fontWeight: 800,
marginTop: '0.4rem',
}}
>
Iniciar atendimento
</button>
<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
type="button"
onClick={handleStartAttendance}
disabled={!canStartAttendance || isStarting}
style={{
border: 'none',
borderRadius: '18px',
padding: '1rem 1.1rem',
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
color: '#fff',
fontWeight: 800,
opacity: canStartAttendance && !isStarting ? 1 : 0.55,
}}
>
{isStarting ? 'Iniciando...' : 'Iniciar atendimento'}
</button>
</div>
</article>
</section>
</section>

View File

@ -1,38 +1,5 @@
export const attendanceChannels = [
{ id: 'whatsapp', label: 'WhatsApp', route: '/chat', accent: '#2bb741' },
{ id: 'sms', label: 'SMS', route: '/chat', accent: '#00a4b7' },
{ id: 'call', label: 'Ligacao', route: '/call', accent: '#e5a22a' },
];
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',
},
{ id: 'sms', label: 'SMS', route: '/chat', accent: '#00a4b7', disabled: true },
{ id: 'email', label: 'E-mail', route: '/chat', accent: '#e5a22a', disabled: true },
];

View File

@ -88,6 +88,8 @@ function SavedContactLabel({ contact }) {
);
}
const CHAT_LIST_HEIGHT = 'min(760px, calc(100vh - 160px))';
export function ChatConversationList({
contacts,
activeContactId,
@ -105,9 +107,9 @@ export function ChatConversationList({
display: 'grid',
gridTemplateRows: 'auto minmax(0, 1fr)',
gap: '0.85rem',
height: isMobile ? 'auto' : '100%',
maxHeight: isMobile ? 'none' : '100%',
alignSelf: isMobile ? 'start' : 'stretch',
height: isMobile ? 'auto' : CHAT_LIST_HEIGHT,
maxHeight: isMobile ? 'none' : CHAT_LIST_HEIGHT,
alignSelf: 'start',
minHeight: 0,
}}
>

View File

@ -274,7 +274,7 @@ export function ChatWindow({
container.scrollTo({
top: container.scrollHeight,
behavior: 'smooth',
behavior: 'auto',
});
}, [messages, isReplying]);
@ -615,6 +615,8 @@ export function ChatWindow({
? 'Aguardando conversa entrar em uma fila'
: canReply
? 'Escreva sua mensagem...'
: assignmentLabel?.includes('Aguardando resposta')
? 'Aguardando resposta do cliente'
: canAssumeChat
? 'Assuma o atendimento para responder'
: 'Atendimento bloqueado para resposta'

View File

@ -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) {
const value = user?.databaseId || user?.id;
const numeric = Number(value);
@ -200,6 +232,7 @@ export function useChat() {
const [isLoadingMessages, setIsLoadingMessages] = useState(false);
const [apiError, setApiError] = useState(null);
const activeContactRef = useRef(activeContactId);
const contactsRef = useRef(contacts);
const activeContact = useMemo(
() => {
@ -225,14 +258,17 @@ export function useChat() {
const isAssignedToCurrentUser = Boolean(
activeAssignment?.user_id && currentUserId && Number(activeAssignment.user_id) === currentUserId,
);
const isWaitingCustomerReply = Boolean(activeAssignment?.awaiting_customer_reply);
const isQueuedForUserArea = Boolean(
activeAssignment?.status === 'queued' &&
(!activeAssignment.area_nome || currentUserAreas.includes(activeAssignment.area_nome)),
);
const canAssumeChat = Boolean(activeContact?.id?.includes('@') && currentUserId && isQueuedForUserArea);
const canReply = Boolean(isAssignedToCurrentUser);
const canReply = Boolean(isAssignedToCurrentUser && !isWaitingCustomerReply);
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
? `Na fila de ${activeAssignment.area_nome}`
: 'Sem fila definida';
@ -250,6 +286,10 @@ export function useChat() {
activeContactRef.current = activeContactId;
}, [activeContactId]);
useEffect(() => {
contactsRef.current = contacts;
}, [contacts]);
useEffect(() => {
let isMounted = true;
@ -282,10 +322,10 @@ export function useChat() {
return currentUserAreas.includes(contact.assignment.area_nome);
}
async function loadChats() {
async function loadChats({ showLoading = false } = {}) {
if (whatsappStatus !== 'CONNECTED') {
const fallbackContacts = buildFallbackContacts();
setContacts(fallbackContacts);
setContacts((current) => (areContactListsEqual(current, fallbackContacts) ? current : fallbackContacts));
setActiveContactId((current) =>
fallbackContacts.some((contact) => contact.id === current) ? current : fallbackContacts[0]?.id,
);
@ -293,7 +333,9 @@ export function useChat() {
return;
}
setIsLoadingChats(true);
if (showLoading) {
setIsLoadingChats(true);
}
try {
const response = await fetch(`${API_BASE_URL}/whatsapp/chats`);
if (!response.ok) throw new Error('Falha ao carregar chats do WhatsApp.');
@ -301,7 +343,7 @@ export function useChat() {
if (!Array.isArray(data)) return;
const nextContacts = data.map(normalizeChat).filter(canSeeContact);
setContacts(nextContacts);
setContacts((current) => (areContactListsEqual(current, nextContacts) ? current : nextContacts));
setActiveContactId((current) =>
nextContacts.some((contact) => contact.id === current) ? current : nextContacts[0]?.id || '',
);
@ -318,7 +360,7 @@ export function useChat() {
async function guardedLoadChats() {
if (!isMounted) return;
await loadChats();
await loadChats({ showLoading: contactsRef.current.length === 0 });
}
guardedLoadChats();
@ -426,7 +468,7 @@ export function useChat() {
});
clearIncomingMessage();
window.setTimeout(loadChats, 1200);
window.setTimeout(() => loadChats({ showLoading: false }), 1200);
}, [incomingMessage, clearIncomingMessage]);
function updateContact(contactId, updater) {
@ -558,11 +600,16 @@ export function useChat() {
const targetIsAssignedToCurrentUser = Boolean(
targetAssignment?.user_id && currentUserId && Number(targetAssignment.user_id) === currentUserId,
);
const targetIsWaitingCustomerReply = Boolean(targetAssignment?.awaiting_customer_reply);
try {
if (!targetIsAssignedToCurrentUser) {
setApiError('Assuma o atendimento antes de responder.');
return;
}
if (targetIsWaitingCustomerReply) {
setApiError('Aguarde o cliente responder antes de enviar novas mensagens.');
return;
}
} catch (error) {
setApiError(error.message);
return;

View File

@ -1,5 +1,5 @@
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 { useViewport } from '../../../shared/hooks/useViewport';
import { ChatConversationList } from '../components/ChatConversationList';
@ -10,7 +10,7 @@ import { useChat } from '../hooks/useChat';
import { quickReplies } from '../services/chatMocks';
export function ChatPage() {
const [searchParams] = useSearchParams();
const [searchParams, setSearchParams] = useSearchParams();
const { isWideDesktop, isDesktop, isTablet, isMobile } = useViewport();
const {
contacts,
@ -49,13 +49,24 @@ export function ChatPage() {
submitTransfer,
} = useChat();
const requestedChatId = searchParams.get('chatId');
const handledRequestedChatIdRef = useRef('');
const [isContactPanelOpen, setIsContactPanelOpen] = useState(false);
useEffect(() => {
if (!requestedChatId) return;
if (handledRequestedChatIdRef.current === requestedChatId) return;
if (!contacts.some((contact) => contact.id === requestedChatId)) return;
handledRequestedChatIdRef.current = 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
? '1fr'
@ -128,7 +139,7 @@ export function ChatPage() {
<ChatConversationList
contacts={contacts}
activeContactId={activeContactId}
onSelectContact={setActiveContactId}
onSelectContact={selectContact}
onOpenContact={() => {
setIsTransferOpen(false);
setIsContactPanelOpen(true);

View File

@ -5,7 +5,7 @@ export const chatContacts = [
channel: 'WhatsApp',
status: 'away',
area: 'Suporte',
lastSeen: 'Ultima atividade as 09:42',
lastSeen: 'Última atividade as 09:42',
preview: 'Preciso atualizar o cadastro do meu pedido.',
time: '09:42',
unread: 2,
@ -22,7 +22,7 @@ export const chatContacts = [
channel: 'SMS',
status: 'offline',
area: 'Financeiro',
lastSeen: 'Ultima atividade as 08:15',
lastSeen: 'Última atividade as 08:15',
preview: 'Pode me ligar em 10 minutos?',
time: '08:15',
unread: 1,

View File

@ -1,5 +1,11 @@
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) {
const response = await fetch(`${API_BASE_URL}/contacts/${encodeURIComponent(chatId)}`);
if (!response.ok) throw new Error('Falha ao carregar contato.');