From 639c70587acea1b9d0e3c467d98aeca19081d155 Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Sun, 25 Jan 2026 09:24:15 -0600 Subject: [PATCH] [OQI-004] feat: Complete investment module with all pages - Add Withdrawals.tsx with global withdrawals list and status filters - Add Transactions.tsx with global transaction history and type/account filters - Add Reports.tsx with allocation chart, performance bars, and export - Add ProductDetail.tsx with performance chart and investment form - Add routes for all new pages in App.tsx OQI-004 progress: 45% -> 75% Co-Authored-By: Claude Opus 4.5 --- src/App.tsx | 8 + .../investment/pages/ProductDetail.tsx | 447 ++++++++++++++++++ src/modules/investment/pages/Reports.tsx | 422 +++++++++++++++++ src/modules/investment/pages/Transactions.tsx | 328 +++++++++++++ src/modules/investment/pages/Withdrawals.tsx | 269 +++++++++++ 5 files changed, 1474 insertions(+) create mode 100644 src/modules/investment/pages/ProductDetail.tsx create mode 100644 src/modules/investment/pages/Reports.tsx create mode 100644 src/modules/investment/pages/Transactions.tsx create mode 100644 src/modules/investment/pages/Withdrawals.tsx diff --git a/src/App.tsx b/src/App.tsx index c1df3b1..d5680a1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -30,6 +30,10 @@ const Investment = lazy(() => import('./modules/investment/pages/Investment')); const InvestmentPortfolio = lazy(() => import('./modules/investment/pages/Portfolio')); const InvestmentProducts = lazy(() => import('./modules/investment/pages/Products')); const AccountDetail = lazy(() => import('./modules/investment/pages/AccountDetail')); +const Withdrawals = lazy(() => import('./modules/investment/pages/Withdrawals')); +const InvestmentTransactions = lazy(() => import('./modules/investment/pages/Transactions')); +const InvestmentReports = lazy(() => import('./modules/investment/pages/Reports')); +const ProductDetail = lazy(() => import('./modules/investment/pages/ProductDetail')); const Settings = lazy(() => import('./modules/settings/pages/Settings')); const Assistant = lazy(() => import('./modules/assistant/pages/Assistant')); @@ -93,6 +97,10 @@ function App() { } /> } /> } /> + } /> + } /> + } /> + } /> {/* Portfolio Manager */} } /> diff --git a/src/modules/investment/pages/ProductDetail.tsx b/src/modules/investment/pages/ProductDetail.tsx new file mode 100644 index 0000000..2769813 --- /dev/null +++ b/src/modules/investment/pages/ProductDetail.tsx @@ -0,0 +1,447 @@ +/** + * Product Detail Page + * Shows detailed information about an investment product + */ + +import React, { useEffect, useState, useRef } from 'react'; +import { useParams, useNavigate, Link } from 'react-router-dom'; +import { ArrowLeft, Shield, TrendingUp, Zap, AlertCircle, Info, CheckCircle } from 'lucide-react'; +import investmentService, { Product, ProductPerformance } from '../../../services/investment.service'; + +// ============================================================================ +// Types +// ============================================================================ + +type Period = 'week' | 'month' | '3months' | 'year'; + +// ============================================================================ +// Subcomponents +// ============================================================================ + +interface PerformanceChartProps { + data: ProductPerformance[]; +} + +const PerformanceChart: React.FC = ({ data }) => { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || data.length === 0) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const width = canvas.width; + const height = canvas.height; + const padding = { top: 20, right: 20, bottom: 40, left: 60 }; + const chartWidth = width - padding.left - padding.right; + const chartHeight = height - padding.top - padding.bottom; + + ctx.clearRect(0, 0, width, height); + + const returns = data.map(d => d.cumulativeReturn * 100); + const minReturn = Math.min(...returns, 0) - 5; + const maxReturn = Math.max(...returns) + 5; + + // Draw grid + ctx.strokeStyle = '#374151'; + ctx.lineWidth = 0.5; + for (let i = 0; i <= 4; i++) { + const y = padding.top + (chartHeight / 4) * i; + ctx.beginPath(); + ctx.moveTo(padding.left, y); + ctx.lineTo(width - padding.right, y); + ctx.stroke(); + + const value = maxReturn - ((maxReturn - minReturn) / 4) * i; + ctx.fillStyle = '#9CA3AF'; + ctx.font = '11px system-ui'; + ctx.textAlign = 'right'; + ctx.fillText(`${value.toFixed(1)}%`, padding.left - 10, y + 4); + } + + // Zero line + const zeroY = padding.top + ((maxReturn - 0) / (maxReturn - minReturn)) * chartHeight; + ctx.strokeStyle = '#6B7280'; + ctx.lineWidth = 1; + ctx.setLineDash([5, 5]); + ctx.beginPath(); + ctx.moveTo(padding.left, zeroY); + ctx.lineTo(width - padding.right, zeroY); + ctx.stroke(); + ctx.setLineDash([]); + + // Draw line + const isPositive = returns[returns.length - 1] >= 0; + ctx.beginPath(); + ctx.strokeStyle = isPositive ? '#10B981' : '#EF4444'; + ctx.lineWidth = 2; + + data.forEach((point, i) => { + const x = padding.left + (chartWidth / (data.length - 1)) * i; + const y = padding.top + ((maxReturn - point.cumulativeReturn * 100) / (maxReturn - minReturn)) * chartHeight; + + if (i === 0) { + ctx.moveTo(x, y); + } else { + ctx.lineTo(x, y); + } + }); + ctx.stroke(); + + // Gradient fill + const gradient = ctx.createLinearGradient(0, padding.top, 0, height - padding.bottom); + gradient.addColorStop(0, isPositive ? 'rgba(16, 185, 129, 0.2)' : 'rgba(239, 68, 68, 0.2)'); + gradient.addColorStop(1, 'rgba(0, 0, 0, 0)'); + + ctx.beginPath(); + data.forEach((point, i) => { + const x = padding.left + (chartWidth / (data.length - 1)) * i; + const y = padding.top + ((maxReturn - point.cumulativeReturn * 100) / (maxReturn - minReturn)) * chartHeight; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + }); + ctx.lineTo(padding.left + chartWidth, zeroY); + ctx.lineTo(padding.left, zeroY); + ctx.closePath(); + ctx.fillStyle = gradient; + ctx.fill(); + + // X-axis labels + const labelIndices = [0, Math.floor(data.length / 2), data.length - 1]; + ctx.fillStyle = '#9CA3AF'; + ctx.font = '11px system-ui'; + ctx.textAlign = 'center'; + labelIndices.forEach(i => { + if (data[i]) { + const x = padding.left + (chartWidth / (data.length - 1)) * i; + const date = new Date(data[i].date); + ctx.fillText(date.toLocaleDateString('es-ES', { month: 'short', day: 'numeric' }), x, height - 15); + } + }); + }, [data]); + + return ; +}; + +// ============================================================================ +// Main Component +// ============================================================================ + +export const ProductDetail: React.FC = () => { + const { productId } = useParams<{ productId: string }>(); + const navigate = useNavigate(); + + const [product, setProduct] = useState(null); + const [performance, setPerformance] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [period, setPeriod] = useState('month'); + const [investing, setInvesting] = useState(false); + const [investAmount, setInvestAmount] = useState(1000); + + useEffect(() => { + if (productId) { + loadProduct(); + } + }, [productId]); + + useEffect(() => { + if (productId) { + loadPerformance(); + } + }, [productId, period]); + + const loadProduct = async () => { + if (!productId) return; + + try { + setLoading(true); + setError(null); + const data = await investmentService.getProductById(productId); + setProduct(data); + setInvestAmount(data.minInvestment); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error loading product'); + } finally { + setLoading(false); + } + }; + + const loadPerformance = async () => { + if (!productId) return; + + try { + const data = await investmentService.getProductPerformance(productId, period); + setPerformance(data); + } catch (err) { + console.error('Error loading performance:', err); + } + }; + + const handleInvest = async () => { + if (!product || investing) return; + + try { + setInvesting(true); + await investmentService.createAccount(product.id, investAmount); + navigate('/investment/portfolio'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error creating account'); + } finally { + setInvesting(false); + } + }; + + const periodOptions: { value: Period; label: string }[] = [ + { value: 'week', label: '7 días' }, + { value: 'month', label: '30 días' }, + { value: '3months', label: '3 meses' }, + { value: 'year', label: '1 año' }, + ]; + + const icons: Record = { + atlas: , + orion: , + nova: , + }; + + const riskColors = { + conservative: 'text-green-500 bg-green-100 dark:bg-green-900/30', + moderate: 'text-yellow-500 bg-yellow-100 dark:bg-yellow-900/30', + aggressive: 'text-red-500 bg-red-100 dark:bg-red-900/30', + }; + + const riskLabels = { + conservative: 'Conservador', + moderate: 'Moderado', + aggressive: 'Agresivo', + }; + + if (loading) { + return ( +
+
+
+ ); + } + + if (error || !product) { + return ( +
+
+ +

+ Error al cargar el producto +

+

{error || 'Producto no encontrado'}

+ +
+
+ ); + } + + const currentReturn = performance.length > 0 ? performance[performance.length - 1].cumulativeReturn * 100 : 0; + + return ( +
+ {/* Header */} +
+ +
+
+ {icons[product.code] || icons.atlas} +
+

+ {product.name} +

+ + {riskLabels[product.riskProfile]} + +
+
+
+
+ +
+ {/* Main Content */} +
+ {/* Description */} +
+

+ Descripción +

+

+ {product.description} +

+
+ + {/* Performance Chart */} +
+
+

+ Rendimiento Histórico +

+
+ {periodOptions.map(option => ( + + ))} +
+
+ {performance.length > 0 ? ( + <> + +
+

Retorno acumulado

+

= 0 ? 'text-green-500' : 'text-red-500'}`}> + {currentReturn >= 0 ? '+' : ''}{currentReturn.toFixed(2)}% +

+
+ + ) : ( +

+ No hay datos de rendimiento disponibles +

+ )} +
+ + {/* Features */} +
+

+ Características +

+
+
+ +
+

Target Mensual

+

+ {product.targetReturnMin}% - {product.targetReturnMax}% +

+
+
+
+ +
+

Max Drawdown

+

{product.maxDrawdown}%

+
+
+
+ +
+

Comisión de Gestión

+

{product.managementFee}% anual

+
+
+
+ +
+

Comisión de Éxito

+

{product.performanceFee}% sobre ganancias

+
+
+
+
+
+ + {/* Sidebar - Investment Card */} +
+
+

+ Invertir +

+ +
+
+ +
+ $ + setInvestAmount(Number(e.target.value))} + className="w-full pl-8 pr-4 py-3 bg-gray-100 dark:bg-gray-900 rounded-lg text-lg font-medium text-gray-900 dark:text-white border-0 focus:ring-2 focus:ring-blue-500" + /> +
+

+ Mínimo: ${product.minInvestment.toLocaleString()} +

+
+ +
+ {[500, 1000, 5000, 10000].map(amount => ( + + ))} +
+
+ + + +
+
+ +

+ Tu inversión será gestionada automáticamente por nuestros agentes de IA. + Podrás retirar fondos en cualquier momento. +

+
+
+
+
+
+ + {/* Risk Warning */} +
+

+ Advertencia de riesgo: El trading de activos financieros conlleva riesgos significativos. + Los rendimientos pasados no garantizan rendimientos futuros. Los rendimientos objetivo son estimados + y pueden variar. Solo invierte lo que puedas permitirte perder. +

+
+
+ ); +}; + +export default ProductDetail; diff --git a/src/modules/investment/pages/Reports.tsx b/src/modules/investment/pages/Reports.tsx new file mode 100644 index 0000000..29374c1 --- /dev/null +++ b/src/modules/investment/pages/Reports.tsx @@ -0,0 +1,422 @@ +/** + * Reports Page + * Investment analytics and performance reports + */ + +import React, { useEffect, useState, useRef } from 'react'; +import { Link } from 'react-router-dom'; +import { ArrowLeft, TrendingUp, TrendingDown, PieChart, BarChart3, Download, Calendar, AlertCircle } from 'lucide-react'; +import investmentService, { AccountSummary, InvestmentAccount } from '../../../services/investment.service'; + +// ============================================================================ +// Types +// ============================================================================ + +type Period = 'week' | 'month' | '3months' | 'year' | 'all'; + +// ============================================================================ +// Subcomponents +// ============================================================================ + +interface AllocationChartProps { + accounts: InvestmentAccount[]; +} + +const AllocationChart: React.FC = ({ accounts }) => { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || accounts.length === 0) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const total = accounts.reduce((sum, a) => sum + a.balance, 0); + if (total === 0) return; + + const colors = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6']; + const centerX = canvas.width / 2; + const centerY = canvas.height / 2; + const radius = Math.min(centerX, centerY) - 20; + + ctx.clearRect(0, 0, canvas.width, canvas.height); + + let startAngle = -Math.PI / 2; + accounts.forEach((account, i) => { + const sliceAngle = (account.balance / total) * 2 * Math.PI; + + ctx.beginPath(); + ctx.moveTo(centerX, centerY); + ctx.arc(centerX, centerY, radius, startAngle, startAngle + sliceAngle); + ctx.closePath(); + ctx.fillStyle = colors[i % colors.length]; + ctx.fill(); + + startAngle += sliceAngle; + }); + + // Inner circle (donut) + ctx.beginPath(); + ctx.arc(centerX, centerY, radius * 0.6, 0, 2 * Math.PI); + ctx.fillStyle = '#1F2937'; + ctx.fill(); + + // Center text + ctx.fillStyle = '#FFFFFF'; + ctx.font = 'bold 24px system-ui'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(`$${(total / 1000).toFixed(1)}k`, centerX, centerY); + }, [accounts]); + + const colors = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6']; + const total = accounts.reduce((sum, a) => sum + a.balance, 0); + + return ( +
+ +
+ {accounts.map((account, i) => ( +
+
+ + {account.product.name} + + + {total > 0 ? ((account.balance / total) * 100).toFixed(1) : 0}% + +
+ ))} +
+
+ ); +}; + +interface PerformanceBarChartProps { + data: { label: string; value: number }[]; +} + +const PerformanceBarChart: React.FC = ({ data }) => { + const maxValue = Math.max(...data.map(d => Math.abs(d.value)), 1); + + return ( +
+ {data.map((item, i) => ( +
+
+ {item.label} + = 0 ? 'text-green-500' : 'text-red-500'}`}> + {item.value >= 0 ? '+' : ''}{item.value.toFixed(2)}% + +
+
+
= 0 ? 'bg-green-500' : 'bg-red-500'}`} + style={{ width: `${(Math.abs(item.value) / maxValue) * 100}%` }} + /> +
+
+ ))} +
+ ); +}; + +// ============================================================================ +// Main Component +// ============================================================================ + +export const Reports: React.FC = () => { + const [summary, setSummary] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [period, setPeriod] = useState('month'); + + useEffect(() => { + loadData(); + }, []); + + const loadData = async () => { + try { + setLoading(true); + setError(null); + const data = await investmentService.getAccountSummary(); + setSummary(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error loading reports'); + } finally { + setLoading(false); + } + }; + + const periodOptions: { value: Period; label: string }[] = [ + { value: 'week', label: '7 días' }, + { value: 'month', label: '30 días' }, + { value: '3months', label: '3 meses' }, + { value: 'year', label: '1 año' }, + { value: 'all', label: 'Todo' }, + ]; + + const performanceByAccount = summary?.accounts.map(account => { + const totalReturn = account.balance - account.totalDeposited + account.totalWithdrawn; + const returnPercent = account.totalDeposited > 0 ? (totalReturn / account.totalDeposited) * 100 : 0; + return { + label: account.product.name, + value: returnPercent, + }; + }) || []; + + const handleExport = () => { + if (!summary) return; + + const reportData = { + generatedAt: new Date().toISOString(), + summary: { + totalBalance: summary.totalBalance, + totalDeposited: summary.totalDeposited, + totalWithdrawn: summary.totalWithdrawn, + overallReturn: summary.overallReturn, + overallReturnPercent: summary.overallReturnPercent, + }, + accounts: summary.accounts.map(a => ({ + name: a.product.name, + balance: a.balance, + deposited: a.totalDeposited, + withdrawn: a.totalWithdrawn, + earnings: a.totalEarnings, + })), + }; + + const blob = new Blob([JSON.stringify(reportData, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `investment-report-${new Date().toISOString().split('T')[0]}.json`; + link.click(); + URL.revokeObjectURL(url); + }; + + if (loading) { + return ( +
+
+
+ ); + } + + if (error) { + return ( +
+
+ +

{error}

+ +
+
+ ); + } + + if (!summary) return null; + + return ( +
+ {/* Header */} +
+
+ + + +
+

+ Reportes +

+

+ Análisis de rendimiento de inversiones +

+
+
+ +
+ + {/* Period Filter */} +
+ + {periodOptions.map(option => ( + + ))} +
+ + {/* Summary Cards */} +
+
+
+ Valor Total + +
+

+ ${summary.totalBalance.toLocaleString(undefined, { minimumFractionDigits: 2 })} +

+
+
+
+ Total Invertido + +
+

+ ${summary.totalDeposited.toLocaleString(undefined, { minimumFractionDigits: 2 })} +

+
+
+
+ Ganancia/Pérdida + {summary.overallReturn >= 0 ? ( + + ) : ( + + )} +
+

= 0 ? 'text-green-500' : 'text-red-500'}`}> + {summary.overallReturn >= 0 ? '+' : ''}${Math.abs(summary.overallReturn).toLocaleString(undefined, { minimumFractionDigits: 2 })} +

