omnichannel-frontend/src/modules/chat/components/ChatWindow.jsx
Rafael Lopes f690c6d652 FEAT: melhora painel admin, templates e fluxos de atendimento
- adiciona flow builder visual, conteúdos da IA e disparo em massa com agenda
- melhora templates com categoria, variáveis e preview estilo WhatsApp
- ajusta abrir atendimento dentro do painel, com preview, tag e variáveis
- refina tela de chat com encerramento, status de fila e correções visuais
2026-05-26 09:08:08 -03:00

807 lines
24 KiB
JavaScript

import { Fragment, useEffect, useMemo, useRef } from 'react';
function getMediaUrl(media) {
if (!media?.data || !media?.mimetype) return '';
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 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 getMessageDate(timestamp) {
if (!timestamp) return null;
const numericTimestamp = Number(timestamp);
const date = new Date(numericTimestamp > 1000000000000 ? numericTimestamp : numericTimestamp * 1000);
if (Number.isNaN(date.getTime())) return null;
return date;
}
function getDateKey(timestamp) {
const date = getMessageDate(timestamp);
if (!date) return '';
return date.toISOString().slice(0, 10);
}
function formatDateSeparator(timestamp) {
const date = getMessageDate(timestamp);
if (!date) return '';
const today = new Date();
const isToday =
date.getFullYear() === today.getFullYear() &&
date.getMonth() === today.getMonth() &&
date.getDate() === today.getDate();
if (isToday) return 'Hoje';
return date.toLocaleDateString('pt-BR');
}
function DateSeparator({ label }) {
if (!label) return null;
return (
<div
style={{
width: '100%',
display: 'grid',
gridTemplateColumns: '1fr auto 1fr',
gap: '0.75rem',
alignItems: 'center',
color: 'var(--color-text-soft)',
fontSize: '0.78rem',
fontWeight: 800,
textTransform: 'uppercase',
}}
>
<span style={{ height: 1, background: 'var(--color-border)' }} />
<span
style={{
border: '1px solid var(--color-border)',
borderRadius: 999,
padding: '0.28rem 0.7rem',
background: 'rgba(255,255,255,0.88)',
}}
>
{label}
</span>
<span style={{ height: 1, background: 'var(--color-border)' }} />
</div>
);
}
function MediaRenderer({ message, contactId, onLoadMedia, isAgent }) {
const mediaUrl = useMemo(() => getMediaUrl(message.media), [message.media]);
const mimetype = message.media?.mimetype || '';
const filename = message.media?.filename || 'arquivo';
useEffect(() => {
if (!message.hasMedia || message.media?.data || message.mediaLoading || message.mediaError) {
return;
}
onLoadMedia?.(contactId, message.id);
}, [contactId, message, onLoadMedia]);
if (!message.hasMedia && !message.media) return null;
if (message.mediaLoading || (!message.media?.data && !message.mediaError)) {
return (
<div
style={{
width: 260,
maxWidth: '100%',
height: 150,
borderRadius: 14,
background: isAgent ? 'rgba(255,255,255,0.18)' : 'rgba(0,49,80,0.08)',
display: 'grid',
placeItems: 'center',
color: isAgent ? '#fff' : 'var(--color-text-soft)',
fontWeight: 700,
}}
>
Carregando mídia...
</div>
);
}
if (message.mediaError) {
return (
<span style={{ color: isAgent ? '#fff' : 'var(--color-text-soft)', fontWeight: 700 }}>
Não foi possível carregar a mídia.
</span>
);
}
if (mimetype.startsWith('image/')) {
return (
<a href={mediaUrl} target="_blank" rel="noreferrer" style={{ display: 'block' }}>
<img
src={mediaUrl}
alt={filename}
style={{
display: 'block',
width: 280,
maxWidth: '100%',
maxHeight: 340,
objectFit: 'cover',
borderRadius: 14,
boxShadow: '0 14px 30px rgba(0,0,0,0.18)',
transition: 'transform 160ms ease',
}}
onMouseEnter={(event) => {
event.currentTarget.style.transform = 'scale(1.015)';
}}
onMouseLeave={(event) => {
event.currentTarget.style.transform = 'scale(1)';
}}
/>
</a>
);
}
if (mimetype.startsWith('video/')) {
return (
<video
src={mediaUrl}
controls
style={{
width: 320,
maxWidth: '100%',
borderRadius: 14,
background: '#111',
}}
/>
);
}
if (mimetype.startsWith('audio/') || mimetype.includes('ogg')) {
return <audio src={mediaUrl} controls style={{ width: 280, maxWidth: '100%' }} />;
}
return (
<a
href={mediaUrl}
download={filename}
style={{
display: 'grid',
gridTemplateColumns: 'auto 1fr',
gap: '0.75rem',
alignItems: 'center',
padding: '0.85rem',
borderRadius: 14,
background: isAgent ? 'rgba(255,255,255,0.16)' : '#fff',
color: isAgent ? '#fff' : 'var(--color-primary)',
fontWeight: 700,
}}
>
<span aria-hidden="true">📄</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{filename}</span>
</a>
);
}
function AttachmentPreview({ file, onRemove }) {
if (!file) return null;
const mediaUrl = getMediaUrl({ data: file.data, mimetype: file.type });
return (
<div
style={{
border: '1px solid var(--color-border)',
borderRadius: 16,
padding: '0.75rem',
display: 'grid',
gridTemplateColumns: 'auto 1fr auto',
gap: '0.75rem',
alignItems: 'center',
background: '#fff',
}}
>
{file.type?.startsWith('image/') ? (
<img
src={mediaUrl}
alt={file.name}
style={{ width: 54, height: 54, objectFit: 'cover', borderRadius: 12 }}
/>
) : (
<span
style={{
width: 54,
height: 54,
borderRadius: 12,
display: 'grid',
placeItems: 'center',
background: 'rgba(0,49,80,0.08)',
}}
aria-hidden="true"
>
📎
</span>
)}
<div style={{ minWidth: 0 }}>
<strong style={{ display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{file.name}
</strong>
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.86rem' }}>{file.type || 'arquivo'}</span>
</div>
<button
type="button"
onClick={onRemove}
title="Remover anexo"
style={{
border: 'none',
borderRadius: 12,
width: 36,
height: 36,
background: 'rgba(214, 40, 40, 0.1)',
color: '#b42318',
fontWeight: 900,
}}
>
x
</button>
</div>
);
}
function ContactActivity({ contact }) {
if (!contact) {
return null;
}
const status = contact.status || 'offline';
const color = status === 'away' ? '#e5a22a' : '#dc2626';
const label = contact.lastSeen || 'Sem atividade recente';
return (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.5rem',
color: 'var(--color-text-soft)',
}}
>
<span
aria-hidden="true"
style={{
width: 10,
height: 10,
borderRadius: 999,
background: color,
boxShadow: `0 0 0 3px ${color}22`,
}}
/>
{label}
</span>
);
}
export function ChatWindow({
contact,
messages,
selectedArea,
setSelectedArea,
draft,
setDraft,
attachedFile,
onAttachFile,
onRemoveAttachedFile,
onLoadMedia,
onSend,
onToggleTransfer,
onAssumeChat,
onReleaseChat,
onCloseChat,
canAssumeChat = false,
canReply = true,
assignmentLabel,
transferNote,
isReplying,
isPaused = false,
pauseDurationLabel = '00:00',
isMobile = false,
}) {
const messagesRef = useRef(null);
const safeContact = contact || {
id: '',
name: isPaused ? 'Atendimento pausado' : 'Nenhuma conversa ativa',
status: 'offline',
lastSeen: isPaused ? `Pausa em andamento: ${pauseDurationLabel}` : 'Aguardando fila do Agente Virtual Sothis',
};
useEffect(() => {
const container = messagesRef.current;
if (!container) {
return;
}
container.scrollTo({
top: container.scrollHeight,
behavior: 'auto',
});
}, [messages, isReplying]);
if (isPaused) {
return (
<section
style={{
background: '#fff',
border: '1px solid var(--color-border)',
borderRadius: '28px',
overflow: 'hidden',
display: 'grid',
gridTemplateRows: 'auto minmax(0, 1fr)',
height: isMobile ? 'auto' : 'min(760px, calc(100vh - 190px))',
minHeight: isMobile ? 420 : 0,
minWidth: 0,
}}
>
<header
style={{
padding: '1.25rem 1.5rem',
borderBottom: '1px solid var(--color-border)',
}}
>
<strong style={{ display: 'block', fontSize: '1.15rem' }}>Atendimento pausado</strong>
<span style={{ color: 'var(--color-text-soft)' }}>Pausa em andamento: {pauseDurationLabel}</span>
</header>
<div
style={{
padding: '1.5rem',
display: 'grid',
placeItems: 'center',
minHeight: 0,
background:
'radial-gradient(circle at top left, rgba(0, 164, 183, 0.06), transparent 22%), linear-gradient(180deg, rgba(245, 248, 251, 0.8), rgba(255, 255, 255, 0.95))',
}}
>
<div
style={{
maxWidth: 460,
border: '1px solid var(--color-border)',
borderRadius: 20,
padding: '1.2rem',
background: '#fff',
color: 'var(--color-text-soft)',
fontWeight: 700,
lineHeight: 1.5,
textAlign: 'center',
}}
>
Voce esta em pausa ha {pauseDurationLabel}. Retome o atendimento pela Home para visualizar a fila,
assumir chamados e responder clientes.
</div>
</div>
</section>
);
}
return (
<section
style={{
background: '#fff',
border: '1px solid var(--color-border)',
borderRadius: '28px',
overflow: 'hidden',
display: 'grid',
gridTemplateRows: 'auto minmax(0, 1fr) auto',
height: isMobile ? 'auto' : 'min(760px, calc(100vh - 190px))',
minHeight: isMobile ? 640 : 0,
minWidth: 0,
}}
>
<header
style={{
padding: '1.25rem 1.5rem',
borderBottom: '1px solid var(--color-border)',
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 1fr) auto',
gap: '1rem',
alignItems: 'center',
}}
>
<div>
<strong style={{ display: 'block', fontSize: '1.15rem' }}>{safeContact.name}</strong>
<ContactActivity contact={safeContact} />
</div>
<div
style={{
display: 'flex',
gap: '0.7rem',
flexWrap: 'wrap',
justifyContent: isMobile ? 'stretch' : 'flex-end',
}}
>
<select
value={selectedArea}
onChange={(event) => setSelectedArea(event.target.value)}
disabled
style={{
border: '1px solid var(--color-border)',
borderRadius: '14px',
padding: '0.8rem 0.95rem',
background: '#fff',
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>
<button
type="button"
onClick={onCloseChat}
style={{
border: 'none',
borderRadius: '14px',
padding: '0.8rem 1rem',
background: 'rgba(181, 31, 31, 0.1)',
color: 'var(--color-secondary)',
fontWeight: 800,
}}
>
Encerrar atendimento
</button>
</>
) : null}
<button
type="button"
onClick={onToggleTransfer}
disabled={!canReply}
style={{
border: 'none',
borderRadius: '14px',
padding: '0.8rem 1rem',
background: 'rgba(0, 49, 80, 0.08)',
color: 'var(--color-primary)',
fontWeight: 700,
opacity: canReply ? 1 : 0.55,
}}
>
Transferir
</button>
</div>
{transferNote ? (
<div
style={{
gridColumn: '1 / -1',
border: '1px solid rgba(0, 164, 183, 0.24)',
borderRadius: 16,
padding: '0.85rem 1rem',
background: 'rgba(0, 164, 183, 0.07)',
color: 'var(--color-text)',
lineHeight: 1.45,
}}
>
<strong style={{ display: 'block', color: 'var(--color-primary)', marginBottom: '0.25rem' }}>
Observação da transferência
</strong>
{transferNote}
</div>
) : null}
</header>
<div
ref={messagesRef}
style={{
padding: '1.5rem',
display: 'grid',
gap: '0.9rem',
alignContent: 'start',
overflowY: 'auto',
minHeight: 0,
background:
'radial-gradient(circle at top left, rgba(0, 164, 183, 0.06), transparent 22%), linear-gradient(180deg, rgba(245, 248, 251, 0.8), rgba(255, 255, 255, 0.95))',
}}
>
{messages.map((message, index) => {
const isAgent = message.sender === 'agent';
const isSystem = message.sender === 'system';
const parsedText = parseMessageText(message.text);
const messageTime = formatMessageTime(message.timestamp);
const dateKey = getDateKey(message.timestamp);
const previousDateKey = index > 0 ? getDateKey(messages[index - 1]?.timestamp) : '';
const shouldShowDateSeparator = dateKey && dateKey !== previousDateKey;
const dateSeparator = formatDateSeparator(message.timestamp);
if (isSystem) {
return (
<Fragment key={message.id}>
{shouldShowDateSeparator ? <DateSeparator label={dateSeparator} /> : null}
<div
style={{
justifySelf: 'center',
padding: '0.7rem 1rem',
borderRadius: '999px',
background: 'rgba(0, 49, 80, 0.08)',
color: 'var(--color-primary)',
fontSize: '0.88rem',
fontWeight: 600,
}}
>
{message.text}
</div>
</Fragment>
);
}
return (
<Fragment key={message.id}>
{shouldShowDateSeparator ? <DateSeparator label={dateSeparator} /> : null}
<div
style={{
justifySelf: isAgent ? 'end' : 'start',
maxWidth: isMobile ? '88%' : '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.65rem',
}}
>
<MediaRenderer
message={message}
contactId={safeContact.id}
onLoadMedia={onLoadMedia}
isAgent={isAgent}
/>
{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}
{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>
</Fragment>
);
})}
{messages.length === 0 ? (
<div
style={{
justifySelf: 'center',
padding: '0.8rem 1rem',
borderRadius: 16,
background: 'rgba(0,49,80,0.06)',
color: 'var(--color-text-soft)',
fontWeight: 700,
}}
>
{isPaused
? `Voce esta em pausa ha ${pauseDurationLabel}. Volte da pausa para visualizar a fila e seus atendimentos.`
: 'Nenhuma mensagem carregada.'}
</div>
) : null}
{isReplying ? (
<div
style={{
justifySelf: 'start',
padding: '0.8rem 0.95rem',
borderRadius: '18px 18px 18px 6px',
background: '#edf1f5',
color: 'var(--color-text-soft)',
fontWeight: 600,
}}
>
Digitando...
</div>
) : null}
</div>
<footer
style={{
padding: '1rem 1.25rem 1.25rem',
borderTop: '1px solid var(--color-border)',
display: 'grid',
gridTemplateColumns: '1fr',
gap: '0.75rem',
}}
>
<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,
}}
>
<span style={{ display: 'block' }}>
{isPaused
? `Voce esta em pausa ha ${pauseDurationLabel}. Nenhum atendimento sera exibido ate voce voltar.`
: canAssumeChat
? 'Este atendimento está na fila. Assuma para responder ou transferir.'
: assignmentLabel || 'Este atendimento está atribuído a outro usuário.'}
</span>
{transferNote ? (
<span style={{ display: 'block', marginTop: '0.45rem', color: 'var(--color-text)' }}>
Obs: {transferNote}
</span>
) : null}
</div>
) : null}
<div
style={{
display: 'grid',
gridTemplateColumns: isMobile ? 'auto 1fr' : 'auto 1fr auto',
gap: '0.75rem',
alignItems: 'center',
}}
>
<label
title="Anexar arquivo"
style={{
border: '1px solid var(--color-border)',
borderRadius: 16,
width: 48,
height: 48,
display: 'grid',
placeItems: 'center',
background: '#fff',
cursor: 'pointer',
fontWeight: 900,
}}
>
📎
<input
type="file"
accept="image/png,image/jpeg,image/jpg,image/webp,video/mp4,video/webm,audio/mp3,audio/mpeg,audio/ogg,audio/wav,application/pdf"
onChange={(event) => {
onAttachFile?.(event.target.files?.[0]);
event.target.value = '';
}}
style={{ display: 'none' }}
disabled={!safeContact.id}
/>
</label>
<input
type="text"
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
onSend?.(draft);
}
}}
disabled={!safeContact.id || !canReply}
placeholder={
isPaused
? 'Voce esta em pausa'
: !safeContact.id
? 'Aguardando conversa entrar em uma fila'
: canReply
? 'Escreva sua mensagem...'
: assignmentLabel?.includes('Aguardando resposta')
? 'Aguardando resposta do cliente'
: canAssumeChat
? 'Assuma o atendimento para responder'
: 'Atendimento bloqueado para resposta'
}
style={{
border: '1px solid var(--color-border)',
borderRadius: '18px',
padding: '0.95rem 1rem',
background: '#fff',
outline: 'none',
minWidth: 0,
opacity: safeContact.id && canReply ? 1 : 0.6,
}}
/>
<button
type="button"
onClick={() => onSend?.(draft)}
disabled={!safeContact.id || !canReply}
style={{
border: 'none',
borderRadius: '18px',
padding: '0.95rem 1.2rem',
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
color: '#fff',
fontWeight: 700,
gridColumn: isMobile ? '1 / -1' : 'auto',
opacity: safeContact.id && canReply ? 1 : 0.6,
}}
>
Enviar
</button>
</div>
</footer>
</section>
);
}