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:
Claude AI
2026-01-29 10:01:06 +00:00
parent 7042aa2061
commit dcb7fb5974
10 changed files with 1305 additions and 13 deletions

View 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>
);
}