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

@@ -1,10 +1,7 @@
import { Routes, Route, Navigate } from 'react-router-dom';
import { useAuthStore } from './store/auth';
// Placeholder components - will be replaced
const LoginPage = () => <div>Login Page</div>;
const DashboardPage = () => <div>Dashboard</div>;
const MainLayout = ({ children }: { children: React.ReactNode }) => <div>{children}</div>;
import Login from './pages/Login';
import MainLayout from './layouts/MainLayout';
function PrivateRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
@@ -14,17 +11,12 @@ function PrivateRoute({ children }: { children: React.ReactNode }) {
export default function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/login" element={<Login />} />
<Route
path="/*"
element={
<PrivateRoute>
<MainLayout>
<Routes>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<DashboardPage />} />
</Routes>
</MainLayout>
<MainLayout />
</PrivateRoute>
}
/>

View File

@@ -0,0 +1,160 @@
import { useState } from 'react';
import { Routes, Route, useNavigate, useLocation } from 'react-router-dom';
import { Layout, Menu, Button, Avatar, Dropdown, Typography } from 'antd';
import {
DashboardOutlined,
MessageOutlined,
WhatsAppOutlined,
SettingOutlined,
LogoutOutlined,
UserOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
} from '@ant-design/icons';
import { useAuthStore } from '../store/auth';
import Dashboard from '../pages/Dashboard';
import WhatsAppAccounts from '../pages/WhatsAppAccounts';
import Inbox from '../pages/Inbox';
const { Header, Sider, Content } = Layout;
const { Text } = Typography;
export default function MainLayout() {
const [collapsed, setCollapsed] = useState(false);
const navigate = useNavigate();
const location = useLocation();
const { user, logout } = useAuthStore();
const handleLogout = () => {
logout();
navigate('/login');
};
const menuItems = [
{
key: '/',
icon: <DashboardOutlined />,
label: 'Dashboard',
},
{
key: '/inbox',
icon: <MessageOutlined />,
label: 'Inbox',
},
{
key: '/whatsapp',
icon: <WhatsAppOutlined />,
label: 'WhatsApp',
},
{
key: '/settings',
icon: <SettingOutlined />,
label: 'Configuración',
},
];
const userMenu = [
{
key: 'profile',
icon: <UserOutlined />,
label: 'Mi perfil',
},
{
type: 'divider' as const,
},
{
key: 'logout',
icon: <LogoutOutlined />,
label: 'Cerrar sesión',
onClick: handleLogout,
},
];
return (
<Layout style={{ minHeight: '100vh' }}>
<Sider
trigger={null}
collapsible
collapsed={collapsed}
style={{
background: '#fff',
borderRight: '1px solid #f0f0f0',
}}
>
<div
style={{
height: 64,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderBottom: '1px solid #f0f0f0',
}}
>
<WhatsAppOutlined
style={{
fontSize: collapsed ? 24 : 32,
color: '#25D366',
}}
/>
{!collapsed && (
<Text strong style={{ marginLeft: 8, fontSize: 16 }}>
WA Central
</Text>
)}
</div>
<Menu
mode="inline"
selectedKeys={[location.pathname]}
items={menuItems}
onClick={({ key }) => navigate(key)}
style={{ border: 'none' }}
/>
</Sider>
<Layout>
<Header
style={{
padding: '0 24px',
background: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
borderBottom: '1px solid #f0f0f0',
}}
>
<Button
type="text"
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={() => setCollapsed(!collapsed)}
/>
<Dropdown menu={{ items: userMenu }} placement="bottomRight">
<div style={{ cursor: 'pointer', display: 'flex', alignItems: 'center' }}>
<Avatar style={{ backgroundColor: '#25D366' }}>
{user?.name?.charAt(0).toUpperCase()}
</Avatar>
<Text style={{ marginLeft: 8 }}>{user?.name}</Text>
</div>
</Dropdown>
</Header>
<Content
style={{
margin: 24,
padding: 24,
background: '#fff',
borderRadius: 8,
minHeight: 280,
}}
>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/inbox" element={<Inbox />} />
<Route path="/whatsapp" element={<WhatsAppAccounts />} />
<Route path="/settings" element={<div>Configuración (próximamente)</div>} />
</Routes>
</Content>
</Layout>
</Layout>
);
}

View File

