FEAT: Adicionado estilização com nome do agente, configuração de fila, e responsividade

This commit is contained in:
Rafael Alves Lopes 2026-05-19 15:29:43 -03:00
parent 7dc07c2a80
commit 217d566057
7 changed files with 537 additions and 109 deletions

View File

@ -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>
);

View File

@ -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' }}>

View File

@ -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

View File

@ -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,

View File

@ -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}

View File

@ -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>

View File

@ -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 },