feat: add Layer 3 - API Gateway main app, WhatsApp routes, Frontend pages
API Gateway: - main.py with FastAPI app, CORS, health endpoints - WhatsApp routes: accounts CRUD, conversations, messages, internal events - WhatsApp schemas for request/response validation Frontend: - Login page with register option for first admin - MainLayout with sidebar navigation and user dropdown - Dashboard with statistics cards (accounts, conversations) - WhatsApp Accounts page with QR modal for connection - Inbox page with conversation list and real-time chat Full feature set for Fase 1 Foundation complete. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
259
frontend/src/pages/Inbox.tsx
Normal file
259
frontend/src/pages/Inbox.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Card,
|
||||
List,
|
||||
Avatar,
|
||||
Typography,
|
||||
Input,
|
||||
Button,
|
||||
Tag,
|
||||
Empty,
|
||||
Spin,
|
||||
Space,
|
||||
Badge,
|
||||
} from 'antd';
|
||||
import { SendOutlined, UserOutlined } from '@ant-design/icons';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '../api/client';
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import 'dayjs/locale/es';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
dayjs.locale('es');
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface Contact {
|
||||
id: string;
|
||||
phone_number: string;
|
||||
name: string | null;
|
||||
}
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
direction: 'inbound' | 'outbound';
|
||||
type: string;
|
||||
content: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface Conversation {
|
||||
id: string;
|
||||
contact: Contact;
|
||||
status: string;
|
||||
last_message_at: string | null;
|
||||
messages?: Message[];
|
||||
}
|
||||
|
||||
export default function Inbox() {
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [messageText, setMessageText] = useState('');
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: conversations, isLoading } = useQuery({
|
||||
queryKey: ['conversations'],
|
||||
queryFn: async () => {
|
||||
return apiClient.get<Conversation[]>('/api/whatsapp/conversations');
|
||||
},
|
||||
refetchInterval: 3000,
|
||||
});
|
||||
|
||||
const { data: selectedConversation } = useQuery({
|
||||
queryKey: ['conversation', selectedId],
|
||||
queryFn: async () => {
|
||||
if (!selectedId) return null;
|
||||
return apiClient.get<Conversation>(`/api/whatsapp/conversations/${selectedId}`);
|
||||
},
|
||||
enabled: !!selectedId,
|
||||
refetchInterval: 2000,
|
||||
});
|
||||
|
||||
const sendMutation = useMutation({
|
||||
mutationFn: async (data: { conversationId: string; content: string }) => {
|
||||
await apiClient.post(`/api/whatsapp/conversations/${data.conversationId}/messages`, {
|
||||
type: 'text',
|
||||
content: data.content,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
setMessageText('');
|
||||
queryClient.invalidateQueries({ queryKey: ['conversation', selectedId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['conversations'] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleSend = () => {
|
||||
if (!messageText.trim() || !selectedId) return;
|
||||
sendMutation.mutate({ conversationId: selectedId, content: messageText });
|
||||
};
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
bot: 'blue',
|
||||
waiting: 'orange',
|
||||
active: 'green',
|
||||
resolved: 'default',
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', height: 'calc(100vh - 180px)', gap: 16 }}>
|
||||
{/* Lista de conversaciones */}
|
||||
<Card
|
||||
style={{ width: 350, overflow: 'auto' }}
|
||||
styles={{ body: { padding: 0 } }}
|
||||
title="Conversaciones"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div style={{ padding: 40, textAlign: 'center' }}>
|
||||
<Spin />
|
||||
</div>
|
||||
) : conversations?.length === 0 ? (
|
||||
<Empty
|
||||
description="Sin conversaciones"
|
||||
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>
|
||||
|
||||
{/* Chat */}
|
||||
<Card
|
||||
style={{ flex: 1, display: 'flex', flexDirection: 'column' }}
|
||||
styles={{ body: { flex: 1, display: 'flex', flexDirection: 'column', padding: 0 } }}
|
||||
>
|
||||
{selectedConversation ? (
|
||||
<>
|
||||
{/* 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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user