FEAT: Adicionado estilização com nome do agente, configuração de fila, e responsividade
This commit is contained in:
parent
7dc07c2a80
commit
217d566057
@ -46,6 +46,32 @@ function PresenceDot({ status }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function UnreadBadge({ count }) {
|
||||||
|
if (!count) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 26,
|
||||||
|
height: 26,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'var(--color-secondary)',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: '0.78rem',
|
||||||
|
fontWeight: 800,
|
||||||
|
display: 'inline-grid',
|
||||||
|
placeItems: 'center',
|
||||||
|
lineHeight: 1,
|
||||||
|
flex: '0 0 auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{count > 99 ? '99+' : count}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHAT_LIST_HEIGHT = 'min(640px, calc(100vh - 220px))';
|
||||||
|
|
||||||
export function ChatConversationList({
|
export function ChatConversationList({
|
||||||
contacts,
|
contacts,
|
||||||
activeContactId,
|
activeContactId,
|
||||||
@ -62,7 +88,9 @@ export function ChatConversationList({
|
|||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateRows: 'auto minmax(0, 1fr)',
|
gridTemplateRows: 'auto minmax(0, 1fr)',
|
||||||
gap: '0.85rem',
|
gap: '0.85rem',
|
||||||
height: isMobile ? 'auto' : 'min(760px, calc(100vh - 190px))',
|
height: isMobile ? 'auto' : CHAT_LIST_HEIGHT,
|
||||||
|
maxHeight: isMobile ? 'none' : CHAT_LIST_HEIGHT,
|
||||||
|
alignSelf: 'start',
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -78,6 +106,8 @@ export function ChatConversationList({
|
|||||||
display: 'grid',
|
display: 'grid',
|
||||||
gap: '0.75rem',
|
gap: '0.75rem',
|
||||||
gridTemplateColumns: isMobile ? '1fr' : '1fr',
|
gridTemplateColumns: isMobile ? '1fr' : '1fr',
|
||||||
|
gridAutoRows: 'max-content',
|
||||||
|
alignContent: 'start',
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
paddingRight: '0.15rem',
|
paddingRight: '0.15rem',
|
||||||
@ -115,27 +145,28 @@ export function ChatConversationList({
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.75rem' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.75rem' }}>
|
||||||
<ChannelBadge channel={contact.channel} />
|
<ChannelBadge channel={contact.channel} />
|
||||||
{contact.unread ? (
|
<UnreadBadge count={contact.unread} />
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
minWidth: 24,
|
|
||||||
borderRadius: 999,
|
|
||||||
padding: '0.15rem 0.45rem',
|
|
||||||
background: 'var(--color-secondary)',
|
|
||||||
color: '#fff',
|
|
||||||
fontSize: '0.78rem',
|
|
||||||
fontWeight: 700,
|
|
||||||
textAlign: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{contact.unread}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
<span style={{ color: 'var(--color-text-soft)' }}>{contact.preview}</span>
|
<span style={{ color: 'var(--color-text-soft)' }}>{contact.preview}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{contacts.length === 0 ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: '18px',
|
||||||
|
padding: '1rem',
|
||||||
|
background: 'rgba(0, 49, 80, 0.04)',
|
||||||
|
color: 'var(--color-text-soft)',
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: 1.45,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Nenhuma conversa ativa na sua fila. Conversas em triagem do Omnino aparecem aqui depois de classificadas.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -4,6 +4,7 @@ export function ChatTransferPanel({
|
|||||||
setTransferArea,
|
setTransferArea,
|
||||||
transferAreas,
|
transferAreas,
|
||||||
attendants,
|
attendants,
|
||||||
|
isSameUserArea = true,
|
||||||
transferAttendant,
|
transferAttendant,
|
||||||
setTransferAttendant,
|
setTransferAttendant,
|
||||||
transferNote,
|
transferNote,
|
||||||
@ -69,17 +70,30 @@ export function ChatTransferPanel({
|
|||||||
|
|
||||||
<label style={{ display: 'grid', gap: '0.45rem' }}>
|
<label style={{ display: 'grid', gap: '0.45rem' }}>
|
||||||
<span style={{ fontWeight: 600 }}>Atendente</span>
|
<span style={{ fontWeight: 600 }}>Atendente</span>
|
||||||
<select
|
{isSameUserArea ? (
|
||||||
value={transferAttendant}
|
<select
|
||||||
onChange={(event) => setTransferAttendant(event.target.value)}
|
value={transferAttendant}
|
||||||
style={fieldStyle}
|
onChange={(event) => setTransferAttendant(event.target.value)}
|
||||||
>
|
style={fieldStyle}
|
||||||
{attendants.map((attendant) => (
|
>
|
||||||
<option key={attendant} value={attendant}>
|
{attendants.map((attendant) => (
|
||||||
{attendant}
|
<option key={attendant.id} value={attendant.id}>
|
||||||
</option>
|
{attendant.nome}
|
||||||
))}
|
</option>
|
||||||
</select>
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...fieldStyle,
|
||||||
|
color: 'var(--color-text-soft)',
|
||||||
|
fontWeight: 700,
|
||||||
|
background: 'rgba(0, 49, 80, 0.04)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Ao transferir para outra area, a conversa caira na fila dessa area.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label style={{ display: 'grid', gap: '0.45rem' }}>
|
<label style={{ display: 'grid', gap: '0.45rem' }}>
|
||||||
|
|||||||
@ -5,6 +5,23 @@ function getMediaUrl(media) {
|
|||||||
return `data:${media.mimetype};base64,${media.data}`;
|
return `data:${media.mimetype};base64,${media.data}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseMessageText(text) {
|
||||||
|
const rawText = String(text || '');
|
||||||
|
const match = rawText.match(/^\*(Atendente(?: virtual)?:\s*[^*]+)\*\s*\n+/i);
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
return {
|
||||||
|
senderLabel: null,
|
||||||
|
body: rawText,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
senderLabel: match[1],
|
||||||
|
body: rawText.slice(match[0].length),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function MediaRenderer({ message, contactId, onLoadMedia, isAgent }) {
|
function MediaRenderer({ message, contactId, onLoadMedia, isAgent }) {
|
||||||
const mediaUrl = useMemo(() => getMediaUrl(message.media), [message.media]);
|
const mediaUrl = useMemo(() => getMediaUrl(message.media), [message.media]);
|
||||||
const mimetype = message.media?.mimetype || '';
|
const mimetype = message.media?.mimetype || '';
|
||||||
@ -180,6 +197,10 @@ function AttachmentPreview({ file, onRemove }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ContactPresence({ contact }) {
|
function ContactPresence({ contact }) {
|
||||||
|
if (!contact) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const status = contact.status || 'offline';
|
const status = contact.status || 'offline';
|
||||||
const color =
|
const color =
|
||||||
status === 'online'
|
status === 'online'
|
||||||
@ -226,10 +247,21 @@ export function ChatWindow({
|
|||||||
onLoadMedia,
|
onLoadMedia,
|
||||||
onSend,
|
onSend,
|
||||||
onToggleTransfer,
|
onToggleTransfer,
|
||||||
|
onAssumeChat,
|
||||||
|
onReleaseChat,
|
||||||
|
canAssumeChat = false,
|
||||||
|
canReply = true,
|
||||||
|
assignmentLabel,
|
||||||
isReplying,
|
isReplying,
|
||||||
isMobile = false,
|
isMobile = false,
|
||||||
}) {
|
}) {
|
||||||
const messagesRef = useRef(null);
|
const messagesRef = useRef(null);
|
||||||
|
const safeContact = contact || {
|
||||||
|
id: '',
|
||||||
|
name: 'Nenhuma conversa ativa',
|
||||||
|
status: 'offline',
|
||||||
|
lastSeen: 'Aguardando fila do Omnino',
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = messagesRef.current;
|
const container = messagesRef.current;
|
||||||
@ -268,8 +300,8 @@ export function ChatWindow({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<strong style={{ display: 'block', fontSize: '1.15rem' }}>{contact.name}</strong>
|
<strong style={{ display: 'block', fontSize: '1.15rem' }}>{safeContact.name}</strong>
|
||||||
<ContactPresence contact={contact} />
|
<ContactPresence contact={safeContact} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@ -283,6 +315,7 @@ export function ChatWindow({
|
|||||||
<select
|
<select
|
||||||
value={selectedArea}
|
value={selectedArea}
|
||||||
onChange={(event) => setSelectedArea(event.target.value)}
|
onChange={(event) => setSelectedArea(event.target.value)}
|
||||||
|
disabled
|
||||||
style={{
|
style={{
|
||||||
border: '1px solid var(--color-border)',
|
border: '1px solid var(--color-border)',
|
||||||
borderRadius: '14px',
|
borderRadius: '14px',
|
||||||
@ -291,13 +324,47 @@ export function ChatWindow({
|
|||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<option>{selectedArea}</option>
|
||||||
<option>Suporte</option>
|
<option>Suporte</option>
|
||||||
<option>Financeiro</option>
|
<option>Financeiro</option>
|
||||||
<option>Comercial</option>
|
<option>Comercial</option>
|
||||||
</select>
|
</select>
|
||||||
|
{canAssumeChat ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onAssumeChat}
|
||||||
|
style={{
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '14px',
|
||||||
|
padding: '0.8rem 1rem',
|
||||||
|
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
|
||||||
|
color: '#fff',
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Assumir atendimento
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{canReply ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onReleaseChat}
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: '14px',
|
||||||
|
padding: '0.8rem 1rem',
|
||||||
|
background: '#fff',
|
||||||
|
color: 'var(--color-primary)',
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sair do atendimento
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onToggleTransfer}
|
onClick={onToggleTransfer}
|
||||||
|
disabled={!canReply}
|
||||||
style={{
|
style={{
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '14px',
|
borderRadius: '14px',
|
||||||
@ -305,6 +372,7 @@ export function ChatWindow({
|
|||||||
background: 'rgba(0, 49, 80, 0.08)',
|
background: 'rgba(0, 49, 80, 0.08)',
|
||||||
color: 'var(--color-primary)',
|
color: 'var(--color-primary)',
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
|
opacity: canReply ? 1 : 0.55,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Transferir
|
Transferir
|
||||||
@ -328,6 +396,7 @@ export function ChatWindow({
|
|||||||
{messages.map((message) => {
|
{messages.map((message) => {
|
||||||
const isAgent = message.sender === 'agent';
|
const isAgent = message.sender === 'agent';
|
||||||
const isSystem = message.sender === 'system';
|
const isSystem = message.sender === 'system';
|
||||||
|
const parsedText = parseMessageText(message.text);
|
||||||
|
|
||||||
if (isSystem) {
|
if (isSystem) {
|
||||||
return (
|
return (
|
||||||
@ -365,11 +434,36 @@ export function ChatWindow({
|
|||||||
>
|
>
|
||||||
<MediaRenderer
|
<MediaRenderer
|
||||||
message={message}
|
message={message}
|
||||||
contactId={contact.id}
|
contactId={safeContact.id}
|
||||||
onLoadMedia={onLoadMedia}
|
onLoadMedia={onLoadMedia}
|
||||||
isAgent={isAgent}
|
isAgent={isAgent}
|
||||||
/>
|
/>
|
||||||
{message.text ? <span>{message.text}</span> : null}
|
{parsedText.senderLabel ? (
|
||||||
|
<strong
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
fontSize: '0.78rem',
|
||||||
|
lineHeight: 1.2,
|
||||||
|
letterSpacing: '0.02em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: isAgent ? 'rgba(255,255,255,0.78)' : 'var(--color-primary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{parsedText.senderLabel}
|
||||||
|
</strong>
|
||||||
|
) : null}
|
||||||
|
{parsedText.body ? (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
lineHeight: 1.45,
|
||||||
|
overflowWrap: 'anywhere',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{parsedText.body}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -415,6 +509,22 @@ export function ChatWindow({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AttachmentPreview file={attachedFile} onRemove={onRemoveAttachedFile} />
|
<AttachmentPreview file={attachedFile} onRemove={onRemoveAttachedFile} />
|
||||||
|
{!canReply ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: '0.8rem 1rem',
|
||||||
|
background: 'rgba(0, 49, 80, 0.04)',
|
||||||
|
color: 'var(--color-text-soft)',
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{canAssumeChat
|
||||||
|
? 'Este atendimento esta na fila. Assuma para responder, ou envie uma mensagem para assumir automaticamente.'
|
||||||
|
: assignmentLabel || 'Este atendimento esta atribuido a outro usuario.'}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
@ -446,6 +556,7 @@ export function ChatWindow({
|
|||||||
event.target.value = '';
|
event.target.value = '';
|
||||||
}}
|
}}
|
||||||
style={{ display: 'none' }}
|
style={{ display: 'none' }}
|
||||||
|
disabled={!safeContact.id}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -457,7 +568,14 @@ export function ChatWindow({
|
|||||||
onSend();
|
onSend();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="Escreva sua mensagem..."
|
disabled={!safeContact.id || (!canReply && !canAssumeChat)}
|
||||||
|
placeholder={
|
||||||
|
!safeContact.id
|
||||||
|
? 'Aguardando conversa entrar em uma fila'
|
||||||
|
: canReply || canAssumeChat
|
||||||
|
? 'Escreva sua mensagem...'
|
||||||
|
: 'Atendimento bloqueado para resposta'
|
||||||
|
}
|
||||||
style={{
|
style={{
|
||||||
border: '1px solid var(--color-border)',
|
border: '1px solid var(--color-border)',
|
||||||
borderRadius: '18px',
|
borderRadius: '18px',
|
||||||
@ -465,11 +583,13 @@ export function ChatWindow({
|
|||||||
background: '#fff',
|
background: '#fff',
|
||||||
outline: 'none',
|
outline: 'none',
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
|
opacity: safeContact.id && (canReply || canAssumeChat) ? 1 : 0.6,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onSend}
|
onClick={onSend}
|
||||||
|
disabled={!safeContact.id || (!canReply && !canAssumeChat)}
|
||||||
style={{
|
style={{
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '18px',
|
borderRadius: '18px',
|
||||||
@ -478,6 +598,7 @@ export function ChatWindow({
|
|||||||
color: '#fff',
|
color: '#fff',
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
gridColumn: isMobile ? '1 / -1' : 'auto',
|
gridColumn: isMobile ? '1 / -1' : 'auto',
|
||||||
|
opacity: safeContact.id && (canReply || canAssumeChat) ? 1 : 0.6,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Enviar
|
Enviar
|
||||||
|
|||||||
@ -1,11 +1,9 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useWhatsappSocket } from '../../../shared/hooks/useWhatsappSocket';
|
import { useWhatsappSocket } from '../../../shared/hooks/useWhatsappSocket';
|
||||||
import { API_BASE_URL } from '../../../shared/services/apiConfig';
|
import { API_BASE_URL } from '../../../shared/services/apiConfig';
|
||||||
import {
|
import { getAccessOptions, getAccessUsers } from '../../management/services/adminAccessService';
|
||||||
attendantsByArea,
|
import { getCurrentUser } from '../../auth/services/sessionService';
|
||||||
chatContacts,
|
import { chatContacts, transferAreas as fallbackTransferAreas } from '../services/chatMocks';
|
||||||
transferAreas,
|
|
||||||
} from '../services/chatMocks';
|
|
||||||
|
|
||||||
function buildInitialMessages() {
|
function buildInitialMessages() {
|
||||||
return chatContacts.reduce((acc, contact) => {
|
return chatContacts.reduce((acc, contact) => {
|
||||||
@ -42,13 +40,15 @@ function normalizeChat(chat) {
|
|||||||
const id = getSerializedId(chat.id);
|
const id = getSerializedId(chat.id);
|
||||||
const lastActivitySeconds = chat.timestamp ? Math.floor(Date.now() / 1000) - chat.timestamp : null;
|
const lastActivitySeconds = chat.timestamp ? Math.floor(Date.now() / 1000) - chat.timestamp : null;
|
||||||
const isRecentlyActive = lastActivitySeconds !== null && lastActivitySeconds < 300;
|
const isRecentlyActive = lastActivitySeconds !== null && lastActivitySeconds < 300;
|
||||||
|
const assignment = chat.assignment || null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
name: getContactName(chat),
|
name: getContactName(chat),
|
||||||
channel: 'WhatsApp',
|
channel: 'WhatsApp',
|
||||||
status: isRecentlyActive ? 'online' : 'away',
|
status: isRecentlyActive ? 'online' : 'away',
|
||||||
area: chat.assignment?.area_id ? String(chat.assignment.area_id) : 'Suporte',
|
area: assignment?.area_nome || (assignment?.area_id ? String(assignment.area_id) : 'Sem fila'),
|
||||||
|
areaId: assignment?.area_id || null,
|
||||||
lastSeen: isRecentlyActive
|
lastSeen: isRecentlyActive
|
||||||
? 'Online agora'
|
? 'Online agora'
|
||||||
: chat.timestamp
|
: chat.timestamp
|
||||||
@ -57,7 +57,7 @@ function normalizeChat(chat) {
|
|||||||
preview: chat.preview || chat.lastMessage?.body || '',
|
preview: chat.preview || chat.lastMessage?.body || '',
|
||||||
time: formatTime(chat.timestamp) || 'Agora',
|
time: formatTime(chat.timestamp) || 'Agora',
|
||||||
unread: chat.unreadCount || 0,
|
unread: chat.unreadCount || 0,
|
||||||
assignment: chat.assignment || null,
|
assignment,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -77,6 +77,53 @@ function normalizeMessage(message) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getComparableMessageTime(message) {
|
||||||
|
if (message.timestamp) return Number(message.timestamp);
|
||||||
|
if (typeof message.id === 'string' && message.id.startsWith('temp-')) {
|
||||||
|
return Math.floor(Number(message.id.replace('temp-', '')) / 1000);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripSenderHeader(text) {
|
||||||
|
return String(text || '')
|
||||||
|
.replace(/^\*(Atendente(?: virtual)?:\s*[^*]+)\*\s*\n+/i, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function areLikelySameMessage(currentMessage, nextMessage) {
|
||||||
|
if (!currentMessage || !nextMessage) return false;
|
||||||
|
if (currentMessage.id && nextMessage.id && currentMessage.id === nextMessage.id) return true;
|
||||||
|
if (currentMessage.chatId && nextMessage.chatId && currentMessage.chatId !== nextMessage.chatId) return false;
|
||||||
|
if (currentMessage.sender !== nextMessage.sender) return false;
|
||||||
|
if (stripSenderHeader(currentMessage.text) !== stripSenderHeader(nextMessage.text)) return false;
|
||||||
|
if (Boolean(currentMessage.hasMedia) !== Boolean(nextMessage.hasMedia)) return false;
|
||||||
|
|
||||||
|
const currentTime = getComparableMessageTime(currentMessage);
|
||||||
|
const nextTime = getComparableMessageTime(nextMessage);
|
||||||
|
if (!currentTime || !nextTime) return false;
|
||||||
|
|
||||||
|
return Math.abs(currentTime - nextTime) <= 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeMessageList(currentMessages, nextMessage) {
|
||||||
|
const exactIndex = currentMessages.findIndex((message) => message.id === nextMessage.id);
|
||||||
|
if (exactIndex >= 0) {
|
||||||
|
return currentMessages.map((message, index) => (index === exactIndex ? { ...message, ...nextMessage } : message));
|
||||||
|
}
|
||||||
|
|
||||||
|
const likelyIndex = currentMessages.findIndex((message) => areLikelySameMessage(message, nextMessage));
|
||||||
|
if (likelyIndex >= 0) {
|
||||||
|
return currentMessages.map((message, index) => (index === likelyIndex ? { ...message, ...nextMessage } : message));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...currentMessages, nextMessage];
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeMessages(messages) {
|
||||||
|
return messages.reduce((acc, message) => mergeMessageList(acc, message), []);
|
||||||
|
}
|
||||||
|
|
||||||
function fileToBase64(file) {
|
function fileToBase64(file) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
@ -90,20 +137,43 @@ function fileToBase64(file) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildFallbackContacts() {
|
function buildFallbackContacts() {
|
||||||
return chatContacts.map((contact) => ({ ...contact }));
|
return chatContacts.map((contact) => ({ ...contact, assignment: null, areaId: null }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserId(user) {
|
||||||
|
const value = user?.databaseId || user?.id;
|
||||||
|
const numeric = Number(value);
|
||||||
|
return Number.isFinite(numeric) ? numeric : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserAreas(user) {
|
||||||
|
const areas = Array.isArray(user?.areas) ? user.areas : [];
|
||||||
|
if (user?.areaPrincipal && !areas.includes(user.areaPrincipal)) {
|
||||||
|
return [user.areaPrincipal, ...areas];
|
||||||
|
}
|
||||||
|
return areas;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserDisplayName(user) {
|
||||||
|
return user?.name || user?.nome || user?.username || user?.email || 'Atendente';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useChat() {
|
export function useChat() {
|
||||||
const { incomingMessage, clearIncomingMessage } = useWhatsappSocket();
|
const currentUser = getCurrentUser();
|
||||||
|
const currentUserId = getUserId(currentUser);
|
||||||
|
const currentUserAreas = getUserAreas(currentUser);
|
||||||
|
const { status: whatsappStatus, incomingMessage, clearIncomingMessage } = useWhatsappSocket();
|
||||||
const [contacts, setContacts] = useState(buildFallbackContacts);
|
const [contacts, setContacts] = useState(buildFallbackContacts);
|
||||||
const [activeContactId, setActiveContactId] = useState(chatContacts[0].id);
|
const [activeContactId, setActiveContactId] = useState(chatContacts[0].id);
|
||||||
const [messagesByContact, setMessagesByContact] = useState(buildInitialMessages);
|
const [messagesByContact, setMessagesByContact] = useState(buildInitialMessages);
|
||||||
const [draft, setDraft] = useState('');
|
const [draft, setDraft] = useState('');
|
||||||
const [attachedFile, setAttachedFile] = useState(null);
|
const [attachedFile, setAttachedFile] = useState(null);
|
||||||
|
const [areaOptions, setAreaOptions] = useState([]);
|
||||||
|
const [accessUsers, setAccessUsers] = useState([]);
|
||||||
const [selectedArea, setSelectedArea] = useState(chatContacts[0].area);
|
const [selectedArea, setSelectedArea] = useState(chatContacts[0].area);
|
||||||
const [isTransferOpen, setIsTransferOpen] = useState(false);
|
const [isTransferOpen, setIsTransferOpen] = useState(false);
|
||||||
const [transferArea, setTransferArea] = useState('Suporte');
|
const [transferArea, setTransferArea] = useState(currentUser?.areaPrincipal || 'Suporte');
|
||||||
const [transferAttendant, setTransferAttendant] = useState(attendantsByArea.Suporte[0]);
|
const [transferAttendant, setTransferAttendant] = useState('');
|
||||||
const [transferNote, setTransferNote] = useState('');
|
const [transferNote, setTransferNote] = useState('');
|
||||||
const [isReplying] = useState(false);
|
const [isReplying] = useState(false);
|
||||||
const [isLoadingChats, setIsLoadingChats] = useState(false);
|
const [isLoadingChats, setIsLoadingChats] = useState(false);
|
||||||
@ -117,15 +187,36 @@ export function useChat() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const messages = messagesByContact[activeContactId] || [];
|
const messages = messagesByContact[activeContactId] || [];
|
||||||
const attendants = attendantsByArea[transferArea] || [];
|
const transferAreas = areaOptions.length ? areaOptions.map((area) => area.nome) : fallbackTransferAreas;
|
||||||
|
const selectedTransferArea = areaOptions.find((area) => area.nome === transferArea) || null;
|
||||||
|
const usersInTransferArea = accessUsers.filter((user) =>
|
||||||
|
user.areas?.some((area) => area.nome === transferArea) || user.areaPrincipal?.nome === transferArea,
|
||||||
|
);
|
||||||
|
const isSameUserArea = currentUserAreas.includes(transferArea);
|
||||||
|
const attendants = isSameUserArea ? usersInTransferArea : [];
|
||||||
|
const activeAssignment = activeContact?.assignment || null;
|
||||||
|
const isAssignedToCurrentUser = Boolean(
|
||||||
|
activeAssignment?.user_id && currentUserId && Number(activeAssignment.user_id) === currentUserId,
|
||||||
|
);
|
||||||
|
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 assignmentLabel = activeAssignment?.user_id
|
||||||
|
? `Atendimento com ${activeAssignment.user_nome || 'outro atendente'}`
|
||||||
|
: activeAssignment?.area_nome
|
||||||
|
? `Na fila de ${activeAssignment.area_nome}`
|
||||||
|
: 'Sem fila definida';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedArea(activeContact.area);
|
setSelectedArea(activeContact?.area || 'Sem fila');
|
||||||
}, [activeContact]);
|
}, [activeContact]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTransferAttendant(attendants[0] || '');
|
setTransferAttendant(attendants[0]?.id ? String(attendants[0].id) : '');
|
||||||
}, [transferArea]);
|
}, [transferArea, accessUsers]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
activeContactRef.current = activeContactId;
|
activeContactRef.current = activeContactId;
|
||||||
@ -134,34 +225,81 @@ export function useChat() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
|
|
||||||
async function loadChats() {
|
async function loadAccessData() {
|
||||||
setIsLoadingChats(true);
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE_URL}/whatsapp/chats`);
|
const [options, users] = await Promise.all([getAccessOptions(), getAccessUsers()]);
|
||||||
if (!response.ok) throw new Error('Falha ao carregar chats do WhatsApp.');
|
if (!isMounted) return;
|
||||||
const data = await response.json();
|
setAreaOptions(options.areas || []);
|
||||||
if (!isMounted || !Array.isArray(data) || data.length === 0) return;
|
setAccessUsers(users || []);
|
||||||
|
} catch {
|
||||||
const nextContacts = data.map(normalizeChat);
|
if (isMounted) {
|
||||||
setContacts(nextContacts);
|
setAreaOptions([]);
|
||||||
setActiveContactId((current) =>
|
setAccessUsers([]);
|
||||||
nextContacts.some((contact) => contact.id === current) ? current : nextContacts[0].id,
|
}
|
||||||
);
|
|
||||||
setApiError(null);
|
|
||||||
} catch (error) {
|
|
||||||
if (isMounted) setApiError(error.message);
|
|
||||||
} finally {
|
|
||||||
if (isMounted) setIsLoadingChats(false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadChats();
|
loadAccessData();
|
||||||
const intervalId = window.setInterval(loadChats, 30000);
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function canSeeContact(contact) {
|
||||||
|
if (!currentUserAreas.length) return false;
|
||||||
|
if (!contact.assignment) return false;
|
||||||
|
if (contact.assignment.status === 'bot_triage') return false;
|
||||||
|
if (!contact.assignment.area_nome) return false;
|
||||||
|
if (contact.assignment.user_id && Number(contact.assignment.user_id) === currentUserId) return true;
|
||||||
|
return currentUserAreas.includes(contact.assignment.area_nome);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadChats() {
|
||||||
|
if (whatsappStatus !== 'CONNECTED') {
|
||||||
|
const fallbackContacts = buildFallbackContacts();
|
||||||
|
setContacts(fallbackContacts);
|
||||||
|
setActiveContactId((current) =>
|
||||||
|
fallbackContacts.some((contact) => contact.id === current) ? current : fallbackContacts[0]?.id,
|
||||||
|
);
|
||||||
|
setIsLoadingChats(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoadingChats(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/whatsapp/chats`);
|
||||||
|
if (!response.ok) throw new Error('Falha ao carregar chats do WhatsApp.');
|
||||||
|
const data = await response.json();
|
||||||
|
if (!Array.isArray(data)) return;
|
||||||
|
|
||||||
|
const nextContacts = data.map(normalizeChat).filter(canSeeContact);
|
||||||
|
setContacts(nextContacts);
|
||||||
|
setActiveContactId((current) =>
|
||||||
|
nextContacts.some((contact) => contact.id === current) ? current : nextContacts[0]?.id || '',
|
||||||
|
);
|
||||||
|
setApiError(null);
|
||||||
|
} catch (error) {
|
||||||
|
setApiError(error.message);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingChats(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
async function guardedLoadChats() {
|
||||||
|
if (!isMounted) return;
|
||||||
|
await loadChats();
|
||||||
|
}
|
||||||
|
|
||||||
|
guardedLoadChats();
|
||||||
|
const intervalId = window.setInterval(guardedLoadChats, 30000);
|
||||||
return () => {
|
return () => {
|
||||||
isMounted = false;
|
isMounted = false;
|
||||||
window.clearInterval(intervalId);
|
window.clearInterval(intervalId);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [currentUserId, currentUserAreas.join('|'), whatsappStatus]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeContactId) return;
|
if (!activeContactId) return;
|
||||||
@ -177,10 +315,12 @@ export function useChat() {
|
|||||||
if (!isMounted || !Array.isArray(data)) return;
|
if (!isMounted || !Array.isArray(data)) return;
|
||||||
setMessagesByContact((current) => ({
|
setMessagesByContact((current) => ({
|
||||||
...current,
|
...current,
|
||||||
[activeContactId]: data.map((message) => ({
|
[activeContactId]: dedupeMessages(
|
||||||
...normalizeMessage(message),
|
data.map((message) => ({
|
||||||
chatId: activeContactId,
|
...normalizeMessage(message),
|
||||||
})),
|
chatId: activeContactId,
|
||||||
|
})),
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
setApiError(null);
|
setApiError(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -209,24 +349,29 @@ export function useChat() {
|
|||||||
|
|
||||||
setMessagesByContact((current) => {
|
setMessagesByContact((current) => {
|
||||||
const currentMessages = current[contactId] || [];
|
const currentMessages = current[contactId] || [];
|
||||||
if (currentMessages.some((item) => item.id === message.id)) return current;
|
const nextMessages = mergeMessageList(currentMessages, message);
|
||||||
|
if (nextMessages === currentMessages) return current;
|
||||||
return {
|
return {
|
||||||
...current,
|
...current,
|
||||||
[contactId]: [...currentMessages, message],
|
[contactId]: nextMessages,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
setContacts((current) => {
|
setContacts((current) => {
|
||||||
const existing = current.find((contact) => contact.id === contactId);
|
const existing = current.find((contact) => contact.id === contactId);
|
||||||
|
if (!existing) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
const nextContact = {
|
const nextContact = {
|
||||||
...(existing || {
|
...(existing || {
|
||||||
id: contactId,
|
id: contactId,
|
||||||
name: incomingMessage.notifyName || contactId.split('@')[0],
|
name: incomingMessage.notifyName || contactId.split('@')[0],
|
||||||
channel: 'WhatsApp',
|
channel: 'WhatsApp',
|
||||||
status: 'online',
|
status: 'online',
|
||||||
area: 'Suporte',
|
area: 'Sem fila',
|
||||||
lastSeen: 'Online agora',
|
lastSeen: 'Online agora',
|
||||||
unread: 0,
|
unread: 0,
|
||||||
|
assignment: null,
|
||||||
}),
|
}),
|
||||||
preview,
|
preview,
|
||||||
time: 'Agora',
|
time: 'Agora',
|
||||||
@ -239,18 +384,24 @@ export function useChat() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
clearIncomingMessage();
|
clearIncomingMessage();
|
||||||
|
window.setTimeout(loadChats, 1200);
|
||||||
}, [incomingMessage, clearIncomingMessage]);
|
}, [incomingMessage, clearIncomingMessage]);
|
||||||
|
|
||||||
function updateContactPreview(contactId, preview, media) {
|
function updateContact(contactId, updater) {
|
||||||
setContacts((current) =>
|
setContacts((current) =>
|
||||||
current.map((contact) =>
|
current.map((contact) => (contact.id === contactId ? updater(contact) : contact)),
|
||||||
contact.id === contactId
|
|
||||||
? { ...contact, preview: media ? `[Midia: ${media.filename || 'Arquivo'}]` : preview, time: 'Agora', unread: 0 }
|
|
||||||
: contact,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateContactPreview(contactId, preview, media) {
|
||||||
|
updateContact(contactId, (contact) => ({
|
||||||
|
...contact,
|
||||||
|
preview: media ? `[Midia: ${media.filename || 'Arquivo'}]` : preview,
|
||||||
|
time: 'Agora',
|
||||||
|
unread: 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
async function attachFile(file) {
|
async function attachFile(file) {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
const data = await fileToBase64(file);
|
const data = await fileToBase64(file);
|
||||||
@ -299,9 +450,61 @@ export function useChat() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function assumeChat() {
|
||||||
|
if (!activeContactId?.includes('@') || !currentUserId) return null;
|
||||||
|
const areaId = activeContact?.areaId || activeAssignment?.area_id || areaOptions.find((area) => currentUserAreas.includes(area.nome))?.id;
|
||||||
|
const response = await fetch(`${API_BASE_URL}/whatsapp/assign`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
chatId: activeContactId,
|
||||||
|
userId: String(currentUserId),
|
||||||
|
areaId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Nao foi possivel assumir o atendimento.');
|
||||||
|
const assignment = await response.json();
|
||||||
|
updateContact(activeContactId, (contact) => ({
|
||||||
|
...contact,
|
||||||
|
assignment,
|
||||||
|
area: assignment.area_nome || contact.area,
|
||||||
|
areaId: assignment.area_id || contact.areaId,
|
||||||
|
}));
|
||||||
|
return assignment;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function releaseChat() {
|
||||||
|
if (!activeContactId?.includes('@')) return;
|
||||||
|
const response = await fetch(`${API_BASE_URL}/whatsapp/release/${encodeURIComponent(activeContactId)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Nao foi possivel sair do atendimento.');
|
||||||
|
const assignment = await response.json();
|
||||||
|
updateContact(activeContactId, (contact) => ({
|
||||||
|
...contact,
|
||||||
|
assignment,
|
||||||
|
area: assignment?.area_nome || contact.area,
|
||||||
|
areaId: assignment?.area_id || contact.areaId,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
async function sendMessage() {
|
async function sendMessage() {
|
||||||
const trimmed = draft.trim();
|
const trimmed = draft.trim();
|
||||||
if (!trimmed && !attachedFile) {
|
if (!trimmed && !attachedFile) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!canReply) {
|
||||||
|
if (canAssumeChat) {
|
||||||
|
await assumeChat();
|
||||||
|
} else {
|
||||||
|
setApiError('Este atendimento esta atribuido a outro usuario.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setApiError(error.message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -317,13 +520,14 @@ export function useChat() {
|
|||||||
chatId: activeContactId,
|
chatId: activeContactId,
|
||||||
sender: 'agent',
|
sender: 'agent',
|
||||||
text: trimmed,
|
text: trimmed,
|
||||||
|
timestamp: Math.floor(Date.now() / 1000),
|
||||||
hasMedia: Boolean(media),
|
hasMedia: Boolean(media),
|
||||||
media,
|
media,
|
||||||
};
|
};
|
||||||
|
|
||||||
setMessagesByContact((current) => ({
|
setMessagesByContact((current) => ({
|
||||||
...current,
|
...current,
|
||||||
[activeContactId]: [...(current[activeContactId] || []), newMessage],
|
[activeContactId]: mergeMessageList(current[activeContactId] || [], newMessage),
|
||||||
}));
|
}));
|
||||||
updateContactPreview(activeContactId, trimmed || '[Midia]', media);
|
updateContactPreview(activeContactId, trimmed || '[Midia]', media);
|
||||||
setDraft('');
|
setDraft('');
|
||||||
@ -338,6 +542,7 @@ export function useChat() {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
to: activeContactId,
|
to: activeContactId,
|
||||||
message: trimmed,
|
message: trimmed,
|
||||||
|
senderName: getUserDisplayName(currentUser),
|
||||||
media,
|
media,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@ -347,11 +552,36 @@ export function useChat() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function submitTransfer() {
|
async function submitTransfer() {
|
||||||
const note = transferNote.trim();
|
const note = transferNote.trim();
|
||||||
const transferMessage = note
|
const areaId = selectedTransferArea?.id;
|
||||||
? `Transferencia solicitada para ${transferArea} com ${transferAttendant}. Obs: ${note}`
|
|
||||||
: `Transferencia solicitada para ${transferArea} com ${transferAttendant}.`;
|
if (!areaId) {
|
||||||
|
setApiError('Selecione uma area valida para transferencia.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetUserId = isSameUserArea && transferAttendant ? Number(transferAttendant) : null;
|
||||||
|
const response = await fetch(`${API_BASE_URL}/whatsapp/transfer`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
chatId: activeContactId,
|
||||||
|
areaId,
|
||||||
|
userId: targetUserId,
|
||||||
|
note,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
setApiError('Nao foi possivel transferir o atendimento.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignment = await response.json();
|
||||||
|
const transferMessage = targetUserId
|
||||||
|
? `Transferencia solicitada para ${transferArea}. Obs: ${note || 'Sem observacao.'}`
|
||||||
|
: `Transferencia enviada para a fila de ${transferArea}. Obs: ${note || 'Sem observacao.'}`;
|
||||||
|
|
||||||
setMessagesByContact((current) => ({
|
setMessagesByContact((current) => ({
|
||||||
...current,
|
...current,
|
||||||
@ -361,14 +591,16 @@ export function useChat() {
|
|||||||
],
|
],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
setContacts((current) =>
|
updateContact(activeContactId, (contact) => ({
|
||||||
current.map((contact) =>
|
...contact,
|
||||||
contact.id === activeContactId ? { ...contact, area: transferArea } : contact,
|
area: assignment.area_nome || transferArea,
|
||||||
),
|
areaId: assignment.area_id || areaId,
|
||||||
);
|
assignment,
|
||||||
|
}));
|
||||||
setSelectedArea(transferArea);
|
setSelectedArea(transferArea);
|
||||||
setIsTransferOpen(false);
|
setIsTransferOpen(false);
|
||||||
setTransferNote('');
|
setTransferNote('');
|
||||||
|
setApiError(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -384,6 +616,13 @@ export function useChat() {
|
|||||||
removeAttachedFile,
|
removeAttachedFile,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
hydrateMessageMedia,
|
hydrateMessageMedia,
|
||||||
|
assumeChat,
|
||||||
|
releaseChat,
|
||||||
|
canAssumeChat,
|
||||||
|
canReply,
|
||||||
|
assignmentLabel,
|
||||||
|
isAssignedToCurrentUser,
|
||||||
|
activeAssignment,
|
||||||
isReplying,
|
isReplying,
|
||||||
isLoadingChats,
|
isLoadingChats,
|
||||||
isLoadingMessages,
|
isLoadingMessages,
|
||||||
@ -396,6 +635,7 @@ export function useChat() {
|
|||||||
setTransferArea,
|
setTransferArea,
|
||||||
transferAreas,
|
transferAreas,
|
||||||
attendants,
|
attendants,
|
||||||
|
isSameUserArea,
|
||||||
transferAttendant,
|
transferAttendant,
|
||||||
setTransferAttendant,
|
setTransferAttendant,
|
||||||
transferNote,
|
transferNote,
|
||||||
|
|||||||
@ -22,6 +22,11 @@ export function ChatPage() {
|
|||||||
removeAttachedFile,
|
removeAttachedFile,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
hydrateMessageMedia,
|
hydrateMessageMedia,
|
||||||
|
assumeChat,
|
||||||
|
releaseChat,
|
||||||
|
canAssumeChat,
|
||||||
|
canReply,
|
||||||
|
assignmentLabel,
|
||||||
isReplying,
|
isReplying,
|
||||||
selectedArea,
|
selectedArea,
|
||||||
setSelectedArea,
|
setSelectedArea,
|
||||||
@ -31,6 +36,7 @@ export function ChatPage() {
|
|||||||
setTransferArea,
|
setTransferArea,
|
||||||
transferAreas,
|
transferAreas,
|
||||||
attendants,
|
attendants,
|
||||||
|
isSameUserArea,
|
||||||
transferAttendant,
|
transferAttendant,
|
||||||
setTransferAttendant,
|
setTransferAttendant,
|
||||||
transferNote,
|
transferNote,
|
||||||
@ -127,6 +133,11 @@ export function ChatPage() {
|
|||||||
onLoadMedia={hydrateMessageMedia}
|
onLoadMedia={hydrateMessageMedia}
|
||||||
onSend={sendMessage}
|
onSend={sendMessage}
|
||||||
onToggleTransfer={() => setIsTransferOpen((current) => !current)}
|
onToggleTransfer={() => setIsTransferOpen((current) => !current)}
|
||||||
|
onAssumeChat={assumeChat}
|
||||||
|
onReleaseChat={releaseChat}
|
||||||
|
canAssumeChat={canAssumeChat}
|
||||||
|
canReply={canReply}
|
||||||
|
assignmentLabel={assignmentLabel}
|
||||||
isReplying={isReplying}
|
isReplying={isReplying}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
/>
|
/>
|
||||||
@ -166,6 +177,7 @@ export function ChatPage() {
|
|||||||
setTransferArea={setTransferArea}
|
setTransferArea={setTransferArea}
|
||||||
transferAreas={transferAreas}
|
transferAreas={transferAreas}
|
||||||
attendants={attendants}
|
attendants={attendants}
|
||||||
|
isSameUserArea={isSameUserArea}
|
||||||
transferAttendant={transferAttendant}
|
transferAttendant={transferAttendant}
|
||||||
setTransferAttendant={setTransferAttendant}
|
setTransferAttendant={setTransferAttendant}
|
||||||
transferNote={transferNote}
|
transferNote={transferNote}
|
||||||
@ -183,6 +195,7 @@ export function ChatPage() {
|
|||||||
setTransferArea={setTransferArea}
|
setTransferArea={setTransferArea}
|
||||||
transferAreas={transferAreas}
|
transferAreas={transferAreas}
|
||||||
attendants={attendants}
|
attendants={attendants}
|
||||||
|
isSameUserArea={isSameUserArea}
|
||||||
transferAttendant={transferAttendant}
|
transferAttendant={transferAttendant}
|
||||||
setTransferAttendant={setTransferAttendant}
|
setTransferAttendant={setTransferAttendant}
|
||||||
transferNote={transferNote}
|
transferNote={transferNote}
|
||||||
|
|||||||
@ -28,6 +28,30 @@ function ChannelBadge({ channel }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function UnreadBadge({ count }) {
|
||||||
|
if (!count) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 26,
|
||||||
|
height: 26,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'var(--color-secondary)',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: '0.78rem',
|
||||||
|
fontWeight: 800,
|
||||||
|
display: 'inline-grid',
|
||||||
|
placeItems: 'center',
|
||||||
|
lineHeight: 1,
|
||||||
|
flex: '0 0 auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{count > 99 ? '99+' : count}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function buildSuggestedReplies(conversation) {
|
function buildSuggestedReplies(conversation) {
|
||||||
const lastMessage = conversation?.lastMessage || conversation?.messages?.at(-1)?.text || '';
|
const lastMessage = conversation?.lastMessage || conversation?.messages?.at(-1)?.text || '';
|
||||||
const firstName = conversation?.name?.split(' ')?.[0] || 'voce';
|
const firstName = conversation?.name?.split(' ')?.[0] || 'voce';
|
||||||
@ -224,22 +248,7 @@ export function MessagesWorkspace({
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.75rem' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.75rem' }}>
|
||||||
<ChannelBadge channel={conversation.channel} />
|
<ChannelBadge channel={conversation.channel} />
|
||||||
{conversation.unread ? (
|
<UnreadBadge count={conversation.unread} />
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
minWidth: 24,
|
|
||||||
borderRadius: 999,
|
|
||||||
padding: '0.15rem 0.45rem',
|
|
||||||
background: 'var(--color-secondary)',
|
|
||||||
color: '#fff',
|
|
||||||
fontSize: '0.78rem',
|
|
||||||
fontWeight: 700,
|
|
||||||
textAlign: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{conversation.unread}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
<span style={{ color: 'var(--color-text-soft)' }}>{conversation.lastMessage}</span>
|
<span style={{ color: 'var(--color-text-soft)' }}>{conversation.lastMessage}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
export const sidebarItems = [
|
export const sidebarItems = [
|
||||||
{ id: 'scripts', label: 'Scripts e respostas prontas' },
|
{ id: 'scripts', label: 'Scripts e respostas prontas' },
|
||||||
{ id: 'personal-reports', label: 'Relatorios pessoais' },
|
{ id: 'personal-reports', label: 'Relatórios pessoais' },
|
||||||
{ id: 'mass-message', label: 'Disparo em massa' },
|
{ id: 'mass-message', label: 'Disparo em massa' },
|
||||||
{ id: 'knowledge-base', label: 'Base de conhecimento' },
|
{ id: 'knowledge-base', label: 'Base de conhecimento' },
|
||||||
{ id: 'completed', label: 'Finalizados', count: 24 },
|
{ id: 'completed', label: 'Finalizados', count: 24 },
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user