Compare commits
No commits in common. "c4b846a079ce7ba290ff5f2a9df64f8ad098b825" and "f690c6d65285b9e47ccbd79172ae0c606ca5c8da" have entirely different histories.
c4b846a079
...
f690c6d652
@ -6,6 +6,7 @@ import { API_BASE_URL } from '../../../shared/services/apiConfig';
|
|||||||
import { getCurrentUser } from '../../auth/services/sessionService';
|
import { getCurrentUser } from '../../auth/services/sessionService';
|
||||||
import { listContactProfiles, saveContactProfile } from '../../chat/services/contactProfileService';
|
import { listContactProfiles, saveContactProfile } from '../../chat/services/contactProfileService';
|
||||||
import { getAccessOptions } from '../../management/services/adminAccessService';
|
import { getAccessOptions } from '../../management/services/adminAccessService';
|
||||||
|
import { RecentContactsList } from '../components/RecentContactsList';
|
||||||
import { attendanceChannels } from '../services/attendanceMocks';
|
import { attendanceChannels } from '../services/attendanceMocks';
|
||||||
|
|
||||||
const countryOptions = [
|
const countryOptions = [
|
||||||
@ -252,7 +253,7 @@ export function NewAttendancePage({ embedded = false }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return contacts.filter((contact) => {
|
return contacts.filter((contact) => {
|
||||||
const haystack = `${contact.name} ${contact.phone} ${contact.rawPhone} ${contact.company} ${contact.note}`.toLowerCase();
|
const haystack = `${contact.name} ${contact.phone} ${contact.rawPhone}`.toLowerCase();
|
||||||
return haystack.includes(search);
|
return haystack.includes(search);
|
||||||
});
|
});
|
||||||
}, [contacts, search]);
|
}, [contacts, search]);
|
||||||
@ -268,9 +269,9 @@ export function NewAttendancePage({ embedded = false }) {
|
|||||||
const gridTemplateColumns = isMobile
|
const gridTemplateColumns = isMobile
|
||||||
? '1fr'
|
? '1fr'
|
||||||
: isWideDesktop
|
: isWideDesktop
|
||||||
? 'minmax(0, 1fr) minmax(340px, 0.8fr)'
|
? 'minmax(300px, 360px) minmax(0, 1fr)'
|
||||||
: isDesktop || isTablet
|
: isDesktop || isTablet
|
||||||
? 'minmax(0, 1fr) minmax(320px, 0.85fr)'
|
? 'minmax(280px, 340px) minmax(0, 1fr)'
|
||||||
: '1fr';
|
: '1fr';
|
||||||
|
|
||||||
function selectContact(contactId) {
|
function selectContact(contactId) {
|
||||||
@ -405,6 +406,12 @@ export function NewAttendancePage({ embedded = false }) {
|
|||||||
alignItems: 'start',
|
alignItems: 'start',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<RecentContactsList
|
||||||
|
contacts={filteredContacts}
|
||||||
|
activeContactId={selectedContactId}
|
||||||
|
onSelectContact={selectContact}
|
||||||
|
/>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
style={{
|
style={{
|
||||||
background: '#fff',
|
background: '#fff',
|
||||||
@ -423,6 +430,20 @@ export function NewAttendancePage({ embedded = false }) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={searchValue}
|
||||||
|
onChange={(event) => setSearchValue(event.target.value)}
|
||||||
|
placeholder="Buscar contato salvo por nome ou número"
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: '18px',
|
||||||
|
padding: '0.95rem 1rem',
|
||||||
|
background: '#fff',
|
||||||
|
outline: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
@ -544,12 +565,12 @@ export function NewAttendancePage({ embedded = false }) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<label style={{ display: 'grid', gap: '0.45rem' }}>
|
<label style={{ display: 'grid', gap: '0.45rem' }}>
|
||||||
<span style={{ fontWeight: 600 }}>Etiqueta de identificação</span>
|
<span style={{ fontWeight: 600 }}>Tag</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={form.company}
|
value={form.company}
|
||||||
onChange={(event) => setForm((current) => ({ ...current, company: event.target.value }))}
|
onChange={(event) => setForm((current) => ({ ...current, company: event.target.value }))}
|
||||||
placeholder="Ex: Departamento, vaga ou conta vinculada"
|
placeholder="Tag ou conta vinculada"
|
||||||
style={{
|
style={{
|
||||||
border: '1px solid var(--color-border)',
|
border: '1px solid var(--color-border)',
|
||||||
borderRadius: '18px',
|
borderRadius: '18px',
|
||||||
@ -747,7 +768,7 @@ export function NewAttendancePage({ embedded = false }) {
|
|||||||
Número: {buildInternationalPhone(form.phone, selectedCountryId) ? `+${buildInternationalPhone(form.phone, selectedCountryId)}` : 'Não informado'}
|
Número: {buildInternationalPhone(form.phone, selectedCountryId) ? `+${buildInternationalPhone(form.phone, selectedCountryId)}` : 'Não informado'}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ color: 'rgba(255, 255, 255, 0.74)' }}>
|
<span style={{ color: 'rgba(255, 255, 255, 0.74)' }}>
|
||||||
Etiqueta de identificação: {form.company || 'Não informada'}
|
Tag: {form.company || 'Não informada'}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ color: 'rgba(255, 255, 255, 0.74)' }}>
|
<span style={{ color: 'rgba(255, 255, 255, 0.74)' }}>
|
||||||
Origem: {selectedContactId ? 'Agenda' : 'Novo contato'}
|
Origem: {selectedContactId ? 'Agenda' : 'Novo contato'}
|
||||||
@ -805,127 +826,6 @@ export function NewAttendancePage({ embedded = false }) {
|
|||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside style={{ display: 'grid', gap: '0.85rem', alignContent: 'start' }}>
|
|
||||||
<article
|
|
||||||
style={{
|
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
borderRadius: 24,
|
|
||||||
padding: '1rem',
|
|
||||||
background: '#fff',
|
|
||||||
display: 'grid',
|
|
||||||
gap: '0.75rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<strong style={{ display: 'block' }}>Agenda de contatos</strong>
|
|
||||||
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.9rem' }}>
|
|
||||||
Selecione um contato salvo para preencher o atendimento.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<input
|
|
||||||
type="search"
|
|
||||||
value={searchValue}
|
|
||||||
onChange={(event) => setSearchValue(event.target.value)}
|
|
||||||
placeholder="Buscar por nome, telefone ou etiqueta"
|
|
||||||
style={{
|
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
borderRadius: '16px',
|
|
||||||
padding: '0.85rem 0.9rem',
|
|
||||||
background: '#fff',
|
|
||||||
outline: 'none',
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div style={{ display: 'grid', gap: '0.45rem', maxHeight: 460, overflowY: 'auto', paddingRight: '0.2rem' }}>
|
|
||||||
{filteredContacts.map((contact) => {
|
|
||||||
const isSelected = selectedContactId === contact.id;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={contact.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => selectContact(contact.id)}
|
|
||||||
style={{
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: isSelected ? 'rgba(0, 164, 183, 0.36)' : 'var(--color-border)',
|
|
||||||
borderRadius: 16,
|
|
||||||
padding: '0.78rem',
|
|
||||||
background: isSelected ? 'rgba(0, 164, 183, 0.08)' : '#fff',
|
|
||||||
textAlign: 'left',
|
|
||||||
display: 'grid',
|
|
||||||
gap: '0.25rem',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<strong style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
||||||
{contact.name}
|
|
||||||
</strong>
|
|
||||||
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.88rem' }}>
|
|
||||||
+{contact.rawPhone || normalizePhone(contact.phone)}
|
|
||||||
</span>
|
|
||||||
{contact.company ? (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
width: 'fit-content',
|
|
||||||
borderRadius: 999,
|
|
||||||
padding: '0.16rem 0.48rem',
|
|
||||||
background: 'rgba(0,49,80,0.06)',
|
|
||||||
color: 'var(--color-text-soft)',
|
|
||||||
fontSize: '0.76rem',
|
|
||||||
fontWeight: 800,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{contact.company}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{!filteredContacts.length ? (
|
|
||||||
<span style={{ color: 'var(--color-text-soft)', fontWeight: 700 }}>
|
|
||||||
Nenhum contato encontrado na agenda.
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedContactId ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={clearSelection}
|
|
||||||
style={{
|
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
borderRadius: 14,
|
|
||||||
padding: '0.75rem',
|
|
||||||
background: '#fff',
|
|
||||||
color: 'var(--color-primary)',
|
|
||||||
fontWeight: 800,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Limpar contato selecionado
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article
|
|
||||||
style={{
|
|
||||||
border: '1px solid rgba(0, 164, 183, 0.24)',
|
|
||||||
borderRadius: 24,
|
|
||||||
padding: '1rem',
|
|
||||||
background: 'rgba(0, 164, 183, 0.06)',
|
|
||||||
color: 'var(--color-text-soft)',
|
|
||||||
lineHeight: 1.45,
|
|
||||||
fontWeight: 700,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{selectedContactId
|
|
||||||
? 'Contato carregado da agenda. Você ainda pode ajustar nome, etiqueta e observação antes de iniciar.'
|
|
||||||
: 'Você também pode digitar um novo número manualmente no formulário.'}
|
|
||||||
</article>
|
|
||||||
</aside>
|
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -18,7 +18,7 @@ export function HomeSidebar({ items, activeItem, isMobile = false }) {
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => navigate('/home')}
|
onClick={() => navigate('/new-attendance')}
|
||||||
style={{
|
style={{
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '20px',
|
borderRadius: '20px',
|
||||||
@ -29,7 +29,7 @@ export function HomeSidebar({ items, activeItem, isMobile = false }) {
|
|||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Home
|
Abrir atendimento
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<nav
|
<nav
|
||||||
|
|||||||
@ -376,13 +376,10 @@ export function MessagesWorkspace({
|
|||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gap: '0.6rem',
|
gap: '0.6rem',
|
||||||
minWidth: 0,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', minWidth: 0 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
|
||||||
<strong style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
<strong>{conversation.name}</strong>
|
||||||
{conversation.name}
|
|
||||||
</strong>
|
|
||||||
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.86rem' }}>
|
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.86rem' }}>
|
||||||
{conversation.time}
|
{conversation.time}
|
||||||
</span>
|
</span>
|
||||||
@ -391,19 +388,7 @@ export function MessagesWorkspace({
|
|||||||
<ChannelBadge channel={conversation.channel} />
|
<ChannelBadge channel={conversation.channel} />
|
||||||
<UnreadBadge count={conversation.unread} />
|
<UnreadBadge count={conversation.unread} />
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span style={{ color: 'var(--color-text-soft)' }}>{conversation.lastMessage}</span>
|
||||||
style={{
|
|
||||||
color: 'var(--color-text-soft)',
|
|
||||||
display: '-webkit-box',
|
|
||||||
WebkitLineClamp: 2,
|
|
||||||
WebkitBoxOrient: 'vertical',
|
|
||||||
overflow: 'hidden',
|
|
||||||
overflowWrap: 'anywhere',
|
|
||||||
lineHeight: 1.35,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{conversation.lastMessage}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -1,174 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { BrandMark } from '../../../shared/components/BrandMark';
|
|
||||||
import { useViewport } from '../../../shared/hooks/useViewport';
|
|
||||||
import { getCurrentUser, getCurrentUserDisplay } from '../../auth/services/sessionService';
|
|
||||||
import { listContactProfiles } from '../../chat/services/contactProfileService';
|
|
||||||
import { MassMessagePanel } from '../../management/components/MassMessagePanel';
|
|
||||||
import { getAccessOptions } from '../../management/services/adminAccessService';
|
|
||||||
import { HomeSidebar } from '../components/HomeSidebar';
|
|
||||||
import { sidebarItems } from '../services/homeMocks';
|
|
||||||
|
|
||||||
function getUserSpecialties(user) {
|
|
||||||
const normalize = (area) => {
|
|
||||||
if (!area) return null;
|
|
||||||
if (typeof area === 'string') return area;
|
|
||||||
return area.nome || area.name || null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const areas = Array.isArray(user?.areas) ? user.areas.map(normalize).filter(Boolean) : [];
|
|
||||||
const primary = normalize(user?.areaPrincipal);
|
|
||||||
return primary && !areas.includes(primary) ? [primary, ...areas] : areas;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AgentMassMessagePage() {
|
|
||||||
const { isDesktop, isMobile } = useViewport();
|
|
||||||
const userDisplay = getCurrentUserDisplay();
|
|
||||||
const currentUser = getCurrentUser();
|
|
||||||
const specialties = getUserSpecialties(currentUser);
|
|
||||||
const [areas, setAreas] = useState([]);
|
|
||||||
const [contactCount, setContactCount] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let isMounted = true;
|
|
||||||
|
|
||||||
getAccessOptions()
|
|
||||||
.then((options) => {
|
|
||||||
if (isMounted) setAreas(options.areas || []);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (isMounted) setAreas([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isMounted = false;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let isMounted = true;
|
|
||||||
|
|
||||||
listContactProfiles()
|
|
||||||
.then((items) => {
|
|
||||||
if (isMounted) setContactCount(Array.isArray(items) ? items.length : 0);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (isMounted) setContactCount(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isMounted = false;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const sidebarWithContactCount = sidebarItems.map((item) =>
|
|
||||||
item.id === 'contacts' ? { ...item, count: contactCount } : item,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main style={{ minHeight: '100vh', padding: '1.5rem' }}>
|
|
||||||
<section
|
|
||||||
style={{
|
|
||||||
width: 'min(1680px, calc(100vw - 3rem))',
|
|
||||||
margin: '0 auto',
|
|
||||||
background: 'var(--color-surface-strong)',
|
|
||||||
borderRadius: '32px',
|
|
||||||
boxShadow: 'var(--shadow-lg)',
|
|
||||||
padding: '1.5rem',
|
|
||||||
display: 'grid',
|
|
||||||
gap: '1.5rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: isDesktop ? 'minmax(340px, 380px) minmax(0, 1fr)' : '1fr',
|
|
||||||
gap: '1.5rem',
|
|
||||||
alignItems: 'start',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'grid', gap: '1.25rem' }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: '#fff',
|
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
borderRadius: '28px',
|
|
||||||
padding: '1.5rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<BrandMark size="lg" />
|
|
||||||
</div>
|
|
||||||
<HomeSidebar items={sidebarWithContactCount} activeItem="mass-message" isMobile={!isDesktop} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'grid', gap: '1.25rem', minWidth: 0 }}>
|
|
||||||
<header
|
|
||||||
style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 1fr) auto',
|
|
||||||
gap: '1rem',
|
|
||||||
alignItems: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: '1.1rem 1.25rem',
|
|
||||||
borderRadius: '22px',
|
|
||||||
background: '#fff',
|
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h1 style={{ margin: 0, fontSize: '1.65rem' }}>Disparo em massa</h1>
|
|
||||||
<p style={{ margin: '0.45rem 0 0', color: 'var(--color-text-soft)' }}>
|
|
||||||
Envie templates aprovados para contatos da agenda ou numeros informados manualmente.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '0.9rem',
|
|
||||||
justifySelf: isMobile ? 'stretch' : 'end',
|
|
||||||
justifyContent: isMobile ? 'space-between' : 'flex-end',
|
|
||||||
padding: '0.85rem 1rem',
|
|
||||||
borderRadius: '22px',
|
|
||||||
background: '#fff',
|
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ textAlign: 'right' }}>
|
|
||||||
<strong style={{ display: 'block' }}>{userDisplay.name}</strong>
|
|
||||||
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.92rem' }}>
|
|
||||||
Atendimento omnichannel
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
borderRadius: '16px',
|
|
||||||
display: 'grid',
|
|
||||||
placeItems: 'center',
|
|
||||||
background: 'linear-gradient(135deg, var(--color-accent), var(--color-primary))',
|
|
||||||
color: '#fff',
|
|
||||||
fontWeight: 800,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{userDisplay.initials}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<MassMessagePanel
|
|
||||||
areas={areas}
|
|
||||||
mode="agent"
|
|
||||||
managedAreaNames={specialties}
|
|
||||||
isMobile={isMobile}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,439 +0,0 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
import { BrandMark } from '../../../shared/components/BrandMark';
|
|
||||||
import { useViewport } from '../../../shared/hooks/useViewport';
|
|
||||||
import { getCurrentUser, getCurrentUserDisplay } from '../../auth/services/sessionService';
|
|
||||||
import { listContactProfiles, saveContactProfile } from '../../chat/services/contactProfileService';
|
|
||||||
import { HomeSidebar } from '../components/HomeSidebar';
|
|
||||||
import { sidebarItems } from '../services/homeMocks';
|
|
||||||
|
|
||||||
const inputStyle = {
|
|
||||||
width: '100%',
|
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
borderRadius: 14,
|
|
||||||
padding: '0.85rem 0.9rem',
|
|
||||||
background: '#fff',
|
|
||||||
color: 'var(--color-text)',
|
|
||||||
fontWeight: 600,
|
|
||||||
};
|
|
||||||
|
|
||||||
function getUserId(user) {
|
|
||||||
const value = user?.databaseId || user?.id;
|
|
||||||
const numeric = Number(value);
|
|
||||||
return Number.isFinite(numeric) ? numeric : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onlyDigits(value) {
|
|
||||||
return String(value || '').replace(/\D/g, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildChatId(phone) {
|
|
||||||
const digits = onlyDigits(phone);
|
|
||||||
return digits ? `${digits}@c.us` : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeContact(contact) {
|
|
||||||
return {
|
|
||||||
chatId: contact.chat_id || buildChatId(contact.phone),
|
|
||||||
name: contact.name || contact.phone || 'Contato sem nome',
|
|
||||||
whatsappPhone: contact.phone || '',
|
|
||||||
callSmsPhone: contact.call_sms_phone || contact.callSmsPhone || '',
|
|
||||||
email: contact.email || '',
|
|
||||||
tag: contact.company || '',
|
|
||||||
note: contact.note || '',
|
|
||||||
updatedAt: contact.updated_at || contact.created_at || null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function emptyDraft() {
|
|
||||||
return {
|
|
||||||
chatId: '',
|
|
||||||
name: '',
|
|
||||||
whatsappPhone: '',
|
|
||||||
callSmsPhone: '',
|
|
||||||
email: '',
|
|
||||||
tag: '',
|
|
||||||
note: '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ContactsPage() {
|
|
||||||
const { isDesktop, isMobile } = useViewport();
|
|
||||||
const currentUser = getCurrentUser();
|
|
||||||
const currentUserId = getUserId(currentUser);
|
|
||||||
const userDisplay = getCurrentUserDisplay();
|
|
||||||
const [contacts, setContacts] = useState([]);
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const [draft, setDraft] = useState(emptyDraft());
|
|
||||||
const [selectedChatId, setSelectedChatId] = useState('');
|
|
||||||
const [status, setStatus] = useState('');
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
|
||||||
|
|
||||||
async function loadContacts() {
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
const data = await listContactProfiles();
|
|
||||||
setContacts(Array.isArray(data) ? data.map(normalizeContact) : []);
|
|
||||||
setStatus('');
|
|
||||||
} catch (error) {
|
|
||||||
setStatus(error.message);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadContacts();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const sidebarWithCount = useMemo(
|
|
||||||
() => sidebarItems.map((item) => (item.id === 'contacts' ? { ...item, count: contacts.length } : item)),
|
|
||||||
[contacts.length],
|
|
||||||
);
|
|
||||||
|
|
||||||
const filteredContacts = useMemo(() => {
|
|
||||||
const value = search.trim().toLowerCase();
|
|
||||||
if (!value) return contacts;
|
|
||||||
return contacts.filter((contact) =>
|
|
||||||
`${contact.name} ${contact.whatsappPhone} ${contact.callSmsPhone} ${contact.email} ${contact.tag} ${contact.note}`
|
|
||||||
.toLowerCase()
|
|
||||||
.includes(value),
|
|
||||||
);
|
|
||||||
}, [contacts, search]);
|
|
||||||
|
|
||||||
function selectContact(contact) {
|
|
||||||
setSelectedChatId(contact.chatId);
|
|
||||||
setDraft({ ...contact });
|
|
||||||
setStatus('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function startNewContact() {
|
|
||||||
setSelectedChatId('');
|
|
||||||
setDraft(emptyDraft());
|
|
||||||
setStatus('');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSave(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
const whatsappPhone = onlyDigits(draft.whatsappPhone);
|
|
||||||
const chatId = selectedChatId || draft.chatId || buildChatId(whatsappPhone);
|
|
||||||
|
|
||||||
if (!chatId || !whatsappPhone) {
|
|
||||||
setStatus('Informe o número de WhatsApp para salvar o contato.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSaving(true);
|
|
||||||
try {
|
|
||||||
await saveContactProfile(chatId, {
|
|
||||||
phone: whatsappPhone,
|
|
||||||
whatsappPhone,
|
|
||||||
callSmsPhone: onlyDigits(draft.callSmsPhone),
|
|
||||||
email: draft.email,
|
|
||||||
name: draft.name,
|
|
||||||
company: draft.tag,
|
|
||||||
note: draft.note,
|
|
||||||
userId: currentUserId,
|
|
||||||
});
|
|
||||||
setStatus('Contato salvo com sucesso.');
|
|
||||||
await loadContacts();
|
|
||||||
setSelectedChatId(chatId);
|
|
||||||
setDraft((current) => ({ ...current, chatId, whatsappPhone }));
|
|
||||||
} catch (error) {
|
|
||||||
setStatus(error.message);
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main style={{ minHeight: '100vh', padding: '1.5rem' }}>
|
|
||||||
<section
|
|
||||||
style={{
|
|
||||||
width: 'min(1680px, calc(100vw - 3rem))',
|
|
||||||
margin: '0 auto',
|
|
||||||
background: 'var(--color-surface-strong)',
|
|
||||||
borderRadius: '32px',
|
|
||||||
boxShadow: 'var(--shadow-lg)',
|
|
||||||
padding: '1.5rem',
|
|
||||||
display: 'grid',
|
|
||||||
gap: '1.5rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: isDesktop ? 'minmax(340px, 380px) minmax(0, 1fr)' : '1fr',
|
|
||||||
gap: '1.5rem',
|
|
||||||
alignItems: 'start',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'grid', gap: '1.25rem' }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: '#fff',
|
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
borderRadius: '28px',
|
|
||||||
padding: '1.5rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<BrandMark size="lg" />
|
|
||||||
</div>
|
|
||||||
<HomeSidebar items={sidebarWithCount} activeItem="contacts" isMobile={!isDesktop} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'grid', gap: '1.25rem', minWidth: 0 }}>
|
|
||||||
<header
|
|
||||||
style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 1fr) auto',
|
|
||||||
gap: '1rem',
|
|
||||||
alignItems: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: '1.1rem 1.25rem',
|
|
||||||
borderRadius: '22px',
|
|
||||||
background: '#fff',
|
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h1 style={{ margin: 0, fontSize: '1.65rem' }}>Contatos</h1>
|
|
||||||
<p style={{ margin: '0.45rem 0 0', color: 'var(--color-text-soft)' }}>
|
|
||||||
Agenda geral com WhatsApp, telefone para ligação/SMS, e-mail, tag e observação.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '0.9rem',
|
|
||||||
justifySelf: isMobile ? 'stretch' : 'end',
|
|
||||||
justifyContent: isMobile ? 'space-between' : 'flex-end',
|
|
||||||
padding: '0.85rem 1rem',
|
|
||||||
borderRadius: '22px',
|
|
||||||
background: '#fff',
|
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ textAlign: 'right' }}>
|
|
||||||
<strong style={{ display: 'block' }}>{userDisplay.name}</strong>
|
|
||||||
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.92rem' }}>
|
|
||||||
Atendimento omnichannel
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
borderRadius: '16px',
|
|
||||||
display: 'grid',
|
|
||||||
placeItems: 'center',
|
|
||||||
background: 'linear-gradient(135deg, var(--color-accent), var(--color-primary))',
|
|
||||||
color: '#fff',
|
|
||||||
fontWeight: 800,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{userDisplay.initials}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section
|
|
||||||
style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: isMobile ? '1fr' : 'minmax(320px, 0.85fr) minmax(0, 1fr)',
|
|
||||||
gap: '1rem',
|
|
||||||
alignItems: 'start',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<aside
|
|
||||||
style={{
|
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
borderRadius: 24,
|
|
||||||
padding: '1rem',
|
|
||||||
background: '#fff',
|
|
||||||
display: 'grid',
|
|
||||||
gap: '0.8rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.75rem', alignItems: 'center' }}>
|
|
||||||
<div>
|
|
||||||
<strong style={{ display: 'block' }}>Agenda</strong>
|
|
||||||
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.9rem' }}>
|
|
||||||
{contacts.length} contato(s)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={startNewContact}
|
|
||||||
style={{
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: 14,
|
|
||||||
padding: '0.7rem 0.85rem',
|
|
||||||
background: 'var(--color-highlight)',
|
|
||||||
color: '#132534',
|
|
||||||
fontWeight: 900,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Novo
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<input
|
|
||||||
value={search}
|
|
||||||
onChange={(event) => setSearch(event.target.value)}
|
|
||||||
placeholder="Buscar contato"
|
|
||||||
style={inputStyle}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div style={{ display: 'grid', gap: '0.45rem', maxHeight: 560, overflowY: 'auto', paddingRight: '0.2rem' }}>
|
|
||||||
{filteredContacts.map((contact) => {
|
|
||||||
const isSelected = selectedChatId === contact.chatId;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={contact.chatId}
|
|
||||||
type="button"
|
|
||||||
onClick={() => selectContact(contact)}
|
|
||||||
style={{
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: isSelected ? 'rgba(0, 164, 183, 0.36)' : 'var(--color-border)',
|
|
||||||
borderRadius: 16,
|
|
||||||
padding: '0.8rem',
|
|
||||||
background: isSelected ? 'rgba(0, 164, 183, 0.08)' : '#fff',
|
|
||||||
textAlign: 'left',
|
|
||||||
display: 'grid',
|
|
||||||
gap: '0.25rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<strong>{contact.name}</strong>
|
|
||||||
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.88rem' }}>
|
|
||||||
WhatsApp: +{contact.whatsappPhone}
|
|
||||||
</span>
|
|
||||||
{contact.email ? (
|
|
||||||
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.84rem' }}>
|
|
||||||
{contact.email}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{!filteredContacts.length ? (
|
|
||||||
<span style={{ color: 'var(--color-text-soft)', fontWeight: 700 }}>
|
|
||||||
Nenhum contato encontrado.
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<form
|
|
||||||
onSubmit={handleSave}
|
|
||||||
style={{
|
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
borderRadius: 24,
|
|
||||||
padding: '1.2rem',
|
|
||||||
background: '#fff',
|
|
||||||
display: 'grid',
|
|
||||||
gap: '0.9rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<strong style={{ display: 'block', fontSize: '1.08rem' }}>
|
|
||||||
{selectedChatId ? 'Editar contato' : 'Novo contato'}
|
|
||||||
</strong>
|
|
||||||
<span style={{ color: 'var(--color-text-soft)' }}>
|
|
||||||
O WhatsApp é usado para vincular o contato à conversa.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : 'repeat(2, minmax(0, 1fr))', gap: '0.85rem' }}>
|
|
||||||
<label style={{ display: 'grid', gap: '0.4rem' }}>
|
|
||||||
<span style={{ fontWeight: 700 }}>Nome</span>
|
|
||||||
<input
|
|
||||||
value={draft.name}
|
|
||||||
onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))}
|
|
||||||
style={inputStyle}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label style={{ display: 'grid', gap: '0.4rem' }}>
|
|
||||||
<span style={{ fontWeight: 700 }}>Tag</span>
|
|
||||||
<input
|
|
||||||
value={draft.tag}
|
|
||||||
onChange={(event) => setDraft((current) => ({ ...current, tag: event.target.value }))}
|
|
||||||
placeholder="Ex: Analista de TI III"
|
|
||||||
style={inputStyle}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label style={{ display: 'grid', gap: '0.4rem' }}>
|
|
||||||
<span style={{ fontWeight: 700 }}>Número WhatsApp</span>
|
|
||||||
<input
|
|
||||||
value={draft.whatsappPhone}
|
|
||||||
onChange={(event) => setDraft((current) => ({ ...current, whatsappPhone: event.target.value }))}
|
|
||||||
placeholder="5511988267544"
|
|
||||||
style={inputStyle}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label style={{ display: 'grid', gap: '0.4rem' }}>
|
|
||||||
<span style={{ fontWeight: 700 }}>Ligação/SMS</span>
|
|
||||||
<input
|
|
||||||
value={draft.callSmsPhone}
|
|
||||||
onChange={(event) => setDraft((current) => ({ ...current, callSmsPhone: event.target.value }))}
|
|
||||||
placeholder="5511988267544"
|
|
||||||
style={inputStyle}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label style={{ display: 'grid', gap: '0.4rem' }}>
|
|
||||||
<span style={{ fontWeight: 700 }}>E-mail</span>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={draft.email}
|
|
||||||
onChange={(event) => setDraft((current) => ({ ...current, email: event.target.value }))}
|
|
||||||
placeholder="nome@empresa.com"
|
|
||||||
style={inputStyle}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label style={{ display: 'grid', gap: '0.4rem' }}>
|
|
||||||
<span style={{ fontWeight: 700 }}>Observação</span>
|
|
||||||
<textarea
|
|
||||||
rows={5}
|
|
||||||
value={draft.note}
|
|
||||||
onChange={(event) => setDraft((current) => ({ ...current, note: event.target.value }))}
|
|
||||||
style={{ ...inputStyle, resize: 'vertical', lineHeight: 1.5 }}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{status ? <span style={{ color: status.includes('sucesso') ? 'var(--color-primary)' : '#b42318', fontWeight: 800 }}>{status}</span> : null}
|
|
||||||
{isLoading ? <span style={{ color: 'var(--color-text-soft)', fontWeight: 700 }}>Carregando agenda...</span> : null}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isSaving}
|
|
||||||
style={{
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: 16,
|
|
||||||
padding: '0.95rem 1rem',
|
|
||||||
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
|
|
||||||
color: '#fff',
|
|
||||||
fontWeight: 900,
|
|
||||||
opacity: isSaving ? 0.7 : 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isSaving ? 'Salvando...' : 'Salvar contato'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { BrandMark } from '../../../shared/components/BrandMark';
|
import { BrandMark } from '../../../shared/components/BrandMark';
|
||||||
import { HomeSidebar } from '../components/HomeSidebar';
|
import { HomeSidebar } from '../components/HomeSidebar';
|
||||||
import { HomeTopbar } from '../components/HomeTopbar';
|
import { HomeTopbar } from '../components/HomeTopbar';
|
||||||
@ -8,23 +8,14 @@ import { AttendantOpsPanel } from '../components/AttendantOpsPanel';
|
|||||||
import { recentCalls, sidebarItems } from '../services/homeMocks';
|
import { recentCalls, sidebarItems } from '../services/homeMocks';
|
||||||
import { useViewport } from '../../../shared/hooks/useViewport';
|
import { useViewport } from '../../../shared/hooks/useViewport';
|
||||||
import { useChat } from '../../chat/hooks/useChat';
|
import { useChat } from '../../chat/hooks/useChat';
|
||||||
import { listContactProfiles } from '../../chat/services/contactProfileService';
|
|
||||||
|
|
||||||
function truncatePreview(value, limit = 96) {
|
|
||||||
const text = String(value || '').replace(/\s+/g, ' ').trim();
|
|
||||||
if (text.length <= limit) return text;
|
|
||||||
return `${text.slice(0, limit).trim()}...`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toHomeConversation(contact, messages = []) {
|
function toHomeConversation(contact, messages = []) {
|
||||||
const lastMessage = contact.preview || messages[messages.length - 1]?.text || '';
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: contact.id,
|
id: contact.id,
|
||||||
name: contact.name,
|
name: contact.name,
|
||||||
channel: contact.channel || 'WhatsApp',
|
channel: contact.channel || 'WhatsApp',
|
||||||
status: contact.status || 'online',
|
status: contact.status || 'online',
|
||||||
lastMessage: truncatePreview(lastMessage),
|
lastMessage: contact.preview || messages[messages.length - 1]?.text || '',
|
||||||
unread: contact.unread || 0,
|
unread: contact.unread || 0,
|
||||||
time: contact.time || 'Agora',
|
time: contact.time || 'Agora',
|
||||||
lastSeen: contact.lastSeen,
|
lastSeen: contact.lastSeen,
|
||||||
@ -54,28 +45,6 @@ export function HomePage() {
|
|||||||
} = useChat();
|
} = useChat();
|
||||||
const [activeTab, setActiveTab] = useState('messages');
|
const [activeTab, setActiveTab] = useState('messages');
|
||||||
const [searchValue, setSearchValue] = useState('');
|
const [searchValue, setSearchValue] = useState('');
|
||||||
const [contactCount, setContactCount] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let isMounted = true;
|
|
||||||
|
|
||||||
listContactProfiles()
|
|
||||||
.then((items) => {
|
|
||||||
if (isMounted) setContactCount(Array.isArray(items) ? items.length : 0);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (isMounted) setContactCount(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isMounted = false;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const sidebarWithContactCount = useMemo(
|
|
||||||
() => sidebarItems.map((item) => (item.id === 'contacts' ? { ...item, count: contactCount } : item)),
|
|
||||||
[contactCount],
|
|
||||||
);
|
|
||||||
|
|
||||||
const conversations = contacts.map((contact) =>
|
const conversations = contacts.map((contact) =>
|
||||||
toHomeConversation(contact, contact.id === activeContactId ? messages : []),
|
toHomeConversation(contact, contact.id === activeContactId ? messages : []),
|
||||||
@ -137,7 +106,7 @@ export function HomePage() {
|
|||||||
>
|
>
|
||||||
<BrandMark size="lg" />
|
<BrandMark size="lg" />
|
||||||
</div>
|
</div>
|
||||||
<HomeSidebar items={sidebarWithContactCount} activeItem="dashboard" isMobile={!isDesktop} />
|
<HomeSidebar items={sidebarItems} activeItem="dashboard" isMobile={!isDesktop} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gap: '1.25rem', minWidth: 0 }}>
|
<div style={{ display: 'grid', gap: '1.25rem', minWidth: 0 }}>
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
export const sidebarItems = [
|
export const sidebarItems = [
|
||||||
{ id: 'new-attendance', label: 'Abrir atendimento', route: '/new-attendance' },
|
|
||||||
{ id: 'mass-message', label: 'Disparo em massa', route: '/mass-message' },
|
|
||||||
{ id: 'knowledge-base', label: 'Base de conhecimento' },
|
|
||||||
{ id: 'scripts', label: 'Scripts e respostas prontas' },
|
{ id: 'scripts', label: 'Scripts e respostas prontas' },
|
||||||
{ id: 'contacts', label: 'Contatos', route: '/contacts' },
|
{ id: 'personal-reports', label: 'Relatórios pessoais' },
|
||||||
|
{ id: 'mass-message', label: 'Disparo em massa' },
|
||||||
|
{ id: 'knowledge-base', label: 'Base de conhecimento' },
|
||||||
|
{ id: 'completed', label: 'Finalizados', count: 24 },
|
||||||
|
{ id: 'contacts', label: 'Contatos', count: 128 },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const conversations = [
|
export const conversations = [
|
||||||
@ -30,21 +31,21 @@ export const conversations = [
|
|||||||
unread: 0,
|
unread: 0,
|
||||||
time: 'Ontem',
|
time: 'Ontem',
|
||||||
messages: [
|
messages: [
|
||||||
{ id: 1, from: 'customer', text: 'Precisamos rever os valores da última proposta.' },
|
{ id: 1, from: 'customer', text: 'Precisamos rever os valores da última proposta.' },
|
||||||
{ id: 2, from: 'agent', text: 'Perfeito, vou encaminhar para o time comercial.' },
|
{ id: 2, from: 'agent', text: 'Perfeito, vou encaminhar para o time comercial.' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'joao-pedro',
|
id: 'joao-pedro',
|
||||||
name: 'João Pedro',
|
name: 'João Pedro',
|
||||||
channel: 'SMS',
|
channel: 'SMS',
|
||||||
status: 'online',
|
status: 'online',
|
||||||
lastMessage: 'Pode me ligar em 10 minutos?',
|
lastMessage: 'Pode me ligar em 10 minutos?',
|
||||||
unread: 1,
|
unread: 1,
|
||||||
time: '08:15',
|
time: '08:15',
|
||||||
messages: [
|
messages: [
|
||||||
{ id: 1, from: 'customer', text: 'Recebi a cobrança em duplicidade.' },
|
{ id: 1, from: 'customer', text: 'Recebi a cobrança em duplicidade.' },
|
||||||
{ id: 2, from: 'agent', text: 'Vou analisar isso agora para você.' },
|
{ id: 2, from: 'agent', text: 'Vou analisar isso agora para você.' },
|
||||||
{ id: 3, from: 'customer', text: 'Pode me ligar em 10 minutos?' },
|
{ id: 3, from: 'customer', text: 'Pode me ligar em 10 minutos?' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@ -78,32 +78,6 @@ function collectPublishWarnings(node, warnings = []) {
|
|||||||
return warnings;
|
return warnings;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFlowNodeWidth(level) {
|
|
||||||
return level >= 2 ? 260 : 300;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFlowChildGap(level) {
|
|
||||||
return level >= 2 ? 56 : 40;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFlowSubtreeWidth(node, level = 0) {
|
|
||||||
if (!node) return getFlowNodeWidth(level);
|
|
||||||
const children = node.children || [];
|
|
||||||
const nodeWidth = getFlowNodeWidth(level);
|
|
||||||
const horizontalPadding = level >= 2 ? 56 : 72;
|
|
||||||
|
|
||||||
if (!children.length) {
|
|
||||||
return nodeWidth + horizontalPadding;
|
|
||||||
}
|
|
||||||
|
|
||||||
const gap = getFlowChildGap(level);
|
|
||||||
const childrenWidth =
|
|
||||||
children.reduce((total, child) => total + getFlowSubtreeWidth(child, level + 1), 0) +
|
|
||||||
Math.max(0, children.length - 1) * gap;
|
|
||||||
|
|
||||||
return Math.max(nodeWidth + horizontalPadding, childrenWidth);
|
|
||||||
}
|
|
||||||
|
|
||||||
function WhatsAppPreview({ message }) {
|
function WhatsAppPreview({ message }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -134,44 +108,18 @@ function WhatsAppPreview({ message }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FlowNode({ node, areasById, onAdd, onEdit, onDelete, level = 0, parentTitle = '' }) {
|
function FlowNode({ node, areasById, onAdd, onEdit, onDelete }) {
|
||||||
const keywords = splitKeywords(node.keywords);
|
const keywords = splitKeywords(node.keywords);
|
||||||
const isRoot = node.node_type === 'greeting';
|
const isRoot = node.node_type === 'greeting';
|
||||||
const isAgent = node.node_type === 'agent';
|
const isAgent = node.node_type === 'agent';
|
||||||
const isClose = node.node_type === 'close';
|
const isClose = node.node_type === 'close';
|
||||||
const isDeep = level >= 2;
|
|
||||||
const nodeWidth = getFlowNodeWidth(level);
|
|
||||||
const visibleKeywordLimit = isDeep ? 4 : 8;
|
|
||||||
const childGap = getFlowChildGap(level);
|
|
||||||
const subtreeWidth = getFlowSubtreeWidth(node, level);
|
|
||||||
const firstChildWidth = node.children?.length ? getFlowSubtreeWidth(node.children[0], level + 1) : 0;
|
|
||||||
const lastChildWidth = node.children?.length
|
|
||||||
? getFlowSubtreeWidth(node.children[node.children.length - 1], level + 1)
|
|
||||||
: 0;
|
|
||||||
const accentColor = isRoot
|
|
||||||
? 'var(--color-primary)'
|
|
||||||
: isAgent
|
|
||||||
? '#3260b3'
|
|
||||||
: isClose
|
|
||||||
? '#0f8f77'
|
|
||||||
: 'var(--color-highlight)';
|
|
||||||
const nodeMessage = node.message_text || (isAgent ? '' : 'Sem mensagem configurada.');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div style={{ display: 'grid', justifyItems: 'center', gap: '0.8rem', minWidth: 260 }}>
|
||||||
style={{
|
|
||||||
display: 'grid',
|
|
||||||
justifyItems: 'center',
|
|
||||||
gap: '0.95rem',
|
|
||||||
minWidth: subtreeWidth,
|
|
||||||
width: subtreeWidth,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<article
|
<article
|
||||||
style={{
|
style={{
|
||||||
width: nodeWidth,
|
width: 280,
|
||||||
border: '1px solid var(--color-border)',
|
border: '1px solid var(--color-border)',
|
||||||
borderTop: `5px solid ${accentColor}`,
|
|
||||||
borderRadius: 18,
|
borderRadius: 18,
|
||||||
background: isRoot
|
background: isRoot
|
||||||
? 'linear-gradient(180deg, #fff, rgba(0,164,183,0.09))'
|
? 'linear-gradient(180deg, #fff, rgba(0,164,183,0.09))'
|
||||||
@ -181,36 +129,17 @@ function FlowNode({ node, areasById, onAdd, onEdit, onDelete, level = 0, parentT
|
|||||||
? 'linear-gradient(180deg, #fff, rgba(0,164,183,0.1))'
|
? 'linear-gradient(180deg, #fff, rgba(0,164,183,0.1))'
|
||||||
: '#fff',
|
: '#fff',
|
||||||
boxShadow: '0 12px 28px rgba(0, 49, 80, 0.08)',
|
boxShadow: '0 12px 28px rgba(0, 49, 80, 0.08)',
|
||||||
padding: isDeep ? '0.8rem' : '0.95rem',
|
padding: '0.95rem',
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gap: '0.7rem',
|
gap: '0.7rem',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.7rem', alignItems: 'start' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.7rem', alignItems: 'start' }}>
|
||||||
<div style={{ minWidth: 0 }}>
|
<div style={{ minWidth: 0 }}>
|
||||||
<div style={{ display: 'flex', gap: '0.4rem', alignItems: 'center', flexWrap: 'wrap', marginBottom: '0.15rem' }}>
|
<span style={{ color: 'var(--color-primary)', fontSize: '0.74rem', fontWeight: 900, textTransform: 'uppercase' }}>
|
||||||
<span style={{ color: 'var(--color-primary)', fontSize: '0.74rem', fontWeight: 900, textTransform: 'uppercase' }}>
|
{nodeTypeLabel(node.node_type)}
|
||||||
{nodeTypeLabel(node.node_type)}
|
</span>
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
borderRadius: 999,
|
|
||||||
padding: '0.12rem 0.42rem',
|
|
||||||
background: 'rgba(0,49,80,0.06)',
|
|
||||||
color: 'var(--color-text-soft)',
|
|
||||||
fontSize: '0.68rem',
|
|
||||||
fontWeight: 900,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Nível {level + 1}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<strong style={{ display: 'block', lineHeight: 1.25 }}>{node.title}</strong>
|
<strong style={{ display: 'block', lineHeight: 1.25 }}>{node.title}</strong>
|
||||||
{!isRoot && parentTitle ? (
|
|
||||||
<span style={{ display: 'block', marginTop: '0.22rem', color: 'var(--color-text-soft)', fontSize: '0.78rem', fontWeight: 700 }}>
|
|
||||||
abaixo de: {parentTitle}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
{!isAgent && !isClose && onAdd ? (
|
{!isAgent && !isClose && onAdd ? (
|
||||||
<button
|
<button
|
||||||
@ -238,49 +167,30 @@ function FlowNode({ node, areasById, onAdd, onEdit, onDelete, level = 0, parentT
|
|||||||
</span>
|
</span>
|
||||||
) : isClose ? (
|
) : isClose ? (
|
||||||
<span style={{ color: 'var(--color-text-soft)', lineHeight: 1.35, whiteSpace: 'pre-wrap' }}>
|
<span style={{ color: 'var(--color-text-soft)', lineHeight: 1.35, whiteSpace: 'pre-wrap' }}>
|
||||||
{isDeep && nodeMessage.length > 96 ? `${nodeMessage.slice(0, 96)}...` : nodeMessage}
|
{node.message_text || 'Fecha o atendimento sem enviar para agente.'}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span style={{ color: 'var(--color-text-soft)', whiteSpace: isDeep ? 'normal' : 'pre-wrap', lineHeight: 1.35 }}>
|
<span style={{ color: 'var(--color-text-soft)', whiteSpace: 'pre-wrap', lineHeight: 1.35 }}>
|
||||||
{isDeep && nodeMessage.length > 96 ? `${nodeMessage.slice(0, 96)}...` : nodeMessage}
|
{node.message_text || 'Sem mensagem configurada.'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isRoot && keywords.length ? (
|
{!isRoot && keywords.length ? (
|
||||||
<div style={{ display: 'grid', gap: '0.35rem' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem' }}>
|
||||||
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.72rem', fontWeight: 900 }}>
|
{keywords.slice(0, 8).map((keyword) => (
|
||||||
Respostas que chegam aqui
|
<span
|
||||||
</span>
|
key={keyword}
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem' }}>
|
style={{
|
||||||
{keywords.slice(0, visibleKeywordLimit).map((keyword) => (
|
borderRadius: 999,
|
||||||
<span
|
background: 'rgba(0,49,80,0.07)',
|
||||||
key={keyword}
|
padding: '0.22rem 0.5rem',
|
||||||
style={{
|
fontSize: '0.75rem',
|
||||||
borderRadius: 999,
|
fontWeight: 800,
|
||||||
background: 'rgba(0,49,80,0.07)',
|
}}
|
||||||
padding: '0.22rem 0.5rem',
|
>
|
||||||
fontSize: '0.75rem',
|
{keyword}
|
||||||
fontWeight: 800,
|
</span>
|
||||||
}}
|
))}
|
||||||
>
|
|
||||||
{keyword}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{keywords.length > visibleKeywordLimit ? (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
borderRadius: 999,
|
|
||||||
background: 'rgba(0,49,80,0.04)',
|
|
||||||
padding: '0.22rem 0.5rem',
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
fontWeight: 800,
|
|
||||||
color: 'var(--color-text-soft)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
+{keywords.length - visibleKeywordLimit}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@ -308,76 +218,27 @@ function FlowNode({ node, areasById, onAdd, onEdit, onDelete, level = 0, parentT
|
|||||||
|
|
||||||
{node.children?.length ? (
|
{node.children?.length ? (
|
||||||
<>
|
<>
|
||||||
<div
|
<div style={{ width: 2, height: 20, background: 'rgba(0,49,80,0.18)' }} />
|
||||||
style={{
|
|
||||||
width: 2,
|
|
||||||
height: 38,
|
|
||||||
background: 'linear-gradient(180deg, rgba(0,49,80,0.28), rgba(0,49,80,0.1))',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
gap: childGap,
|
gap: '1rem',
|
||||||
alignItems: 'start',
|
alignItems: 'start',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
flexWrap: 'nowrap',
|
flexWrap: 'nowrap',
|
||||||
paddingTop: 34,
|
paddingTop: '0.2rem',
|
||||||
position: 'relative',
|
|
||||||
width: '100%',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{node.children.length > 1 ? (
|
{node.children.map((child) => (
|
||||||
<div
|
<FlowNode
|
||||||
aria-hidden="true"
|
key={child.id}
|
||||||
style={{
|
node={child}
|
||||||
position: 'absolute',
|
areasById={areasById}
|
||||||
top: 0,
|
onAdd={onAdd}
|
||||||
left: firstChildWidth / 2,
|
onEdit={onEdit}
|
||||||
right: lastChildWidth / 2,
|
onDelete={onDelete}
|
||||||
height: 2,
|
|
||||||
background: 'rgba(0,49,80,0.16)',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
))}
|
||||||
{node.children.map((child) => {
|
|
||||||
const childWidth = getFlowSubtreeWidth(child, level + 1);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={child.id}
|
|
||||||
style={{
|
|
||||||
position: 'relative',
|
|
||||||
display: 'grid',
|
|
||||||
justifyItems: 'center',
|
|
||||||
minWidth: childWidth,
|
|
||||||
width: childWidth,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: -34,
|
|
||||||
left: '50%',
|
|
||||||
width: 2,
|
|
||||||
height: 34,
|
|
||||||
background: 'rgba(0,49,80,0.2)',
|
|
||||||
transform: 'translateX(-50%)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<FlowNode
|
|
||||||
node={child}
|
|
||||||
areasById={areasById}
|
|
||||||
onAdd={onAdd}
|
|
||||||
onEdit={onEdit}
|
|
||||||
onDelete={onDelete}
|
|
||||||
level={level + 1}
|
|
||||||
parentTitle={node.title}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
@ -689,7 +550,6 @@ export function KnowledgeBasePanel({ areas, mode = 'admin', isMobile = false })
|
|||||||
const root = flow?.tree;
|
const root = flow?.tree;
|
||||||
const hasPublished = Boolean(flow?.latestPublished);
|
const hasPublished = Boolean(flow?.latestPublished);
|
||||||
const publishWarnings = useMemo(() => collectPublishWarnings(root).slice(0, 5), [root]);
|
const publishWarnings = useMemo(() => collectPublishWarnings(root).slice(0, 5), [root]);
|
||||||
const treeMinWidth = useMemo(() => Math.max(1100, getFlowSubtreeWidth(root)), [root]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'grid', gap: '1rem' }}>
|
<div style={{ display: 'grid', gap: '1rem' }}>
|
||||||
@ -764,9 +624,7 @@ export function KnowledgeBasePanel({ areas, mode = 'admin', isMobile = false })
|
|||||||
style={{
|
style={{
|
||||||
border: '1px solid var(--color-border)',
|
border: '1px solid var(--color-border)',
|
||||||
borderRadius: 22,
|
borderRadius: 22,
|
||||||
background:
|
background: 'linear-gradient(180deg, #fff, rgba(0,49,80,0.03))',
|
||||||
'linear-gradient(180deg, #fff, rgba(0,49,80,0.03)), radial-gradient(circle at 1px 1px, rgba(0,49,80,0.08) 1px, transparent 0)',
|
|
||||||
backgroundSize: 'auto, 22px 22px',
|
|
||||||
overflow: 'auto',
|
overflow: 'auto',
|
||||||
minHeight: 520,
|
minHeight: 520,
|
||||||
padding: '1.25rem',
|
padding: '1.25rem',
|
||||||
@ -776,7 +634,7 @@ export function KnowledgeBasePanel({ areas, mode = 'admin', isMobile = false })
|
|||||||
style={{
|
style={{
|
||||||
transform: `scale(${zoom})`,
|
transform: `scale(${zoom})`,
|
||||||
transformOrigin: 'top center',
|
transformOrigin: 'top center',
|
||||||
minWidth: treeMinWidth,
|
minWidth: 900,
|
||||||
minHeight: 480,
|
minHeight: 480,
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
|||||||
@ -24,7 +24,7 @@ const navigationBySection = {
|
|||||||
{ id: 'knowledge', label: 'Fluxo do Bot' },
|
{ id: 'knowledge', label: 'Fluxo do Bot' },
|
||||||
{ id: 'ai-contents', label: 'Conteúdos da IA' },
|
{ id: 'ai-contents', label: 'Conteúdos da IA' },
|
||||||
{ id: 'audit', label: 'Auditoria' },
|
{ id: 'audit', label: 'Auditoria' },
|
||||||
{ id: 'channels', label: 'Canais e Integração' },
|
{ id: 'channels', label: 'Canais' },
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ id: 'attendance', label: 'Atendimento' },
|
{ id: 'attendance', label: 'Atendimento' },
|
||||||
{ id: 'new-attendance', label: 'Abrir Atendimento' },
|
{ id: 'new-attendance', label: 'Abrir Atendimento' },
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { DataPanel } from '../components/DataPanel';
|
import { DataPanel } from '../components/DataPanel';
|
||||||
import { ManagementLayout } from '../components/ManagementLayout';
|
import { ManagementLayout } from '../components/ManagementLayout';
|
||||||
import { ManagementTable } from '../components/ManagementTable';
|
import { ManagementTable } from '../components/ManagementTable';
|
||||||
@ -59,54 +58,6 @@ const initialNotices = [
|
|||||||
{ id: 'n2', text: 'Templates de abertura ativa atualizados para WhatsApp.' },
|
{ id: 'n2', text: 'Templates de abertura ativa atualizados para WhatsApp.' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const integrationCards = [
|
|
||||||
{
|
|
||||||
id: 'whatsapp',
|
|
||||||
group: 'Canal',
|
|
||||||
name: 'WhatsApp',
|
|
||||||
icon: 'WA',
|
|
||||||
color: '#20a45b',
|
|
||||||
configured: true,
|
|
||||||
description: 'Canal principal para atendimento, abertura ativa e continuidade das conversas no chat.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'sms',
|
|
||||||
group: 'Canal',
|
|
||||||
name: 'SMS',
|
|
||||||
icon: 'SM',
|
|
||||||
color: '#00a4b7',
|
|
||||||
configured: false,
|
|
||||||
description: 'Envio de comunicados curtos, confirmações e mensagens transacionais para contatos sem WhatsApp.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'email',
|
|
||||||
group: 'Canal',
|
|
||||||
name: 'Email',
|
|
||||||
icon: 'EM',
|
|
||||||
color: '#d8891c',
|
|
||||||
configured: false,
|
|
||||||
description: 'Recebimento e resposta de demandas por email dentro da fila omnichannel.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'sharepoint',
|
|
||||||
group: 'Integração',
|
|
||||||
name: 'SharePoint',
|
|
||||||
icon: 'SP',
|
|
||||||
color: '#036c70',
|
|
||||||
configured: false,
|
|
||||||
description: 'Permite que a IA visualize documentos autorizados para alimentar e manter a base de conhecimento.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'gupy',
|
|
||||||
group: 'Integração',
|
|
||||||
name: 'Gupy',
|
|
||||||
icon: 'GP',
|
|
||||||
color: '#7b4cc2',
|
|
||||||
configured: false,
|
|
||||||
description: 'Conecta vagas abertas e processos dos candidatos para enriquecer a base de conhecimento.',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function formatMinutes(minutes) {
|
function formatMinutes(minutes) {
|
||||||
if (minutes === null || minutes === undefined || Number.isNaN(Number(minutes))) return 'Sem dados';
|
if (minutes === null || minutes === undefined || Number.isNaN(Number(minutes))) return 'Sem dados';
|
||||||
return `${Number(minutes)} min`;
|
return `${Number(minutes)} min`;
|
||||||
@ -186,7 +137,6 @@ export function AdminAttendanceWorkspace({ isWideDesktop, isDesktop, isTablet, i
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AdminPage() {
|
export function AdminPage() {
|
||||||
const navigate = useNavigate();
|
|
||||||
const { isWideDesktop, isDesktop, isTablet, isMobile } = useViewport();
|
const { isWideDesktop, isDesktop, isTablet, isMobile } = useViewport();
|
||||||
const userDisplay = getCurrentUserDisplay();
|
const userDisplay = getCurrentUserDisplay();
|
||||||
const [activeAdminSection, setActiveAdminSection] = useState('home');
|
const [activeAdminSection, setActiveAdminSection] = useState('home');
|
||||||
@ -213,15 +163,6 @@ export function AdminPage() {
|
|||||||
const [editUserProfileId, setEditUserProfileId] = useState('');
|
const [editUserProfileId, setEditUserProfileId] = useState('');
|
||||||
const [editUserSpecialties, setEditUserSpecialties] = useState([]);
|
const [editUserSpecialties, setEditUserSpecialties] = useState([]);
|
||||||
const [specialtyToAdd, setSpecialtyToAdd] = useState('');
|
const [specialtyToAdd, setSpecialtyToAdd] = useState('');
|
||||||
const [integrationStates, setIntegrationStates] = useState({
|
|
||||||
whatsapp: true,
|
|
||||||
sms: false,
|
|
||||||
email: false,
|
|
||||||
sharepoint: false,
|
|
||||||
gupy: false,
|
|
||||||
});
|
|
||||||
const [integrationNotice, setIntegrationNotice] = useState('');
|
|
||||||
const [configurationModal, setConfigurationModal] = useState(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
@ -1275,299 +1216,6 @@ export function AdminPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderChannelsIntegrations() {
|
|
||||||
const activeCount = integrationCards.filter((item) => integrationStates[item.id]).length;
|
|
||||||
const channelCount = integrationCards.filter((item) => item.group === 'Canal' && integrationStates[item.id]).length;
|
|
||||||
const integrationCount = integrationCards.filter((item) => item.group === 'Integração' && integrationStates[item.id]).length;
|
|
||||||
|
|
||||||
function toggleIntegration(item) {
|
|
||||||
const isEnabled = Boolean(integrationStates[item.id]);
|
|
||||||
|
|
||||||
if (!isEnabled && !item.configured) {
|
|
||||||
setIntegrationNotice(`${item.name} ainda não pode ser habilitado porque precisa ser configurado primeiro.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIntegrationNotice('');
|
|
||||||
setIntegrationStates((current) => ({
|
|
||||||
...current,
|
|
||||||
[item.id]: !current[item.id],
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section style={{ display: 'grid', gap: '1rem' }}>
|
|
||||||
<DataPanel
|
|
||||||
title="Canais e Integrações"
|
|
||||||
description="Controle quais canais ficam disponíveis e quais integrações alimentam a operação e a IA."
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: isMobile ? '1fr' : 'repeat(3, minmax(0, 1fr))',
|
|
||||||
gap: '0.8rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{[
|
|
||||||
{ label: 'Ativos', value: activeCount },
|
|
||||||
{ label: 'Canais habilitados', value: channelCount },
|
|
||||||
{ label: 'Integrações habilitadas', value: integrationCount },
|
|
||||||
].map((item) => (
|
|
||||||
<div
|
|
||||||
key={item.label}
|
|
||||||
style={{
|
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
borderRadius: 18,
|
|
||||||
padding: '1rem',
|
|
||||||
background: '#f8fbfc',
|
|
||||||
display: 'grid',
|
|
||||||
gap: '0.25rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ color: 'var(--color-text-soft)', fontWeight: 700 }}>{item.label}</span>
|
|
||||||
<strong style={{ fontSize: '1.55rem' }}>{item.value}</strong>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{integrationNotice ? (
|
|
||||||
<div
|
|
||||||
role="alert"
|
|
||||||
style={{
|
|
||||||
border: '1px solid rgba(216, 137, 28, 0.32)',
|
|
||||||
borderRadius: 16,
|
|
||||||
padding: '0.85rem 1rem',
|
|
||||||
background: 'rgba(216, 137, 28, 0.08)',
|
|
||||||
color: '#7a4a08',
|
|
||||||
fontWeight: 800,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{integrationNotice}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</DataPanel>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: isMobile ? '1fr' : isTablet ? 'repeat(2, minmax(0, 1fr))' : 'repeat(3, minmax(0, 1fr))',
|
|
||||||
gap: '1rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{integrationCards.map((item) => {
|
|
||||||
const isEnabled = Boolean(integrationStates[item.id]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<article
|
|
||||||
key={item.id}
|
|
||||||
style={{
|
|
||||||
border: `1px solid ${isEnabled ? `${item.color}55` : 'var(--color-border)'}`,
|
|
||||||
borderRadius: 22,
|
|
||||||
padding: '1.1rem',
|
|
||||||
background: isEnabled ? `${item.color}0f` : '#fff',
|
|
||||||
display: 'grid',
|
|
||||||
gap: '1rem',
|
|
||||||
minHeight: 260,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '0.85rem' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.8rem', minWidth: 0 }}>
|
|
||||||
<div
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{
|
|
||||||
width: 52,
|
|
||||||
height: 52,
|
|
||||||
borderRadius: 16,
|
|
||||||
display: 'grid',
|
|
||||||
placeItems: 'center',
|
|
||||||
background: item.color,
|
|
||||||
color: '#fff',
|
|
||||||
fontWeight: 900,
|
|
||||||
letterSpacing: 0,
|
|
||||||
boxShadow: `0 12px 22px ${item.color}24`,
|
|
||||||
flex: '0 0 auto',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.icon}
|
|
||||||
</div>
|
|
||||||
<div style={{ minWidth: 0 }}>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
display: 'inline-flex',
|
|
||||||
borderRadius: 999,
|
|
||||||
padding: '0.18rem 0.55rem',
|
|
||||||
background: `${item.color}18`,
|
|
||||||
color: item.color,
|
|
||||||
fontSize: '0.78rem',
|
|
||||||
fontWeight: 800,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.group}
|
|
||||||
</span>
|
|
||||||
<strong style={{ display: 'block', marginTop: '0.35rem', fontSize: '1.15rem' }}>{item.name}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-pressed={isEnabled}
|
|
||||||
onClick={() => toggleIntegration(item)}
|
|
||||||
style={{
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: 999,
|
|
||||||
width: 54,
|
|
||||||
height: 30,
|
|
||||||
padding: 3,
|
|
||||||
background: isEnabled ? item.color : '#d6e0e5',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: isEnabled ? 'flex-end' : 'flex-start',
|
|
||||||
cursor: 'pointer',
|
|
||||||
flex: '0 0 auto',
|
|
||||||
}}
|
|
||||||
title={isEnabled ? `Desabilitar ${item.name}` : `Habilitar ${item.name}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{
|
|
||||||
width: 24,
|
|
||||||
height: 24,
|
|
||||||
borderRadius: '50%',
|
|
||||||
background: '#fff',
|
|
||||||
boxShadow: '0 3px 8px rgba(0, 0, 0, 0.18)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p style={{ margin: 0, color: 'var(--color-text-soft)', lineHeight: 1.5, fontWeight: 650 }}>
|
|
||||||
{item.description}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: 'auto',
|
|
||||||
borderTop: '1px solid var(--color-border)',
|
|
||||||
paddingTop: '0.85rem',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
gap: '0.75rem',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ color: isEnabled ? item.color : 'var(--color-text-soft)', fontWeight: 800 }}>
|
|
||||||
{isEnabled ? 'Habilitado' : item.configured ? 'Desabilitado' : 'Pendente de configuração'}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
if (item.id === 'whatsapp') {
|
|
||||||
navigate('/admin/whatsapp');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setConfigurationModal(item);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
border: `1px solid ${item.color}44`,
|
|
||||||
borderRadius: 14,
|
|
||||||
padding: '0.65rem 0.8rem',
|
|
||||||
background: '#fff',
|
|
||||||
color: item.color,
|
|
||||||
fontWeight: 800,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Configurar
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{configurationModal ? (
|
|
||||||
<div
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="integration-config-title"
|
|
||||||
style={{
|
|
||||||
position: 'fixed',
|
|
||||||
inset: 0,
|
|
||||||
background: 'rgba(0, 20, 32, 0.42)',
|
|
||||||
display: 'grid',
|
|
||||||
placeItems: 'center',
|
|
||||||
padding: '1rem',
|
|
||||||
zIndex: 50,
|
|
||||||
}}
|
|
||||||
onClick={() => setConfigurationModal(null)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
onClick={(event) => event.stopPropagation()}
|
|
||||||
style={{
|
|
||||||
width: 'min(460px, 100%)',
|
|
||||||
borderRadius: 22,
|
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
background: '#fff',
|
|
||||||
padding: '1.25rem',
|
|
||||||
display: 'grid',
|
|
||||||
gap: '0.9rem',
|
|
||||||
boxShadow: 'var(--shadow-lg)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.8rem' }}>
|
|
||||||
<div
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{
|
|
||||||
width: 44,
|
|
||||||
height: 44,
|
|
||||||
borderRadius: 14,
|
|
||||||
display: 'grid',
|
|
||||||
placeItems: 'center',
|
|
||||||
background: configurationModal.color,
|
|
||||||
color: '#fff',
|
|
||||||
fontWeight: 900,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{configurationModal.icon}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<strong id="integration-config-title" style={{ display: 'block', fontSize: '1.08rem' }}>
|
|
||||||
Configurar {configurationModal.name}
|
|
||||||
</strong>
|
|
||||||
<span style={{ color: 'var(--color-text-soft)', fontWeight: 700 }}>
|
|
||||||
{configurationModal.group}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p style={{ margin: 0, color: 'var(--color-text-soft)', lineHeight: 1.5, fontWeight: 700 }}>
|
|
||||||
Esta configuração ainda está em construção. Assim que a integração estiver disponível, este espaço vai reunir credenciais, permissões e parâmetros de sincronização.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setConfigurationModal(null)}
|
|
||||||
style={{
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: 14,
|
|
||||||
padding: '0.75rem 0.95rem',
|
|
||||||
background: 'var(--color-primary)',
|
|
||||||
color: '#fff',
|
|
||||||
fontWeight: 800,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Entendi
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderPlaceholder(title, description) {
|
function renderPlaceholder(title, description) {
|
||||||
return (
|
return (
|
||||||
<DataPanel title={title} description={description}>
|
<DataPanel title={title} description={description}>
|
||||||
@ -1586,7 +1234,7 @@ export function AdminPage() {
|
|||||||
knowledge: <KnowledgeBasePanel areas={areas} mode="admin" isMobile={isMobile} />,
|
knowledge: <KnowledgeBasePanel areas={areas} mode="admin" isMobile={isMobile} />,
|
||||||
'ai-contents': renderAiContents(),
|
'ai-contents': renderAiContents(),
|
||||||
audit: renderAudit(),
|
audit: renderAudit(),
|
||||||
channels: renderChannelsIntegrations(),
|
channels: renderPlaceholder('Canais', 'Status e configurações dos canais conectados.'),
|
||||||
attendance: (
|
attendance: (
|
||||||
<AdminAttendanceWorkspace
|
<AdminAttendanceWorkspace
|
||||||
isWideDesktop={isWideDesktop}
|
isWideDesktop={isWideDesktop}
|
||||||
@ -1611,8 +1259,6 @@ export function AdminPage() {
|
|||||||
? 'Operação'
|
? 'Operação'
|
||||||
: activeAdminSection === 'audit'
|
: activeAdminSection === 'audit'
|
||||||
? 'Auditoria'
|
? 'Auditoria'
|
||||||
: activeAdminSection === 'channels'
|
|
||||||
? 'Canais e Integração'
|
|
||||||
: activeAdminSection === 'ai-contents'
|
: activeAdminSection === 'ai-contents'
|
||||||
? 'Conteúdos da IA'
|
? 'Conteúdos da IA'
|
||||||
: activeAdminSection === 'knowledge'
|
: activeAdminSection === 'knowledge'
|
||||||
@ -1629,8 +1275,6 @@ export function AdminPage() {
|
|||||||
? 'Indicadores do dia, fila de espera e acompanhamento operacional do time.'
|
? 'Indicadores do dia, fila de espera e acompanhamento operacional do time.'
|
||||||
: activeAdminSection === 'audit'
|
: activeAdminSection === 'audit'
|
||||||
? 'Logs administrativos e operacionais com paginação de 100 eventos.'
|
? 'Logs administrativos e operacionais com paginação de 100 eventos.'
|
||||||
: activeAdminSection === 'channels'
|
|
||||||
? 'Canais de atendimento e integrações que alimentam a operação e a IA.'
|
|
||||||
: activeAdminSection === 'ai-contents'
|
: activeAdminSection === 'ai-contents'
|
||||||
? 'Base de documentos que será consultada pela IA em fase de testes.'
|
? 'Base de documentos que será consultada pela IA em fase de testes.'
|
||||||
: activeAdminSection === 'knowledge'
|
: activeAdminSection === 'knowledge'
|
||||||
|
|||||||
@ -1,8 +1,6 @@
|
|||||||
import { createBrowserRouter, Navigate } from 'react-router-dom';
|
import { createBrowserRouter, Navigate } from 'react-router-dom';
|
||||||
import { LoginPage } from '../modules/auth/pages/LoginPage';
|
import { LoginPage } from '../modules/auth/pages/LoginPage';
|
||||||
import { ProfileHomePage } from '../modules/home/pages/ProfileHomePage';
|
import { ProfileHomePage } from '../modules/home/pages/ProfileHomePage';
|
||||||
import { AgentMassMessagePage } from '../modules/home/pages/AgentMassMessagePage';
|
|
||||||
import { ContactsPage } from '../modules/home/pages/ContactsPage';
|
|
||||||
import { ChatPage } from '../modules/chat/pages/ChatPage';
|
import { ChatPage } from '../modules/chat/pages/ChatPage';
|
||||||
import { CallPage } from '../modules/call/pages/CallPage';
|
import { CallPage } from '../modules/call/pages/CallPage';
|
||||||
import { NewAttendancePage } from '../modules/attendance/pages/NewAttendancePage';
|
import { NewAttendancePage } from '../modules/attendance/pages/NewAttendancePage';
|
||||||
@ -33,14 +31,6 @@ export const router = createBrowserRouter([
|
|||||||
path: '/new-attendance',
|
path: '/new-attendance',
|
||||||
element: <NewAttendancePage />,
|
element: <NewAttendancePage />,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/mass-message',
|
|
||||||
element: <AgentMassMessagePage />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/contacts',
|
|
||||||
element: <ContactsPage />,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '/admin/whatsapp',
|
path: '/admin/whatsapp',
|
||||||
element: <WhatsappAdminPage />,
|
element: <WhatsappAdminPage />,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user