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({
|
||||
contacts,
|
||||
activeContactId,
|
||||
@ -62,7 +88,9 @@ export function ChatConversationList({
|
||||
display: 'grid',
|
||||
gridTemplateRows: 'auto minmax(0, 1fr)',
|
||||
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,
|
||||
}}
|
||||
>
|
||||
@ -78,6 +106,8 @@ export function ChatConversationList({
|
||||
display: 'grid',
|
||||
gap: '0.75rem',
|
||||
gridTemplateColumns: isMobile ? '1fr' : '1fr',
|
||||
gridAutoRows: 'max-content',
|
||||
alignContent: 'start',
|
||||
overflowY: 'auto',
|
||||
minHeight: 0,
|
||||
paddingRight: '0.15rem',
|
||||
@ -115,27 +145,28 @@ export function ChatConversationList({
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.75rem' }}>
|
||||
<ChannelBadge channel={contact.channel} />
|
||||
{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}
|
||||
<UnreadBadge count={contact.unread} />
|
||||
</div>
|
||||
<span style={{ color: 'var(--color-text-soft)' }}>{contact.preview}</span>
|
||||
</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>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@ -4,6 +4,7 @@ export function ChatTransferPanel({
|
||||
setTransferArea,
|
||||
transferAreas,
|
||||
attendants,
|
||||
isSameUserArea = true,
|
||||
transferAttendant,
|
||||
setTransferAttendant,
|
||||
transferNote,
|
||||
@ -69,17 +70,30 @@ export function ChatTransferPanel({
|
||||
|
||||
<label style={{ display: 'grid', gap: '0.45rem' }}>
|
||||
<span style={{ fontWeight: 600 }}>Atendente</span>
|
||||
<select
|
||||
value={transferAttendant}
|
||||
onChange={(event) => setTransferAttendant(event.target.value)}
|
||||
style={fieldStyle}
|
||||
>
|
||||
{attendants.map((attendant) => (
|
||||
<option key={attendant} value={attendant}>
|
||||
{attendant}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{isSameUserArea ? (
|
||||
<select
|
||||
value={transferAttendant}
|
||||
onChange={(event) => setTransferAttendant(event.target.value)}
|
||||
style={fieldStyle}
|
||||
>
|
||||
{attendants.map((attendant) => (
|
||||
<option key={attendant.id} value={attendant.id}>
|
||||
{attendant.nome}
|
||||
</option>
|
||||
))}
|
||||
</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 style={{ display: 'grid', gap: '0.45rem' }}>
|
||||
|
||||
@ -5,6 +5,23 @@ function getMediaUrl(media) {
|
||||
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 }) {
|
||||
const mediaUrl = useMemo(() => getMediaUrl(message.media), [message.media]);
|
||||
const mimetype = message.media?.mimetype || '';
|
||||
@ -180,6 +197,10 @@ function AttachmentPreview({ file, onRemove }) {
|
||||
}
|
||||
|
||||
function ContactPresence({ contact }) {
|
||||
if (!contact) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const status = contact.status || 'offline';
|
||||
const color =
|
||||
status === 'online'
|
||||
@ -226,10 +247,21 @@ export function ChatWindow({
|
||||
onLoadMedia,
|
||||
onSend,
|
||||
onToggleTransfer,
|
||||
onAssumeChat,
|
||||
onReleaseChat,
|
||||
canAssumeChat = false,
|
||||
canReply = true,
|
||||
assignmentLabel,
|
||||
isReplying,
|
||||
isMobile = false,
|
||||
}) {
|
||||
const messagesRef = useRef(null);
|
||||
const safeContact = contact || {
|
||||
id: '',
|
||||
name: 'Nenhuma conversa ativa',
|
||||
status: 'offline',
|
||||
lastSeen: 'Aguardando fila do Omnino',
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const container = messagesRef.current;
|
||||
@ -268,8 +300,8 @@ export function ChatWindow({
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong style={{ display: 'block', fontSize: '1.15rem' }}>{contact.name}</strong>
|
||||
<ContactPresence contact={contact} />
|
||||
<strong style={{ display: 'block', fontSize: '1.15rem' }}>{safeContact.name}</strong>
|
||||
<ContactPresence contact={safeContact} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
@ -283,6 +315,7 @@ export function ChatWindow({
|
||||
<select
|
||||
value={selectedArea}
|
||||
onChange={(event) => setSelectedArea(event.target.value)}
|
||||
disabled
|
||||
style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '14px',
|
||||
@ -291,13 +324,47 @@ export function ChatWindow({
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<option>{selectedArea}</option>
|
||||
<option>Suporte</option>
|
||||
<option>Financeiro</option>
|
||||
<option>Comercial</option>
|
||||
</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
|
||||
type="button"
|
||||
onClick={onToggleTransfer}
|
||||
disabled={!canReply}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '14px',
|
||||
@ -305,6 +372,7 @@ export function ChatWindow({
|
||||
background: 'rgba(0, 49, 80, 0.08)',
|
||||
color: 'var(--color-primary)',
|
||||
fontWeight: 700,
|
||||
opacity: canReply ? 1 : 0.55,
|
||||
}}
|
||||
>
|
||||
Transferir
|
||||
@ -328,6 +396,7 @@ export function ChatWindow({
|
||||
{messages.map((message) => {
|
||||
const isAgent = message.sender === 'agent';
|
||||
const isSystem = message.sender === 'system';
|
||||
const parsedText = parseMessageText(message.text);
|
||||
|
||||
if (isSystem) {
|
||||
return (
|
||||
@ -365,11 +434,36 @@ export function ChatWindow({
|
||||
>
|
||||
<MediaRenderer
|
||||
message={message}
|
||||
contactId={contact.id}
|
||||
contactId={safeContact.id}
|
||||
onLoadMedia={onLoadMedia}
|
||||
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>
|
||||
);
|
||||
})}
|
||||
@ -415,6 +509,22 @@ export function ChatWindow({
|
||||
}}
|
||||
>
|
||||
<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
|
||||
style={{
|
||||
display: 'grid',
|
||||
@ -446,6 +556,7 @@ export function ChatWindow({
|
||||
event.target.value = '';
|
||||
}}
|
||||
style={{ display: 'none' }}
|
||||
disabled={!safeContact.id}
|
||||
/>
|
||||
</label>
|
||||
<input
|
||||
@ -457,7 +568,14 @@ export function ChatWindow({
|
||||
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={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '18px',
|
||||
@ -465,11 +583,13 @@ export function ChatWindow({
|
||||
background: '#fff',
|
||||
outline: 'none',
|
||||
minWidth: 0,
|
||||
opacity: safeContact.id && (canReply || canAssumeChat) ? 1 : 0.6,
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSend}
|
||||
disabled={!safeContact.id || (!canReply && !canAssumeChat)}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '18px',
|
||||
@ -478,6 +598,7 @@ export function ChatWindow({
|
||||
color: '#fff',
|
||||
fontWeight: 700,
|
||||
gridColumn: isMobile ? '1 / -1' : 'auto',
|
||||
opacity: safeContact.id && (canReply || canAssumeChat) ? 1 : 0.6,
|
||||
}}
|
||||
>
|
||||
Enviar
|
||||
|
||||
@ -1,11 +1,9 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useWhatsappSocket } from '../../../shared/hooks/useWhatsappSocket';
|
||||
import { API_BASE_URL } from '../../../shared/services/apiConfig';
|
||||
import {
|
||||
attendantsByArea,
|
||||
chatContacts,
|
||||
transferAreas,
|
||||
} from '../services/chatMocks';
|
||||
import { getAccessOptions, getAccessUsers } from '../../management/services/adminAccessService';
|
||||
import { getCurrentUser } from '../../auth/services/sessionService';
|
||||
import { chatContacts, transferAreas as fallbackTransferAreas } from '../services/chatMocks';
|
||||
|
||||
function buildInitialMessages() {
|
||||
return chatContacts.reduce((acc, contact) => {
|
||||
@ -42,13 +40,15 @@ function normalizeChat(chat) {
|
||||
const id = getSerializedId(chat.id);
|
||||
const lastActivitySeconds = chat.timestamp ? Math.floor(Date.now() / 1000) - chat.timestamp : null;
|
||||
const isRecentlyActive = lastActivitySeconds !== null && lastActivitySeconds < 300;
|
||||
const assignment = chat.assignment || null;
|
||||
|
||||
return {
|
||||
id,
|
||||
name: getContactName(chat),
|
||||
channel: 'WhatsApp',
|
||||
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
|
||||
? 'Online agora'
|
||||
: chat.timestamp
|
||||
@ -57,7 +57,7 @@ function normalizeChat(chat) {
|
||||
preview: chat.preview || chat.lastMessage?.body || '',
|
||||
time: formatTime(chat.timestamp) || 'Agora',
|
||||
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) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
@ -90,20 +137,43 @@ function fileToBase64(file) {
|
||||
}
|
||||
|
||||
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() {
|
||||
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 [activeContactId, setActiveContactId] = useState(chatContacts[0].id);
|
||||
const [messagesByContact, setMessagesByContact] = useState(buildInitialMessages);
|
||||
const [draft, setDraft] = useState('');
|
||||
const [attachedFile, setAttachedFile] = useState(null);
|
||||
const [areaOptions, setAreaOptions] = useState([]);
|
||||
const [accessUsers, setAccessUsers] = useState([]);
|
||||
const [selectedArea, setSelectedArea] = useState(chatContacts[0].area);
|
||||
const [isTransferOpen, setIsTransferOpen] = useState(false);
|
||||
const [transferArea, setTransferArea] = useState('Suporte');
|
||||
const [transferAttendant, setTransferAttendant] = useState(attendantsByArea.Suporte[0]);
|
||||
const [transferArea, setTransferArea] = useState(currentUser?.areaPrincipal || 'Suporte');
|
||||
const [transferAttendant, setTransferAttendant] = useState('');
|
||||
const [transferNote, setTransferNote] = useState('');
|
||||
const [isReplying] = useState(false);
|
||||
const [isLoadingChats, setIsLoadingChats] = useState(false);
|
||||
@ -117,15 +187,36 @@ export function useChat() {
|
||||
);
|
||||
|
||||
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(() => {
|
||||
setSelectedArea(activeContact.area);
|
||||
setSelectedArea(activeContact?.area || 'Sem fila');
|
||||
}, [activeContact]);
|
||||
|
||||
useEffect(() => {
|
||||
setTransferAttendant(attendants[0] || '');
|
||||
}, [transferArea]);
|
||||
setTransferAttendant(attendants[0]?.id ? String(attendants[0].id) : '');
|
||||
}, [transferArea, accessUsers]);
|
||||
|
||||
useEffect(() => {
|
||||
activeContactRef.current = activeContactId;
|
||||
@ -134,34 +225,81 @@ export function useChat() {
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
async function loadChats() {
|
||||
setIsLoadingChats(true);
|
||||
async function loadAccessData() {
|
||||
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 (!isMounted || !Array.isArray(data) || data.length === 0) return;
|
||||
|
||||
const nextContacts = data.map(normalizeChat);
|
||||
setContacts(nextContacts);
|
||||
setActiveContactId((current) =>
|
||||
nextContacts.some((contact) => contact.id === current) ? current : nextContacts[0].id,
|
||||
);
|
||||
setApiError(null);
|
||||
} catch (error) {
|
||||
if (isMounted) setApiError(error.message);
|
||||
} finally {
|
||||
if (isMounted) setIsLoadingChats(false);
|
||||
const [options, users] = await Promise.all([getAccessOptions(), getAccessUsers()]);
|
||||
if (!isMounted) return;
|
||||
setAreaOptions(options.areas || []);
|
||||
setAccessUsers(users || []);
|
||||
} catch {
|
||||
if (isMounted) {
|
||||
setAreaOptions([]);
|
||||
setAccessUsers([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadChats();
|
||||
const intervalId = window.setInterval(loadChats, 30000);
|
||||
loadAccessData();
|
||||
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 () => {
|
||||
isMounted = false;
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, []);
|
||||
}, [currentUserId, currentUserAreas.join('|'), whatsappStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeContactId) return;
|
||||
@ -177,10 +315,12 @@ export function useChat() {
|
||||
if (!isMounted || !Array.isArray(data)) return;
|
||||
setMessagesByContact((current) => ({
|
||||
...current,
|
||||
[activeContactId]: data.map((message) => ({
|
||||
...normalizeMessage(message),
|
||||
chatId: activeContactId,
|
||||
})),
|
||||
[activeContactId]: dedupeMessages(
|
||||
data.map((message) => ({
|
||||
...normalizeMessage(message),
|
||||
chatId: activeContactId,
|
||||
})),
|
||||
),
|
||||
}));
|
||||
setApiError(null);
|
||||
} catch (error) {
|
||||
@ -209,24 +349,29 @@ export function useChat() {
|
||||
|
||||
setMessagesByContact((current) => {
|
||||
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 {
|
||||
...current,
|
||||
[contactId]: [...currentMessages, message],
|
||||
[contactId]: nextMessages,
|
||||
};
|
||||
});
|
||||
|
||||
setContacts((current) => {
|
||||
const existing = current.find((contact) => contact.id === contactId);
|
||||
if (!existing) {
|
||||
return current;
|
||||
}
|
||||
const nextContact = {
|
||||
...(existing || {
|
||||
id: contactId,
|
||||
name: incomingMessage.notifyName || contactId.split('@')[0],
|
||||
channel: 'WhatsApp',
|
||||
status: 'online',
|
||||
area: 'Suporte',
|
||||
area: 'Sem fila',
|
||||
lastSeen: 'Online agora',
|
||||
unread: 0,
|
||||
assignment: null,
|
||||
}),
|
||||
preview,
|
||||
time: 'Agora',
|
||||
@ -239,18 +384,24 @@ export function useChat() {
|
||||
});
|
||||
|
||||
clearIncomingMessage();
|
||||
window.setTimeout(loadChats, 1200);
|
||||
}, [incomingMessage, clearIncomingMessage]);
|
||||
|
||||
function updateContactPreview(contactId, preview, media) {
|
||||
function updateContact(contactId, updater) {
|
||||
setContacts((current) =>
|
||||
current.map((contact) =>
|
||||
contact.id === contactId
|
||||
? { ...contact, preview: media ? `[Midia: ${media.filename || 'Arquivo'}]` : preview, time: 'Agora', unread: 0 }
|
||||
: contact,
|
||||
),
|
||||
current.map((contact) => (contact.id === contactId ? updater(contact) : 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) {
|
||||
if (!file) return;
|
||||
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() {
|
||||
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;
|
||||
}
|
||||
|
||||
@ -317,13 +520,14 @@ export function useChat() {
|
||||
chatId: activeContactId,
|
||||
sender: 'agent',
|
||||
text: trimmed,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
hasMedia: Boolean(media),
|
||||
media,
|
||||
};
|
||||
|
||||
setMessagesByContact((current) => ({
|
||||
...current,
|
||||
[activeContactId]: [...(current[activeContactId] || []), newMessage],
|
||||
[activeContactId]: mergeMessageList(current[activeContactId] || [], newMessage),
|
||||
}));
|
||||
updateContactPreview(activeContactId, trimmed || '[Midia]', media);
|
||||
setDraft('');
|
||||
@ -338,6 +542,7 @@ export function useChat() {
|
||||
body: JSON.stringify({
|
||||
to: activeContactId,
|
||||
message: trimmed,
|
||||
senderName: getUserDisplayName(currentUser),
|
||||
media,
|
||||
}),
|
||||
});
|
||||
@ -347,11 +552,36 @@ export function useChat() {
|
||||
}
|
||||
}
|
||||
|
||||
function submitTransfer() {
|
||||
async function submitTransfer() {
|
||||
const note = transferNote.trim();
|
||||
const transferMessage = note
|
||||
? `Transferencia solicitada para ${transferArea} com ${transferAttendant}. Obs: ${note}`
|
||||
: `Transferencia solicitada para ${transferArea} com ${transferAttendant}.`;
|
||||
const areaId = selectedTransferArea?.id;
|
||||
|
||||
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) => ({
|
||||
...current,
|
||||
@ -361,14 +591,16 @@ export function useChat() {
|
||||
],
|
||||
}));
|
||||
|
||||
setContacts((current) =>
|
||||
current.map((contact) =>
|
||||
contact.id === activeContactId ? { ...contact, area: transferArea } : contact,
|
||||
),
|
||||
);
|
||||
updateContact(activeContactId, (contact) => ({
|
||||
...contact,
|
||||
area: assignment.area_nome || transferArea,
|
||||
areaId: assignment.area_id || areaId,
|
||||
assignment,
|
||||
}));
|
||||
setSelectedArea(transferArea);
|
||||
setIsTransferOpen(false);
|
||||
setTransferNote('');
|
||||
setApiError(null);
|
||||
}
|
||||
|
||||
return {
|
||||
@ -384,6 +616,13 @@ export function useChat() {
|
||||
removeAttachedFile,
|
||||
sendMessage,
|
||||
hydrateMessageMedia,
|
||||
assumeChat,
|
||||
releaseChat,
|
||||
canAssumeChat,
|
||||
canReply,
|
||||
assignmentLabel,
|
||||
isAssignedToCurrentUser,
|
||||
activeAssignment,
|
||||
isReplying,
|
||||
isLoadingChats,
|
||||
isLoadingMessages,
|
||||
@ -396,6 +635,7 @@ export function useChat() {
|
||||
setTransferArea,
|
||||
transferAreas,
|
||||
attendants,
|
||||
isSameUserArea,
|
||||
transferAttendant,
|
||||
setTransferAttendant,
|
||||
transferNote,
|
||||
|
||||
@ -22,6 +22,11 @@ export function ChatPage() {
|
||||
removeAttachedFile,
|
||||
sendMessage,
|
||||
hydrateMessageMedia,
|
||||
assumeChat,
|
||||
releaseChat,
|
||||
canAssumeChat,
|
||||
canReply,
|
||||
assignmentLabel,
|
||||
isReplying,
|
||||
selectedArea,
|
||||
setSelectedArea,
|
||||
@ -31,6 +36,7 @@ export function ChatPage() {
|
||||
setTransferArea,
|
||||
transferAreas,
|
||||
attendants,
|
||||
isSameUserArea,
|
||||
transferAttendant,
|
||||
setTransferAttendant,
|
||||
transferNote,
|
||||
@ -127,6 +133,11 @@ export function ChatPage() {
|
||||
onLoadMedia={hydrateMessageMedia}
|
||||
onSend={sendMessage}
|
||||
onToggleTransfer={() => setIsTransferOpen((current) => !current)}
|
||||
onAssumeChat={assumeChat}
|
||||
onReleaseChat={releaseChat}
|
||||
canAssumeChat={canAssumeChat}
|
||||
canReply={canReply}
|
||||
assignmentLabel={assignmentLabel}
|
||||
isReplying={isReplying}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
@ -166,6 +177,7 @@ export function ChatPage() {
|
||||
setTransferArea={setTransferArea}
|
||||
transferAreas={transferAreas}
|
||||
attendants={attendants}
|
||||
isSameUserArea={isSameUserArea}
|
||||
transferAttendant={transferAttendant}
|
||||
setTransferAttendant={setTransferAttendant}
|
||||
transferNote={transferNote}
|
||||
@ -183,6 +195,7 @@ export function ChatPage() {
|
||||
setTransferArea={setTransferArea}
|
||||
transferAreas={transferAreas}
|
||||
attendants={attendants}
|
||||
isSameUserArea={isSameUserArea}
|
||||
transferAttendant={transferAttendant}
|
||||
setTransferAttendant={setTransferAttendant}
|
||||
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) {
|
||||
const lastMessage = conversation?.lastMessage || conversation?.messages?.at(-1)?.text || '';
|
||||
const firstName = conversation?.name?.split(' ')?.[0] || 'voce';
|
||||
@ -224,22 +248,7 @@ export function MessagesWorkspace({
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.75rem' }}>
|
||||
<ChannelBadge channel={conversation.channel} />
|
||||
{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}
|
||||
<UnreadBadge count={conversation.unread} />
|
||||
</div>
|
||||
<span style={{ color: 'var(--color-text-soft)' }}>{conversation.lastMessage}</span>
|
||||
</button>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
export const sidebarItems = [
|
||||
{ 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: 'knowledge-base', label: 'Base de conhecimento' },
|
||||
{ id: 'completed', label: 'Finalizados', count: 24 },
|
||||
|
||||
Loading…
Reference in New Issue
Block a user