Changes include: - Updated architecture documentation - Enhanced module definitions (OQI-001 to OQI-008) - ML integration documentation updates - Trading strategies documentation - Orchestration and inventory updates - Docker configuration updates 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
26 KiB
26 KiB
| id | title | type | status | priority | epic | project | version | created_date | updated_date |
|---|---|---|---|---|---|---|---|---|---|
| ET-INV-005 | Componentes React Frontend | Technical Specification | Done | Alta | OQI-004 | trading-platform | 1.0.0 | 2025-12-05 | 2026-01-04 |
ET-INV-005: Componentes React Frontend
Epic: OQI-004 Cuentas de Inversión Versión: 1.0 Fecha: 2025-12-05 Responsable: Requirements-Analyst
1. Descripción
Define la implementación frontend para el módulo de cuentas de inversión usando React 18, TypeScript y Zustand:
- Páginas principales (Products, Portfolio, AccountDetail)
- Componentes reutilizables
- Estado global con Zustand
- Integración con API backend
- Formularios con validación
2. Arquitectura Frontend
┌─────────────────────────────────────────────────────────────────┐
│ Frontend Architecture │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Pages Components Stores │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ │ │ │ │ │ │
│ │ ProductsPage │─────►│ ProductCard │ │ investment │ │
│ │ │ │ │ │ Store │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ ▲ ▲ │
│ ┌──────────────┐ │ │ │
│ │ │ │ │ │
│ │ PortfolioPage├───────────────┼────────────────────┤ │
│ │ │ │ │ │
│ └──────────────┘ ┌──────────────┐ │ │
│ │ │ │ │
│ ┌──────────────┐ │DepositForm │ │ │
│ │ │ │ │ │ │
│ │AccountDetail │─────►│PerformanceChart◄──────────┘ │
│ │ Page │ │ │ │
│ └──────────────┘ │WithdrawalForm│ │
│ │ │ │
│ └──────────────┘ │
│ │
│ ┌──────────────┐ │
│ │ API Layer │ │
│ │ (Axios) │ │
│ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
3. Estructura de Archivos
src/
├── pages/
│ └── investment/
│ ├── ProductsPage.tsx
│ ├── PortfolioPage.tsx
│ ├── AccountDetailPage.tsx
│ └── WithdrawalsPage.tsx
├── components/
│ └── investment/
│ ├── ProductCard.tsx
│ ├── ProductList.tsx
│ ├── AccountCard.tsx
│ ├── DepositForm.tsx
│ ├── WithdrawalForm.tsx
│ ├── PerformanceChart.tsx
│ ├── TransactionList.tsx
│ └── PortfolioSummary.tsx
├── stores/
│ └── investmentStore.ts
├── api/
│ └── investment.api.ts
├── types/
│ └── investment.types.ts
└── hooks/
└── useInvestment.ts
4. Store con Zustand
4.1 Investment Store
// src/stores/investmentStore.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { investmentApi } from '../api/investment.api';
import {
Product,
Account,
Transaction,
WithdrawalRequest,
DailyPerformance,
} from '../types/investment.types';
interface InvestmentState {
// State
products: Product[];
accounts: Account[];
selectedAccount: Account | null;
transactions: Transaction[];
withdrawalRequests: WithdrawalRequest[];
dailyPerformance: DailyPerformance[];
portfolioSummary: any | null;
// Loading states
loading: {
products: boolean;
accounts: boolean;
transactions: boolean;
portfolio: boolean;
};
// Error states
error: string | null;
// Actions
fetchProducts: (filters?: any) => Promise<void>;
fetchAccounts: () => Promise<void>;
fetchAccountById: (id: string) => Promise<void>;
fetchPortfolio: () => Promise<void>;
fetchTransactions: (filters?: any) => Promise<void>;
fetchWithdrawalRequests: () => Promise<void>;
fetchPerformance: (accountId: string, filters?: any) => Promise<void>;
createAccount: (data: any) => Promise<any>;
deposit: (accountId: string, data: any) => Promise<any>;
requestWithdrawal: (accountId: string, data: any) => Promise<any>;
clearError: () => void;
}
export const useInvestmentStore = create<InvestmentState>()(
devtools(
(set, get) => ({
// Initial state
products: [],
accounts: [],
selectedAccount: null,
transactions: [],
withdrawalRequests: [],
dailyPerformance: [],
portfolioSummary: null,
loading: {
products: false,
accounts: false,
transactions: false,
portfolio: false,
},
error: null,
// Actions
fetchProducts: async (filters) => {
set((state) => ({
loading: { ...state.loading, products: true },
error: null,
}));
try {
const response = await investmentApi.getProducts(filters);
set({
products: response.data.products,
loading: { ...get().loading, products: false },
});
} catch (error: any) {
set({
error: error.message,
loading: { ...get().loading, products: false },
});
}
},
fetchAccounts: async () => {
set((state) => ({
loading: { ...state.loading, accounts: true },
error: null,
}));
try {
const response = await investmentApi.getAccounts();
set({
accounts: response.data.accounts,
loading: { ...get().loading, accounts: false },
});
} catch (error: any) {
set({
error: error.message,
loading: { ...get().loading, accounts: false },
});
}
},
fetchAccountById: async (id) => {
try {
const response = await investmentApi.getAccountById(id);
set({ selectedAccount: response.data.account });
} catch (error: any) {
set({ error: error.message });
}
},
fetchPortfolio: async () => {
set((state) => ({
loading: { ...state.loading, portfolio: true },
error: null,
}));
try {
const response = await investmentApi.getPortfolio();
set({
portfolioSummary: response.data,
loading: { ...get().loading, portfolio: false },
});
} catch (error: any) {
set({
error: error.message,
loading: { ...get().loading, portfolio: false },
});
}
},
fetchTransactions: async (filters) => {
set((state) => ({
loading: { ...state.loading, transactions: true },
error: null,
}));
try {
const response = await investmentApi.getTransactions(filters);
set({
transactions: response.data.transactions,
loading: { ...get().loading, transactions: false },
});
} catch (error: any) {
set({
error: error.message,
loading: { ...get().loading, transactions: false },
});
}
},
fetchWithdrawalRequests: async () => {
try {
const response = await investmentApi.getWithdrawalRequests();
set({ withdrawalRequests: response.data.requests });
} catch (error: any) {
set({ error: error.message });
}
},
fetchPerformance: async (accountId, filters) => {
try {
const response = await investmentApi.getPerformance(accountId, filters);
set({ dailyPerformance: response.data.performance });
} catch (error: any) {
set({ error: error.message });
}
},
createAccount: async (data) => {
set({ error: null });
try {
const response = await investmentApi.createAccount(data);
return response.data;
} catch (error: any) {
set({ error: error.message });
throw error;
}
},
deposit: async (accountId, data) => {
set({ error: null });
try {
const response = await investmentApi.deposit(accountId, data);
return response.data;
} catch (error: any) {
set({ error: error.message });
throw error;
}
},
requestWithdrawal: async (accountId, data) => {
set({ error: null });
try {
const response = await investmentApi.requestWithdrawal(accountId, data);
return response.data;
} catch (error: any) {
set({ error: error.message });
throw error;
}
},
clearError: () => set({ error: null }),
}),
{ name: 'InvestmentStore' }
)
);
5. API Layer
5.1 Investment API Client
// src/api/investment.api.ts
import axios from './axios-instance';
import { AxiosResponse } from 'axios';
const BASE_PATH = '/api/v1/investment';
export const investmentApi = {
// Products
getProducts: (params?: any): Promise<AxiosResponse> => {
return axios.get(`${BASE_PATH}/products`, { params });
},
getProductById: (id: string): Promise<AxiosResponse> => {
return axios.get(`${BASE_PATH}/products/${id}`);
},
// Accounts
getAccounts: (params?: any): Promise<AxiosResponse> => {
return axios.get(`${BASE_PATH}/accounts`, { params });
},
getAccountById: (id: string): Promise<AxiosResponse> => {
return axios.get(`${BASE_PATH}/accounts/${id}`);
},
createAccount: (data: {
product_id: string;
initial_investment: number;
payment_method_id: string;
}): Promise<AxiosResponse> => {
return axios.post(`${BASE_PATH}/accounts`, data);
},
// Deposits
deposit: (
accountId: string,
data: { amount: number; payment_method_id: string }
): Promise<AxiosResponse> => {
return axios.post(`${BASE_PATH}/accounts/${accountId}/deposit`, data);
},
// Withdrawals
requestWithdrawal: (
accountId: string,
data: {
amount: number;
withdrawal_method: string;
destination_details: any;
}
): Promise<AxiosResponse> => {
return axios.post(`${BASE_PATH}/accounts/${accountId}/withdraw`, data);
},
getWithdrawalRequests: (params?: any): Promise<AxiosResponse> => {
return axios.get(`${BASE_PATH}/withdrawal-requests`, { params });
},
// Portfolio
getPortfolio: (): Promise<AxiosResponse> => {
return axios.get(`${BASE_PATH}/portfolio`);
},
getPerformance: (accountId: string, params?: any): Promise<AxiosResponse> => {
return axios.get(`${BASE_PATH}/accounts/${accountId}/performance`, { params });
},
// Transactions
getTransactions: (params?: any): Promise<AxiosResponse> => {
return axios.get(`${BASE_PATH}/transactions`, { params });
},
};
6. Páginas Principales
6.1 Products Page
// src/pages/investment/ProductsPage.tsx
import React, { useEffect, useState } from 'react';
import { useInvestmentStore } from '../../stores/investmentStore';
import { ProductCard } from '../../components/investment/ProductCard';
import { DepositModal } from '../../components/investment/DepositModal';
import { Product } from '../../types/investment.types';
import './ProductsPage.css';
export const ProductsPage: React.FC = () => {
const { products, loading, fetchProducts } = useInvestmentStore();
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
const [showDepositModal, setShowDepositModal] = useState(false);
useEffect(() => {
fetchProducts({ status: 'active' });
}, [fetchProducts]);
const handleInvest = (product: Product) => {
setSelectedProduct(product);
setShowDepositModal(true);
};
if (loading.products) {
return <div className="loading">Loading products...</div>;
}
return (
<div className="products-page">
<header className="page-header">
<h1>Investment Products</h1>
<p>Choose from our AI-powered trading agents</p>
</header>
<div className="products-grid">
{products.map((product) => (
<ProductCard
key={product.id}
product={product}
onInvest={handleInvest}
/>
))}
</div>
{showDepositModal && selectedProduct && (
<DepositModal
product={selectedProduct}
onClose={() => setShowDepositModal(false)}
/>
)}
</div>
);
};
6.2 Portfolio Page
// src/pages/investment/PortfolioPage.tsx
import React, { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useInvestmentStore } from '../../stores/investmentStore';
import { PortfolioSummary } from '../../components/investment/PortfolioSummary';
import { AccountCard } from '../../components/investment/AccountCard';
import './PortfolioPage.css';
export const PortfolioPage: React.FC = () => {
const navigate = useNavigate();
const { portfolioSummary, loading, fetchPortfolio } = useInvestmentStore();
useEffect(() => {
fetchPortfolio();
}, [fetchPortfolio]);
if (loading.portfolio) {
return <div className="loading">Loading portfolio...</div>;
}
if (!portfolioSummary) {
return (
<div className="empty-state">
<h2>No investments yet</h2>
<p>Start investing in AI-powered trading agents</p>
<button onClick={() => navigate('/investment/products')}>
Browse Products
</button>
</div>
);
}
return (
<div className="portfolio-page">
<header className="page-header">
<h1>My Portfolio</h1>
</header>
<PortfolioSummary summary={portfolioSummary.summary} />
<section className="accounts-section">
<h2>My Accounts</h2>
<div className="accounts-grid">
{portfolioSummary.accounts.map((account: any) => (
<AccountCard
key={account.account_id}
account={account}
onClick={() => navigate(`/investment/accounts/${account.account_id}`)}
/>
))}
</div>
</section>
<section className="allocation-section">
<h2>Risk Allocation</h2>
<div className="risk-chart">
{/* Implementar gráfico de dona con allocation_by_risk */}
</div>
</section>
</div>
);
};
6.3 Account Detail Page
// src/pages/investment/AccountDetailPage.tsx
import React, { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useInvestmentStore } from '../../stores/investmentStore';
import { PerformanceChart } from '../../components/investment/PerformanceChart';
import { TransactionList } from '../../components/investment/TransactionList';
import { DepositForm } from '../../components/investment/DepositForm';
import { WithdrawalForm } from '../../components/investment/WithdrawalForm';
import './AccountDetailPage.css';
export const AccountDetailPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const {
selectedAccount,
dailyPerformance,
fetchAccountById,
fetchPerformance,
fetchTransactions,
} = useInvestmentStore();
const [activeTab, setActiveTab] = useState<'overview' | 'deposit' | 'withdraw'>('overview');
useEffect(() => {
if (id) {
fetchAccountById(id);
fetchPerformance(id, { period: 'month' });
fetchTransactions({ account_id: id, limit: 20 });
}
}, [id, fetchAccountById, fetchPerformance, fetchTransactions]);
if (!selectedAccount) {
return <div className="loading">Loading account...</div>;
}
return (
<div className="account-detail-page">
<header className="account-header">
<div className="account-info">
<h1>{selectedAccount.product.name}</h1>
<span className={`status-badge ${selectedAccount.status}`}>
{selectedAccount.status}
</span>
</div>
<div className="account-balance">
<div className="balance-label">Current Balance</div>
<div className="balance-amount">
${selectedAccount.current_balance.toLocaleString('en-US', {
minimumFractionDigits: 2,
})}
</div>
<div className="balance-return">
{selectedAccount.total_return_percentage >= 0 ? '+' : ''}
{selectedAccount.total_return_percentage?.toFixed(2)}%
</div>
</div>
</header>
<nav className="account-tabs">
<button
className={activeTab === 'overview' ? 'active' : ''}
onClick={() => setActiveTab('overview')}
>
Overview
</button>
<button
className={activeTab === 'deposit' ? 'active' : ''}
onClick={() => setActiveTab('deposit')}
>
Deposit
</button>
<button
className={activeTab === 'withdraw' ? 'active' : ''}
onClick={() => setActiveTab('withdraw')}
>
Withdraw
</button>
</nav>
<div className="account-content">
{activeTab === 'overview' && (
<>
<section className="performance-section">
<h2>Performance</h2>
<PerformanceChart data={dailyPerformance} />
</section>
<section className="stats-section">
<div className="stat-card">
<div className="stat-label">Total Invested</div>
<div className="stat-value">
${selectedAccount.total_deposited.toLocaleString()}
</div>
</div>
<div className="stat-card">
<div className="stat-label">Total Profit</div>
<div className="stat-value">
${(selectedAccount.current_balance - selectedAccount.total_deposited).toLocaleString()}
</div>
</div>
<div className="stat-card">
<div className="stat-label">Annualized Return</div>
<div className="stat-value">
{selectedAccount.annualized_return_percentage?.toFixed(2)}%
</div>
</div>
</section>
<section className="transactions-section">
<h2>Recent Transactions</h2>
<TransactionList />
</section>
</>
)}
{activeTab === 'deposit' && (
<DepositForm
accountId={selectedAccount.id}
productId={selectedAccount.product_id}
minAmount={50}
onSuccess={() => {
fetchAccountById(selectedAccount.id);
setActiveTab('overview');
}}
/>
)}
{activeTab === 'withdraw' && (
<WithdrawalForm
accountId={selectedAccount.id}
maxAmount={selectedAccount.current_balance}
onSuccess={() => {
fetchAccountById(selectedAccount.id);
setActiveTab('overview');
}}
/>
)}
</div>
</div>
);
};
7. Componentes Reutilizables
7.1 Product Card
// src/components/investment/ProductCard.tsx
import React from 'react';
import { Product } from '../../types/investment.types';
import './ProductCard.css';
interface ProductCardProps {
product: Product;
onInvest: (product: Product) => void;
}
export const ProductCard: React.FC<ProductCardProps> = ({ product, onInvest }) => {
const riskColors = {
low: 'green',
medium: 'yellow',
high: 'orange',
very_high: 'red',
};
return (
<div className="product-card">
<div className="product-header">
<h3>{product.name}</h3>
<span className={`risk-badge ${riskColors[product.risk_level]}`}>
{product.risk_level.toUpperCase()}
</span>
</div>
<p className="product-description">{product.description}</p>
<div className="product-stats">
<div className="stat">
<span className="stat-label">Agent Type</span>
<span className="stat-value">{product.agent_type}</span>
</div>
<div className="stat">
<span className="stat-label">Target Return</span>
<span className="stat-value">{product.target_annual_return}%</span>
</div>
<div className="stat">
<span className="stat-label">Performance Fee</span>
<span className="stat-value">{product.performance_fee_percentage}%</span>
</div>
<div className="stat">
<span className="stat-label">Min Investment</span>
<span className="stat-value">${product.min_investment}</span>
</div>
</div>
<div className="product-metrics">
<div className="metric">
<span>{product.total_investors} investors</span>
</div>
<div className="metric">
<span>${(product.total_aum / 1000).toFixed(0)}K AUM</span>
</div>
</div>
<button
className="invest-button"
onClick={() => onInvest(product)}
disabled={!product.is_accepting_new_investors}
>
{product.is_accepting_new_investors ? 'Invest Now' : 'Not Accepting'}
</button>
</div>
);
};
7.2 Performance Chart
// src/components/investment/PerformanceChart.tsx
import React from 'react';
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
import { DailyPerformance } from '../../types/investment.types';
interface PerformanceChartProps {
data: DailyPerformance[];
}
export const PerformanceChart: React.FC<PerformanceChartProps> = ({ data }) => {
const chartData = data.map((item) => ({
date: new Date(item.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
}),
balance: item.closing_balance,
return: item.cumulative_return_percentage,
}));
return (
<ResponsiveContainer width="100%" height={400}>
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis yAxisId="left" />
<YAxis yAxisId="right" orientation="right" />
<Tooltip />
<Legend />
<Line
yAxisId="left"
type="monotone"
dataKey="balance"
stroke="#8884d8"
name="Balance ($)"
/>
<Line
yAxisId="right"
type="monotone"
dataKey="return"
stroke="#82ca9d"
name="Return (%)"
/>
</LineChart>
</ResponsiveContainer>
);
};
8. Hooks Personalizados
// src/hooks/useInvestment.ts
import { useEffect } from 'react';
import { useInvestmentStore } from '../stores/investmentStore';
export const usePortfolio = () => {
const { portfolioSummary, loading, fetchPortfolio } = useInvestmentStore();
useEffect(() => {
fetchPortfolio();
}, [fetchPortfolio]);
return { portfolio: portfolioSummary, loading: loading.portfolio };
};
export const useAccount = (accountId: string) => {
const { selectedAccount, fetchAccountById } = useInvestmentStore();
useEffect(() => {
if (accountId) {
fetchAccountById(accountId);
}
}, [accountId, fetchAccountById]);
return { account: selectedAccount };
};
9. Configuración
9.1 Variables de Entorno
# Frontend .env
REACT_APP_API_URL=http://localhost:3000
REACT_APP_STRIPE_PUBLISHABLE_KEY=pk_test_...
10. Testing
10.1 Component Tests
// tests/components/ProductCard.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { ProductCard } from '../src/components/investment/ProductCard';
describe('ProductCard', () => {
const mockProduct = {
id: '1',
name: 'Swing Trader Pro',
risk_level: 'medium',
// ... otros campos
};
it('renders product information', () => {
render(<ProductCard product={mockProduct} onInvest={jest.fn()} />);
expect(screen.getByText('Swing Trader Pro')).toBeInTheDocument();
});
it('calls onInvest when button clicked', () => {
const onInvest = jest.fn();
render(<ProductCard product={mockProduct} onInvest={onInvest} />);
fireEvent.click(screen.getByText('Invest Now'));
expect(onInvest).toHaveBeenCalledWith(mockProduct);
});
});
11. Referencias
- React 18 Documentation
- Zustand State Management
- Recharts for Charts
- Stripe React Elements