feat(fase3): enhance Inbox with transfers, notes, and quick replies

Add comprehensive agent inbox functionality including:
- Status filter dropdown to filter conversations by bot/waiting/active/resolved
- Priority tags displayed in conversation list for non-normal priorities
- Internal notes feature with visual distinction (orange styling, dashed border)
- Quick replies panel showing shortcut tags with tooltip previews
- Transfer modal for moving conversations to queues or agents
- Transfer to bot action for returning conversations to bot handling
- Resolve conversation action to close conversations
- Helper functions for message styling (background, color, border)
- Refactored render functions for improved code organization

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Claude AI
2026-01-29 10:59:49 +00:00
parent 9a640b7136
commit f76c9bb3f4

View File

@@ -11,8 +11,22 @@ import {
Spin, Spin,
Space, Space,
Badge, Badge,
Dropdown,
Select,
Tooltip,
Modal,
message,
} from 'antd'; } from 'antd';
import { SendOutlined, UserOutlined } from '@ant-design/icons'; import {
SendOutlined,
UserOutlined,
MoreOutlined,
SwapOutlined,
RobotOutlined,
CheckCircleOutlined,
MessageOutlined,
FileTextOutlined,
} from '@ant-design/icons';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../api/client'; import { apiClient } from '../api/client';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
@@ -36,25 +50,94 @@ interface Message {
type: string; type: string;
content: string | null; content: string | null;
created_at: string; created_at: string;
is_internal_note: boolean;
sent_by: string | null;
} }
interface Conversation { interface Conversation {
id: string; id: string;
contact: Contact; contact: Contact;
status: string; status: string;
priority: string;
assigned_to: string | null;
queue_id: string | null;
last_message_at: string | null; last_message_at: string | null;
messages?: Message[]; messages?: Message[];
} }
export default function Inbox() { interface Queue {
id: string;
name: string;
}
interface Agent {
id: string;
name: string;
status: string;
}
interface QuickReply {
shortcut: string;
content: string;
}
const STATUS_COLORS: Record<string, string> = {
bot: 'blue',
waiting: 'orange',
active: 'green',
resolved: 'default',
};
const PRIORITY_COLORS: Record<string, string> = {
low: 'default',
normal: 'blue',
high: 'orange',
urgent: 'red',
};
function getMessageBackground(msg: Message): string {
if (msg.is_internal_note) {
return '#fff7e6';
}
if (msg.direction === 'outbound') {
return '#25D366';
}
return '#f0f0f0';
}
function getMessageColor(msg: Message): string {
if (msg.is_internal_note) {
return '#d46b08';
}
if (msg.direction === 'outbound') {
return 'white';
}
return 'inherit';
}
function getMessageBorder(msg: Message): string {
if (msg.is_internal_note) {
return '1px dashed #ffc069';
}
return 'none';
}
export default function Inbox(): JSX.Element {
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedId, setSelectedId] = useState<string | null>(null);
const [messageText, setMessageText] = useState(''); const [messageText, setMessageText] = useState('');
const [statusFilter, setStatusFilter] = useState<string | null>(null);
const [isNoteMode, setIsNoteMode] = useState(false);
const [transferModalOpen, setTransferModalOpen] = useState(false);
const [transferType, setTransferType] = useState<'queue' | 'agent'>('queue');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: conversations, isLoading } = useQuery({ const { data: conversations, isLoading } = useQuery({
queryKey: ['conversations'], queryKey: ['conversations', statusFilter],
queryFn: async () => { queryFn: async () => {
return apiClient.get<Conversation[]>('/api/whatsapp/conversations'); const url = statusFilter
? `/api/whatsapp/conversations?status=${statusFilter}`
: '/api/whatsapp/conversations';
return apiClient.get<Conversation[]>(url);
}, },
refetchInterval: 3000, refetchInterval: 3000,
}); });
@@ -69,6 +152,21 @@ export default function Inbox() {
refetchInterval: 2000, refetchInterval: 2000,
}); });
const { data: queues } = useQuery({
queryKey: ['queues'],
queryFn: () => apiClient.get<Queue[]>('/api/queues'),
});
const { data: agents } = useQuery({
queryKey: ['agents'],
queryFn: () => apiClient.get<Agent[]>('/api/auth/agents'),
});
const { data: quickReplies } = useQuery({
queryKey: ['quick-replies'],
queryFn: () => apiClient.get<QuickReply[]>('/api/queues/quick-replies'),
});
const sendMutation = useMutation({ const sendMutation = useMutation({
mutationFn: async (data: { conversationId: string; content: string }) => { mutationFn: async (data: { conversationId: string; content: string }) => {
await apiClient.post(`/api/whatsapp/conversations/${data.conversationId}/messages`, { await apiClient.post(`/api/whatsapp/conversations/${data.conversationId}/messages`, {
@@ -83,177 +181,441 @@ export default function Inbox() {
}, },
}); });
const handleSend = () => { const noteMutation = useMutation({
if (!messageText.trim() || !selectedId) return; mutationFn: async (data: { conversationId: string; content: string }) => {
sendMutation.mutate({ conversationId: selectedId, content: messageText }); await apiClient.post(`/api/whatsapp/conversations/${data.conversationId}/notes`, {
}; content: data.content,
});
},
onSuccess: () => {
setMessageText('');
setIsNoteMode(false);
message.success('Nota agregada');
queryClient.invalidateQueries({ queryKey: ['conversation', selectedId] });
},
});
const statusColors: Record<string, string> = { const transferQueueMutation = useMutation({
bot: 'blue', mutationFn: async (data: { conversationId: string; queueId: string }) => {
waiting: 'orange', await apiClient.post(`/api/whatsapp/conversations/${data.conversationId}/transfer-to-queue`, {
active: 'green', queue_id: data.queueId,
resolved: 'default', });
}; },
onSuccess: () => {
message.success('Transferido a cola');
setTransferModalOpen(false);
queryClient.invalidateQueries({ queryKey: ['conversations'] });
},
});
const transferAgentMutation = useMutation({
mutationFn: async (data: { conversationId: string; agentId: string }) => {
await apiClient.post(`/api/whatsapp/conversations/${data.conversationId}/transfer-to-agent`, {
agent_id: data.agentId,
});
},
onSuccess: () => {
message.success('Transferido a agente');
setTransferModalOpen(false);
queryClient.invalidateQueries({ queryKey: ['conversations'] });
},
});
const transferBotMutation = useMutation({
mutationFn: async (conversationId: string) => {
await apiClient.post(`/api/whatsapp/conversations/${conversationId}/transfer-to-bot`, {});
},
onSuccess: () => {
message.success('Transferido a bot');
queryClient.invalidateQueries({ queryKey: ['conversations'] });
},
});
const resolveMutation = useMutation({
mutationFn: async (conversationId: string) => {
await apiClient.post(`/api/whatsapp/conversations/${conversationId}/resolve`, {});
},
onSuccess: () => {
message.success('Conversacion resuelta');
queryClient.invalidateQueries({ queryKey: ['conversations'] });
},
});
function handleSend(): void {
if (!messageText.trim() || !selectedId) return;
if (isNoteMode) {
noteMutation.mutate({ conversationId: selectedId, content: messageText });
} else {
sendMutation.mutate({ conversationId: selectedId, content: messageText });
}
}
function handleTransferQueue(): void {
setTransferType('queue');
setTransferModalOpen(true);
}
function handleTransferAgent(): void {
setTransferType('agent');
setTransferModalOpen(true);
}
function handleTransferBot(): void {
if (selectedId) {
transferBotMutation.mutate(selectedId);
}
}
function handleResolve(): void {
if (selectedId) {
resolveMutation.mutate(selectedId);
}
}
function handleQueueSelect(value: string): void {
if (selectedId) {
transferQueueMutation.mutate({ conversationId: selectedId, queueId: value });
}
}
function handleAgentSelect(value: string): void {
if (selectedId) {
transferAgentMutation.mutate({ conversationId: selectedId, agentId: value });
}
}
const actionMenuItems = [
{
key: 'transfer-queue',
icon: <SwapOutlined />,
label: 'Transferir a cola',
onClick: handleTransferQueue,
},
{
key: 'transfer-agent',
icon: <UserOutlined />,
label: 'Transferir a agente',
onClick: handleTransferAgent,
},
{
key: 'transfer-bot',
icon: <RobotOutlined />,
label: 'Transferir a bot',
onClick: handleTransferBot,
},
{ type: 'divider' as const },
{
key: 'resolve',
icon: <CheckCircleOutlined />,
label: 'Resolver conversacion',
onClick: handleResolve,
},
];
function renderConversationList(): JSX.Element {
if (isLoading) {
return (
<div style={{ padding: 40, textAlign: 'center' }}>
<Spin />
</div>
);
}
if (!conversations || conversations.length === 0) {
return <Empty description="Sin conversaciones" style={{ padding: 40 }} />;
}
return (
<List
dataSource={conversations}
renderItem={(conv) => (
<List.Item
onClick={() => setSelectedId(conv.id)}
style={{
padding: '12px 16px',
cursor: 'pointer',
background: selectedId === conv.id ? '#f5f5f5' : 'transparent',
}}
>
<List.Item.Meta
avatar={
<Badge dot={conv.status !== 'resolved'} color={STATUS_COLORS[conv.status]}>
<Avatar icon={<UserOutlined />} />
</Badge>
}
title={
<Space>
<Text strong>{conv.contact.name || conv.contact.phone_number}</Text>
<Tag color={STATUS_COLORS[conv.status]} style={{ fontSize: 10 }}>
{conv.status}
</Tag>
{conv.priority !== 'normal' && (
<Tag color={PRIORITY_COLORS[conv.priority]} style={{ fontSize: 10 }}>
{conv.priority}
</Tag>
)}
</Space>
}
description={
<Text type="secondary" style={{ fontSize: 12 }}>
{conv.last_message_at ? dayjs(conv.last_message_at).fromNow() : 'Sin mensajes'}
</Text>
}
/>
</List.Item>
)}
/>
);
}
function renderMessage(msg: Message): JSX.Element {
return (
<div
key={msg.id}
style={{
alignSelf: msg.direction === 'outbound' ? 'flex-end' : 'flex-start',
maxWidth: '70%',
}}
>
<div
style={{
padding: '8px 12px',
borderRadius: 8,
background: getMessageBackground(msg),
color: getMessageColor(msg),
border: getMessageBorder(msg),
}}
>
{msg.is_internal_note && (
<Text type="secondary" style={{ fontSize: 10, display: 'block', marginBottom: 4 }}>
<FileTextOutlined /> Nota interna
</Text>
)}
<Text style={{ color: 'inherit' }}>{msg.content}</Text>
</div>
<Text
type="secondary"
style={{
fontSize: 10,
display: 'block',
textAlign: msg.direction === 'outbound' ? 'right' : 'left',
marginTop: 4,
}}
>
{dayjs(msg.created_at).format('HH:mm')}
</Text>
</div>
);
}
function renderQuickReplies(): JSX.Element | null {
if (!quickReplies || quickReplies.length === 0) {
return null;
}
return (
<div style={{ padding: '8px 16px', borderTop: '1px solid #f0f0f0' }}>
<Space wrap size={4}>
{quickReplies.slice(0, 5).map((qr) => (
<Tooltip key={qr.shortcut} title={qr.content}>
<Tag style={{ cursor: 'pointer' }} onClick={() => setMessageText(qr.content)}>
{qr.shortcut}
</Tag>
</Tooltip>
))}
</Space>
</div>
);
}
function renderMessageInput(): JSX.Element {
const inputStyle = isNoteMode ? { borderColor: '#ffc069' } : {};
const buttonStyle = isNoteMode
? { background: '#d46b08', borderColor: '#d46b08' }
: { background: '#25D366', borderColor: '#25D366' };
return (
<div style={{ padding: 16, borderTop: '1px solid #f0f0f0' }}>
<div style={{ marginBottom: 8 }}>
<Space>
<Button
size="small"
type={isNoteMode ? 'default' : 'primary'}
icon={<MessageOutlined />}
onClick={() => setIsNoteMode(false)}
>
Mensaje
</Button>
<Button
size="small"
type={isNoteMode ? 'primary' : 'default'}
icon={<FileTextOutlined />}
onClick={() => setIsNoteMode(true)}
style={isNoteMode ? { background: '#d46b08', borderColor: '#d46b08' } : {}}
>
Nota interna
</Button>
</Space>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<Input
placeholder={isNoteMode ? 'Escribe una nota interna...' : 'Escribe un mensaje...'}
value={messageText}
onChange={(e) => setMessageText(e.target.value)}
onPressEnter={handleSend}
style={inputStyle}
/>
<Button
type="primary"
icon={<SendOutlined />}
onClick={handleSend}
loading={sendMutation.isPending || noteMutation.isPending}
style={buttonStyle}
/>
</div>
</div>
);
}
function renderChatContent(): JSX.Element {
if (!selectedConversation) {
return (
<div
style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Empty description="Selecciona una conversacion" />
</div>
);
}
return (
<>
<div
style={{
padding: 16,
borderBottom: '1px solid #f0f0f0',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<div>
<Text strong style={{ fontSize: 16 }}>
{selectedConversation.contact.name || selectedConversation.contact.phone_number}
</Text>
<br />
<Text type="secondary">{selectedConversation.contact.phone_number}</Text>
</div>
<Space>
<Tag color={STATUS_COLORS[selectedConversation.status]}>
{selectedConversation.status}
</Tag>
<Dropdown menu={{ items: actionMenuItems }} trigger={['click']}>
<Button icon={<MoreOutlined />} />
</Dropdown>
</Space>
</div>
<div
style={{
flex: 1,
overflow: 'auto',
padding: 16,
display: 'flex',
flexDirection: 'column',
gap: 8,
}}
>
{selectedConversation.messages?.map(renderMessage)}
</div>
{renderQuickReplies()}
{renderMessageInput()}
</>
);
}
function renderTransferModal(): JSX.Element {
const title = transferType === 'queue' ? 'Transferir a Cola' : 'Transferir a Agente';
return (
<Modal
title={title}
open={transferModalOpen}
onCancel={() => setTransferModalOpen(false)}
footer={null}
>
{transferType === 'queue' ? (
<Select
placeholder="Seleccionar cola"
style={{ width: '100%' }}
onChange={handleQueueSelect}
>
{queues?.map((q) => (
<Select.Option key={q.id} value={q.id}>
{q.name}
</Select.Option>
))}
</Select>
) : (
<Select
placeholder="Seleccionar agente"
style={{ width: '100%' }}
onChange={handleAgentSelect}
>
{agents
?.filter((a) => a.status === 'online')
.map((a) => (
<Select.Option key={a.id} value={a.id}>
{a.name} ({a.status})
</Select.Option>
))}
</Select>
)}
</Modal>
);
}
return ( return (
<div style={{ display: 'flex', height: 'calc(100vh - 180px)', gap: 16 }}> <div style={{ display: 'flex', height: 'calc(100vh - 180px)', gap: 16 }}>
{/* Lista de conversaciones */}
<Card <Card
style={{ width: 350, overflow: 'auto' }} style={{ width: 380, overflow: 'auto' }}
styles={{ body: { padding: 0 } }} styles={{ body: { padding: 0 } }}
title="Conversaciones" title={
> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
{isLoading ? ( <span>Conversaciones</span>
<div style={{ padding: 40, textAlign: 'center' }}> <Select
<Spin /> placeholder="Filtrar"
allowClear
size="small"
style={{ width: 120 }}
value={statusFilter}
onChange={setStatusFilter}
>
<Select.Option value="bot">Bot</Select.Option>
<Select.Option value="waiting">En espera</Select.Option>
<Select.Option value="active">Activas</Select.Option>
<Select.Option value="resolved">Resueltas</Select.Option>
</Select>
</div> </div>
) : conversations?.length === 0 ? ( }
<Empty >
description="Sin conversaciones" {renderConversationList()}
style={{ padding: 40 }}
/>
) : (
<List
dataSource={conversations}
renderItem={(conv) => (
<List.Item
onClick={() => setSelectedId(conv.id)}
style={{
padding: '12px 16px',
cursor: 'pointer',
background: selectedId === conv.id ? '#f5f5f5' : 'transparent',
}}
>
<List.Item.Meta
avatar={
<Badge dot={conv.status !== 'resolved'} color={statusColors[conv.status]}>
<Avatar icon={<UserOutlined />} />
</Badge>
}
title={
<Space>
<Text strong>{conv.contact.name || conv.contact.phone_number}</Text>
<Tag color={statusColors[conv.status]} style={{ fontSize: 10 }}>
{conv.status}
</Tag>
</Space>
}
description={
<Text type="secondary" style={{ fontSize: 12 }}>
{conv.last_message_at
? dayjs(conv.last_message_at).fromNow()
: 'Sin mensajes'}
</Text>
}
/>
</List.Item>
)}
/>
)}
</Card> </Card>
{/* Chat */}
<Card <Card
style={{ flex: 1, display: 'flex', flexDirection: 'column' }} style={{ flex: 1, display: 'flex', flexDirection: 'column' }}
styles={{ body: { flex: 1, display: 'flex', flexDirection: 'column', padding: 0 } }} styles={{ body: { flex: 1, display: 'flex', flexDirection: 'column', padding: 0 } }}
> >
{selectedConversation ? ( {renderChatContent()}
<>
{/* Header */}
<div
style={{
padding: 16,
borderBottom: '1px solid #f0f0f0',
}}
>
<Text strong style={{ fontSize: 16 }}>
{selectedConversation.contact.name || selectedConversation.contact.phone_number}
</Text>
<br />
<Text type="secondary">{selectedConversation.contact.phone_number}</Text>
</div>
{/* Messages */}
<div
style={{
flex: 1,
overflow: 'auto',
padding: 16,
display: 'flex',
flexDirection: 'column',
gap: 8,
}}
>
{selectedConversation.messages?.map((msg) => (
<div
key={msg.id}
style={{
alignSelf: msg.direction === 'outbound' ? 'flex-end' : 'flex-start',
maxWidth: '70%',
}}
>
<div
style={{
padding: '8px 12px',
borderRadius: 8,
background: msg.direction === 'outbound' ? '#25D366' : '#f0f0f0',
color: msg.direction === 'outbound' ? 'white' : 'inherit',
}}
>
<Text style={{ color: 'inherit' }}>{msg.content}</Text>
</div>
<Text
type="secondary"
style={{
fontSize: 10,
display: 'block',
textAlign: msg.direction === 'outbound' ? 'right' : 'left',
marginTop: 4,
}}
>
{dayjs(msg.created_at).format('HH:mm')}
</Text>
</div>
))}
</div>
{/* Input */}
<div
style={{
padding: 16,
borderTop: '1px solid #f0f0f0',
display: 'flex',
gap: 8,
}}
>
<Input
placeholder="Escribe un mensaje..."
value={messageText}
onChange={(e) => setMessageText(e.target.value)}
onPressEnter={handleSend}
/>
<Button
type="primary"
icon={<SendOutlined />}
onClick={handleSend}
loading={sendMutation.isPending}
style={{ background: '#25D366', borderColor: '#25D366' }}
/>
</div>
</>
) : (
<div
style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Empty description="Selecciona una conversación" />
</div>
)}
</Card> </Card>
{renderTransferModal()}
</div> </div>
); );
} }