+

= 0 ? 'text-green-500' : 'text-red-500'}`}> + {summary.overallReturnPercent >= 0 ? '+' : ''}{summary.overallReturnPercent.toFixed(2)}% +

+
+
+
+ Total Retirado + +
+

+ ${summary.totalWithdrawn.toLocaleString(undefined, { minimumFractionDigits: 2 })} +

+
+
+ + {/* Charts */} +
+ {/* Allocation Chart */} +
+

+ Distribución del Portfolio +

+ {summary.accounts.length > 0 ? ( + + ) : ( +

+ No hay cuentas para mostrar +

+ )} +
+ + {/* Performance by Account */} +
+

+ Rendimiento por Cuenta +

+ {performanceByAccount.length > 0 ? ( + + ) : ( +

+ No hay datos de rendimiento +

+ )} +
+
+ + {/* Account Details Table */} +
+
+

+ Detalle por Cuenta +

+
+
+ + + + + + + + + + + + {summary.accounts.map(account => { + const totalReturn = account.balance - account.totalDeposited + account.totalWithdrawn; + const returnPercent = account.totalDeposited > 0 ? (totalReturn / account.totalDeposited) * 100 : 0; + return ( + + + + + + + + ); + })} + +
+ Cuenta + + Balance + + Invertido + + Ganancias + + Retorno +
+ + {account.product.name} + +

+ {account.product.riskProfile} +

+
+ ${account.balance.toLocaleString(undefined, { minimumFractionDigits: 2 })} + + ${account.totalDeposited.toLocaleString(undefined, { minimumFractionDigits: 2 })} + = 0 ? 'text-green-500' : 'text-red-500'}`}> + {totalReturn >= 0 ? '+' : ''}${Math.abs(totalReturn).toLocaleString(undefined, { minimumFractionDigits: 2 })} + = 0 ? 'text-green-500' : 'text-red-500'}`}> + {returnPercent >= 0 ? '+' : ''}{returnPercent.toFixed(2)}% +
+
+
+
+ ); +}; + +export default Reports; diff --git a/src/modules/investment/pages/Transactions.tsx b/src/modules/investment/pages/Transactions.tsx new file mode 100644 index 0000000..3c2c025 --- /dev/null +++ b/src/modules/investment/pages/Transactions.tsx @@ -0,0 +1,328 @@ +/** + * Transactions Page + * Global transaction history across all investment accounts + */ + +import React, { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { ArrowLeft, ArrowDownCircle, ArrowUpCircle, Gift, Receipt, RefreshCw, Filter, Calendar, AlertCircle } from 'lucide-react'; +import investmentService, { Transaction, InvestmentAccount } from '../../../services/investment.service'; + +// ============================================================================ +// Types +// ============================================================================ + +type TransactionType = 'all' | 'deposit' | 'withdrawal' | 'distribution' | 'fee'; + +interface TransactionWithAccount extends Transaction { + accountName?: string; +} + +// ============================================================================ +// Subcomponents +// ============================================================================ + +interface TransactionRowProps { + transaction: TransactionWithAccount; +} + +const TransactionRow: React.FC = ({ transaction }) => { + const typeConfig = { + deposit: { + icon: , + color: 'text-green-500', + bg: 'bg-green-100 dark:bg-green-900/30', + label: 'Depósito', + sign: '+', + }, + withdrawal: { + icon: , + color: 'text-red-500', + bg: 'bg-red-100 dark:bg-red-900/30', + label: 'Retiro', + sign: '-', + }, + distribution: { + icon: , + color: 'text-blue-500', + bg: 'bg-blue-100 dark:bg-blue-900/30', + label: 'Distribución', + sign: '+', + }, + fee: { + icon: , + color: 'text-orange-500', + bg: 'bg-orange-100 dark:bg-orange-900/30', + label: 'Comisión', + sign: '-', + }, + adjustment: { + icon: , + color: 'text-purple-500', + bg: 'bg-purple-100 dark:bg-purple-900/30', + label: 'Ajuste', + sign: '', + }, + }; + + const config = typeConfig[transaction.type] || typeConfig.adjustment; + const isCredit = ['deposit', 'distribution'].includes(transaction.type); + + const statusColors: Record = { + pending: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400', + completed: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400', + failed: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400', + cancelled: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400', + }; + + return ( +
+
+
+ {config.icon} +
+
+
+

{config.label}

+ + {transaction.status} + +
+

+ {new Date(transaction.createdAt).toLocaleDateString()} - {transaction.description || transaction.accountName || 'Sin descripción'} +

+
+
+
+

+ {config.sign}${Math.abs(transaction.amount).toLocaleString(undefined, { minimumFractionDigits: 2 })} +

+

+ Balance: ${transaction.balanceAfter.toLocaleString(undefined, { minimumFractionDigits: 2 })} +

+
+
+ ); +}; + +// ============================================================================ +// Main Component +// ============================================================================ + +export const Transactions: React.FC = () => { + const [transactions, setTransactions] = useState([]); + const [accounts, setAccounts] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [typeFilter, setTypeFilter] = useState('all'); + const [selectedAccount, setSelectedAccount] = useState('all'); + const [dateRange, setDateRange] = useState<'week' | 'month' | '3months' | 'all'>('month'); + + useEffect(() => { + loadData(); + }, []); + + useEffect(() => { + if (accounts.length > 0) { + loadTransactions(); + } + }, [typeFilter, selectedAccount, accounts]); + + const loadData = async () => { + try { + setLoading(true); + setError(null); + const accountsData = await investmentService.getUserAccounts(); + setAccounts(accountsData); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error loading accounts'); + } finally { + setLoading(false); + } + }; + + const loadTransactions = async () => { + try { + setLoading(true); + const allTransactions: TransactionWithAccount[] = []; + + const accountsToFetch = selectedAccount === 'all' + ? accounts + : accounts.filter(a => a.id === selectedAccount); + + for (const account of accountsToFetch) { + const { transactions: txs } = await investmentService.getTransactions(account.id, { + type: typeFilter === 'all' ? undefined : typeFilter, + limit: 50, + }); + allTransactions.push( + ...txs.map(tx => ({ ...tx, accountName: account.product.name })) + ); + } + + // Sort by date descending + allTransactions.sort((a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ); + + setTransactions(allTransactions); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error loading transactions'); + } finally { + setLoading(false); + } + }; + + const typeOptions: { value: TransactionType; label: string }[] = [ + { value: 'all', label: 'Todos' }, + { value: 'deposit', label: 'Depósitos' }, + { value: 'withdrawal', label: 'Retiros' }, + { value: 'distribution', label: 'Distribuciones' }, + { value: 'fee', label: 'Comisiones' }, + ]; + + const stats = { + deposits: transactions.filter(t => t.type === 'deposit').reduce((sum, t) => sum + t.amount, 0), + withdrawals: transactions.filter(t => t.type === 'withdrawal').reduce((sum, t) => sum + t.amount, 0), + distributions: transactions.filter(t => t.type === 'distribution').reduce((sum, t) => sum + t.amount, 0), + fees: transactions.filter(t => t.type === 'fee').reduce((sum, t) => sum + t.amount, 0), + }; + + return ( +
+ {/* Header */} +
+ + + +
+

+ Transacciones +

+

+ Historial completo de movimientos +

+
+
+ + {/* Stats */} +
+
+

Depósitos

+

+ +${stats.deposits.toLocaleString(undefined, { minimumFractionDigits: 2 })} +

+
+
+

Retiros

+

+ -${stats.withdrawals.toLocaleString(undefined, { minimumFractionDigits: 2 })} +

+
+
+

Distribuciones

+

+ +${stats.distributions.toLocaleString(undefined, { minimumFractionDigits: 2 })} +

+
+
+

Comisiones

+

+ -${stats.fees.toLocaleString(undefined, { minimumFractionDigits: 2 })} +

+
+
+ + {/* Filters */} +
+
+ {/* Type Filter */} +
+ +
+ {typeOptions.map(option => ( + + ))} +
+
+ + {/* Account Filter */} + {accounts.length > 1 && ( +
+ Cuenta: + +
+ )} +
+
+ + {/* Content */} + {loading ? ( +
+
+
+ ) : error ? ( +
+ +

{error}

+ +
+ ) : transactions.length === 0 ? ( +
+ 📋 +

+ No hay transacciones +

+

+ {typeFilter === 'all' + ? 'Aún no tienes transacciones registradas' + : `No hay transacciones de tipo "${typeOptions.find(f => f.value === typeFilter)?.label}"`} +

+ + Ir al Portfolio + +
+ ) : ( +
+ {transactions.map(transaction => ( + + ))} +
+ )} +
+ ); +}; + +export default Transactions; diff --git a/src/modules/investment/pages/Withdrawals.tsx b/src/modules/investment/pages/Withdrawals.tsx new file mode 100644 index 0000000..7a1c92d --- /dev/null +++ b/src/modules/investment/pages/Withdrawals.tsx @@ -0,0 +1,269 @@ +/** + * Withdrawals Page + * Lists all user withdrawal requests across all investment accounts + */ + +import React, { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { ArrowLeft, Clock, CheckCircle, XCircle, AlertCircle, Filter } from 'lucide-react'; +import investmentService, { Withdrawal } from '../../../services/investment.service'; + +// ============================================================================ +// Types +// ============================================================================ + +type StatusFilter = 'all' | 'pending' | 'approved' | 'processing' | 'completed' | 'rejected'; + +// ============================================================================ +// Subcomponents +// ============================================================================ + +interface WithdrawalCardProps { + withdrawal: Withdrawal; +} + +const WithdrawalCard: React.FC = ({ withdrawal }) => { + const statusConfig = { + pending: { + icon: , + color: 'text-yellow-500', + bg: 'bg-yellow-100 dark:bg-yellow-900/30', + label: 'Pendiente', + }, + approved: { + icon: , + color: 'text-blue-500', + bg: 'bg-blue-100 dark:bg-blue-900/30', + label: 'Aprobado', + }, + processing: { + icon: , + color: 'text-purple-500', + bg: 'bg-purple-100 dark:bg-purple-900/30', + label: 'Procesando', + }, + completed: { + icon: , + color: 'text-green-500', + bg: 'bg-green-100 dark:bg-green-900/30', + label: 'Completado', + }, + rejected: { + icon: , + color: 'text-red-500', + bg: 'bg-red-100 dark:bg-red-900/30', + label: 'Rechazado', + }, + }; + + const config = statusConfig[withdrawal.status]; + + return ( +
+
+
+
+ {config.icon} +
+
+

+ ${withdrawal.amount.toLocaleString(undefined, { minimumFractionDigits: 2 })} +

+

+ {new Date(withdrawal.requestedAt).toLocaleDateString()} {new Date(withdrawal.requestedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} +

+
+
+ + {config.label} + +
+ +
+ {withdrawal.bankInfo && ( +
+ Destino + + {withdrawal.bankInfo.bankName} ****{withdrawal.bankInfo.accountLast4} + +
+ )} + {withdrawal.cryptoInfo && ( +
+ Destino + + {withdrawal.cryptoInfo.network}: ...{withdrawal.cryptoInfo.addressLast8} + +
+ )} + {withdrawal.processedAt && ( +
+ Procesado + + {new Date(withdrawal.processedAt).toLocaleDateString()} + +
+ )} + {withdrawal.rejectionReason && ( +
+

+ Motivo: {withdrawal.rejectionReason} +

+
+ )} +
+
+ ); +}; + +// ============================================================================ +// Main Component +// ============================================================================ + +export const Withdrawals: React.FC = () => { + const [withdrawals, setWithdrawals] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [statusFilter, setStatusFilter] = useState('all'); + + useEffect(() => { + loadWithdrawals(); + }, [statusFilter]); + + const loadWithdrawals = async () => { + try { + setLoading(true); + setError(null); + const status = statusFilter === 'all' ? undefined : statusFilter; + const data = await investmentService.getWithdrawals(status); + setWithdrawals(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error loading withdrawals'); + } finally { + setLoading(false); + } + }; + + const stats = { + total: withdrawals.length, + pending: withdrawals.filter(w => ['pending', 'approved', 'processing'].includes(w.status)).length, + completed: withdrawals.filter(w => w.status === 'completed').length, + totalAmount: withdrawals + .filter(w => w.status === 'completed') + .reduce((sum, w) => sum + w.amount, 0), + }; + + const filterOptions: { value: StatusFilter; label: string }[] = [ + { value: 'all', label: 'Todos' }, + { value: 'pending', label: 'Pendientes' }, + { value: 'processing', label: 'En Proceso' }, + { value: 'completed', label: 'Completados' }, + { value: 'rejected', label: 'Rechazados' }, + ]; + + return ( +
+ {/* Header */} +
+ + + +
+

+ Mis Retiros +

+

+ Historial de solicitudes de retiro +

+
+
+ + {/* Stats */} +
+
+

Total Solicitudes

+

{stats.total}

+
+
+

En Proceso

+

{stats.pending}

+
+
+

Completados

+

{stats.completed}

+
+
+

Total Retirado

+

+ ${stats.totalAmount.toLocaleString(undefined, { minimumFractionDigits: 2 })} +

+
+
+ + {/* Filters */} +
+ + {filterOptions.map(option => ( + + ))} +
+ + {/* Content */} + {loading ? ( +
+
+
+ ) : error ? ( +
+ +

{error}

+ +
+ ) : withdrawals.length === 0 ? ( +
+ 💸 +

+ No hay retiros +

+

+ {statusFilter === 'all' + ? 'Aún no has solicitado ningún retiro' + : `No hay retiros con estado "${filterOptions.find(f => f.value === statusFilter)?.label}"`} +

+ + Ir al Portfolio + +
+ ) : ( +
+ {withdrawals.map(withdrawal => ( + + ))} +
+ )} +
+ ); +}; + +export default Withdrawals;