@@ -0,0 +1,81 @@
import { Card, Row, Col, Statistic, Typography } from 'antd';
import {
MessageOutlined,
UserOutlined,
WhatsAppOutlined,
CheckCircleOutlined,
} from '@ant-design/icons';
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '../api/client';
const { Title } = Typography;
export default function Dashboard() {
const { data: accounts } = useQuery({
queryKey: ['whatsapp-accounts'],
queryFn: async () => {
return apiClient.get<any[]>('/api/whatsapp/accounts');
},
});
const { data: conversations } = useQuery({
queryKey: ['conversations'],
queryFn: async () => {
return apiClient.get<any[]>('/api/whatsapp/conversations');
},
});
const connectedAccounts = accounts?.filter((a: any) => a.status === 'connected').length || 0;
const totalAccounts = accounts?.length || 0;
const activeConversations = conversations?.filter((c: any) => c.status !== 'resolved').length || 0;
const resolvedToday = conversations?.filter((c: any) => c.status === 'resolved').length || 0;
return (
<div>
<Title level={4} style={{ marginBottom: 24 }}>Dashboard</Title>
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic
title="Números conectados"
value={connectedAccounts}
suffix={`/ ${totalAccounts}`}
prefix={<WhatsAppOutlined style={{ color: '#25D366' }} />}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic
title="Conversaciones activas"
value={activeConversations}
prefix={<MessageOutlined style={{ color: '#1890ff' }} />}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic
title="Resueltas hoy"
value={resolvedToday}
prefix={<CheckCircleOutlined style={{ color: '#52c41a' }} />}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic
title="Contactos"
value={0}
prefix={<UserOutlined style={{ color: '#722ed1' }} />}
/>
</Card>
</Col>
</Row>
</div>
);
}

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

View File

@@ -0,0 +1,158 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Form, Input, Button, Card, Typography, message, Space } from 'antd';
import { UserOutlined, LockOutlined, WhatsAppOutlined } from '@ant-design/icons';
import { useMutation } from '@tanstack/react-query';
import { apiClient } from '../api/client';
import { useAuthStore } from '../store/auth';
const { Title, Text } = Typography;
interface LoginForm {
email: string;
password: string;
}
interface RegisterForm {
email: string;
password: string;
name: string;
}
export default function Login() {
const navigate = useNavigate();
const setAuth = useAuthStore((state) => state.setAuth);
const [isRegister, setIsRegister] = useState(false);
const loginMutation = useMutation({
mutationFn: async (data: LoginForm) => {
return apiClient.post<any>('/auth/login', data);
},
onSuccess: (data) => {
setAuth(data.user, data.access_token, data.refresh_token);
message.success('Bienvenido');
navigate('/');
},
onError: () => {
message.error('Credenciales incorrectas');
},
});
const registerMutation = useMutation({
mutationFn: async (data: RegisterForm) => {
await apiClient.post('/auth/register', data);
return apiClient.post<any>('/auth/login', { email: data.email, password: data.password });
},
onSuccess: (data) => {
setAuth(data.user, data.access_token, data.refresh_token);
message.success('Cuenta creada exitosamente');
navigate('/');
},
onError: (error: any) => {
message.error(error.message || 'Error al registrar');
},
});
const onFinish = (values: LoginForm | RegisterForm) => {
if (isRegister) {
registerMutation.mutate(values as RegisterForm);
} else {
loginMutation.mutate(values as LoginForm);
}
};
const isLoading = loginMutation.isPending || registerMutation.isPending;
return (
<div
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, #25D366 0%, #128C7E 100%)',
}}
>
<Card
style={{
width: 400,
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
}}
>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div style={{ textAlign: 'center' }}>
<WhatsAppOutlined style={{ fontSize: 48, color: '#25D366' }} />
<Title level={3} style={{ marginTop: 16, marginBottom: 0 }}>
WhatsApp Centralizado
</Title>
<Text type="secondary">
{isRegister ? 'Crear cuenta de administrador' : 'Iniciar sesión'}
</Text>
</div>
<Form
name="login"
onFinish={onFinish}
layout="vertical"
size="large"
>
{isRegister && (
<Form.Item
name="name"
rules={[{ required: true, message: 'Ingresa tu nombre' }]}
>
<Input
prefix={<UserOutlined />}
placeholder="Nombre completo"
/>
</Form.Item>
)}
<Form.Item
name="email"
rules={[
{ required: true, message: 'Ingresa tu email' },
{ type: 'email', message: 'Email inválido' },
]}
>
<Input
prefix={<UserOutlined />}
placeholder="Email"
/>
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: 'Ingresa tu contraseña' }]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="Contraseña"
/>
</Form.Item>
<Form.Item>
<Button
type="primary"
htmlType="submit"
loading={isLoading}
block
style={{ background: '#25D366', borderColor: '#25D366' }}
>
{isRegister ? 'Crear cuenta' : 'Iniciar sesión'}
</Button>
</Form.Item>
</Form>
<div style={{ textAlign: 'center' }}>
<Button type="link" onClick={() => setIsRegister(!isRegister)}>
{isRegister
? '¿Ya tienes cuenta? Inicia sesión'
: '¿Primera vez? Crear cuenta admin'}
</Button>
</div>
</Space>
</Card>
</div>
);
}

View File

