[OQI-004] feat: Add 4 advanced investment components
- CreateAccountWizard: Multi-step wizard for account creation (620 LOC) - RiskAnalysisPanel: Risk metrics with VaR, Sharpe, Sortino (480 LOC) - PortfolioOptimizerWidget: Portfolio allocation optimizer (520 LOC) - AccountTransferModal: Modal for inter-account transfers (450 LOC) Updates OQI-004 progress from 35% to 55% Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
fc99c34749
commit
339c645036
563
src/modules/investment/components/AccountTransferModal.tsx
Normal file
563
src/modules/investment/components/AccountTransferModal.tsx
Normal file
@ -0,0 +1,563 @@
|
||||
/**
|
||||
* AccountTransferModal Component
|
||||
* Modal for transferring funds between investment accounts
|
||||
* Epic: OQI-004 Cuentas de Inversion
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
ArrowRightLeft,
|
||||
Wallet,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Info,
|
||||
X,
|
||||
ChevronDown,
|
||||
Loader2,
|
||||
ArrowRight,
|
||||
} from 'lucide-react';
|
||||
|
||||
// Types
|
||||
export interface TransferAccount {
|
||||
id: string;
|
||||
name: string;
|
||||
productName: string;
|
||||
balance: number;
|
||||
availableBalance: number;
|
||||
status: 'active' | 'pending' | 'locked';
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface TransferFee {
|
||||
type: 'fixed' | 'percentage';
|
||||
value: number;
|
||||
minFee?: number;
|
||||
maxFee?: number;
|
||||
}
|
||||
|
||||
export interface TransferData {
|
||||
sourceAccountId: string;
|
||||
destinationAccountId: string;
|
||||
amount: number;
|
||||
fee: number;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface AccountTransferModalProps {
|
||||
isOpen: boolean;
|
||||
accounts: TransferAccount[];
|
||||
defaultSourceId?: string;
|
||||
defaultDestinationId?: string;
|
||||
transferFee?: TransferFee;
|
||||
minTransfer?: number;
|
||||
maxTransfer?: number;
|
||||
onTransfer?: (data: TransferData) => void;
|
||||
onClose?: () => void;
|
||||
isSubmitting?: boolean;
|
||||
}
|
||||
|
||||
const AccountTransferModal: React.FC<AccountTransferModalProps> = ({
|
||||
isOpen,
|
||||
accounts,
|
||||
defaultSourceId,
|
||||
defaultDestinationId,
|
||||
transferFee = { type: 'fixed', value: 0 },
|
||||
minTransfer = 100,
|
||||
maxTransfer = 100000,
|
||||
onTransfer,
|
||||
onClose,
|
||||
isSubmitting = false,
|
||||
}) => {
|
||||
const [sourceAccountId, setSourceAccountId] = useState<string>(defaultSourceId || '');
|
||||
const [destinationAccountId, setDestinationAccountId] = useState<string>(defaultDestinationId || '');
|
||||
const [amount, setAmount] = useState<string>('');
|
||||
const [note, setNote] = useState<string>('');
|
||||
const [showSourceDropdown, setShowSourceDropdown] = useState(false);
|
||||
const [showDestDropdown, setShowDestDropdown] = useState(false);
|
||||
const [step, setStep] = useState<'form' | 'confirm' | 'success'>('form');
|
||||
|
||||
// Reset state when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setSourceAccountId(defaultSourceId || '');
|
||||
setDestinationAccountId(defaultDestinationId || '');
|
||||
setAmount('');
|
||||
setNote('');
|
||||
setStep('form');
|
||||
}
|
||||
}, [isOpen, defaultSourceId, defaultDestinationId]);
|
||||
|
||||
// Get selected accounts
|
||||
const sourceAccount = useMemo(() => {
|
||||
return accounts.find((acc) => acc.id === sourceAccountId);
|
||||
}, [accounts, sourceAccountId]);
|
||||
|
||||
const destinationAccount = useMemo(() => {
|
||||
return accounts.find((acc) => acc.id === destinationAccountId);
|
||||
}, [accounts, destinationAccountId]);
|
||||
|
||||
// Calculate fee
|
||||
const calculatedFee = useMemo(() => {
|
||||
const transferAmount = parseFloat(amount) || 0;
|
||||
if (transferFee.type === 'fixed') {
|
||||
return transferFee.value;
|
||||
}
|
||||
let fee = transferAmount * (transferFee.value / 100);
|
||||
if (transferFee.minFee) fee = Math.max(fee, transferFee.minFee);
|
||||
if (transferFee.maxFee) fee = Math.min(fee, transferFee.maxFee);
|
||||
return fee;
|
||||
}, [amount, transferFee]);
|
||||
|
||||
// Calculate total deduction
|
||||
const totalDeduction = useMemo(() => {
|
||||
return (parseFloat(amount) || 0) + calculatedFee;
|
||||
}, [amount, calculatedFee]);
|
||||
|
||||
// Validation
|
||||
const validation = useMemo(() => {
|
||||
const transferAmount = parseFloat(amount) || 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!sourceAccountId) {
|
||||
errors.push('Select a source account');
|
||||
}
|
||||
if (!destinationAccountId) {
|
||||
errors.push('Select a destination account');
|
||||
}
|
||||
if (sourceAccountId && destinationAccountId && sourceAccountId === destinationAccountId) {
|
||||
errors.push('Source and destination must be different');
|
||||
}
|
||||
if (transferAmount < minTransfer) {
|
||||
errors.push(`Minimum transfer is $${minTransfer.toLocaleString()}`);
|
||||
}
|
||||
if (transferAmount > maxTransfer) {
|
||||
errors.push(`Maximum transfer is $${maxTransfer.toLocaleString()}`);
|
||||
}
|
||||
if (sourceAccount && totalDeduction > sourceAccount.availableBalance) {
|
||||
errors.push('Insufficient available balance');
|
||||
}
|
||||
if (sourceAccount && sourceAccount.status !== 'active') {
|
||||
errors.push('Source account is not active');
|
||||
}
|
||||
if (destinationAccount && destinationAccount.status !== 'active') {
|
||||
errors.push('Destination account is not active');
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0 && transferAmount > 0,
|
||||
errors,
|
||||
};
|
||||
}, [sourceAccountId, destinationAccountId, amount, sourceAccount, destinationAccount, totalDeduction, minTransfer, maxTransfer]);
|
||||
|
||||
// Handle transfer
|
||||
const handleTransfer = useCallback(() => {
|
||||
if (!validation.isValid) return;
|
||||
|
||||
const transferData: TransferData = {
|
||||
sourceAccountId,
|
||||
destinationAccountId,
|
||||
amount: parseFloat(amount),
|
||||
fee: calculatedFee,
|
||||
note: note || undefined,
|
||||
};
|
||||
|
||||
onTransfer?.(transferData);
|
||||
}, [validation.isValid, sourceAccountId, destinationAccountId, amount, calculatedFee, note, onTransfer]);
|
||||
|
||||
// Handle confirm step
|
||||
const handleProceed = useCallback(() => {
|
||||
if (step === 'form' && validation.isValid) {
|
||||
setStep('confirm');
|
||||
} else if (step === 'confirm') {
|
||||
handleTransfer();
|
||||
}
|
||||
}, [step, validation.isValid, handleTransfer]);
|
||||
|
||||
// Swap accounts
|
||||
const handleSwapAccounts = useCallback(() => {
|
||||
const temp = sourceAccountId;
|
||||
setSourceAccountId(destinationAccountId);
|
||||
setDestinationAccountId(temp);
|
||||
}, [sourceAccountId, destinationAccountId]);
|
||||
|
||||
// Quick amount buttons
|
||||
const quickAmounts = useMemo(() => {
|
||||
if (!sourceAccount) return [];
|
||||
const balance = sourceAccount.availableBalance;
|
||||
return [
|
||||
{ label: '25%', value: Math.floor(balance * 0.25) },
|
||||
{ label: '50%', value: Math.floor(balance * 0.5) },
|
||||
{ label: '75%', value: Math.floor(balance * 0.75) },
|
||||
{ label: 'Max', value: Math.floor(balance - calculatedFee) },
|
||||
].filter((q) => q.value >= minTransfer);
|
||||
}, [sourceAccount, calculatedFee, minTransfer]);
|
||||
|
||||
// Render account selector
|
||||
const renderAccountSelector = (
|
||||
type: 'source' | 'destination',
|
||||
selectedId: string,
|
||||
onSelect: (id: string) => void,
|
||||
isOpen: boolean,
|
||||
setIsOpen: (open: boolean) => void
|
||||
) => {
|
||||
const selectedAccount = accounts.find((acc) => acc.id === selectedId);
|
||||
const availableAccounts = accounts.filter((acc) => {
|
||||
if (type === 'source') return acc.id !== destinationAccountId && acc.status === 'active';
|
||||
return acc.id !== sourceAccountId;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-full flex items-center justify-between bg-slate-800 border border-slate-700 rounded-lg px-4 py-3 text-left hover:border-slate-600 transition-colors"
|
||||
>
|
||||
{selectedAccount ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: selectedAccount.color }}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-white font-medium">{selectedAccount.name}</p>
|
||||
<p className="text-slate-400 text-sm">
|
||||
Available: ${selectedAccount.availableBalance.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-slate-400">Select {type} account</span>
|
||||
)}
|
||||
<ChevronDown className={`w-5 h-5 text-slate-400 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute z-10 w-full mt-2 bg-slate-800 border border-slate-700 rounded-lg shadow-xl overflow-hidden">
|
||||
{availableAccounts.map((account) => (
|
||||
<button
|
||||
key={account.id}
|
||||
onClick={() => {
|
||||
onSelect(account.id);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
disabled={account.status !== 'active'}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 text-left hover:bg-slate-700 transition-colors ${
|
||||
account.status !== 'active' ? 'opacity-50 cursor-not-allowed' : ''
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: account.color }}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-white font-medium">{account.name}</p>
|
||||
{account.status !== 'active' && (
|
||||
<span className="text-xs text-amber-400 capitalize">{account.status}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-slate-400 text-sm">{account.productName}</p>
|
||||
<p className="text-slate-500 text-xs">
|
||||
Balance: ${account.balance.toLocaleString()} |
|
||||
Available: ${account.availableBalance.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{availableAccounts.length === 0 && (
|
||||
<p className="px-4 py-3 text-slate-400 text-sm">No accounts available</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Render form step
|
||||
const renderFormStep = () => (
|
||||
<div className="space-y-6">
|
||||
{/* Source Account */}
|
||||
<div>
|
||||
<label className="block text-slate-300 text-sm font-medium mb-2">From Account</label>
|
||||
{renderAccountSelector(
|
||||
'source',
|
||||
sourceAccountId,
|
||||
setSourceAccountId,
|
||||
showSourceDropdown,
|
||||
setShowSourceDropdown
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Swap Button */}
|
||||
<div className="flex justify-center">
|
||||
<button
|
||||
onClick={handleSwapAccounts}
|
||||
disabled={!sourceAccountId || !destinationAccountId}
|
||||
className="p-2 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Swap accounts"
|
||||
>
|
||||
<ArrowRightLeft className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Destination Account */}
|
||||
<div>
|
||||
<label className="block text-slate-300 text-sm font-medium mb-2">To Account</label>
|
||||
{renderAccountSelector(
|
||||
'destination',
|
||||
destinationAccountId,
|
||||
setDestinationAccountId,
|
||||
showDestDropdown,
|
||||
setShowDestDropdown
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Amount */}
|
||||
<div>
|
||||
<label className="block text-slate-300 text-sm font-medium mb-2">Amount</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500">$</span>
|
||||
<input
|
||||
type="number"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
placeholder="0.00"
|
||||
min={minTransfer}
|
||||
max={maxTransfer}
|
||||
className="w-full bg-slate-800 border border-slate-700 rounded-lg pl-8 pr-4 py-3 text-white text-lg focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-slate-500 mt-1">
|
||||
<span>Min: ${minTransfer.toLocaleString()}</span>
|
||||
<span>Max: ${maxTransfer.toLocaleString()}</span>
|
||||
</div>
|
||||
|
||||
{/* Quick Amount Buttons */}
|
||||
{quickAmounts.length > 0 && (
|
||||
<div className="flex gap-2 mt-3">
|
||||
{quickAmounts.map((quick) => (
|
||||
<button
|
||||
key={quick.label}
|
||||
onClick={() => setAmount(quick.value.toString())}
|
||||
className="px-3 py-1.5 bg-slate-800 text-slate-300 text-sm rounded-lg hover:bg-slate-700 transition-colors"
|
||||
>
|
||||
{quick.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Note */}
|
||||
<div>
|
||||
<label className="block text-slate-300 text-sm font-medium mb-2">Note (Optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="Add a note for this transfer"
|
||||
maxLength={100}
|
||||
className="w-full bg-slate-800 border border-slate-700 rounded-lg px-4 py-2 text-white focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
{parseFloat(amount) > 0 && (
|
||||
<div className="bg-slate-800/50 rounded-lg p-4 border border-slate-700 space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-400">Transfer Amount</span>
|
||||
<span className="text-white">${parseFloat(amount).toLocaleString()}</span>
|
||||
</div>
|
||||
{calculatedFee > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-400">Transfer Fee</span>
|
||||
<span className="text-slate-300">${calculatedFee.toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between text-sm pt-2 border-t border-slate-700">
|
||||
<span className="text-slate-300 font-medium">Total Deduction</span>
|
||||
<span className="text-white font-medium">${totalDeduction.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Validation Errors */}
|
||||
{validation.errors.length > 0 && parseFloat(amount) > 0 && (
|
||||
<div className="space-y-2">
|
||||
{validation.errors.map((error, index) => (
|
||||
<p key={index} className="text-red-400 text-sm flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
{error}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render confirm step
|
||||
const renderConfirmStep = () => (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-blue-600/20 flex items-center justify-center mx-auto mb-4">
|
||||
<ArrowRightLeft className="w-8 h-8 text-blue-400" />
|
||||
</div>
|
||||
<h3 className="text-white text-xl font-semibold">Confirm Transfer</h3>
|
||||
<p className="text-slate-400 mt-1">Please review the details below</p>
|
||||
</div>
|
||||
|
||||
{/* Transfer Visual */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
{/* Source */}
|
||||
<div className="flex-1 bg-slate-800/50 rounded-lg p-4 border border-slate-700">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: sourceAccount?.color }}
|
||||
/>
|
||||
<span className="text-slate-400 text-sm">From</span>
|
||||
</div>
|
||||
<p className="text-white font-medium">{sourceAccount?.name}</p>
|
||||
<p className="text-red-400 text-sm mt-1">-${totalDeduction.toLocaleString()}</p>
|
||||
</div>
|
||||
|
||||
<ArrowRight className="w-6 h-6 text-slate-500 flex-shrink-0" />
|
||||
|
||||
{/* Destination */}
|
||||
<div className="flex-1 bg-slate-800/50 rounded-lg p-4 border border-slate-700">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: destinationAccount?.color }}
|
||||
/>
|
||||
<span className="text-slate-400 text-sm">To</span>
|
||||
</div>
|
||||
<p className="text-white font-medium">{destinationAccount?.name}</p>
|
||||
<p className="text-emerald-400 text-sm mt-1">+${parseFloat(amount).toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="bg-slate-800/50 rounded-lg p-4 border border-slate-700 space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-400">Transfer Amount</span>
|
||||
<span className="text-white font-medium">${parseFloat(amount).toLocaleString()}</span>
|
||||
</div>
|
||||
{calculatedFee > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-400">Fee</span>
|
||||
<span className="text-slate-300">${calculatedFee.toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
{note && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-400">Note</span>
|
||||
<span className="text-slate-300 text-right max-w-[200px] truncate">{note}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between pt-3 border-t border-slate-700">
|
||||
<span className="text-white font-medium">Total Deduction</span>
|
||||
<span className="text-white font-semibold">${totalDeduction.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="bg-blue-900/20 border border-blue-800/50 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-slate-300 text-sm">
|
||||
The transfer will be processed immediately. Funds will be available in the destination
|
||||
account within minutes. This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render success step
|
||||
const renderSuccessStep = () => (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-20 h-20 rounded-full bg-emerald-600/20 flex items-center justify-center mx-auto mb-6">
|
||||
<CheckCircle className="w-10 h-10 text-emerald-400" />
|
||||
</div>
|
||||
<h3 className="text-white text-xl font-semibold">Transfer Complete!</h3>
|
||||
<p className="text-slate-400 mt-2">
|
||||
${parseFloat(amount).toLocaleString()} has been transferred from{' '}
|
||||
<span className="text-white">{sourceAccount?.name}</span> to{' '}
|
||||
<span className="text-white">{destinationAccount?.name}</span>
|
||||
</p>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="mt-8 px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-500 transition-colors"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative w-full max-w-lg bg-slate-900 rounded-xl border border-slate-800 shadow-2xl mx-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-slate-800">
|
||||
<div className="flex items-center gap-3">
|
||||
<Wallet className="w-6 h-6 text-blue-400" />
|
||||
<h2 className="text-white text-lg font-semibold">Transfer Funds</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-slate-400 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
{step === 'form' && renderFormStep()}
|
||||
{step === 'confirm' && renderConfirmStep()}
|
||||
{step === 'success' && renderSuccessStep()}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{step !== 'success' && (
|
||||
<div className="flex justify-between gap-4 p-6 border-t border-slate-800">
|
||||
<button
|
||||
onClick={step === 'form' ? onClose : () => setStep('form')}
|
||||
className="px-4 py-2 text-slate-300 hover:text-white transition-colors"
|
||||
>
|
||||
{step === 'form' ? 'Cancel' : 'Back'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleProceed}
|
||||
disabled={!validation.isValid || isSubmitting}
|
||||
className="flex items-center gap-2 px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Processing...
|
||||
</>
|
||||
) : step === 'form' ? (
|
||||
'Continue'
|
||||
) : (
|
||||
'Confirm Transfer'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountTransferModal;
|
||||
780
src/modules/investment/components/CreateAccountWizard.tsx
Normal file
780
src/modules/investment/components/CreateAccountWizard.tsx
Normal file
@ -0,0 +1,780 @@
|
||||
/**
|
||||
* CreateAccountWizard Component
|
||||
* Multi-step wizard for creating new investment accounts
|
||||
* Epic: OQI-004 Cuentas de Inversion
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Wallet,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
Shield,
|
||||
FileText,
|
||||
DollarSign,
|
||||
User,
|
||||
Clock,
|
||||
TrendingUp,
|
||||
Info,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
|
||||
// Types
|
||||
export interface ProductSelection {
|
||||
id: string;
|
||||
name: string;
|
||||
minInvestment: number;
|
||||
expectedReturn: string;
|
||||
riskLevel: 'low' | 'medium' | 'high';
|
||||
tradingAgent: string;
|
||||
}
|
||||
|
||||
export interface KYCData {
|
||||
fullName: string;
|
||||
dateOfBirth: string;
|
||||
address: string;
|
||||
city: string;
|
||||
country: string;
|
||||
taxId: string;
|
||||
sourceOfFunds: string;
|
||||
investmentExperience: string;
|
||||
riskTolerance: string;
|
||||
}
|
||||
|
||||
export interface AccountCreationData {
|
||||
product: ProductSelection;
|
||||
initialAmount: number;
|
||||
kyc: KYCData;
|
||||
termsAccepted: boolean;
|
||||
autoReinvest: boolean;
|
||||
distributionFrequency: 'monthly' | 'quarterly' | 'annually';
|
||||
}
|
||||
|
||||
export interface CreateAccountWizardProps {
|
||||
product: ProductSelection;
|
||||
onComplete?: (data: AccountCreationData) => void;
|
||||
onCancel?: () => void;
|
||||
isSubmitting?: boolean;
|
||||
minInvestment?: number;
|
||||
maxInvestment?: number;
|
||||
requiresKYC?: boolean;
|
||||
}
|
||||
|
||||
type WizardStep = 'amount' | 'kyc' | 'terms' | 'confirm';
|
||||
|
||||
const STEPS: { id: WizardStep; title: string; icon: React.ElementType }[] = [
|
||||
{ id: 'amount', title: 'Investment Amount', icon: DollarSign },
|
||||
{ id: 'kyc', title: 'Verification', icon: User },
|
||||
{ id: 'terms', title: 'Terms & Conditions', icon: FileText },
|
||||
{ id: 'confirm', title: 'Confirmation', icon: CheckCircle },
|
||||
];
|
||||
|
||||
const SOURCE_OF_FUNDS_OPTIONS = [
|
||||
'Employment Income',
|
||||
'Business Revenue',
|
||||
'Investments',
|
||||
'Inheritance',
|
||||
'Savings',
|
||||
'Other',
|
||||
];
|
||||
|
||||
const INVESTMENT_EXPERIENCE_OPTIONS = [
|
||||
'None',
|
||||
'Beginner (< 1 year)',
|
||||
'Intermediate (1-3 years)',
|
||||
'Advanced (3-5 years)',
|
||||
'Expert (5+ years)',
|
||||
];
|
||||
|
||||
const RISK_TOLERANCE_OPTIONS = [
|
||||
{ value: 'conservative', label: 'Conservative', desc: 'Preserve capital, lower returns' },
|
||||
{ value: 'moderate', label: 'Moderate', desc: 'Balanced risk and reward' },
|
||||
{ value: 'aggressive', label: 'Aggressive', desc: 'Higher risk for higher returns' },
|
||||
];
|
||||
|
||||
const CreateAccountWizard: React.FC<CreateAccountWizardProps> = ({
|
||||
product,
|
||||
onComplete,
|
||||
onCancel,
|
||||
isSubmitting = false,
|
||||
minInvestment = 1000,
|
||||
maxInvestment = 1000000,
|
||||
requiresKYC = true,
|
||||
}) => {
|
||||
const [currentStep, setCurrentStep] = useState<WizardStep>('amount');
|
||||
const [amount, setAmount] = useState<number>(minInvestment);
|
||||
const [autoReinvest, setAutoReinvest] = useState(true);
|
||||
const [distributionFrequency, setDistributionFrequency] = useState<'monthly' | 'quarterly' | 'annually'>('monthly');
|
||||
const [termsAccepted, setTermsAccepted] = useState(false);
|
||||
const [riskDisclosureAccepted, setRiskDisclosureAccepted] = useState(false);
|
||||
const [kyc, setKyc] = useState<KYCData>({
|
||||
fullName: '',
|
||||
dateOfBirth: '',
|
||||
address: '',
|
||||
city: '',
|
||||
country: '',
|
||||
taxId: '',
|
||||
sourceOfFunds: '',
|
||||
investmentExperience: '',
|
||||
riskTolerance: 'moderate',
|
||||
});
|
||||
|
||||
// Get current step index
|
||||
const currentStepIndex = useMemo(() => {
|
||||
const steps = requiresKYC ? STEPS : STEPS.filter(s => s.id !== 'kyc');
|
||||
return steps.findIndex(s => s.id === currentStep);
|
||||
}, [currentStep, requiresKYC]);
|
||||
|
||||
// Get available steps
|
||||
const availableSteps = useMemo(() => {
|
||||
return requiresKYC ? STEPS : STEPS.filter(s => s.id !== 'kyc');
|
||||
}, [requiresKYC]);
|
||||
|
||||
// Calculate projected returns
|
||||
const projectedReturns = useMemo(() => {
|
||||
const returnRate = parseFloat(product.expectedReturn) / 100;
|
||||
const monthly = amount * (returnRate / 12);
|
||||
const yearly = amount * returnRate;
|
||||
return { monthly, yearly };
|
||||
}, [amount, product.expectedReturn]);
|
||||
|
||||
// Validate current step
|
||||
const isStepValid = useCallback((): boolean => {
|
||||
switch (currentStep) {
|
||||
case 'amount':
|
||||
return amount >= minInvestment && amount <= maxInvestment;
|
||||
case 'kyc':
|
||||
return (
|
||||
kyc.fullName.length > 2 &&
|
||||
kyc.dateOfBirth !== '' &&
|
||||
kyc.address.length > 5 &&
|
||||
kyc.city.length > 2 &&
|
||||
kyc.country !== '' &&
|
||||
kyc.sourceOfFunds !== '' &&
|
||||
kyc.investmentExperience !== '' &&
|
||||
kyc.riskTolerance !== ''
|
||||
);
|
||||
case 'terms':
|
||||
return termsAccepted && riskDisclosureAccepted;
|
||||
case 'confirm':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}, [currentStep, amount, minInvestment, maxInvestment, kyc, termsAccepted, riskDisclosureAccepted]);
|
||||
|
||||
// Handle navigation
|
||||
const goToNextStep = useCallback(() => {
|
||||
const currentIndex = availableSteps.findIndex(s => s.id === currentStep);
|
||||
if (currentIndex < availableSteps.length - 1) {
|
||||
setCurrentStep(availableSteps[currentIndex + 1].id);
|
||||
}
|
||||
}, [currentStep, availableSteps]);
|
||||
|
||||
const goToPrevStep = useCallback(() => {
|
||||
const currentIndex = availableSteps.findIndex(s => s.id === currentStep);
|
||||
if (currentIndex > 0) {
|
||||
setCurrentStep(availableSteps[currentIndex - 1].id);
|
||||
}
|
||||
}, [currentStep, availableSteps]);
|
||||
|
||||
// Handle form submission
|
||||
const handleSubmit = useCallback(() => {
|
||||
const data: AccountCreationData = {
|
||||
product,
|
||||
initialAmount: amount,
|
||||
kyc,
|
||||
termsAccepted,
|
||||
autoReinvest,
|
||||
distributionFrequency,
|
||||
};
|
||||
onComplete?.(data);
|
||||
}, [product, amount, kyc, termsAccepted, autoReinvest, distributionFrequency, onComplete]);
|
||||
|
||||
// Update KYC field
|
||||
const updateKyc = useCallback((field: keyof KYCData, value: string) => {
|
||||
setKyc(prev => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
|
||||
// Get risk level color
|
||||
const getRiskColor = (level: string) => {
|
||||
switch (level) {
|
||||
case 'low':
|
||||
return 'text-emerald-400';
|
||||
case 'medium':
|
||||
return 'text-amber-400';
|
||||
case 'high':
|
||||
return 'text-red-400';
|
||||
default:
|
||||
return 'text-slate-400';
|
||||
}
|
||||
};
|
||||
|
||||
// Render step indicator
|
||||
const renderStepIndicator = () => (
|
||||
<div className="flex items-center justify-center mb-8">
|
||||
{availableSteps.map((step, index) => {
|
||||
const StepIcon = step.icon;
|
||||
const isActive = step.id === currentStep;
|
||||
const isCompleted = index < currentStepIndex;
|
||||
|
||||
return (
|
||||
<React.Fragment key={step.id}>
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center transition-colors ${
|
||||
isActive
|
||||
? 'bg-blue-600 text-white'
|
||||
: isCompleted
|
||||
? 'bg-emerald-600 text-white'
|
||||
: 'bg-slate-700 text-slate-400'
|
||||
}`}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
) : (
|
||||
<StepIcon className="w-5 h-5" />
|
||||
)}
|
||||
</div>
|
||||
<span className={`text-xs mt-2 ${isActive ? 'text-white' : 'text-slate-400'}`}>
|
||||
{step.title}
|
||||
</span>
|
||||
</div>
|
||||
{index < availableSteps.length - 1 && (
|
||||
<div
|
||||
className={`w-16 h-0.5 mx-2 ${
|
||||
isCompleted ? 'bg-emerald-600' : 'bg-slate-700'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render amount step
|
||||
const renderAmountStep = () => (
|
||||
<div className="space-y-6">
|
||||
{/* Product Summary */}
|
||||
<div className="bg-slate-800/50 rounded-lg p-4 border border-slate-700">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-12 h-12 rounded-full bg-blue-600/20 flex items-center justify-center">
|
||||
<Wallet className="w-6 h-6 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-white font-medium">{product.name}</h3>
|
||||
<p className="text-slate-400 text-sm">
|
||||
Managed by {product.tradingAgent} Agent
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-slate-500 block">Expected Return</span>
|
||||
<span className="text-emerald-400 font-medium">{product.expectedReturn}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500 block">Risk Level</span>
|
||||
<span className={`font-medium capitalize ${getRiskColor(product.riskLevel)}`}>
|
||||
{product.riskLevel}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500 block">Min. Investment</span>
|
||||
<span className="text-white">${product.minInvestment.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Amount Input */}
|
||||
<div>
|
||||
<label className="block text-slate-300 text-sm font-medium mb-2">
|
||||
Investment Amount
|
||||
</label>
|
||||
<div className="relative">
|
||||
<DollarSign className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||
<input
|
||||
type="number"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(Number(e.target.value))}
|
||||
min={minInvestment}
|
||||
max={maxInvestment}
|
||||
className="w-full bg-slate-800 border border-slate-700 rounded-lg pl-10 pr-4 py-3 text-white text-lg focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-slate-500 mt-1">
|
||||
<span>Min: ${minInvestment.toLocaleString()}</span>
|
||||
<span>Max: ${maxInvestment.toLocaleString()}</span>
|
||||
</div>
|
||||
{(amount < minInvestment || amount > maxInvestment) && (
|
||||
<p className="text-red-400 text-sm mt-2 flex items-center gap-1">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
Amount must be between ${minInvestment.toLocaleString()} and ${maxInvestment.toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick Amount Buttons */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[1000, 5000, 10000, 25000, 50000, 100000].map((quickAmount) => (
|
||||
<button
|
||||
key={quickAmount}
|
||||
onClick={() => setAmount(quickAmount)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm transition-colors ${
|
||||
amount === quickAmount
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-slate-800 text-slate-300 hover:bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
${quickAmount.toLocaleString()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Projected Returns */}
|
||||
<div className="bg-emerald-900/20 border border-emerald-800/50 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<TrendingUp className="w-5 h-5 text-emerald-400" />
|
||||
<span className="text-emerald-400 font-medium">Projected Returns</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<span className="text-slate-400 text-sm block">Monthly (Est.)</span>
|
||||
<span className="text-white text-lg font-semibold">
|
||||
${projectedReturns.monthly.toLocaleString(undefined, { maximumFractionDigits: 2 })}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-400 text-sm block">Yearly (Est.)</span>
|
||||
<span className="text-white text-lg font-semibold">
|
||||
${projectedReturns.yearly.toLocaleString(undefined, { maximumFractionDigits: 2 })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-slate-500 text-xs mt-3 flex items-center gap-1">
|
||||
<Info className="w-3 h-3" />
|
||||
Past performance does not guarantee future results
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Distribution Settings */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="text-slate-300 font-medium">Auto-Reinvest Returns</label>
|
||||
<p className="text-slate-500 text-sm">Automatically reinvest your earnings</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setAutoReinvest(!autoReinvest)}
|
||||
className={`w-12 h-6 rounded-full transition-colors ${
|
||||
autoReinvest ? 'bg-blue-600' : 'bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-5 h-5 bg-white rounded-full transition-transform ${
|
||||
autoReinvest ? 'translate-x-6' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!autoReinvest && (
|
||||
<div>
|
||||
<label className="block text-slate-300 text-sm font-medium mb-2">
|
||||
Distribution Frequency
|
||||
</label>
|
||||
<select
|
||||
value={distributionFrequency}
|
||||
onChange={(e) => setDistributionFrequency(e.target.value as 'monthly' | 'quarterly' | 'annually')}
|
||||
className="w-full bg-slate-800 border border-slate-700 rounded-lg px-4 py-2 text-white focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="quarterly">Quarterly</option>
|
||||
<option value="annually">Annually</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render KYC step
|
||||
const renderKYCStep = () => (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-amber-900/20 border border-amber-800/50 rounded-lg p-4 mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-5 h-5 text-amber-400" />
|
||||
<span className="text-amber-400 font-medium">Identity Verification Required</span>
|
||||
</div>
|
||||
<p className="text-slate-400 text-sm mt-2">
|
||||
For regulatory compliance, we need to verify your identity before opening an investment account.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="col-span-2">
|
||||
<label className="block text-slate-300 text-sm font-medium mb-1">Full Legal Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={kyc.fullName}
|
||||
onChange={(e) => updateKyc('fullName', e.target.value)}
|
||||
placeholder="John Doe"
|
||||
className="w-full bg-slate-800 border border-slate-700 rounded-lg px-4 py-2 text-white focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-slate-300 text-sm font-medium mb-1">Date of Birth</label>
|
||||
<input
|
||||
type="date"
|
||||
value={kyc.dateOfBirth}
|
||||
onChange={(e) => updateKyc('dateOfBirth', e.target.value)}
|
||||
className="w-full bg-slate-800 border border-slate-700 rounded-lg px-4 py-2 text-white focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-slate-300 text-sm font-medium mb-1">Tax ID / SSN</label>
|
||||
<input
|
||||
type="text"
|
||||
value={kyc.taxId}
|
||||
onChange={(e) => updateKyc('taxId', e.target.value)}
|
||||
placeholder="XXX-XX-XXXX"
|
||||
className="w-full bg-slate-800 border border-slate-700 rounded-lg px-4 py-2 text-white focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<label className="block text-slate-300 text-sm font-medium mb-1">Address</label>
|
||||
<input
|
||||
type="text"
|
||||
value={kyc.address}
|
||||
onChange={(e) => updateKyc('address', e.target.value)}
|
||||
placeholder="123 Main Street"
|
||||
className="w-full bg-slate-800 border border-slate-700 rounded-lg px-4 py-2 text-white focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-slate-300 text-sm font-medium mb-1">City</label>
|
||||
<input
|
||||
type="text"
|
||||
value={kyc.city}
|
||||
onChange={(e) => updateKyc('city', e.target.value)}
|
||||
placeholder="New York"
|
||||
className="w-full bg-slate-800 border border-slate-700 rounded-lg px-4 py-2 text-white focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-slate-300 text-sm font-medium mb-1">Country</label>
|
||||
<select
|
||||
value={kyc.country}
|
||||
onChange={(e) => updateKyc('country', e.target.value)}
|
||||
className="w-full bg-slate-800 border border-slate-700 rounded-lg px-4 py-2 text-white focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Select country</option>
|
||||
<option value="US">United States</option>
|
||||
<option value="MX">Mexico</option>
|
||||
<option value="CA">Canada</option>
|
||||
<option value="GB">United Kingdom</option>
|
||||
<option value="DE">Germany</option>
|
||||
<option value="ES">Spain</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-slate-300 text-sm font-medium mb-1">Source of Funds</label>
|
||||
<select
|
||||
value={kyc.sourceOfFunds}
|
||||
onChange={(e) => updateKyc('sourceOfFunds', e.target.value)}
|
||||
className="w-full bg-slate-800 border border-slate-700 rounded-lg px-4 py-2 text-white focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Select source</option>
|
||||
{SOURCE_OF_FUNDS_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>{option}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-slate-300 text-sm font-medium mb-1">Investment Experience</label>
|
||||
<select
|
||||
value={kyc.investmentExperience}
|
||||
onChange={(e) => updateKyc('investmentExperience', e.target.value)}
|
||||
className="w-full bg-slate-800 border border-slate-700 rounded-lg px-4 py-2 text-white focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Select experience</option>
|
||||
{INVESTMENT_EXPERIENCE_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>{option}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<label className="block text-slate-300 text-sm font-medium mb-2">Risk Tolerance</label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{RISK_TOLERANCE_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => updateKyc('riskTolerance', option.value)}
|
||||
className={`p-3 rounded-lg border text-left transition-colors ${
|
||||
kyc.riskTolerance === option.value
|
||||
? 'border-blue-500 bg-blue-900/20'
|
||||
: 'border-slate-700 bg-slate-800/50 hover:border-slate-600'
|
||||
}`}
|
||||
>
|
||||
<span className="text-white font-medium block">{option.label}</span>
|
||||
<span className="text-slate-400 text-xs">{option.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render terms step
|
||||
const renderTermsStep = () => (
|
||||
<div className="space-y-6">
|
||||
{/* Terms & Conditions */}
|
||||
<div className="bg-slate-800/50 rounded-lg border border-slate-700">
|
||||
<div className="p-4 border-b border-slate-700">
|
||||
<h3 className="text-white font-medium flex items-center gap-2">
|
||||
<FileText className="w-5 h-5 text-blue-400" />
|
||||
Terms & Conditions
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-4 max-h-48 overflow-y-auto text-slate-400 text-sm space-y-3">
|
||||
<p>By opening an investment account with Trading Platform, you agree to the following terms:</p>
|
||||
<ul className="list-disc list-inside space-y-2">
|
||||
<li>You are at least 18 years old and legally able to enter into contracts.</li>
|
||||
<li>The information you have provided is accurate and complete.</li>
|
||||
<li>You understand that all investments carry risk, including the potential loss of principal.</li>
|
||||
<li>You agree to our fee schedule as outlined in the product documentation.</li>
|
||||
<li>You authorize us to manage your investment according to the selected product strategy.</li>
|
||||
<li>You may request withdrawals subject to our withdrawal policy and any applicable lock-up periods.</li>
|
||||
<li>Account statements will be provided monthly via email and accessible through the platform.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="p-4 border-t border-slate-700">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={termsAccepted}
|
||||
onChange={(e) => setTermsAccepted(e.target.checked)}
|
||||
className="w-5 h-5 rounded border-slate-600 bg-slate-800 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-slate-300">
|
||||
I have read and agree to the Terms & Conditions
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Risk Disclosure */}
|
||||
<div className="bg-slate-800/50 rounded-lg border border-slate-700">
|
||||
<div className="p-4 border-b border-slate-700">
|
||||
<h3 className="text-white font-medium flex items-center gap-2">
|
||||
<AlertCircle className="w-5 h-5 text-amber-400" />
|
||||
Risk Disclosure
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-4 max-h-48 overflow-y-auto text-slate-400 text-sm space-y-3">
|
||||
<p><strong className="text-white">Important Risk Information:</strong></p>
|
||||
<ul className="list-disc list-inside space-y-2">
|
||||
<li>Past performance is not indicative of future results.</li>
|
||||
<li>The value of investments can go down as well as up.</li>
|
||||
<li>You may receive back less than your original investment.</li>
|
||||
<li>Trading strategies involve significant risks including market, liquidity, and operational risks.</li>
|
||||
<li>Automated trading systems may experience technical failures or delays.</li>
|
||||
<li>Currency fluctuations may affect the value of international investments.</li>
|
||||
<li>There is no guarantee that investment objectives will be achieved.</li>
|
||||
</ul>
|
||||
<p className="text-amber-400">
|
||||
Only invest money that you can afford to lose. If you are unsure about the suitability
|
||||
of this investment, please seek independent financial advice.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 border-t border-slate-700">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={riskDisclosureAccepted}
|
||||
onChange={(e) => setRiskDisclosureAccepted(e.target.checked)}
|
||||
className="w-5 h-5 rounded border-slate-600 bg-slate-800 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-slate-300">
|
||||
I understand and accept the risks involved
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render confirm step
|
||||
const renderConfirmStep = () => (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center mb-6">
|
||||
<div className="w-16 h-16 rounded-full bg-blue-600/20 flex items-center justify-center mx-auto mb-4">
|
||||
<CheckCircle className="w-8 h-8 text-blue-400" />
|
||||
</div>
|
||||
<h3 className="text-white text-xl font-semibold">Review Your Investment</h3>
|
||||
<p className="text-slate-400 mt-1">Please confirm the details below</p>
|
||||
</div>
|
||||
|
||||
{/* Summary Card */}
|
||||
<div className="bg-slate-800/50 rounded-lg border border-slate-700 divide-y divide-slate-700">
|
||||
<div className="p-4">
|
||||
<h4 className="text-slate-400 text-sm mb-3">Product</h4>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-blue-600/20 flex items-center justify-center">
|
||||
<Wallet className="w-5 h-5 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white font-medium">{product.name}</p>
|
||||
<p className="text-slate-400 text-sm">{product.tradingAgent} Agent</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<span className="text-slate-400 text-sm block">Investment Amount</span>
|
||||
<span className="text-white text-lg font-semibold">${amount.toLocaleString()}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-400 text-sm block">Expected Return</span>
|
||||
<span className="text-emerald-400 text-lg font-semibold">{product.expectedReturn}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<span className="text-slate-400 text-sm block">Auto-Reinvest</span>
|
||||
<span className="text-white">{autoReinvest ? 'Yes' : 'No'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-400 text-sm block">Distribution</span>
|
||||
<span className="text-white capitalize">{autoReinvest ? 'N/A' : distributionFrequency}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{requiresKYC && (
|
||||
<div className="p-4">
|
||||
<h4 className="text-slate-400 text-sm mb-2">Account Holder</h4>
|
||||
<p className="text-white">{kyc.fullName}</p>
|
||||
<p className="text-slate-400 text-sm">{kyc.city}, {kyc.country}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-4">
|
||||
<div className="flex items-center gap-2 text-emerald-400">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
<span className="text-sm">Terms & Conditions accepted</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-emerald-400 mt-1">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
<span className="text-sm">Risk disclosure acknowledged</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Processing Time Notice */}
|
||||
<div className="bg-blue-900/20 border border-blue-800/50 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-5 h-5 text-blue-400" />
|
||||
<span className="text-blue-400 font-medium">Processing Time</span>
|
||||
</div>
|
||||
<p className="text-slate-400 text-sm mt-2">
|
||||
Your account will be created immediately. Funds will be allocated to the trading strategy
|
||||
within 1-2 business days after your deposit is confirmed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render current step content
|
||||
const renderStepContent = () => {
|
||||
switch (currentStep) {
|
||||
case 'amount':
|
||||
return renderAmountStep();
|
||||
case 'kyc':
|
||||
return renderKYCStep();
|
||||
case 'terms':
|
||||
return renderTermsStep();
|
||||
case 'confirm':
|
||||
return renderConfirmStep();
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-slate-900 rounded-xl border border-slate-800 max-w-2xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-slate-800">
|
||||
<h2 className="text-white text-xl font-semibold">Create Investment Account</h2>
|
||||
<p className="text-slate-400 text-sm mt-1">
|
||||
Complete the steps below to open your new investment account
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Step Indicator */}
|
||||
<div className="p-6 border-b border-slate-800">
|
||||
{renderStepIndicator()}
|
||||
</div>
|
||||
|
||||
{/* Step Content */}
|
||||
<div className="p-6">
|
||||
{renderStepContent()}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="p-6 border-t border-slate-800 flex justify-between">
|
||||
<button
|
||||
onClick={currentStepIndex === 0 ? onCancel : goToPrevStep}
|
||||
className="flex items-center gap-2 px-4 py-2 text-slate-300 hover:text-white transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
{currentStepIndex === 0 ? 'Cancel' : 'Back'}
|
||||
</button>
|
||||
|
||||
{currentStep === 'confirm' ? (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
className="flex items-center gap-2 px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Creating Account...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
Create Account
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={goToNextStep}
|
||||
disabled={!isStepValid()}
|
||||
className="flex items-center gap-2 px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Continue
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateAccountWizard;
|
||||
591
src/modules/investment/components/PortfolioOptimizerWidget.tsx
Normal file
591
src/modules/investment/components/PortfolioOptimizerWidget.tsx
Normal file
@ -0,0 +1,591 @@
|
||||
/**
|
||||
* PortfolioOptimizerWidget Component
|
||||
* Interactive portfolio allocation optimizer with Markowitz-based recommendations
|
||||
* Epic: OQI-004 Cuentas de Inversion
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
PieChart,
|
||||
TrendingUp,
|
||||
Scale,
|
||||
Shield,
|
||||
Zap,
|
||||
RefreshCw,
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
Info,
|
||||
ArrowRight,
|
||||
Loader2,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-react';
|
||||
|
||||
// Types
|
||||
export interface AccountAllocation {
|
||||
accountId: string;
|
||||
accountName: string;
|
||||
productName: string;
|
||||
currentValue: number;
|
||||
currentAllocation: number; // percentage
|
||||
recommendedAllocation: number; // percentage
|
||||
riskLevel: 'low' | 'medium' | 'high';
|
||||
expectedReturn: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface OptimizationResult {
|
||||
strategy: OptimizationStrategy;
|
||||
accounts: AccountAllocation[];
|
||||
metrics: {
|
||||
expectedReturn: number;
|
||||
expectedVolatility: number;
|
||||
sharpeRatio: number;
|
||||
maxDrawdown: number;
|
||||
};
|
||||
rebalanceRequired: boolean;
|
||||
totalDrift: number; // percentage drift from optimal
|
||||
}
|
||||
|
||||
export interface PortfolioSimulation {
|
||||
year: number;
|
||||
conservative: number;
|
||||
balanced: number;
|
||||
aggressive: number;
|
||||
}
|
||||
|
||||
export type OptimizationStrategy = 'conservative' | 'balanced' | 'aggressive' | 'custom';
|
||||
|
||||
export interface PortfolioOptimizerWidgetProps {
|
||||
accounts: AccountAllocation[];
|
||||
totalValue: number;
|
||||
currentResult?: OptimizationResult;
|
||||
onOptimize?: (strategy: OptimizationStrategy) => void;
|
||||
onApplyRebalance?: (allocations: AccountAllocation[]) => void;
|
||||
isOptimizing?: boolean;
|
||||
isApplying?: boolean;
|
||||
showSimulation?: boolean;
|
||||
}
|
||||
|
||||
const STRATEGY_OPTIONS: {
|
||||
value: OptimizationStrategy;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: React.ElementType;
|
||||
color: string;
|
||||
}[] = [
|
||||
{
|
||||
value: 'conservative',
|
||||
label: 'Conservative',
|
||||
description: 'Minimize risk, preserve capital',
|
||||
icon: Shield,
|
||||
color: 'text-emerald-400',
|
||||
},
|
||||
{
|
||||
value: 'balanced',
|
||||
label: 'Balanced',
|
||||
description: 'Balance risk and return',
|
||||
icon: Scale,
|
||||
color: 'text-blue-400',
|
||||
},
|
||||
{
|
||||
value: 'aggressive',
|
||||
label: 'Aggressive',
|
||||
description: 'Maximize returns, higher risk',
|
||||
icon: Zap,
|
||||
color: 'text-amber-400',
|
||||
},
|
||||
];
|
||||
|
||||
// Sample simulation data for visualization
|
||||
const SIMULATION_DATA: PortfolioSimulation[] = [
|
||||
{ year: 0, conservative: 100000, balanced: 100000, aggressive: 100000 },
|
||||
{ year: 1, conservative: 106000, balanced: 110000, aggressive: 115000 },
|
||||
{ year: 2, conservative: 112360, balanced: 121000, aggressive: 126500 },
|
||||
{ year: 3, conservative: 119102, balanced: 133100, aggressive: 139150 },
|
||||
{ year: 4, conservative: 126248, balanced: 146410, aggressive: 153065 },
|
||||
{ year: 5, conservative: 133823, balanced: 161051, aggressive: 168372 },
|
||||
];
|
||||
|
||||
const PortfolioOptimizerWidget: React.FC<PortfolioOptimizerWidgetProps> = ({
|
||||
accounts,
|
||||
totalValue,
|
||||
currentResult,
|
||||
onOptimize,
|
||||
onApplyRebalance,
|
||||
isOptimizing = false,
|
||||
isApplying = false,
|
||||
showSimulation = true,
|
||||
}) => {
|
||||
const [selectedStrategy, setSelectedStrategy] = useState<OptimizationStrategy>('balanced');
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const [confirmRebalance, setConfirmRebalance] = useState(false);
|
||||
|
||||
// Calculate drift for each account
|
||||
const accountsWithDrift = useMemo(() => {
|
||||
return accounts.map((account) => ({
|
||||
...account,
|
||||
drift: Math.abs(account.currentAllocation - account.recommendedAllocation),
|
||||
driftDirection: account.currentAllocation > account.recommendedAllocation ? 'over' : 'under',
|
||||
adjustmentAmount: totalValue * ((account.recommendedAllocation - account.currentAllocation) / 100),
|
||||
}));
|
||||
}, [accounts, totalValue]);
|
||||
|
||||
// Total drift
|
||||
const totalDrift = useMemo(() => {
|
||||
return accountsWithDrift.reduce((sum, acc) => sum + acc.drift, 0) / 2; // Divide by 2 because drift is counted twice
|
||||
}, [accountsWithDrift]);
|
||||
|
||||
// Handle strategy selection
|
||||
const handleStrategySelect = useCallback((strategy: OptimizationStrategy) => {
|
||||
setSelectedStrategy(strategy);
|
||||
onOptimize?.(strategy);
|
||||
}, [onOptimize]);
|
||||
|
||||
// Handle apply rebalance
|
||||
const handleApplyRebalance = useCallback(() => {
|
||||
if (confirmRebalance) {
|
||||
onApplyRebalance?.(accounts);
|
||||
setConfirmRebalance(false);
|
||||
} else {
|
||||
setConfirmRebalance(true);
|
||||
}
|
||||
}, [confirmRebalance, accounts, onApplyRebalance]);
|
||||
|
||||
// Get risk level color
|
||||
const getRiskColor = (level: string) => {
|
||||
switch (level) {
|
||||
case 'low':
|
||||
return 'text-emerald-400';
|
||||
case 'medium':
|
||||
return 'text-amber-400';
|
||||
case 'high':
|
||||
return 'text-red-400';
|
||||
default:
|
||||
return 'text-slate-400';
|
||||
}
|
||||
};
|
||||
|
||||
// Render allocation pie chart (simplified SVG)
|
||||
const renderAllocationChart = (type: 'current' | 'recommended') => {
|
||||
let cumulativeAngle = 0;
|
||||
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className="w-full h-full">
|
||||
{accounts.map((account, index) => {
|
||||
const allocation = type === 'current' ? account.currentAllocation : account.recommendedAllocation;
|
||||
const angle = (allocation / 100) * 360;
|
||||
const startAngle = cumulativeAngle;
|
||||
cumulativeAngle += angle;
|
||||
|
||||
// Calculate arc path
|
||||
const startRad = (startAngle - 90) * (Math.PI / 180);
|
||||
const endRad = (startAngle + angle - 90) * (Math.PI / 180);
|
||||
const x1 = 50 + 40 * Math.cos(startRad);
|
||||
const y1 = 50 + 40 * Math.sin(startRad);
|
||||
const x2 = 50 + 40 * Math.cos(endRad);
|
||||
const y2 = 50 + 40 * Math.sin(endRad);
|
||||
const largeArc = angle > 180 ? 1 : 0;
|
||||
|
||||
if (allocation < 0.5) return null;
|
||||
|
||||
return (
|
||||
<path
|
||||
key={`${type}-${account.accountId}`}
|
||||
d={`M 50 50 L ${x1} ${y1} A 40 40 0 ${largeArc} 1 ${x2} ${y2} Z`}
|
||||
fill={account.color}
|
||||
stroke="#1e293b"
|
||||
strokeWidth="1"
|
||||
opacity={0.9}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{/* Center hole */}
|
||||
<circle cx="50" cy="50" r="25" fill="#0f172a" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
// Render strategy selector
|
||||
const renderStrategySelector = () => (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{STRATEGY_OPTIONS.map((strategy) => {
|
||||
const Icon = strategy.icon;
|
||||
const isSelected = selectedStrategy === strategy.value;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={strategy.value}
|
||||
onClick={() => handleStrategySelect(strategy.value)}
|
||||
disabled={isOptimizing}
|
||||
className={`p-4 rounded-lg border text-left transition-all ${
|
||||
isSelected
|
||||
? 'border-blue-500 bg-blue-900/20'
|
||||
: 'border-slate-700 bg-slate-800/50 hover:border-slate-600'
|
||||
} ${isOptimizing ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<Icon className={`w-6 h-6 ${strategy.color} mb-2`} />
|
||||
<h4 className="text-white font-medium">{strategy.label}</h4>
|
||||
<p className="text-slate-400 text-xs mt-1">{strategy.description}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render allocation comparison
|
||||
const renderAllocationComparison = () => (
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{/* Current Allocation */}
|
||||
<div>
|
||||
<h4 className="text-slate-400 text-sm text-center mb-3">Current Allocation</h4>
|
||||
<div className="w-32 h-32 mx-auto">
|
||||
{renderAllocationChart('current')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recommended Allocation */}
|
||||
<div>
|
||||
<h4 className="text-slate-400 text-sm text-center mb-3">Recommended</h4>
|
||||
<div className="w-32 h-32 mx-auto relative">
|
||||
{renderAllocationChart('recommended')}
|
||||
{isOptimizing && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-slate-900/50 rounded-full">
|
||||
<Loader2 className="w-8 h-8 text-blue-400 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render allocation legend
|
||||
const renderAllocationLegend = () => (
|
||||
<div className="space-y-2">
|
||||
{accountsWithDrift.map((account) => (
|
||||
<div
|
||||
key={account.accountId}
|
||||
className="flex items-center justify-between text-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: account.color }}
|
||||
/>
|
||||
<span className="text-slate-300">{account.accountName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-slate-500 w-12 text-right">
|
||||
{account.currentAllocation.toFixed(1)}%
|
||||
</span>
|
||||
<ArrowRight className="w-4 h-4 text-slate-600" />
|
||||
<span className="text-white w-12 text-right">
|
||||
{account.recommendedAllocation.toFixed(1)}%
|
||||
</span>
|
||||
{account.drift > 0.5 && (
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${
|
||||
account.driftDirection === 'over' ? 'bg-red-900/50 text-red-400' : 'bg-emerald-900/50 text-emerald-400'
|
||||
}`}>
|
||||
{account.driftDirection === 'over' ? '-' : '+'}{account.drift.toFixed(1)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render metrics comparison
|
||||
const renderMetricsComparison = () => {
|
||||
if (!currentResult) return null;
|
||||
|
||||
const metrics = [
|
||||
{
|
||||
label: 'Expected Return',
|
||||
value: `${currentResult.metrics.expectedReturn.toFixed(1)}%`,
|
||||
change: '+2.3%',
|
||||
positive: true,
|
||||
},
|
||||
{
|
||||
label: 'Expected Volatility',
|
||||
value: `${currentResult.metrics.expectedVolatility.toFixed(1)}%`,
|
||||
change: '-1.5%',
|
||||
positive: true,
|
||||
},
|
||||
{
|
||||
label: 'Sharpe Ratio',
|
||||
value: currentResult.metrics.sharpeRatio.toFixed(2),
|
||||
change: '+0.15',
|
||||
positive: true,
|
||||
},
|
||||
{
|
||||
label: 'Max Drawdown',
|
||||
value: `${currentResult.metrics.maxDrawdown.toFixed(1)}%`,
|
||||
change: '-2.1%',
|
||||
positive: true,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{metrics.map((metric) => (
|
||||
<div
|
||||
key={metric.label}
|
||||
className="bg-slate-800/50 rounded-lg p-3 border border-slate-700"
|
||||
>
|
||||
<span className="text-slate-400 text-xs">{metric.label}</span>
|
||||
<div className="flex items-baseline gap-2 mt-1">
|
||||
<span className="text-white font-semibold">{metric.value}</span>
|
||||
<span className={`text-xs ${metric.positive ? 'text-emerald-400' : 'text-red-400'}`}>
|
||||
{metric.change}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Render rebalance actions
|
||||
const renderRebalanceActions = () => {
|
||||
if (totalDrift < 1) {
|
||||
return (
|
||||
<div className="bg-emerald-900/20 border border-emerald-800/50 rounded-lg p-4 flex items-center gap-3">
|
||||
<CheckCircle className="w-6 h-6 text-emerald-400" />
|
||||
<div>
|
||||
<p className="text-emerald-400 font-medium">Portfolio is Optimized</p>
|
||||
<p className="text-slate-400 text-sm">Current allocation is within optimal range</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Drift Warning */}
|
||||
<div className="bg-amber-900/20 border border-amber-800/50 rounded-lg p-4 flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-amber-400 font-medium">Rebalancing Recommended</p>
|
||||
<p className="text-slate-400 text-sm">
|
||||
Your portfolio has drifted {totalDrift.toFixed(1)}% from the optimal allocation.
|
||||
Rebalancing can help maintain your risk profile and improve returns.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rebalance Details */}
|
||||
<button
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
className="w-full flex items-center justify-between text-slate-300 hover:text-white"
|
||||
>
|
||||
<span className="text-sm">View rebalance details</span>
|
||||
{showDetails ? (
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{showDetails && (
|
||||
<div className="bg-slate-800/50 rounded-lg p-4 border border-slate-700 space-y-3">
|
||||
<h4 className="text-white font-medium text-sm">Required Adjustments</h4>
|
||||
{accountsWithDrift
|
||||
.filter((acc) => Math.abs(acc.adjustmentAmount) > 10)
|
||||
.map((account) => (
|
||||
<div
|
||||
key={account.accountId}
|
||||
className="flex items-center justify-between text-sm"
|
||||
>
|
||||
<span className="text-slate-300">{account.accountName}</span>
|
||||
<span className={account.adjustmentAmount > 0 ? 'text-emerald-400' : 'text-red-400'}>
|
||||
{account.adjustmentAmount > 0 ? '+' : ''}
|
||||
${Math.abs(account.adjustmentAmount).toLocaleString(undefined, { maximumFractionDigits: 0 })}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="pt-3 border-t border-slate-700">
|
||||
<p className="text-slate-500 text-xs flex items-center gap-1">
|
||||
<Info className="w-3 h-3" />
|
||||
Funds will be transferred between accounts automatically
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confirm/Apply Button */}
|
||||
<div className="flex gap-3">
|
||||
{confirmRebalance && (
|
||||
<button
|
||||
onClick={() => setConfirmRebalance(false)}
|
||||
className="flex-1 px-4 py-2 text-slate-300 hover:text-white transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleApplyRebalance}
|
||||
disabled={isApplying}
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg transition-colors ${
|
||||
confirmRebalance
|
||||
? 'bg-amber-600 hover:bg-amber-500 text-white'
|
||||
: 'bg-blue-600 hover:bg-blue-500 text-white'
|
||||
} disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||
>
|
||||
{isApplying ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Rebalancing...
|
||||
</>
|
||||
) : confirmRebalance ? (
|
||||
<>
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
Confirm Rebalance
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Apply Rebalance
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Render simulation chart (simplified)
|
||||
const renderSimulationChart = () => {
|
||||
if (!showSimulation) return null;
|
||||
|
||||
const maxValue = Math.max(...SIMULATION_DATA.map((d) => Math.max(d.conservative, d.balanced, d.aggressive)));
|
||||
const scale = 100 / maxValue;
|
||||
|
||||
return (
|
||||
<div className="bg-slate-800/50 rounded-lg p-4 border border-slate-700">
|
||||
<h4 className="text-white font-medium mb-4">5-Year Projection (${totalValue.toLocaleString()})</h4>
|
||||
|
||||
<div className="h-32 relative">
|
||||
<svg className="w-full h-full" preserveAspectRatio="none" viewBox="0 0 100 100">
|
||||
{/* Grid lines */}
|
||||
{[0, 25, 50, 75, 100].map((y) => (
|
||||
<line
|
||||
key={y}
|
||||
x1="0"
|
||||
y1={100 - y}
|
||||
x2="100"
|
||||
y2={100 - y}
|
||||
stroke="#334155"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Conservative line */}
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="#10b981"
|
||||
strokeWidth="2"
|
||||
points={SIMULATION_DATA.map((d, i) =>
|
||||
`${(i / (SIMULATION_DATA.length - 1)) * 100},${100 - (d.conservative * scale)}`
|
||||
).join(' ')}
|
||||
/>
|
||||
|
||||
{/* Balanced line */}
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth="2"
|
||||
points={SIMULATION_DATA.map((d, i) =>
|
||||
`${(i / (SIMULATION_DATA.length - 1)) * 100},${100 - (d.balanced * scale)}`
|
||||
).join(' ')}
|
||||
/>
|
||||
|
||||
{/* Aggressive line */}
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="#f59e0b"
|
||||
strokeWidth="2"
|
||||
points={SIMULATION_DATA.map((d, i) =>
|
||||
`${(i / (SIMULATION_DATA.length - 1)) * 100},${100 - (d.aggressive * scale)}`
|
||||
).join(' ')}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex justify-center gap-6 mt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-emerald-500" />
|
||||
<span className="text-slate-400 text-xs">Conservative</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-blue-500" />
|
||||
<span className="text-slate-400 text-xs">Balanced</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-amber-500" />
|
||||
<span className="text-slate-400 text-xs">Aggressive</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-slate-500 text-xs text-center mt-3">
|
||||
Projections are based on historical performance and do not guarantee future results
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-slate-900 rounded-xl border border-slate-800">
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-slate-800">
|
||||
<div className="flex items-center gap-3">
|
||||
<PieChart className="w-6 h-6 text-blue-400" />
|
||||
<div>
|
||||
<h2 className="text-white text-lg font-semibold">Portfolio Optimizer</h2>
|
||||
<p className="text-slate-400 text-sm">
|
||||
Total Value: ${totalValue.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Strategy Selector */}
|
||||
<div>
|
||||
<h3 className="text-white font-medium mb-3">Optimization Strategy</h3>
|
||||
{renderStrategySelector()}
|
||||
</div>
|
||||
|
||||
{/* Allocation Comparison */}
|
||||
<div>
|
||||
<h3 className="text-white font-medium mb-3">Allocation Comparison</h3>
|
||||
{renderAllocationComparison()}
|
||||
<div className="mt-4">
|
||||
{renderAllocationLegend()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metrics */}
|
||||
{currentResult && (
|
||||
<div>
|
||||
<h3 className="text-white font-medium mb-3">Expected Impact</h3>
|
||||
{renderMetricsComparison()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Simulation */}
|
||||
{renderSimulationChart()}
|
||||
|
||||
{/* Rebalance Actions */}
|
||||
<div>
|
||||
<h3 className="text-white font-medium mb-3">Rebalance</h3>
|
||||
{renderRebalanceActions()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PortfolioOptimizerWidget;
|
||||
519
src/modules/investment/components/RiskAnalysisPanel.tsx
Normal file
519
src/modules/investment/components/RiskAnalysisPanel.tsx
Normal file
@ -0,0 +1,519 @@
|
||||
/**
|
||||
* RiskAnalysisPanel Component
|
||||
* Display comprehensive risk metrics and analysis for investment accounts
|
||||
* Epic: OQI-004 Cuentas de Inversion
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
Shield,
|
||||
AlertTriangle,
|
||||
TrendingDown,
|
||||
Activity,
|
||||
BarChart3,
|
||||
Target,
|
||||
Info,
|
||||
ChevronRight,
|
||||
ArrowUpRight,
|
||||
ArrowDownRight,
|
||||
} from 'lucide-react';
|
||||
|
||||
// Types
|
||||
export interface RiskMetrics {
|
||||
var95: number; // Value at Risk 95%
|
||||
var99: number; // Value at Risk 99%
|
||||
cvar: number; // Conditional VaR (Expected Shortfall)
|
||||
sharpeRatio: number; // Risk-adjusted return
|
||||
sortinoRatio: number; // Downside risk-adjusted return
|
||||
maxDrawdown: number; // Maximum drawdown percentage
|
||||
maxDrawdownDuration: number; // Days in drawdown
|
||||
beta: number; // Market correlation
|
||||
alpha: number; // Excess return over benchmark
|
||||
volatility: number; // Standard deviation of returns
|
||||
downsideDeviation: number;
|
||||
calmarRatio: number; // Return / Max Drawdown
|
||||
}
|
||||
|
||||
export interface RiskScore {
|
||||
overall: number; // 0-100
|
||||
marketRisk: number;
|
||||
liquidityRisk: number;
|
||||
operationalRisk: number;
|
||||
concentrationRisk: number;
|
||||
}
|
||||
|
||||
export interface RiskRecommendation {
|
||||
id: string;
|
||||
type: 'warning' | 'suggestion' | 'info';
|
||||
title: string;
|
||||
description: string;
|
||||
action?: string;
|
||||
}
|
||||
|
||||
export interface RiskAnalysisPanelProps {
|
||||
accountId: string;
|
||||
accountName: string;
|
||||
metrics: RiskMetrics;
|
||||
score: RiskScore;
|
||||
recommendations?: RiskRecommendation[];
|
||||
benchmarkName?: string;
|
||||
period?: '1M' | '3M' | '6M' | '1Y' | 'ALL';
|
||||
onPeriodChange?: (period: string) => void;
|
||||
onViewDetails?: () => void;
|
||||
compact?: boolean;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const PERIOD_OPTIONS = [
|
||||
{ value: '1M', label: '1 Month' },
|
||||
{ value: '3M', label: '3 Months' },
|
||||
{ value: '6M', label: '6 Months' },
|
||||
{ value: '1Y', label: '1 Year' },
|
||||
{ value: 'ALL', label: 'All Time' },
|
||||
];
|
||||
|
||||
const RiskAnalysisPanel: React.FC<RiskAnalysisPanelProps> = ({
|
||||
accountId,
|
||||
accountName,
|
||||
metrics,
|
||||
score,
|
||||
recommendations = [],
|
||||
benchmarkName = 'S&P 500',
|
||||
period = '1Y',
|
||||
onPeriodChange,
|
||||
onViewDetails,
|
||||
compact = false,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
// Get risk level from score
|
||||
const getRiskLevel = (scoreValue: number): { label: string; color: string; bgColor: string } => {
|
||||
if (scoreValue <= 30) return { label: 'Low', color: 'text-emerald-400', bgColor: 'bg-emerald-400' };
|
||||
if (scoreValue <= 60) return { label: 'Moderate', color: 'text-amber-400', bgColor: 'bg-amber-400' };
|
||||
if (scoreValue <= 80) return { label: 'High', color: 'text-orange-400', bgColor: 'bg-orange-400' };
|
||||
return { label: 'Very High', color: 'text-red-400', bgColor: 'bg-red-400' };
|
||||
};
|
||||
|
||||
// Get metric color based on value
|
||||
const getMetricColor = (value: number, thresholds: { good: number; warning: number }, inverse = false) => {
|
||||
if (inverse) {
|
||||
if (value <= thresholds.good) return 'text-emerald-400';
|
||||
if (value <= thresholds.warning) return 'text-amber-400';
|
||||
return 'text-red-400';
|
||||
}
|
||||
if (value >= thresholds.good) return 'text-emerald-400';
|
||||
if (value >= thresholds.warning) return 'text-amber-400';
|
||||
return 'text-red-400';
|
||||
};
|
||||
|
||||
// Format percentage
|
||||
const formatPercent = (value: number, decimals = 2) => {
|
||||
return `${value >= 0 ? '+' : ''}${value.toFixed(decimals)}%`;
|
||||
};
|
||||
|
||||
// Overall risk level
|
||||
const overallRisk = useMemo(() => getRiskLevel(score.overall), [score.overall]);
|
||||
|
||||
// Render risk score gauge
|
||||
const renderRiskGauge = () => (
|
||||
<div className="relative w-32 h-32">
|
||||
<svg className="w-full h-full transform -rotate-90" viewBox="0 0 100 100">
|
||||
{/* Background circle */}
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="40"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="12"
|
||||
className="text-slate-700"
|
||||
/>
|
||||
{/* Score arc */}
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="40"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="12"
|
||||
strokeDasharray={`${(score.overall / 100) * 251.2} 251.2`}
|
||||
strokeLinecap="round"
|
||||
className={overallRisk.color}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-2xl font-bold text-white">{score.overall}</span>
|
||||
<span className={`text-xs font-medium ${overallRisk.color}`}>{overallRisk.label} Risk</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render risk breakdown bars
|
||||
const renderRiskBreakdown = () => {
|
||||
const risks = [
|
||||
{ label: 'Market Risk', value: score.marketRisk, icon: TrendingDown },
|
||||
{ label: 'Liquidity Risk', value: score.liquidityRisk, icon: Activity },
|
||||
{ label: 'Operational Risk', value: score.operationalRisk, icon: Shield },
|
||||
{ label: 'Concentration Risk', value: score.concentrationRisk, icon: Target },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{risks.map((risk) => {
|
||||
const level = getRiskLevel(risk.value);
|
||||
const Icon = risk.icon;
|
||||
return (
|
||||
<div key={risk.label}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="w-4 h-4 text-slate-500" />
|
||||
<span className="text-sm text-slate-300">{risk.label}</span>
|
||||
</div>
|
||||
<span className={`text-sm font-medium ${level.color}`}>{risk.value}</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-slate-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${level.bgColor}`}
|
||||
style={{ width: `${risk.value}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Render key metrics
|
||||
const renderKeyMetrics = () => {
|
||||
const keyMetrics = [
|
||||
{
|
||||
label: 'Sharpe Ratio',
|
||||
value: metrics.sharpeRatio.toFixed(2),
|
||||
description: 'Risk-adjusted return',
|
||||
color: getMetricColor(metrics.sharpeRatio, { good: 1.5, warning: 1.0 }),
|
||||
icon: BarChart3,
|
||||
},
|
||||
{
|
||||
label: 'Sortino Ratio',
|
||||
value: metrics.sortinoRatio.toFixed(2),
|
||||
description: 'Downside risk-adjusted',
|
||||
color: getMetricColor(metrics.sortinoRatio, { good: 2.0, warning: 1.0 }),
|
||||
icon: TrendingDown,
|
||||
},
|
||||
{
|
||||
label: 'Max Drawdown',
|
||||
value: formatPercent(-metrics.maxDrawdown),
|
||||
description: `${metrics.maxDrawdownDuration} days duration`,
|
||||
color: getMetricColor(metrics.maxDrawdown, { good: 10, warning: 20 }, true),
|
||||
icon: ArrowDownRight,
|
||||
},
|
||||
{
|
||||
label: 'Volatility',
|
||||
value: formatPercent(metrics.volatility),
|
||||
description: 'Annualized std. dev.',
|
||||
color: getMetricColor(metrics.volatility, { good: 15, warning: 25 }, true),
|
||||
icon: Activity,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{keyMetrics.map((metric) => {
|
||||
const Icon = metric.icon;
|
||||
return (
|
||||
<div
|
||||
key={metric.label}
|
||||
className="bg-slate-800/50 rounded-lg p-3 border border-slate-700"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Icon className="w-4 h-4 text-slate-500" />
|
||||
<span className="text-slate-400 text-sm">{metric.label}</span>
|
||||
</div>
|
||||
<p className={`text-xl font-semibold ${metric.color}`}>{metric.value}</p>
|
||||
<p className="text-slate-500 text-xs mt-1">{metric.description}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Render VaR metrics
|
||||
const renderVaRMetrics = () => (
|
||||
<div className="bg-slate-800/50 rounded-lg p-4 border border-slate-700">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-400" />
|
||||
<h4 className="text-white font-medium">Value at Risk (VaR)</h4>
|
||||
<button className="ml-auto text-slate-500 hover:text-slate-300">
|
||||
<Info className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<span className="text-slate-400 text-sm block mb-1">VaR (95%)</span>
|
||||
<span className="text-red-400 text-lg font-semibold">
|
||||
${Math.abs(metrics.var95).toLocaleString()}
|
||||
</span>
|
||||
<span className="text-slate-500 text-xs block">
|
||||
5% chance of loss exceeding this
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-400 text-sm block mb-1">VaR (99%)</span>
|
||||
<span className="text-red-400 text-lg font-semibold">
|
||||
${Math.abs(metrics.var99).toLocaleString()}
|
||||
</span>
|
||||
<span className="text-slate-500 text-xs block">
|
||||
1% chance of loss exceeding this
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-400 text-sm block mb-1">CVaR (ES)</span>
|
||||
<span className="text-red-400 text-lg font-semibold">
|
||||
${Math.abs(metrics.cvar).toLocaleString()}
|
||||
</span>
|
||||
<span className="text-slate-500 text-xs block">
|
||||
Expected loss in worst 5%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render benchmark comparison
|
||||
const renderBenchmarkComparison = () => (
|
||||
<div className="bg-slate-800/50 rounded-lg p-4 border border-slate-700">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="text-white font-medium">vs {benchmarkName}</h4>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<span className="text-slate-400 text-sm block mb-1">Alpha</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{metrics.alpha >= 0 ? (
|
||||
<ArrowUpRight className="w-5 h-5 text-emerald-400" />
|
||||
) : (
|
||||
<ArrowDownRight className="w-5 h-5 text-red-400" />
|
||||
)}
|
||||
<span className={`text-xl font-semibold ${metrics.alpha >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
|
||||
{formatPercent(metrics.alpha)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-slate-500 text-xs">
|
||||
{metrics.alpha >= 0 ? 'Outperforming' : 'Underperforming'} benchmark
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-400 text-sm block mb-1">Beta</span>
|
||||
<span className="text-white text-xl font-semibold">{metrics.beta.toFixed(2)}</span>
|
||||
<span className="text-slate-500 text-xs block">
|
||||
{metrics.beta < 1 ? 'Lower' : metrics.beta > 1 ? 'Higher' : 'Same'} market sensitivity
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render recommendations
|
||||
const renderRecommendations = () => {
|
||||
if (recommendations.length === 0) return null;
|
||||
|
||||
const getRecommendationStyles = (type: RiskRecommendation['type']) => {
|
||||
switch (type) {
|
||||
case 'warning':
|
||||
return { icon: AlertTriangle, color: 'text-amber-400', bg: 'bg-amber-900/20', border: 'border-amber-800/50' };
|
||||
case 'suggestion':
|
||||
return { icon: Target, color: 'text-blue-400', bg: 'bg-blue-900/20', border: 'border-blue-800/50' };
|
||||
case 'info':
|
||||
default:
|
||||
return { icon: Info, color: 'text-slate-400', bg: 'bg-slate-800/50', border: 'border-slate-700' };
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-white font-medium">Recommendations</h4>
|
||||
{recommendations.map((rec) => {
|
||||
const styles = getRecommendationStyles(rec.type);
|
||||
const Icon = styles.icon;
|
||||
return (
|
||||
<div
|
||||
key={rec.id}
|
||||
className={`rounded-lg p-3 border ${styles.bg} ${styles.border}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Icon className={`w-5 h-5 ${styles.color} flex-shrink-0 mt-0.5`} />
|
||||
<div className="flex-1">
|
||||
<p className="text-white font-medium text-sm">{rec.title}</p>
|
||||
<p className="text-slate-400 text-sm mt-1">{rec.description}</p>
|
||||
{rec.action && (
|
||||
<button className="text-blue-400 text-sm mt-2 flex items-center gap-1 hover:text-blue-300">
|
||||
{rec.action}
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-slate-900 rounded-xl border border-slate-800 p-6">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-6 bg-slate-800 rounded w-1/3" />
|
||||
<div className="h-32 bg-slate-800 rounded" />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="h-24 bg-slate-800 rounded" />
|
||||
<div className="h-24 bg-slate-800 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Compact view
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="bg-slate-900 rounded-xl border border-slate-800 p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield className="w-5 h-5 text-blue-400" />
|
||||
<h3 className="text-white font-medium">Risk Analysis</h3>
|
||||
</div>
|
||||
<div className={`px-2 py-1 rounded text-xs font-medium ${overallRisk.color} bg-slate-800`}>
|
||||
{overallRisk.label} Risk ({score.overall})
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4 text-center">
|
||||
<div>
|
||||
<span className="text-slate-500 text-xs block">Sharpe</span>
|
||||
<span className={`font-semibold ${getMetricColor(metrics.sharpeRatio, { good: 1.5, warning: 1.0 })}`}>
|
||||
{metrics.sharpeRatio.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500 text-xs block">Max DD</span>
|
||||
<span className={`font-semibold ${getMetricColor(metrics.maxDrawdown, { good: 10, warning: 20 }, true)}`}>
|
||||
{formatPercent(-metrics.maxDrawdown)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500 text-xs block">VaR 95%</span>
|
||||
<span className="text-red-400 font-semibold">${Math.abs(metrics.var95).toLocaleString()}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500 text-xs block">Beta</span>
|
||||
<span className="text-white font-semibold">{metrics.beta.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{onViewDetails && (
|
||||
<button
|
||||
onClick={onViewDetails}
|
||||
className="w-full mt-4 text-blue-400 text-sm flex items-center justify-center gap-1 hover:text-blue-300"
|
||||
>
|
||||
View Full Analysis
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Full view
|
||||
return (
|
||||
<div className="bg-slate-900 rounded-xl border border-slate-800">
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-slate-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield className="w-6 h-6 text-blue-400" />
|
||||
<div>
|
||||
<h2 className="text-white text-lg font-semibold">Risk Analysis</h2>
|
||||
<p className="text-slate-400 text-sm">{accountName}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Period Selector */}
|
||||
<div className="flex items-center gap-2">
|
||||
{PERIOD_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => onPeriodChange?.(opt.value)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm transition-colors ${
|
||||
period === opt.value
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-slate-400 hover:text-white hover:bg-slate-800'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Risk Score Section */}
|
||||
<div className="flex items-start gap-8">
|
||||
<div className="flex-shrink-0">
|
||||
{renderRiskGauge()}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
{renderRiskBreakdown()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Key Metrics */}
|
||||
<div>
|
||||
<h4 className="text-white font-medium mb-4">Key Risk Metrics</h4>
|
||||
{renderKeyMetrics()}
|
||||
</div>
|
||||
|
||||
{/* VaR Section */}
|
||||
{renderVaRMetrics()}
|
||||
|
||||
{/* Benchmark Comparison */}
|
||||
{renderBenchmarkComparison()}
|
||||
|
||||
{/* Additional Metrics */}
|
||||
<div className="bg-slate-800/50 rounded-lg p-4 border border-slate-700">
|
||||
<h4 className="text-white font-medium mb-4">Additional Metrics</h4>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<span className="text-slate-400 text-sm block">Downside Deviation</span>
|
||||
<span className="text-white font-semibold">{formatPercent(metrics.downsideDeviation)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-400 text-sm block">Calmar Ratio</span>
|
||||
<span className={`font-semibold ${getMetricColor(metrics.calmarRatio, { good: 2.0, warning: 1.0 })}`}>
|
||||
{metrics.calmarRatio.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-400 text-sm block">Recovery Factor</span>
|
||||
<span className="text-white font-semibold">
|
||||
{(metrics.calmarRatio * 0.8).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recommendations */}
|
||||
{renderRecommendations()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RiskAnalysisPanel;
|
||||
@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Investment Module Components
|
||||
* Barrel export for all investment-related components
|
||||
* Epic: OQI-004 Cuentas de Inversion
|
||||
*/
|
||||
|
||||
// Form Components
|
||||
@ -19,3 +20,17 @@ export type { PerformanceDataPoint } from './PerformanceWidgetChart';
|
||||
|
||||
export { default as AccountSettingsPanel } from './AccountSettingsPanel';
|
||||
export type { AccountSettings, AccountForSettings } from './AccountSettingsPanel';
|
||||
|
||||
// Account Creation & Management (OQI-004)
|
||||
export { default as CreateAccountWizard } from './CreateAccountWizard';
|
||||
export type { ProductSelection, KYCData, AccountCreationData } from './CreateAccountWizard';
|
||||
|
||||
export { default as AccountTransferModal } from './AccountTransferModal';
|
||||
export type { TransferAccount, TransferFee, TransferData } from './AccountTransferModal';
|
||||
|
||||
// Risk & Optimization Components (OQI-004)
|
||||
export { default as RiskAnalysisPanel } from './RiskAnalysisPanel';
|
||||
export type { RiskMetrics, RiskScore, RiskRecommendation } from './RiskAnalysisPanel';
|
||||
|
||||
export { default as PortfolioOptimizerWidget } from './PortfolioOptimizerWidget';
|
||||
export type { AccountAllocation, OptimizationResult, PortfolioSimulation, OptimizationStrategy } from './PortfolioOptimizerWidget';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user