FEAT: Editado Home page para se adequar ao serviço de pausa

This commit is contained in:
Rafael Alves Lopes 2026-05-25 14:32:41 -03:00
parent fe40e8bd76
commit 4b0a4bb3e3
7 changed files with 345 additions and 52 deletions

View File

@ -318,14 +318,16 @@ export function ChatWindow({
assignmentLabel,
transferNote,
isReplying,
isPaused = false,
pauseDurationLabel = '00:00',
isMobile = false,
}) {
const messagesRef = useRef(null);
const safeContact = contact || {
id: '',
name: 'Nenhuma conversa ativa',
name: isPaused ? 'Atendimento pausado' : 'Nenhuma conversa ativa',
status: 'offline',
lastSeen: 'Aguardando fila do Omnino',
lastSeen: isPaused ? `Pausa em andamento: ${pauseDurationLabel}` : 'Aguardando fila do Omnino',
};
useEffect(() => {
@ -340,6 +342,62 @@ export function ChatWindow({
});
}, [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={{
@ -572,7 +630,7 @@ export function ChatWindow({
);
})}
{messages.length === 0 ? (
{messages.length === 0 ? (
<div
style={{
justifySelf: 'center',
@ -583,7 +641,9 @@ export function ChatWindow({
fontWeight: 700,
}}
>
Nenhuma mensagem carregada.
{isPaused
? `Voce esta em pausa ha ${pauseDurationLabel}. Volte da pausa para visualizar a fila e seus atendimentos.`
: 'Nenhuma mensagem carregada.'}
</div>
) : null}
@ -625,7 +685,9 @@ export function ChatWindow({
}}
>
<span style={{ display: 'block' }}>
{canAssumeChat
{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>
@ -681,7 +743,9 @@ export function ChatWindow({
}}
disabled={!safeContact.id || !canReply}
placeholder={
!safeContact.id
isPaused
? 'Voce esta em pausa'
: !safeContact.id
? 'Aguardando conversa entrar em uma fila'
: canReply
? 'Escreva sua mensagem...'

View File

@ -4,9 +4,33 @@ import { API_BASE_URL } from '../../../shared/services/apiConfig';
import { getAccessOptions, getAccessUsers } from '../../management/services/adminAccessService';
import { getCurrentUser, getCurrentUserProfile } from '../../auth/services/sessionService';
import { transferAreas as fallbackTransferAreas } from '../services/chatMocks';
import {
getAgentPresence,
listAgentPresence,
pauseAgent,
resumeAgent,
} from '../services/agentPresenceService';
const MAX_ATTACHMENT_SIZE_BYTES = 15 * 1024 * 1024;
function getPresenceByUserId(presenceList, userId) {
return presenceList.find((presence) => Number(presence.user_id) === Number(userId)) || null;
}
function formatPauseDuration(totalSeconds) {
const seconds = Math.max(0, Number(totalSeconds || 0));
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
if (hours > 0) {
return `${hours}h ${String(remainingMinutes).padStart(2, '0')}m`;
}
return `${String(remainingMinutes).padStart(2, '0')}:${String(remainingSeconds).padStart(2, '0')}`;
}
function getLastMessageFromMe(messages = []) {
const lastMessage = [...messages].reverse().find(isDisplayableMessage);
if (!lastMessage) return false;
@ -210,6 +234,10 @@ export function useChat() {
const [attachedFile, setAttachedFile] = useState(null);
const [areaOptions, setAreaOptions] = useState([]);
const [accessUsers, setAccessUsers] = useState([]);
const [presenceList, setPresenceList] = useState([]);
const [agentPresence, setAgentPresence] = useState(null);
const [pauseSeconds, setPauseSeconds] = useState(0);
const [isPresenceLoading, setIsPresenceLoading] = useState(false);
const [selectedArea, setSelectedArea] = useState('Sem fila');
const [isTransferOpen, setIsTransferOpen] = useState(false);
const [transferArea, setTransferArea] = useState(currentUserAreas[0] || 'Suporte');
@ -221,6 +249,7 @@ export function useChat() {
const [apiError, setApiError] = useState(null);
const activeContactRef = useRef(activeContactId);
const contactsRef = useRef(contacts);
const isPaused = agentPresence?.status === 'paused';
const activeContact = useMemo(
() => {
@ -240,8 +269,12 @@ export function useChat() {
const usersInTransferArea = accessUsers.filter((user) =>
user.areas?.some((area) => area.nome === transferArea) || user.areaPrincipal?.nome === transferArea,
);
const availableUsersInTransferArea = usersInTransferArea.filter((user) => {
const presence = getPresenceByUserId(presenceList, user.id);
return !presence || presence.status === 'available';
});
const isSameUserArea = currentUserAreas.includes(transferArea);
const attendants = isSameUserArea ? usersInTransferArea : [];
const attendants = isSameUserArea ? availableUsersInTransferArea : [];
const activeAssignment = activeContact?.assignment || null;
const isAssignedToCurrentUser = Boolean(
activeAssignment?.user_id && currentUserId && Number(activeAssignment.user_id) === currentUserId,
@ -251,8 +284,8 @@ export function useChat() {
activeAssignment?.status === 'queued' &&
(isAdminUser || !activeAssignment.area_nome || currentUserAreas.includes(activeAssignment.area_nome)),
);
const canAssumeChat = Boolean(activeContact?.id?.includes('@') && currentUserId && isQueuedForUserArea);
const canReply = Boolean(isAssignedToCurrentUser && !isWaitingCustomerReply);
const canAssumeChat = Boolean(!isPaused && activeContact?.id?.includes('@') && currentUserId && isQueuedForUserArea);
const canReply = Boolean(!isPaused && isAssignedToCurrentUser && !isWaitingCustomerReply);
const assignmentLabel = activeAssignment?.user_id
? isWaitingCustomerReply
? 'Aguardando resposta do cliente para liberar novas mensagens'
@ -268,7 +301,7 @@ export function useChat() {
useEffect(() => {
setTransferAttendant(attendants[0]?.id ? String(attendants[0].id) : '');
}, [transferArea, accessUsers]);
}, [transferArea, accessUsers, presenceList]);
useEffect(() => {
activeContactRef.current = activeContactId;
@ -283,14 +316,23 @@ export function useChat() {
async function loadAccessData() {
try {
const [options, users] = await Promise.all([getAccessOptions(), getAccessUsers()]);
const [options, users, presences, currentPresence] = await Promise.all([
getAccessOptions(),
getAccessUsers(),
listAgentPresence(),
currentUserId ? getAgentPresence(currentUserId) : Promise.resolve(null),
]);
if (!isMounted) return;
setAreaOptions(options.areas || []);
setAccessUsers(users || []);
setPresenceList(Array.isArray(presences) ? presences : []);
setAgentPresence(currentPresence);
setPauseSeconds(Number(currentPresence?.paused_seconds || 0));
} catch {
if (isMounted) {
setAreaOptions([]);
setAccessUsers([]);
setPresenceList([]);
}
}
}
@ -299,7 +341,43 @@ export function useChat() {
return () => {
isMounted = false;
};
}, []);
}, [currentUserId]);
useEffect(() => {
if (!currentUserId) return undefined;
let isMounted = true;
async function refreshPresence() {
try {
const [presences, currentPresence] = await Promise.all([
listAgentPresence(),
getAgentPresence(currentUserId),
]);
if (!isMounted) return;
setPresenceList(Array.isArray(presences) ? presences : []);
setAgentPresence(currentPresence);
setPauseSeconds(Number(currentPresence?.paused_seconds || 0));
} catch {
if (isMounted) setPresenceList([]);
}
}
const intervalId = window.setInterval(refreshPresence, 30000);
return () => {
isMounted = false;
window.clearInterval(intervalId);
};
}, [currentUserId]);
useEffect(() => {
if (!isPaused) return undefined;
const intervalId = window.setInterval(() => {
setPauseSeconds((current) => current + 1);
}, 1000);
return () => window.clearInterval(intervalId);
}, [isPaused]);
function canSeeContact(contact) {
if (isAdminUser) {
@ -313,7 +391,15 @@ export function useChat() {
return currentUserAreas.includes(contact.assignment.area_nome);
}
async function loadChats({ showLoading = false } = {}) {
async function loadChats({ showLoading = false, ignorePause = false } = {}) {
if (isPaused && !ignorePause) {
setContacts((current) => (current.length ? [] : current));
setActiveContactId('');
setMessagesByContact({});
setIsLoadingChats(false);
return;
}
if (whatsappStatus !== 'CONNECTED') {
setContacts((current) => (current.length ? [] : current));
setActiveContactId('');
@ -358,7 +444,7 @@ export function useChat() {
isMounted = false;
window.clearInterval(intervalId);
};
}, [currentUserId, currentUserAreas.join('|'), isAdminUser, whatsappStatus]);
}, [currentUserId, currentUserAreas.join('|'), isAdminUser, isPaused, whatsappStatus]);
useEffect(() => {
if (!activeContactId) return;
@ -586,6 +672,43 @@ export function useChat() {
}));
}
async function pauseAttendance() {
if (!currentUserId) return;
setIsPresenceLoading(true);
try {
const result = await pauseAgent(currentUserId);
setAgentPresence(result.presence);
setPauseSeconds(0);
setContacts([]);
setActiveContactId('');
setMessagesByContact({});
setIsTransferOpen(false);
setApiError(null);
} catch (error) {
setApiError(error.message);
} finally {
setIsPresenceLoading(false);
}
}
async function resumeAttendance() {
if (!currentUserId) return;
setIsPresenceLoading(true);
try {
const result = await resumeAgent(currentUserId);
setAgentPresence(result.presence);
setPauseSeconds(0);
setApiError(null);
await loadChats({ showLoading: true, ignorePause: true });
const presences = await listAgentPresence();
setPresenceList(Array.isArray(presences) ? presences : []);
} catch (error) {
setApiError(error.message);
} finally {
setIsPresenceLoading(false);
}
}
async function sendMessage(messageText = draft, contactId = activeContactId) {
const rawMessage = typeof messageText === 'string' ? messageText : draft;
const trimmed = rawMessage.trim();
@ -769,5 +892,12 @@ export function useChat() {
transferNote,
setTransferNote,
submitTransfer,
agentPresence,
isPaused,
pauseSeconds,
pauseDurationLabel: formatPauseDuration(pauseSeconds),
isPresenceLoading,
pauseAttendance,
resumeAttendance,
};
}

View File

@ -48,6 +48,8 @@ export function ChatPage() {
transferNote,
setTransferNote,
submitTransfer,
isPaused,
pauseDurationLabel,
} = useChat();
const requestedChatId = searchParams.get('chatId');
const handledRequestedChatIdRef = useRef('');
@ -111,7 +113,7 @@ export function ChatPage() {
textAlign: 'center',
}}
>
Atendimento em tempo real
{isPaused ? `Pausado ha ${pauseDurationLabel}` : 'Atendimento em tempo real'}
</div>
<Link
to="/home"
@ -173,6 +175,8 @@ export function ChatPage() {
assignmentLabel={assignmentLabel}
transferNote={transferNoteLabel}
isReplying={isReplying}
isPaused={isPaused}
pauseDurationLabel={pauseDurationLabel}
isMobile={isMobile}
/>
@ -188,6 +192,7 @@ export function ChatPage() {
key={reply}
type="button"
onClick={() => setDraft(reply)}
disabled={isPaused}
style={{
border: '1px solid var(--color-border)',
borderRadius: '18px',
@ -196,6 +201,7 @@ export function ChatPage() {
color: 'var(--color-primary)',
fontWeight: 600,
textAlign: 'left',
opacity: isPaused ? 0.55 : 1,
}}
>
{reply}

View File

@ -0,0 +1,46 @@
import { API_BASE_URL } from '../../../shared/services/apiConfig';
async function request(path, options = {}) {
const response = await fetch(`${API_BASE_URL}${path}`, {
headers: {
'Content-Type': 'application/json',
...options.headers,
},
...options,
});
if (!response.ok) {
throw new Error('Falha ao atualizar presenca do agente');
}
return response.json();
}
export async function getAgentPresence(userId) {
return request(`/agent/presence/me?userId=${encodeURIComponent(userId)}`);
}
export async function listAgentPresence() {
return request('/agent/presence');
}
export async function pauseAgent(userId) {
return request('/agent/presence/pause', {
method: 'POST',
body: JSON.stringify({ userId }),
});
}
export async function resumeAgent(userId) {
return request('/agent/presence/resume', {
method: 'POST',
body: JSON.stringify({ userId }),
});
}
export async function markAgentOffline(userId) {
return request('/agent/presence/offline', {
method: 'POST',
body: JSON.stringify({ userId }),
});
}

View File

@ -1,29 +1,39 @@
import { useState, useEffect } from 'react';
import { useEffect, useState } from 'react';
export function AttendantOpsPanel({ activeChatsCount }) {
const [isPaused, setIsPaused] = useState(false);
export function AttendantOpsPanel({
activeChatsCount,
isPaused = false,
pauseDurationLabel = '00:00',
isPresenceLoading = false,
onTogglePause,
}) {
const [secondsOnline, setSecondsOnline] = useState(0);
useEffect(() => {
let interval;
if (!isPaused) {
interval = setInterval(() => {
setSecondsOnline((s) => s + 1);
}, 1000);
}
return () => clearInterval(interval);
if (isPaused) return undefined;
const intervalId = window.setInterval(() => {
setSecondsOnline((current) => current + 1);
}, 1000);
return () => window.clearInterval(intervalId);
}, [isPaused]);
const formatTime = (totalSeconds) => {
const h = Math.floor(totalSeconds / 3600);
const m = Math.floor((totalSeconds % 3600) / 60);
const s = totalSeconds % 60;
return [h, m, s]
.map(v => v.toString().padStart(2, '0'))
.filter((v, i) => v !== '00' || i > 0)
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
return [hours, minutes, seconds]
.map((value) => value.toString().padStart(2, '0'))
.filter((value, index) => value !== '00' || index > 0)
.join(':');
};
const presenceLabel = isPaused ? 'Tempo em pausa' : 'Tempo online';
const presenceTime = isPaused ? pauseDurationLabel : formatTime(secondsOnline);
const statusColor = isPaused ? '#ef4444' : '#10b981';
return (
<div
style={{
@ -46,20 +56,22 @@ export function AttendantOpsPanel({ activeChatsCount }) {
>
<div>
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.9rem', fontWeight: 600 }}>
Tempo Online
{presenceLabel}
</span>
<strong style={{ display: 'block', fontSize: '1.6rem', marginTop: '0.2rem', color: 'var(--color-text)' }}>
{formatTime(secondsOnline)}
{presenceTime}
</strong>
</div>
<div style={{
width: '12px',
height: '12px',
borderRadius: '50%',
background: isPaused ? '#ef4444' : '#10b981',
boxShadow: `0 0 10px ${isPaused ? '#ef4444' : '#10b981'}`,
animation: !isPaused ? 'pulse 2s infinite' : 'none'
}} />
<div
title={isPaused ? 'Agente pausado' : 'Agente disponivel'}
style={{
width: 12,
height: 12,
borderRadius: '50%',
background: statusColor,
boxShadow: `0 0 10px ${statusColor}`,
}}
/>
</article>
<article
@ -76,10 +88,10 @@ export function AttendantOpsPanel({ activeChatsCount }) {
>
<div>
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.9rem', fontWeight: 600 }}>
Atendimentos Abertos
Atendimentos abertos
</span>
<strong style={{ display: 'block', fontSize: '1.6rem', marginTop: '0.2rem', color: 'var(--color-text)' }}>
{activeChatsCount}
{isPaused ? 0 : activeChatsCount}
</strong>
</div>
</article>
@ -93,11 +105,13 @@ export function AttendantOpsPanel({ activeChatsCount }) {
boxShadow: 'var(--shadow-sm)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
justifyContent: 'center',
}}
>
<button
onClick={() => setIsPaused(!isPaused)}
type="button"
onClick={onTogglePause}
disabled={isPresenceLoading}
style={{
width: '100%',
height: '100%',
@ -107,12 +121,13 @@ export function AttendantOpsPanel({ activeChatsCount }) {
background: isPaused ? 'rgba(16, 185, 129, 0.1)' : 'rgba(239, 68, 68, 0.1)',
color: isPaused ? '#10b981' : '#ef4444',
fontSize: '1rem',
fontWeight: 700,
cursor: 'pointer',
fontWeight: 800,
cursor: isPresenceLoading ? 'wait' : 'pointer',
transition: 'all 0.2s ease',
opacity: isPresenceLoading ? 0.7 : 1,
}}
>
{isPaused ? 'Retomar Atendimento' : 'Pausar'}
{isPaused ? 'Retomar Atendimento' : 'Pausar'}
</button>
</article>
</div>

View File

@ -200,6 +200,8 @@ export function MessagesWorkspace({
isDesktop = false,
isTablet = false,
isMobile = false,
isPaused = false,
pauseDurationLabel = '00:00',
}) {
const navigate = useNavigate();
const messagesRef = useRef(null);
@ -311,6 +313,7 @@ export function MessagesWorkspace({
}
async function sendSuggestedReply() {
if (isPaused) return;
if (!safeActiveConversation.id || safeActiveConversation.id === 'empty') return;
await onSendSuggestedReply?.(safeActiveConversation.id, selectedReply);
@ -393,7 +396,10 @@ export function MessagesWorkspace({
{conversations.length > 3 ? (
<button
type="button"
onClick={() => navigate('/chat')}
onClick={() => {
if (!isPaused) navigate('/chat');
}}
disabled={isPaused}
style={{
border: '1px solid var(--color-border)',
borderRadius: '16px',
@ -401,6 +407,8 @@ export function MessagesWorkspace({
background: '#fff',
color: 'var(--color-primary)',
fontWeight: 700,
opacity: isPaused ? 0.55 : 1,
cursor: isPaused ? 'not-allowed' : 'pointer',
}}
>
Ver todos no chat
@ -442,7 +450,10 @@ export function MessagesWorkspace({
<div style={{ display: 'flex', gap: '0.6rem', flexWrap: 'wrap' }}>
<button
type="button"
onClick={() => navigate('/chat')}
onClick={() => {
if (!isPaused) navigate('/chat');
}}
disabled={isPaused}
style={{
border: '1px solid var(--color-border)',
borderRadius: '14px',
@ -450,6 +461,8 @@ export function MessagesWorkspace({
background: '#fff',
color: 'var(--color-primary)',
fontWeight: 700,
opacity: isPaused ? 0.55 : 1,
cursor: isPaused ? 'not-allowed' : 'pointer',
}}
>
Abrir chat
@ -571,12 +584,15 @@ export function MessagesWorkspace({
type="button"
onClick={selectPreviousReply}
title="Resposta anterior"
disabled={isPaused}
style={{
border: '1px solid var(--color-border)',
borderRadius: '14px',
background: '#fff',
color: 'var(--color-primary)',
fontWeight: 900,
opacity: isPaused ? 0.55 : 1,
cursor: isPaused ? 'not-allowed' : 'pointer',
}}
>
@ -584,6 +600,7 @@ export function MessagesWorkspace({
<button
type="button"
onClick={sendSuggestedReply}
disabled={isPaused}
style={{
border: '1px solid rgba(0, 164, 183, 0.32)',
borderRadius: '16px',
@ -598,9 +615,11 @@ export function MessagesWorkspace({
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
opacity: isPaused ? 0.55 : 1,
cursor: isPaused ? 'not-allowed' : 'pointer',
}}
>
{selectedReply}
{isPaused ? `Voce esta em pausa ha ${pauseDurationLabel}. Retome para responder.` : selectedReply}
</button>
<button
type="button"

View File

@ -37,6 +37,11 @@ export function HomePage() {
messages,
sendMessage,
isLoadingChats,
isPaused,
pauseDurationLabel,
isPresenceLoading,
pauseAttendance,
resumeAttendance,
} = useChat();
const [activeTab, setActiveTab] = useState('messages');
const [searchValue, setSearchValue] = useState('');
@ -122,7 +127,13 @@ export function HomePage() {
gap: '1rem',
}}
>
<AttendantOpsPanel activeChatsCount={filteredConversations.length} />
<AttendantOpsPanel
activeChatsCount={filteredConversations.length}
isPaused={isPaused}
pauseDurationLabel={pauseDurationLabel}
isPresenceLoading={isPresenceLoading}
onTogglePause={isPaused ? resumeAttendance : pauseAttendance}
/>
{isLoadingChats ? (
<div
@ -152,6 +163,8 @@ export function HomePage() {
isDesktop={isDesktop}
isTablet={isTablet}
isMobile={isMobile}
isPaused={isPaused}
pauseDurationLabel={pauseDurationLabel}
/>
) : (
<CallsWorkspace