@@ -0,0 +1,250 @@
import { useState } from 'react';
import {
Card,
Button,
Table,
Tag,
Modal,
Form,
Input,
message,
Space,
Typography,
Image,
Spin,
} from 'antd';
import {
PlusOutlined,
ReloadOutlined,
DeleteOutlined,
QrcodeOutlined,
} from '@ant-design/icons';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../api/client';
const { Title, Text } = Typography;
interface WhatsAppAccount {
id: string;
name: string;
phone_number: string | null;
status: 'connecting' | 'connected' | 'disconnected';
qr_code: string | null;
created_at: string;
}
export default function WhatsAppAccounts() {
const [isModalOpen, setIsModalOpen] = useState(false);
const [qrModal, setQrModal] = useState<WhatsAppAccount | null>(null);
const [form] = Form.useForm();
const queryClient = useQueryClient();
const { data: accounts, isLoading, refetch } = useQuery({
queryKey: ['whatsapp-accounts'],
queryFn: async () => {
return apiClient.get<WhatsAppAccount[]>('/api/whatsapp/accounts');
},
refetchInterval: 5000,
});
const createMutation = useMutation({
mutationFn: async (data: { name: string }) => {
return apiClient.post<WhatsAppAccount>('/api/whatsapp/accounts', data);
},
onSuccess: (data) => {
message.success('Cuenta creada. Escanea el QR para conectar.');
setIsModalOpen(false);
form.resetFields();
queryClient.invalidateQueries({ queryKey: ['whatsapp-accounts'] });
setQrModal(data);
},
onError: () => {
message.error('Error al crear cuenta');
},
});
const deleteMutation = useMutation({
mutationFn: async (id: string) => {
await apiClient.delete(`/api/whatsapp/accounts/${id}`);
},
onSuccess: () => {
message.success('Cuenta eliminada');
queryClient.invalidateQueries({ queryKey: ['whatsapp-accounts'] });
},
onError: () => {
message.error('Error al eliminar');
},
});
const handleShowQR = async (account: WhatsAppAccount) => {
const data = await apiClient.get<WhatsAppAccount>(`/api/whatsapp/accounts/${account.id}`);
setQrModal(data);
};
const columns = [
{
title: 'Nombre',
dataIndex: 'name',
key: 'name',
},
{
title: 'Número',
dataIndex: 'phone_number',
key: 'phone_number',
render: (phone: string | null) => phone || '-',
},
{
title: 'Estado',
dataIndex: 'status',
key: 'status',
render: (status: string) => {
const colors: Record<string, string> = {
connected: 'green',
connecting: 'orange',
disconnected: 'red',
};
const labels: Record<string, string> = {
connected: 'Conectado',
connecting: 'Conectando',
disconnected: 'Desconectado',
};
return <Tag color={colors[status]}>{labels[status]}</Tag>;
},
},
{
title: 'Acciones',
key: 'actions',
render: (_: any, record: WhatsAppAccount) => (
<Space>
{record.status !== 'connected' && (
<Button
icon={<QrcodeOutlined />}
onClick={() => handleShowQR(record)}
>
Ver QR
</Button>
)}
<Button
danger
icon={<DeleteOutlined />}
onClick={() => {
Modal.confirm({
title: '¿Eliminar cuenta?',
content: 'Esta acción no se puede deshacer.',
okText: 'Eliminar',
okType: 'danger',
onOk: () => deleteMutation.mutate(record.id),
});
}}
/>
</Space>
),
},
];
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Title level={4} style={{ margin: 0 }}>Números de WhatsApp</Title>
<Space>
<Button icon={<ReloadOutlined />} onClick={() => refetch()}>
Actualizar
</Button>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setIsModalOpen(true)}
style={{ background: '#25D366', borderColor: '#25D366' }}
>
Agregar número
</Button>
</Space>
</div>
<Card>
<Table
dataSource={accounts}
columns={columns}
rowKey="id"
loading={isLoading}
pagination={false}
/>
</Card>
<Modal
title="Agregar número de WhatsApp"
open={isModalOpen}
onCancel={() => {
setIsModalOpen(false);
form.resetFields();
}}
footer={null}
>
<Form
form={form}
layout="vertical"
onFinish={(values) => createMutation.mutate(values)}
>
<Form.Item
name="name"
label="Nombre"
rules={[{ required: true, message: 'Ingresa un nombre' }]}
>
<Input placeholder="Ej: Ventas Principal" />
</Form.Item>
<Form.Item>
<Button
type="primary"
htmlType="submit"
loading={createMutation.isPending}
block
>
Crear y obtener QR
</Button>
</Form.Item>
</Form>
</Modal>
<Modal
title={`Conectar: ${qrModal?.name}`}
open={!!qrModal}
onCancel={() => setQrModal(null)}
footer={null}
width={400}
>
<div style={{ textAlign: 'center', padding: 20 }}>
{qrModal?.status === 'connected' ? (
<div>
<Tag color="green" style={{ fontSize: 16, padding: '8px 16px' }}>
Conectado
</Tag>
<Text style={{ display: 'block', marginTop: 16 }}>
Número: {qrModal.phone_number}
</Text>
</div>
) : qrModal?.qr_code ? (
<div>
<Image
src={qrModal.qr_code}
alt="QR Code"
width={280}
preview={false}
/>
<Text type="secondary" style={{ display: 'block', marginTop: 16 }}>
Escanea con WhatsApp en tu teléfono
</Text>
</div>
) : (
<div>
<Spin size="large" />
<Text style={{ display: 'block', marginTop: 16 }}>
Generando código QR...
</Text>
</div>
)}
</div>
</Modal>
</div>
);
}