[OQI-004] feat: Add AccountDetail page and investment service
- Create AccountDetail.tsx with tabs for overview, transactions, distributions, deposit, withdraw - Create investment.service.ts with full API client for investment endpoints - Add routes /investment/portfolio, /investment/products, /investment/accounts/:accountId - Integrate DepositForm and WithdrawForm components - Add canvas-based performance chart Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
fd54724ede
commit
e158d4228e
@ -27,6 +27,9 @@ const Trading = lazy(() => import('./modules/trading/pages/Trading'));
|
||||
const MLDashboard = lazy(() => import('./modules/ml/pages/MLDashboard'));
|
||||
const BacktestingDashboard = lazy(() => import('./modules/backtesting/pages/BacktestingDashboard'));
|
||||
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 Settings = lazy(() => import('./modules/settings/pages/Settings'));
|
||||
const Assistant = lazy(() => import('./modules/assistant/pages/Assistant'));
|
||||
|
||||
@ -87,6 +90,9 @@ function App() {
|
||||
<Route path="/ml-dashboard" element={<MLDashboard />} />
|
||||
<Route path="/backtesting" element={<BacktestingDashboard />} />
|
||||
<Route path="/investment" element={<Investment />} />
|
||||
<Route path="/investment/portfolio" element={<InvestmentPortfolio />} />
|
||||
<Route path="/investment/products" element={<InvestmentProducts />} />
|
||||
<Route path="/investment/accounts/:accountId" element={<AccountDetail />} />
|
||||
|
||||
{/* Portfolio Manager */}
|
||||
<Route path="/portfolio" element={<PortfolioDashboard />} />
|
||||
|
||||
608
src/modules/investment/pages/AccountDetail.tsx
Normal file
608
src/modules/investment/pages/AccountDetail.tsx
Normal file
@ -0,0 +1,608 @@
|
||||
/**
|
||||
* Account Detail Page
|
||||
* Shows detailed information about a specific investment account
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import { ArrowLeft, TrendingUp, TrendingDown, Calendar, Clock, DollarSign, AlertCircle } from 'lucide-react';
|
||||
import investmentService, { AccountDetail as AccountDetailType, Transaction, Distribution } from '../../../services/investment.service';
|
||||
import { DepositForm } from '../components/DepositForm';
|
||||
import { WithdrawForm } from '../components/WithdrawForm';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
type TabType = 'overview' | 'transactions' | 'distributions' | 'deposit' | 'withdraw';
|
||||
|
||||
// ============================================================================
|
||||
// Subcomponents
|
||||
// ============================================================================
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: string;
|
||||
subValue?: string;
|
||||
icon: React.ReactNode;
|
||||
trend?: 'up' | 'down' | 'neutral';
|
||||
}
|
||||
|
||||
const StatCard: React.FC<StatCardProps> = ({ label, value, subValue, icon, trend }) => {
|
||||
const trendColors = {
|
||||
up: 'text-green-500',
|
||||
down: 'text-red-500',
|
||||
neutral: 'text-gray-500',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-5 shadow-lg">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-900/30">{icon}</div>
|
||||
{trend && (
|
||||
<span className={trendColors[trend]}>
|
||||
{trend === 'up' ? <TrendingUp size={20} /> : trend === 'down' ? <TrendingDown size={20} /> : null}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-gray-500 dark:text-gray-400">{label}</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white">{value}</p>
|
||||
{subValue && <p className={`text-sm ${trend ? trendColors[trend] : 'text-gray-500'}`}>{subValue}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface TransactionRowProps {
|
||||
transaction: Transaction;
|
||||
}
|
||||
|
||||
const TransactionRow: React.FC<TransactionRowProps> = ({ transaction }) => {
|
||||
const typeIcons: Record<string, string> = {
|
||||
deposit: '📥',
|
||||
withdrawal: '📤',
|
||||
distribution: '💰',
|
||||
fee: '📋',
|
||||
adjustment: '🔄',
|
||||
};
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
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',
|
||||
};
|
||||
|
||||
const isCredit = ['deposit', 'distribution'].includes(transaction.type);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between py-4 border-b border-gray-200 dark:border-gray-700 last:border-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-2xl">{typeIcons[transaction.type] || '📋'}</span>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 dark:text-white capitalize">
|
||||
{transaction.type.replace('_', ' ')}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{new Date(transaction.createdAt).toLocaleDateString()} {new Date(transaction.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className={`font-bold ${isCredit ? 'text-green-500' : 'text-red-500'}`}>
|
||||
{isCredit ? '+' : '-'}${Math.abs(transaction.amount).toLocaleString(undefined, { minimumFractionDigits: 2 })}
|
||||
</p>
|
||||
<span className={`text-xs px-2 py-1 rounded-full ${statusColors[transaction.status]}`}>
|
||||
{transaction.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface DistributionRowProps {
|
||||
distribution: Distribution;
|
||||
}
|
||||
|
||||
const DistributionRow: React.FC<DistributionRowProps> = ({ distribution }) => {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-4 border-b border-gray-200 dark:border-gray-700 last:border-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-2xl">💰</span>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 dark:text-white">
|
||||
Distribución de Rendimientos
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{new Date(distribution.distributedAt).toLocaleDateString()} - Tasa: {(distribution.rate * 100).toFixed(4)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-bold text-green-500">
|
||||
+${distribution.amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Balance: ${distribution.balanceAfter.toLocaleString(undefined, { minimumFractionDigits: 2 })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Performance Chart (Simple Canvas)
|
||||
// ============================================================================
|
||||
|
||||
interface PerformanceChartProps {
|
||||
data: Array<{ date: string; balance: number; pnl: number }>;
|
||||
}
|
||||
|
||||
const PerformanceChart: React.FC<PerformanceChartProps> = ({ data }) => {
|
||||
const canvasRef = React.useRef<HTMLCanvasElement>(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: 30, left: 60 };
|
||||
const chartWidth = width - padding.left - padding.right;
|
||||
const chartHeight = height - padding.top - padding.bottom;
|
||||
|
||||
// Clear canvas
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// Calculate min/max
|
||||
const balances = data.map(d => d.balance);
|
||||
const minBalance = Math.min(...balances) * 0.99;
|
||||
const maxBalance = Math.max(...balances) * 1.01;
|
||||
|
||||
// Draw grid lines
|
||||
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();
|
||||
|
||||
// Y-axis labels
|
||||
const value = maxBalance - ((maxBalance - minBalance) / 4) * i;
|
||||
ctx.fillStyle = '#9CA3AF';
|
||||
ctx.font = '12px system-ui';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(`$${value.toLocaleString(undefined, { maximumFractionDigits: 0 })}`, padding.left - 10, y + 4);
|
||||
}
|
||||
|
||||
// Draw line
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = '#3B82F6';
|
||||
ctx.lineWidth = 2;
|
||||
|
||||
data.forEach((point, i) => {
|
||||
const x = padding.left + (chartWidth / (data.length - 1)) * i;
|
||||
const y = padding.top + chartHeight - ((point.balance - minBalance) / (maxBalance - minBalance)) * chartHeight;
|
||||
|
||||
if (i === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
});
|
||||
ctx.stroke();
|
||||
|
||||
// Draw gradient fill
|
||||
const gradient = ctx.createLinearGradient(0, padding.top, 0, height - padding.bottom);
|
||||
gradient.addColorStop(0, 'rgba(59, 130, 246, 0.3)');
|
||||
gradient.addColorStop(1, 'rgba(59, 130, 246, 0)');
|
||||
|
||||
ctx.beginPath();
|
||||
data.forEach((point, i) => {
|
||||
const x = padding.left + (chartWidth / (data.length - 1)) * i;
|
||||
const y = padding.top + chartHeight - ((point.balance - minBalance) / (maxBalance - minBalance)) * chartHeight;
|
||||
if (i === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
});
|
||||
ctx.lineTo(padding.left + chartWidth, height - padding.bottom);
|
||||
ctx.lineTo(padding.left, height - padding.bottom);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fill();
|
||||
|
||||
// X-axis labels (show a few dates)
|
||||
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 - 10);
|
||||
}
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={800}
|
||||
height={300}
|
||||
className="w-full h-auto"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Main Component
|
||||
// ============================================================================
|
||||
|
||||
export const AccountDetail: React.FC = () => {
|
||||
const { accountId } = useParams<{ accountId: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [account, setAccount] = useState<AccountDetailType | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<TabType>('overview');
|
||||
const [allTransactions, setAllTransactions] = useState<Transaction[]>([]);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (accountId) {
|
||||
loadAccount();
|
||||
}
|
||||
}, [accountId]);
|
||||
|
||||
const loadAccount = async () => {
|
||||
if (!accountId) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await investmentService.getAccountById(accountId);
|
||||
setAccount(data);
|
||||
setAllTransactions(data.recentTransactions || []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error loading account');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadMoreTransactions = async () => {
|
||||
if (!accountId || loadingMore) return;
|
||||
|
||||
try {
|
||||
setLoadingMore(true);
|
||||
const { transactions } = await investmentService.getTransactions(accountId, {
|
||||
limit: 20,
|
||||
offset: allTransactions.length,
|
||||
});
|
||||
setAllTransactions(prev => [...prev, ...transactions]);
|
||||
} catch (err) {
|
||||
console.error('Error loading transactions:', err);
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDepositSuccess = () => {
|
||||
setActiveTab('overview');
|
||||
loadAccount();
|
||||
};
|
||||
|
||||
const handleWithdrawSuccess = () => {
|
||||
setActiveTab('overview');
|
||||
loadAccount();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-96">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !account) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-6 text-center">
|
||||
<AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-4" />
|
||||
<h2 className="text-xl font-bold text-red-700 dark:text-red-400 mb-2">
|
||||
Error al cargar la cuenta
|
||||
</h2>
|
||||
<p className="text-red-600 dark:text-red-300 mb-4">{error || 'Cuenta no encontrada'}</p>
|
||||
<button
|
||||
onClick={() => navigate('/investment/portfolio')}
|
||||
className="px-6 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
|
||||
>
|
||||
Volver al Portfolio
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const totalReturn = account.balance - account.totalDeposited + account.totalWithdrawn;
|
||||
const returnPercent = account.totalDeposited > 0 ? (totalReturn / account.totalDeposited) * 100 : 0;
|
||||
|
||||
const tabs: { key: TabType; label: string }[] = [
|
||||
{ key: 'overview', label: 'Resumen' },
|
||||
{ key: 'transactions', label: 'Transacciones' },
|
||||
{ key: 'distributions', label: 'Distribuciones' },
|
||||
{ key: 'deposit', label: 'Depositar' },
|
||||
{ key: 'withdraw', label: 'Retirar' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<button
|
||||
onClick={() => navigate('/investment/portfolio')}
|
||||
className="p-2 rounded-lg bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{account.product.name}
|
||||
</h1>
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-medium ${
|
||||
account.status === 'active'
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
|
||||
: account.status === 'suspended'
|
||||
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400'
|
||||
: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400'
|
||||
}`}>
|
||||
{account.status === 'active' ? 'Activa' : account.status === 'suspended' ? 'Suspendida' : 'Cerrada'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Cuenta #{account.accountNumber} - Desde {new Date(account.openedAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
<StatCard
|
||||
label="Balance Actual"
|
||||
value={`$${account.balance.toLocaleString(undefined, { minimumFractionDigits: 2 })}`}
|
||||
icon={<DollarSign className="w-5 h-5 text-blue-600" />}
|
||||
/>
|
||||
<StatCard
|
||||
label="Total Invertido"
|
||||
value={`$${account.totalDeposited.toLocaleString(undefined, { minimumFractionDigits: 2 })}`}
|
||||
icon={<TrendingUp className="w-5 h-5 text-purple-600" />}
|
||||
/>
|
||||
<StatCard
|
||||
label="Ganancias"
|
||||
value={`$${Math.abs(totalReturn).toLocaleString(undefined, { minimumFractionDigits: 2 })}`}
|
||||
subValue={`${returnPercent >= 0 ? '+' : ''}${returnPercent.toFixed(2)}%`}
|
||||
icon={<TrendingUp className="w-5 h-5 text-green-600" />}
|
||||
trend={totalReturn >= 0 ? 'up' : 'down'}
|
||||
/>
|
||||
<StatCard
|
||||
label="Total Retirado"
|
||||
value={`$${account.totalWithdrawn.toLocaleString(undefined, { minimumFractionDigits: 2 })}`}
|
||||
icon={<Clock className="w-5 h-5 text-orange-600" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200 dark:border-gray-700 mb-6">
|
||||
<div className="flex gap-1 overflow-x-auto">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
|
||||
activeTab === tab.key
|
||||
? 'border-blue-600 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6">
|
||||
{activeTab === 'overview' && (
|
||||
<div className="space-y-8">
|
||||
{/* Performance Chart */}
|
||||
{account.performanceHistory && account.performanceHistory.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Rendimiento Histórico
|
||||
</h3>
|
||||
<PerformanceChart data={account.performanceHistory} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Account Info */}
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Información de la Cuenta
|
||||
</h3>
|
||||
<dl className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500 dark:text-gray-400">Producto</dt>
|
||||
<dd className="font-medium text-gray-900 dark:text-white">{account.product.name}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500 dark:text-gray-400">Perfil de Riesgo</dt>
|
||||
<dd className="font-medium text-gray-900 dark:text-white capitalize">{account.product.riskProfile}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500 dark:text-gray-400">Inversión Inicial</dt>
|
||||
<dd className="font-medium text-gray-900 dark:text-white">
|
||||
${account.initialInvestment.toLocaleString(undefined, { minimumFractionDigits: 2 })}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500 dark:text-gray-400">Fecha de Apertura</dt>
|
||||
<dd className="font-medium text-gray-900 dark:text-white">
|
||||
{new Date(account.openedAt).toLocaleDateString()}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Últimas Transacciones
|
||||
</h3>
|
||||
{account.recentTransactions && account.recentTransactions.length > 0 ? (
|
||||
<div>
|
||||
{account.recentTransactions.slice(0, 5).map(tx => (
|
||||
<TransactionRow key={tx.id} transaction={tx} />
|
||||
))}
|
||||
{account.recentTransactions.length > 5 && (
|
||||
<button
|
||||
onClick={() => setActiveTab('transactions')}
|
||||
className="w-full mt-4 text-center text-blue-600 hover:text-blue-700 text-sm font-medium"
|
||||
>
|
||||
Ver todas las transacciones
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 dark:text-gray-400 text-center py-8">
|
||||
No hay transacciones recientes
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'transactions' && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Historial de Transacciones
|
||||
</h3>
|
||||
{allTransactions.length > 0 ? (
|
||||
<div>
|
||||
{allTransactions.map(tx => (
|
||||
<TransactionRow key={tx.id} transaction={tx} />
|
||||
))}
|
||||
<button
|
||||
onClick={loadMoreTransactions}
|
||||
disabled={loadingMore}
|
||||
className="w-full mt-4 py-3 text-center text-blue-600 hover:text-blue-700 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{loadingMore ? 'Cargando...' : 'Cargar más'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 dark:text-gray-400 text-center py-12">
|
||||
No hay transacciones registradas
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'distributions' && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Historial de Distribuciones
|
||||
</h3>
|
||||
{account.recentDistributions && account.recentDistributions.length > 0 ? (
|
||||
<div>
|
||||
{account.recentDistributions.map(dist => (
|
||||
<DistributionRow key={dist.id} distribution={dist} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 dark:text-gray-400 text-center py-12">
|
||||
No hay distribuciones registradas
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'deposit' && (
|
||||
<div className="max-w-lg mx-auto">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Depositar Fondos
|
||||
</h3>
|
||||
<DepositForm
|
||||
accounts={[
|
||||
{
|
||||
id: account.id,
|
||||
accountNumber: account.accountNumber,
|
||||
productName: account.product.name,
|
||||
currentBalance: account.balance,
|
||||
},
|
||||
]}
|
||||
onSuccess={handleDepositSuccess}
|
||||
onCancel={() => setActiveTab('overview')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'withdraw' && (
|
||||
<div className="max-w-lg mx-auto">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Solicitar Retiro
|
||||
</h3>
|
||||
<WithdrawForm
|
||||
accounts={[
|
||||
{
|
||||
id: account.id,
|
||||
accountNumber: account.accountNumber,
|
||||
productName: account.product.name,
|
||||
currentBalance: account.balance,
|
||||
},
|
||||
]}
|
||||
onSuccess={handleWithdrawSuccess}
|
||||
onCancel={() => setActiveTab('overview')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
{activeTab === 'overview' && account.status === 'active' && (
|
||||
<div className="mt-6 flex flex-wrap gap-4">
|
||||
<button
|
||||
onClick={() => setActiveTab('deposit')}
|
||||
className="px-6 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Depositar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('withdraw')}
|
||||
className="px-6 py-3 bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white font-medium rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Retirar
|
||||
</button>
|
||||
<Link
|
||||
to="/investment/products"
|
||||
className="px-6 py-3 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 font-medium rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
Ver Productos
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountDetail;
|
||||
247
src/services/investment.service.ts
Normal file
247
src/services/investment.service.ts
Normal file
@ -0,0 +1,247 @@
|
||||
/**
|
||||
* Investment Service
|
||||
* API client for investment accounts, products, transactions, and withdrawals
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
|
||||
const API_BASE = '/api/v1/investment';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface Product {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
riskProfile: 'conservative' | 'moderate' | 'aggressive';
|
||||
targetReturnMin: number;
|
||||
targetReturnMax: number;
|
||||
maxDrawdown: number;
|
||||
minInvestment: number;
|
||||
managementFee: number;
|
||||
performanceFee: number;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface ProductPerformance {
|
||||
date: string;
|
||||
cumulativeReturn: number;
|
||||
dailyReturn: number;
|
||||
}
|
||||
|
||||
export interface InvestmentAccount {
|
||||
id: string;
|
||||
accountNumber: string;
|
||||
productId: string;
|
||||
product: {
|
||||
code: string;
|
||||
name: string;
|
||||
riskProfile: string;
|
||||
};
|
||||
status: 'active' | 'suspended' | 'closed';
|
||||
balance: number;
|
||||
initialInvestment: number;
|
||||
totalDeposited: number;
|
||||
totalWithdrawn: number;
|
||||
totalEarnings: number;
|
||||
unrealizedPnl: number;
|
||||
unrealizedPnlPercent: number;
|
||||
openedAt: string;
|
||||
closedAt?: string;
|
||||
}
|
||||
|
||||
export interface AccountSummary {
|
||||
totalBalance: number;
|
||||
totalEarnings: number;
|
||||
totalDeposited: number;
|
||||
totalWithdrawn: number;
|
||||
overallReturn: number;
|
||||
overallReturnPercent: number;
|
||||
accounts: InvestmentAccount[];
|
||||
}
|
||||
|
||||
export interface Transaction {
|
||||
id: string;
|
||||
accountId: string;
|
||||
type: 'deposit' | 'withdrawal' | 'distribution' | 'fee' | 'adjustment';
|
||||
amount: number;
|
||||
balanceAfter: number;
|
||||
status: 'pending' | 'completed' | 'failed' | 'cancelled';
|
||||
description: string;
|
||||
reference?: string;
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
export interface Withdrawal {
|
||||
id: string;
|
||||
accountId: string;
|
||||
amount: number;
|
||||
status: 'pending' | 'approved' | 'processing' | 'completed' | 'rejected';
|
||||
requestedAt: string;
|
||||
processedAt?: string;
|
||||
bankInfo?: {
|
||||
bankName: string;
|
||||
accountLast4: string;
|
||||
};
|
||||
cryptoInfo?: {
|
||||
network: string;
|
||||
addressLast8: string;
|
||||
};
|
||||
rejectionReason?: string;
|
||||
}
|
||||
|
||||
export interface Distribution {
|
||||
id: string;
|
||||
accountId: string;
|
||||
amount: number;
|
||||
rate: number;
|
||||
balanceBefore: number;
|
||||
balanceAfter: number;
|
||||
distributedAt: string;
|
||||
}
|
||||
|
||||
export interface AccountDetail extends InvestmentAccount {
|
||||
recentTransactions: Transaction[];
|
||||
recentDistributions: Distribution[];
|
||||
performanceHistory: Array<{
|
||||
date: string;
|
||||
balance: number;
|
||||
pnl: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API Functions - Products
|
||||
// ============================================================================
|
||||
|
||||
export async function getProducts(riskProfile?: string): Promise<Product[]> {
|
||||
const params = riskProfile ? { riskProfile } : {};
|
||||
const response = await axios.get(`${API_BASE}/products`, { params });
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
export async function getProductById(productId: string): Promise<Product> {
|
||||
const response = await axios.get(`${API_BASE}/products/${productId}`);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
export async function getProductPerformance(
|
||||
productId: string,
|
||||
period: 'week' | 'month' | '3months' | 'year' = 'month'
|
||||
): Promise<ProductPerformance[]> {
|
||||
const response = await axios.get(`${API_BASE}/products/${productId}/performance`, {
|
||||
params: { period },
|
||||
});
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API Functions - Accounts
|
||||
// ============================================================================
|
||||
|
||||
export async function getUserAccounts(): Promise<InvestmentAccount[]> {
|
||||
const response = await axios.get(`${API_BASE}/accounts`);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
export async function getAccountSummary(): Promise<AccountSummary> {
|
||||
const response = await axios.get(`${API_BASE}/accounts/summary`);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
export async function getAccountById(accountId: string): Promise<AccountDetail> {
|
||||
const response = await axios.get(`${API_BASE}/accounts/${accountId}`);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
export async function createAccount(productId: string, initialDeposit: number): Promise<InvestmentAccount> {
|
||||
const response = await axios.post(`${API_BASE}/accounts`, { productId, initialDeposit });
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
export async function closeAccount(accountId: string): Promise<void> {
|
||||
await axios.post(`${API_BASE}/accounts/${accountId}/close`);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API Functions - Transactions
|
||||
// ============================================================================
|
||||
|
||||
export async function getTransactions(
|
||||
accountId: string,
|
||||
options?: {
|
||||
type?: string;
|
||||
status?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
): Promise<{ transactions: Transaction[]; total: number }> {
|
||||
const response = await axios.get(`${API_BASE}/accounts/${accountId}/transactions`, {
|
||||
params: options,
|
||||
});
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
export async function createDeposit(accountId: string, amount: number): Promise<Transaction> {
|
||||
const response = await axios.post(`${API_BASE}/accounts/${accountId}/deposit`, { amount });
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
export async function createWithdrawal(
|
||||
accountId: string,
|
||||
amount: number,
|
||||
destination: {
|
||||
bankInfo?: { bankName: string; accountNumber: string; routingNumber: string };
|
||||
cryptoInfo?: { network: string; address: string };
|
||||
}
|
||||
): Promise<Withdrawal> {
|
||||
const response = await axios.post(`${API_BASE}/accounts/${accountId}/withdraw`, {
|
||||
amount,
|
||||
...destination,
|
||||
});
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API Functions - Distributions
|
||||
// ============================================================================
|
||||
|
||||
export async function getDistributions(accountId: string): Promise<Distribution[]> {
|
||||
const response = await axios.get(`${API_BASE}/accounts/${accountId}/distributions`);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API Functions - Withdrawals
|
||||
// ============================================================================
|
||||
|
||||
export async function getWithdrawals(status?: string): Promise<Withdrawal[]> {
|
||||
const params = status ? { status } : {};
|
||||
const response = await axios.get(`${API_BASE}/withdrawals`, { params });
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
export default {
|
||||
// Products
|
||||
getProducts,
|
||||
getProductById,
|
||||
getProductPerformance,
|
||||
// Accounts
|
||||
getUserAccounts,
|
||||
getAccountSummary,
|
||||
getAccountById,
|
||||
createAccount,
|
||||
closeAccount,
|
||||
// Transactions
|
||||
getTransactions,
|
||||
createDeposit,
|
||||
createWithdrawal,
|
||||
// Distributions
|
||||
getDistributions,
|
||||
// Withdrawals
|
||||
getWithdrawals,
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user