omnichannel-frontend/src/modules/chat/hooks/useChat.js

646 lines
21 KiB
JavaScript
Raw Normal View History

import { useEffect, useMemo, useRef, useState } from 'react';
import { useWhatsappSocket } from '../../../shared/hooks/useWhatsappSocket';
import { API_BASE_URL } from '../../../shared/services/apiConfig';
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) => {
acc[contact.id] = contact.messages;
return acc;
}, {});
}
function getSerializedId(value) {
if (!value) return '';
if (typeof value === 'string') return value;
return value._serialized || `${value.user || ''}@${value.server || 'c.us'}`;
}
function formatTime(timestamp) {
if (!timestamp) return '';
const date = new Date(timestamp * 1000);
return date.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
}
function getContactName(chat) {
const serializedId = getSerializedId(chat.id);
return chat.name || chat.pushname || serializedId.split('@')[0] || 'Contato';
}
function getPreviewFromMessage(message) {
if (message?.body) return message.body;
if (message?.text) return message.text;
if (message?.hasMedia || message?.media) return '[Midia]';
return '';
}
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: assignment?.area_nome || (assignment?.area_id ? String(assignment.area_id) : 'Sem fila'),
areaId: assignment?.area_id || null,
lastSeen: isRecentlyActive
? 'Online agora'
: chat.timestamp
? `Visto as ${formatTime(chat.timestamp)}`
: 'Sem atividade recente',
preview: chat.preview || chat.lastMessage?.body || '',
time: formatTime(chat.timestamp) || 'Agora',
unread: chat.unreadCount || 0,
assignment,
};
}
function normalizeMessage(message) {
const id = getSerializedId(message.id) || message.id || `msg-${Date.now()}`;
const sender = message.sender || (message.fromMe ? 'agent' : 'customer');
return {
id,
chatId: message.from || message.to || message.chatId,
sender,
text: message.body ?? message.text ?? '',
timestamp: message.timestamp,
hasMedia: Boolean(message.hasMedia || message.media),
media: message.media || null,
mediaLoading: false,
mediaError: null,
};
}
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();
reader.onload = () => {
const result = String(reader.result || '');
resolve(result.includes(',') ? result.split(',')[1] : result);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
function buildFallbackContacts() {
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 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(currentUser?.areaPrincipal || 'Suporte');
const [transferAttendant, setTransferAttendant] = useState('');
const [transferNote, setTransferNote] = useState('');
const [isReplying] = useState(false);
const [isLoadingChats, setIsLoadingChats] = useState(false);
const [isLoadingMessages, setIsLoadingMessages] = useState(false);
const [apiError, setApiError] = useState(null);
const activeContactRef = useRef(activeContactId);
const activeContact = useMemo(
() => contacts.find((contact) => contact.id === activeContactId) || contacts[0],
[contacts, activeContactId],
);
const messages = messagesByContact[activeContactId] || [];
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 || 'Sem fila');
}, [activeContact]);
useEffect(() => {
setTransferAttendant(attendants[0]?.id ? String(attendants[0].id) : '');
}, [transferArea, accessUsers]);
useEffect(() => {
activeContactRef.current = activeContactId;
}, [activeContactId]);
useEffect(() => {
let isMounted = true;
async function loadAccessData() {
try {
const [options, users] = await Promise.all([getAccessOptions(), getAccessUsers()]);
if (!isMounted) return;
setAreaOptions(options.areas || []);
setAccessUsers(users || []);
} catch {
if (isMounted) {
setAreaOptions([]);
setAccessUsers([]);
}
}
}
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;
let isMounted = true;
async function loadMessages() {
if (!activeContactId.includes('@')) return;
setIsLoadingMessages(true);
try {
const response = await fetch(`${API_BASE_URL}/whatsapp/messages/${encodeURIComponent(activeContactId)}`);
if (!response.ok) throw new Error('Falha ao carregar mensagens do WhatsApp.');
const data = await response.json();
if (!isMounted || !Array.isArray(data)) return;
setMessagesByContact((current) => ({
...current,
[activeContactId]: dedupeMessages(
data.map((message) => ({
...normalizeMessage(message),
chatId: activeContactId,
})),
),
}));
setApiError(null);
} catch (error) {
if (isMounted) setApiError(error.message);
} finally {
if (isMounted) setIsLoadingMessages(false);
}
}
loadMessages();
return () => {
isMounted = false;
};
}, [activeContactId]);
useEffect(() => {
if (!incomingMessage) return;
const contactId = incomingMessage.from || incomingMessage.to || incomingMessage.chatId;
if (!contactId) return;
const message = {
...normalizeMessage(incomingMessage),
chatId: contactId,
};
const preview = getPreviewFromMessage(message);
setMessagesByContact((current) => {
const currentMessages = current[contactId] || [];
const nextMessages = mergeMessageList(currentMessages, message);
if (nextMessages === currentMessages) return current;
return {
...current,
[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: 'Sem fila',
lastSeen: 'Online agora',
unread: 0,
assignment: null,
}),
preview,
time: 'Agora',
unread:
incomingMessage.fromMe || contactId === activeContactRef.current
? 0
: (existing?.unread || 0) + 1,
};
return [nextContact, ...current.filter((contact) => contact.id !== contactId)];
});
clearIncomingMessage();
window.setTimeout(loadChats, 1200);
}, [incomingMessage, clearIncomingMessage]);
function updateContact(contactId, updater) {
setContacts((current) =>
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);
setAttachedFile({
name: file.name,
type: file.type || 'application/octet-stream',
data,
});
}
function removeAttachedFile() {
setAttachedFile(null);
}
async function hydrateMessageMedia(contactId, messageId) {
if (!contactId || !messageId) return;
setMessagesByContact((current) => ({
...current,
[contactId]: (current[contactId] || []).map((message) =>
message.id === messageId ? { ...message, mediaLoading: true, mediaError: null } : message,
),
}));
try {
const response = await fetch(
`${API_BASE_URL}/whatsapp/media/${encodeURIComponent(contactId)}/${encodeURIComponent(messageId)}`,
);
if (!response.ok) throw new Error('Falha ao carregar midia.');
const media = await response.json();
setMessagesByContact((current) => ({
...current,
[contactId]: (current[contactId] || []).map((message) =>
message.id === messageId ? { ...message, media, mediaLoading: false } : message,
),
}));
} catch (error) {
setMessagesByContact((current) => ({
...current,
[contactId]: (current[contactId] || []).map((message) =>
message.id === messageId
? { ...message, mediaLoading: false, mediaError: error.message || 'Erro ao carregar midia.' }
: message,
),
}));
}
}
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) return;
try {
if (!canReply) {
if (canAssumeChat) {
await assumeChat();
} else {
setApiError('Este atendimento esta atribuido a outro usuario.');
return;
}
}
} catch (error) {
setApiError(error.message);
return;
}
const media = attachedFile
? {
data: attachedFile.data,
mimetype: attachedFile.type,
filename: attachedFile.name,
}
: null;
const newMessage = {
id: `temp-${Date.now()}`,
chatId: activeContactId,
sender: 'agent',
text: trimmed,
timestamp: Math.floor(Date.now() / 1000),
hasMedia: Boolean(media),
media,
};
setMessagesByContact((current) => ({
...current,
[activeContactId]: mergeMessageList(current[activeContactId] || [], newMessage),
}));
updateContactPreview(activeContactId, trimmed || '[Midia]', media);
setDraft('');
setAttachedFile(null);
if (!activeContactId.includes('@')) return;
try {
await fetch(`${API_BASE_URL}/whatsapp/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
to: activeContactId,
message: trimmed,
senderName: getUserDisplayName(currentUser),
media,
}),
});
setApiError(null);
} catch (error) {
setApiError(error.message);
}
}
async function submitTransfer() {
const note = transferNote.trim();
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,
[activeContactId]: [
...(current[activeContactId] || []),
{ id: Date.now() + 2, sender: 'system', text: transferMessage },
],
}));
updateContact(activeContactId, (contact) => ({
...contact,
area: assignment.area_nome || transferArea,
areaId: assignment.area_id || areaId,
assignment,
}));
setSelectedArea(transferArea);
setIsTransferOpen(false);
setTransferNote('');
setApiError(null);
}
return {
contacts,
activeContact,
activeContactId,
setActiveContactId,
messages,
draft,
setDraft,
attachedFile,
attachFile,
removeAttachedFile,
sendMessage,
hydrateMessageMedia,
assumeChat,
releaseChat,
canAssumeChat,
canReply,
assignmentLabel,
isAssignedToCurrentUser,
activeAssignment,
isReplying,
isLoadingChats,
isLoadingMessages,
apiError,
selectedArea,
setSelectedArea,
isTransferOpen,
setIsTransferOpen,
transferArea,
setTransferArea,
transferAreas,
attendants,
isSameUserArea,
transferAttendant,
setTransferAttendant,
transferNote,
setTransferNote,
submitTransfer,
};
}