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:
@@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
160
frontend/src/layouts/MainLayout.tsx
Normal file
160
frontend/src/layouts/MainLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
81
frontend/src/pages/Dashboard.tsx
Normal file
81
frontend/src/pages/Dashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
158
frontend/src/pages/Login.tsx
Normal file
158
frontend/src/pages/Login.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
250
frontend/src/pages/WhatsAppAccounts.tsx
Normal file
250
frontend/src/pages/WhatsAppAccounts.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user