omnichannel-frontend/src/modules/home/components/MessagesWorkspace.jsx
Rafael Lopes 4d287faf28 FEAT/FIX: Melhor funcionamento da deifinção de usuários e correção de acentuação
- Front completamente corrigido para tudo estar acentuado
- Melhora na exibição de áreas/especialidades eusuários;
2026-05-21 15:50:55 -03:00

675 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { createAgentNote, deleteAgentNote, listAgentNotes } from '../services/agentNotesService';
import { getCurrentUser } from '../../auth/services/sessionService';
const WORKSPACE_HEIGHT = 660;
function ChannelBadge({ channel }) {
const colors = {
WhatsApp: '#2bb741',
Email: '#e5a22a',
SMS: '#00a4b7',
};
return (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
borderRadius: 999,
padding: '0.22rem 0.6rem',
background: `${colors[channel] || '#003150'}16`,
color: colors[channel] || '#003150',
fontSize: '0.8rem',
fontWeight: 700,
}}
>
{channel}
</span>
);
}
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] || 'você';
const lowerContext = lastMessage.toLowerCase();
if (
lowerContext.includes('fatura') ||
lowerContext.includes('cobranca') ||
lowerContext.includes('pagamento')
) {
return [
`${firstName}, vou conferir os dados financeiros e já te retorno com a posição correta.`,
'Recebi sua mensagem sobre cobrança. Vou validar o histórico antes de seguir com a orientação.',
'Consigo te ajudar com isso. Pode me confirmar o CPF/CNPJ ou protocolo vinculado ao atendimento?',
];
}
if (
lowerContext.includes('endereco') ||
lowerContext.includes('cadastro') ||
lowerContext.includes('atualizar')
) {
return [
`${firstName}, vou validar seu cadastro e confirmar se a alteração já foi registrada.`,
'Para seguir com a atualização, me confirme por favor os dados que precisam ser ajustados.',
'Entendi. Vou verificar o cadastro atual e te retorno com o próximo passo.',
];
}
if (
lowerContext.includes('ligar') ||
lowerContext.includes('telefone') ||
lowerContext.includes('retorno')
) {
return [
`${firstName}, consigo organizar esse retorno. Qual o melhor horário para contato?`,
'Vou registrar sua solicitação e direcionar o retorno para o time responsável.',
'Obrigado pelo aviso. Vou confirmar disponibilidade e te retorno por aqui.',
];
}
return [
`${firstName}, recebi sua mensagem e vou verificar o contexto para te orientar corretamente.`,
'Entendi. Vou analisar as informações do atendimento e retorno com o melhor encaminhamento.',
'Posso acionar o time responsável e te atualizar por aqui assim que tiver uma posição.',
];
}
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 formatMessageTime(timestamp) {
if (!timestamp) return '';
const numericTimestamp = Number(timestamp);
const date = new Date(numericTimestamp > 1000000000000 ? numericTimestamp : numericTimestamp * 1000);
return date.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
}
function getUserId(user) {
const value = user?.databaseId || user?.id;
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : null;
}
export function MessagesWorkspace({
conversations,
activeConversationId,
onSelectConversation,
onSendSuggestedReply,
isWideDesktop = false,
isDesktop = false,
isTablet = false,
isMobile = false,
}) {
const navigate = useNavigate();
const currentUser = getCurrentUser();
const currentUserId = getUserId(currentUser);
const recentConversations = conversations.slice(0, 3);
const activeConversation =
recentConversations.find((conversation) => conversation.id === activeConversationId) ||
recentConversations[0] ||
conversations[0];
const safeActiveConversation = activeConversation || {
id: 'empty',
name: 'Nenhuma conversa',
status: 'offline',
messages: [],
};
const suggestedReplies = useMemo(
() => buildSuggestedReplies(safeActiveConversation),
[safeActiveConversation],
);
const [selectedReplyIndex, setSelectedReplyIndex] = useState(0);
const [noteDraft, setNoteDraft] = useState('');
const [notes, setNotes] = useState([]);
const [notesError, setNotesError] = useState('');
const selectedReply = suggestedReplies[selectedReplyIndex] || suggestedReplies[0];
const managerMessages = [
{
id: 'sla',
title: 'Comunicado do supervisor',
text: 'Priorizar atendimentos com SLA abaixo de 15 minutos antes de abrir novos casos.',
},
{
id: 'script',
title: 'Atualização de script',
text: 'Use o novo roteiro de confirmação de dados em atendimentos financeiros.',
},
];
useEffect(() => {
setSelectedReplyIndex(0);
}, [safeActiveConversation.id]);
useEffect(() => {
let isMounted = true;
async function loadNotes() {
try {
const data = await listAgentNotes(currentUserId);
if (isMounted) {
setNotes(Array.isArray(data) ? data : []);
setNotesError('');
}
} catch (error) {
if (isMounted) setNotesError(error.message);
}
}
loadNotes();
return () => {
isMounted = false;
};
}, [currentUserId]);
function selectPreviousReply() {
setSelectedReplyIndex((current) =>
current === 0 ? suggestedReplies.length - 1 : current - 1,
);
}
function selectNextReply() {
setSelectedReplyIndex((current) => (current + 1) % suggestedReplies.length);
}
async function saveNote() {
const text = noteDraft.trim();
if (!text || !currentUserId) return;
try {
const note = await createAgentNote(currentUserId, text);
setNotes((current) => [note, ...current]);
setNoteDraft('');
setNotesError('');
} catch (error) {
setNotesError(error.message);
}
}
async function removeNote(noteId) {
if (!currentUserId) return;
try {
await deleteAgentNote(currentUserId, noteId);
setNotes((current) => current.filter((note) => note.id !== noteId));
setNotesError('');
} catch (error) {
setNotesError(error.message);
}
}
async function sendSuggestedReply() {
if (!safeActiveConversation.id || safeActiveConversation.id === 'empty') return;
await onSendSuggestedReply?.(safeActiveConversation.id, selectedReply);
navigate(`/chat?chatId=${encodeURIComponent(safeActiveConversation.id)}`);
}
const gridTemplateColumns = isMobile
? '1fr'
: isWideDesktop
? 'minmax(240px, 0.95fr) minmax(360px, 1.8fr) minmax(220px, 0.8fr)'
: isDesktop || isTablet
? 'minmax(260px, 320px) minmax(0, 1fr)'
: '1fr';
const panelHeight = isMobile ? 'auto' : WORKSPACE_HEIGHT;
return (
<div
style={{
display: 'grid',
gridTemplateColumns,
gap: '1rem',
alignItems: 'stretch',
}}
>
<section
style={{
background: '#fff',
borderRadius: '26px',
border: '1px solid var(--color-border)',
padding: '1rem',
display: 'grid',
gap: '0.75rem',
alignContent: 'start',
height: panelHeight,
minWidth: 0,
}}
>
<div>
<strong style={{ fontSize: '1.05rem' }}>Conversas</strong>
<p style={{ margin: '0.35rem 0 0', color: 'var(--color-text-soft)' }}>
Últimos 3 atendimentos em tempo real.
</p>
</div>
{recentConversations.map((conversation) => {
const isActive = conversation.id === safeActiveConversation.id;
return (
<button
key={conversation.id}
type="button"
onClick={() => onSelectConversation(conversation.id)}
style={{
border: '1px solid',
borderColor: isActive ? 'rgba(0, 164, 183, 0.26)' : 'var(--color-border)',
borderRadius: '20px',
padding: '1rem',
background: isActive ? 'rgba(0, 164, 183, 0.08)' : '#fff',
textAlign: 'left',
display: 'grid',
gap: '0.6rem',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
<strong>{conversation.name}</strong>
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.86rem' }}>
{conversation.time}
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.75rem' }}>
<ChannelBadge channel={conversation.channel} />
<UnreadBadge count={conversation.unread} />
</div>
<span style={{ color: 'var(--color-text-soft)' }}>{conversation.lastMessage}</span>
</button>
);
})}
{conversations.length > 3 ? (
<button
type="button"
onClick={() => navigate('/chat')}
style={{
border: '1px solid var(--color-border)',
borderRadius: '16px',
padding: '0.85rem 1rem',
background: '#fff',
color: 'var(--color-primary)',
fontWeight: 700,
}}
>
Ver todos no chat
</button>
) : null}
</section>
<section
style={{
background: '#fff',
borderRadius: '26px',
border: '1px solid var(--color-border)',
display: 'grid',
gridTemplateRows: 'auto minmax(0, 1fr) auto',
height: panelHeight,
minHeight: isMobile ? 580 : 'auto',
overflow: 'hidden',
minWidth: 0,
}}
>
<header
style={{
padding: '1.15rem 1.25rem',
borderBottom: '1px solid var(--color-border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '1rem',
}}
>
<div>
<strong style={{ display: 'block', fontSize: '1.08rem' }}>
{safeActiveConversation.name}
</strong>
<span style={{ color: 'var(--color-text-soft)' }}>
{safeActiveConversation.lastSeen || 'Sem atividade recente'}
</span>
</div>
<div style={{ display: 'flex', gap: '0.6rem', flexWrap: 'wrap' }}>
<button
type="button"
onClick={() => navigate('/chat')}
style={{
border: '1px solid var(--color-border)',
borderRadius: '14px',
padding: '0.7rem 0.9rem',
background: '#fff',
color: 'var(--color-primary)',
fontWeight: 700,
}}
>
Abrir chat
</button>
<button
type="button"
style={{
border: 'none',
borderRadius: '14px',
padding: '0.7rem 0.9rem',
background: 'rgba(0, 49, 80, 0.08)',
color: 'var(--color-primary)',
fontWeight: 700,
}}
>
Transferir
</button>
</div>
</header>
<div
style={{
padding: '1.25rem',
display: 'grid',
gap: '0.9rem',
alignContent: 'start',
overflowY: 'auto',
background:
'linear-gradient(180deg, rgba(245, 248, 251, 0.45), rgba(255, 255, 255, 0.9))',
}}
>
{safeActiveConversation.messages.map((message) => {
const isAgent = message.from === 'agent';
const parsedText = parseMessageText(message.text);
const messageTime = formatMessageTime(message.timestamp);
return (
<div
key={message.id}
style={{
justifySelf: isAgent ? 'end' : 'start',
maxWidth: '72%',
padding: '0.95rem 1rem',
borderRadius: isAgent ? '18px 18px 6px 18px' : '18px 18px 18px 6px',
background: isAgent ? 'var(--color-primary)' : '#edf1f5',
color: isAgent ? '#fff' : 'var(--color-text)',
boxShadow: 'var(--shadow-md)',
display: 'grid',
gap: '0.55rem',
}}
>
{parsedText.senderLabel ? (
<strong
style={{
display: 'block',
fontSize: '0.76rem',
lineHeight: 1.2,
letterSpacing: '0.02em',
textTransform: 'uppercase',
color: isAgent ? 'rgba(255,255,255,0.78)' : 'var(--color-primary)',
}}
>
{parsedText.senderLabel}
</strong>
) : null}
<span
style={{
whiteSpace: 'pre-wrap',
lineHeight: 1.45,
overflowWrap: 'anywhere',
}}
>
{parsedText.body}
</span>
{messageTime ? (
<span
style={{
justifySelf: 'end',
fontSize: '0.72rem',
lineHeight: 1,
color: isAgent ? 'rgba(255,255,255,0.7)' : 'var(--color-text-soft)',
}}
>
{messageTime}
</span>
) : null}
</div>
);
})}
</div>
<footer
style={{
padding: '0.85rem 1.25rem 1rem',
borderTop: '1px solid var(--color-border)',
display: 'grid',
gap: '0.65rem',
}}
>
<strong style={{ display: 'block', fontSize: '0.94rem' }}>Resposta sugerida</strong>
<div
style={{
display: 'grid',
gridTemplateColumns: '40px minmax(0, 1fr) 40px',
gap: '0.6rem',
alignItems: 'stretch',
}}
>
<button
type="button"
onClick={selectPreviousReply}
title="Resposta anterior"
style={{
border: '1px solid var(--color-border)',
borderRadius: '14px',
background: '#fff',
color: 'var(--color-primary)',
fontWeight: 900,
}}
>
</button>
<button
type="button"
onClick={sendSuggestedReply}
style={{
border: '1px solid rgba(0, 164, 183, 0.32)',
borderRadius: '16px',
padding: '0.75rem 0.9rem',
background: 'rgba(0, 164, 183, 0.07)',
color: 'var(--color-text)',
fontWeight: 600,
textAlign: 'left',
lineHeight: 1.35,
minWidth: 0,
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
>
{selectedReply}
</button>
<button
type="button"
onClick={selectNextReply}
title="Próxima resposta"
style={{
border: '1px solid var(--color-border)',
borderRadius: '14px',
background: '#fff',
color: 'var(--color-primary)',
fontWeight: 900,
}}
>
</button>
</div>
</footer>
</section>
<aside
style={{
background: '#fff',
borderRadius: '26px',
border: '1px solid var(--color-border)',
padding: '1.2rem',
display: 'grid',
gridTemplateRows: 'auto minmax(0, 1fr)',
gap: '1rem',
gridColumn: isWideDesktop ? 'auto' : '1 / -1',
height: panelHeight,
minWidth: 0,
}}
>
<div>
<strong style={{ fontSize: '1.05rem' }}>Comunicados e notas</strong>
</div>
<div
style={{
display: 'grid',
gap: '0.85rem',
alignContent: 'start',
overflowY: 'auto',
paddingRight: '0.15rem',
}}
>
{managerMessages.map((message) => (
<article
key={message.id}
style={{
borderRadius: '18px',
padding: '0.95rem',
background: 'rgba(0, 49, 80, 0.04)',
display: 'grid',
gap: '0.4rem',
}}
>
<strong>{message.title}</strong>
<p style={{ margin: 0, color: 'var(--color-text-soft)', lineHeight: 1.5 }}>
{message.text}
</p>
</article>
))}
<label style={{ display: 'grid', gap: '0.45rem' }}>
<span style={{ color: 'var(--color-text-soft)', fontWeight: 700 }}>Anotação rápida</span>
<textarea
value={noteDraft}
onChange={(event) => setNoteDraft(event.target.value)}
placeholder="Ex: cliente pediu retorno após as 15h"
rows={4}
style={{
border: '1px solid var(--color-border)',
borderRadius: '14px',
padding: '0.85rem 0.9rem',
background: '#fff',
color: 'var(--color-text)',
resize: 'none',
outline: 'none',
lineHeight: 1.45,
}}
/>
</label>
<button
type="button"
onClick={saveNote}
disabled={!currentUserId}
style={{
border: 'none',
borderRadius: '18px',
padding: '0.95rem 1rem',
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
color: '#fff',
fontWeight: 800,
opacity: currentUserId ? 1 : 0.55,
}}
>
Salvar anotação
</button>
{notesError ? (
<span style={{ color: '#b42318', fontWeight: 700 }}>{notesError}</span>
) : null}
<div style={{ display: 'grid', gap: '0.55rem' }}>
{notes.length ? (
notes.map((note) => (
<article
key={note.id}
style={{
border: '1px solid var(--color-border)',
borderRadius: '16px',
padding: '0.8rem',
background: '#fff',
display: 'grid',
gap: '0.35rem',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.75rem' }}>
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.82rem' }}>
{formatMessageTime(new Date(note.created_at).getTime())}
</span>
<button
type="button"
onClick={() => removeNote(note.id)}
title="Excluir anotação"
style={{
border: 'none',
borderRadius: 999,
width: 26,
height: 26,
background: 'rgba(214, 40, 40, 0.1)',
color: '#b42318',
fontWeight: 900,
}}
>
x
</button>
</div>
<p style={{ margin: 0, lineHeight: 1.45 }}>{note.text}</p>
</article>
))
) : (
<span style={{ color: 'var(--color-text-soft)' }}>Nenhuma anotação salva.</span>
)}
</div>
</div>
</aside>
</div>
);
}