[OQI-004] feat: Add investment account management components

- AccountSummaryCard: Reusable account stats with balance, gains, and status
- ProductComparisonTable: Side-by-side product comparison (Atlas/Orion/Nova)
- PerformanceWidgetChart: Compact sparkline chart for embedding in cards
- AccountSettingsPanel: Account configuration (distribution, reinvest, alerts)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-25 12:24:01 -06:00
parent e9aa29fccd
commit 7d9e8d2da9
5 changed files with 1464 additions and 0 deletions

View File

@ -0,0 +1,524 @@
/**
* AccountSettingsPanel Component
* Account-level configuration for investment accounts
* OQI-004: Cuentas de Inversion
*/
import React, { useState } from 'react';
import {
Settings,
Bell,
RefreshCw,
Calendar,
Shield,
DollarSign,
AlertTriangle,
CheckCircle,
Info,
ChevronRight,
Save,
X,
TrendingUp,
} from 'lucide-react';
// ============================================================================
// Types
// ============================================================================
export interface AccountSettings {
distributionFrequency: 'weekly' | 'biweekly' | 'monthly' | 'quarterly';
autoReinvest: boolean;
reinvestPercentage: number;
notifications: {
distributionAlert: boolean;
performanceAlert: boolean;
riskAlert: boolean;
newsAlert: boolean;
};
riskAlerts: {
enabled: boolean;
drawdownThreshold: number;
dailyLossThreshold: number;
};
withdrawalSettings: {
preferredMethod: 'bank' | 'crypto' | 'wallet';
autoWithdraw: boolean;
autoWithdrawThreshold: number;
};
}
export interface AccountForSettings {
id: string;
accountNumber: string;
productName: string;
currentBalance: number;
}
interface AccountSettingsPanelProps {
account: AccountForSettings;
settings: AccountSettings;
onSave?: (settings: AccountSettings) => void;
onCancel?: () => void;
isLoading?: boolean;
compact?: boolean;
}
// ============================================================================
// Sub-Components
// ============================================================================
interface ToggleSwitchProps {
enabled: boolean;
onChange: (value: boolean) => void;
disabled?: boolean;
}
const ToggleSwitch: React.FC<ToggleSwitchProps> = ({ enabled, onChange, disabled = false }) => (
<button
type="button"
onClick={() => !disabled && onChange(!enabled)}
disabled={disabled}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
enabled ? 'bg-emerald-500' : 'bg-slate-600'
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
enabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
);
interface SettingRowProps {
icon: React.ReactNode;
label: string;
description?: string;
children: React.ReactNode;
}
const SettingRow: React.FC<SettingRowProps> = ({ icon, label, description, children }) => (
<div className="flex items-center justify-between py-4 border-b border-slate-700/50 last:border-b-0">
<div className="flex items-start gap-3">
<div className="p-2 bg-slate-700/50 rounded-lg text-slate-400">{icon}</div>
<div>
<div className="font-medium text-white">{label}</div>
{description && <div className="text-sm text-slate-500 mt-0.5">{description}</div>}
</div>
</div>
<div>{children}</div>
</div>
);
// ============================================================================
// Component
// ============================================================================
export const AccountSettingsPanel: React.FC<AccountSettingsPanelProps> = ({
account,
settings: initialSettings,
onSave,
onCancel,
isLoading = false,
compact = false,
}) => {
const [settings, setSettings] = useState<AccountSettings>(initialSettings);
const [activeSection, setActiveSection] = useState<string>('distribution');
const [hasChanges, setHasChanges] = useState(false);
const updateSettings = <K extends keyof AccountSettings>(
key: K,
value: AccountSettings[K]
) => {
setSettings((prev) => ({ ...prev, [key]: value }));
setHasChanges(true);
};
const updateNestedSettings = <K extends keyof AccountSettings>(
key: K,
nestedKey: keyof AccountSettings[K],
value: AccountSettings[K][keyof AccountSettings[K]]
) => {
setSettings((prev) => ({
...prev,
[key]: { ...prev[key], [nestedKey]: value },
}));
setHasChanges(true);
};
const handleSave = () => {
onSave?.(settings);
setHasChanges(false);
};
const sections = [
{ id: 'distribution', label: 'Distribution', icon: Calendar },
{ id: 'reinvest', label: 'Auto-Reinvest', icon: RefreshCw },
{ id: 'notifications', label: 'Notifications', icon: Bell },
{ id: 'risk', label: 'Risk Alerts', icon: Shield },
{ id: 'withdrawal', label: 'Withdrawal', icon: DollarSign },
];
if (compact) {
return (
<div className="bg-slate-800/50 rounded-xl border border-slate-700 p-4">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 bg-slate-700 rounded-lg">
<Settings className="w-5 h-5 text-slate-400" />
</div>
<div>
<h3 className="font-semibold text-white">Account Settings</h3>
<p className="text-xs text-slate-500">{account.accountNumber}</p>
</div>
</div>
<div className="space-y-3">
{sections.map((section) => {
const Icon = section.icon;
return (
<button
key={section.id}
onClick={() => setActiveSection(section.id)}
className="w-full flex items-center justify-between p-3 bg-slate-900/50 rounded-lg hover:bg-slate-700/50 transition-colors"
>
<div className="flex items-center gap-2">
<Icon className="w-4 h-4 text-slate-400" />
<span className="text-sm text-white">{section.label}</span>
</div>
<ChevronRight className="w-4 h-4 text-slate-500" />
</button>
);
})}
</div>
</div>
);
}
return (
<div className="bg-slate-800/50 rounded-xl border border-slate-700 overflow-hidden">
{/* Header */}
<div className="p-5 border-b border-slate-700">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-slate-700 rounded-lg">
<Settings className="w-5 h-5 text-slate-400" />
</div>
<div>
<h3 className="font-semibold text-white text-lg">Account Settings</h3>
<p className="text-sm text-slate-500">
{account.productName} {account.accountNumber}
</p>
</div>
</div>
{hasChanges && (
<span className="px-2 py-1 bg-amber-500/20 text-amber-400 text-xs rounded">
Unsaved Changes
</span>
)}
</div>
</div>
{/* Navigation Tabs */}
<div className="flex border-b border-slate-700 overflow-x-auto">
{sections.map((section) => {
const Icon = section.icon;
const isActive = activeSection === section.id;
return (
<button
key={section.id}
onClick={() => setActiveSection(section.id)}
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
isActive
? 'border-blue-500 text-blue-400'
: 'border-transparent text-slate-400 hover:text-white'
}`}
>
<Icon className="w-4 h-4" />
{section.label}
</button>
);
})}
</div>
{/* Content */}
<div className="p-5">
{/* Distribution Settings */}
{activeSection === 'distribution' && (
<div className="space-y-1">
<SettingRow
icon={<Calendar className="w-4 h-4" />}
label="Distribution Frequency"
description="How often you receive profit distributions"
>
<select
value={settings.distributionFrequency}
onChange={(e) =>
updateSettings('distributionFrequency', e.target.value as AccountSettings['distributionFrequency'])
}
className="px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="weekly">Weekly</option>
<option value="biweekly">Bi-weekly</option>
<option value="monthly">Monthly</option>
<option value="quarterly">Quarterly</option>
</select>
</SettingRow>
<div className="mt-4 p-3 bg-blue-500/10 border border-blue-500/20 rounded-lg">
<div className="flex items-start gap-2">
<Info className="w-4 h-4 text-blue-400 mt-0.5" />
<p className="text-sm text-blue-300">
Distributions are processed on the last business day of each period.
Changes take effect from the next distribution cycle.
</p>
</div>
</div>
</div>
)}
{/* Auto-Reinvest Settings */}
{activeSection === 'reinvest' && (
<div className="space-y-1">
<SettingRow
icon={<RefreshCw className="w-4 h-4" />}
label="Auto-Reinvest Profits"
description="Automatically reinvest your distributions"
>
<ToggleSwitch
enabled={settings.autoReinvest}
onChange={(value) => updateSettings('autoReinvest', value)}
/>
</SettingRow>
{settings.autoReinvest && (
<SettingRow
icon={<DollarSign className="w-4 h-4" />}
label="Reinvest Percentage"
description="Percentage of profits to reinvest"
>
<div className="flex items-center gap-2">
<input
type="range"
min="0"
max="100"
step="5"
value={settings.reinvestPercentage}
onChange={(e) => updateSettings('reinvestPercentage', parseInt(e.target.value))}
className="w-24"
/>
<span className="text-white font-medium w-12 text-right">
{settings.reinvestPercentage}%
</span>
</div>
</SettingRow>
)}
</div>
)}
{/* Notifications Settings */}
{activeSection === 'notifications' && (
<div className="space-y-1">
<SettingRow
icon={<DollarSign className="w-4 h-4" />}
label="Distribution Alerts"
description="Get notified when distributions are processed"
>
<ToggleSwitch
enabled={settings.notifications.distributionAlert}
onChange={(value) => updateNestedSettings('notifications', 'distributionAlert', value)}
/>
</SettingRow>
<SettingRow
icon={<TrendingUp className="w-4 h-4" />}
label="Performance Alerts"
description="Weekly performance summary notifications"
>
<ToggleSwitch
enabled={settings.notifications.performanceAlert}
onChange={(value) => updateNestedSettings('notifications', 'performanceAlert', value)}
/>
</SettingRow>
<SettingRow
icon={<AlertTriangle className="w-4 h-4" />}
label="Risk Alerts"
description="Get notified about significant drawdowns"
>
<ToggleSwitch
enabled={settings.notifications.riskAlert}
onChange={(value) => updateNestedSettings('notifications', 'riskAlert', value)}
/>
</SettingRow>
<SettingRow
icon={<Bell className="w-4 h-4" />}
label="News & Updates"
description="Product updates and market news"
>
<ToggleSwitch
enabled={settings.notifications.newsAlert}
onChange={(value) => updateNestedSettings('notifications', 'newsAlert', value)}
/>
</SettingRow>
</div>
)}
{/* Risk Alerts Settings */}
{activeSection === 'risk' && (
<div className="space-y-1">
<SettingRow
icon={<Shield className="w-4 h-4" />}
label="Enable Risk Alerts"
description="Receive alerts when thresholds are breached"
>
<ToggleSwitch
enabled={settings.riskAlerts.enabled}
onChange={(value) => updateNestedSettings('riskAlerts', 'enabled', value)}
/>
</SettingRow>
{settings.riskAlerts.enabled && (
<>
<SettingRow
icon={<AlertTriangle className="w-4 h-4" />}
label="Drawdown Threshold"
description="Alert when drawdown exceeds this percentage"
>
<div className="flex items-center gap-2">
<input
type="number"
min="5"
max="50"
value={settings.riskAlerts.drawdownThreshold}
onChange={(e) =>
updateNestedSettings('riskAlerts', 'drawdownThreshold', parseInt(e.target.value))
}
className="w-20 px-3 py-1.5 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm text-right"
/>
<span className="text-slate-400">%</span>
</div>
</SettingRow>
<SettingRow
icon={<AlertTriangle className="w-4 h-4" />}
label="Daily Loss Threshold"
description="Alert when daily loss exceeds this percentage"
>
<div className="flex items-center gap-2">
<input
type="number"
min="1"
max="20"
value={settings.riskAlerts.dailyLossThreshold}
onChange={(e) =>
updateNestedSettings('riskAlerts', 'dailyLossThreshold', parseInt(e.target.value))
}
className="w-20 px-3 py-1.5 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm text-right"
/>
<span className="text-slate-400">%</span>
</div>
</SettingRow>
</>
)}
</div>
)}
{/* Withdrawal Settings */}
{activeSection === 'withdrawal' && (
<div className="space-y-1">
<SettingRow
icon={<DollarSign className="w-4 h-4" />}
label="Preferred Withdrawal Method"
description="Default method for withdrawals"
>
<select
value={settings.withdrawalSettings.preferredMethod}
onChange={(e) =>
updateNestedSettings('withdrawalSettings', 'preferredMethod', e.target.value as 'bank' | 'crypto' | 'wallet')
}
className="px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="bank">Bank Transfer</option>
<option value="crypto">Cryptocurrency</option>
<option value="wallet">Platform Wallet</option>
</select>
</SettingRow>
<SettingRow
icon={<RefreshCw className="w-4 h-4" />}
label="Auto-Withdraw"
description="Automatically withdraw when balance exceeds threshold"
>
<ToggleSwitch
enabled={settings.withdrawalSettings.autoWithdraw}
onChange={(value) => updateNestedSettings('withdrawalSettings', 'autoWithdraw', value)}
/>
</SettingRow>
{settings.withdrawalSettings.autoWithdraw && (
<SettingRow
icon={<DollarSign className="w-4 h-4" />}
label="Auto-Withdraw Threshold"
description="Withdraw when balance exceeds this amount"
>
<div className="flex items-center gap-1">
<span className="text-slate-400">$</span>
<input
type="number"
min="1000"
step="100"
value={settings.withdrawalSettings.autoWithdrawThreshold}
onChange={(e) =>
updateNestedSettings('withdrawalSettings', 'autoWithdrawThreshold', parseInt(e.target.value))
}
className="w-28 px-3 py-1.5 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm text-right"
/>
</div>
</SettingRow>
)}
</div>
)}
</div>
{/* Footer Actions */}
<div className="p-5 border-t border-slate-700 bg-slate-900/30">
<div className="flex items-center justify-end gap-3">
{onCancel && (
<button
onClick={onCancel}
disabled={isLoading}
className="px-4 py-2 text-slate-300 hover:text-white transition-colors flex items-center gap-2"
>
<X className="w-4 h-4" />
Cancel
</button>
)}
<button
onClick={handleSave}
disabled={isLoading || !hasChanges}
className="px-6 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
>
{isLoading ? (
<>
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Saving...
</>
) : (
<>
<Save className="w-4 h-4" />
Save Changes
</>
)}
</button>
</div>
</div>
</div>
);
};
export default AccountSettingsPanel;

View File

@ -0,0 +1,285 @@
/**
* AccountSummaryCard Component
* Reusable investment account summary card with key metrics
* OQI-004: Cuentas de Inversion
*/
import React from 'react';
import {
Wallet,
TrendingUp,
TrendingDown,
Percent,
Calendar,
ArrowUpRight,
ArrowDownRight,
MoreVertical,
Eye,
Settings,
Clock,
} from 'lucide-react';
// ============================================================================
// Types
// ============================================================================
export interface InvestmentAccount {
id: string;
accountNumber: string;
productName: string;
productType: 'atlas' | 'orion' | 'nova' | 'custom';
currentBalance: number;
initialDeposit: number;
totalGains: number;
totalGainsPercent: number;
monthlyReturn: number;
monthlyReturnPercent: number;
status: 'active' | 'paused' | 'pending' | 'closed';
riskLevel: 'low' | 'medium' | 'high';
createdAt: string;
lastDistributionDate?: string;
nextDistributionDate?: string;
}
interface AccountSummaryCardProps {
account: InvestmentAccount;
onViewDetails?: (accountId: string) => void;
onManageSettings?: (accountId: string) => void;
compact?: boolean;
showActions?: boolean;
}
// ============================================================================
// Helper Functions
// ============================================================================
const getProductColor = (productType: InvestmentAccount['productType']) => {
switch (productType) {
case 'atlas':
return 'from-blue-500 to-blue-600';
case 'orion':
return 'from-purple-500 to-purple-600';
case 'nova':
return 'from-amber-500 to-amber-600';
default:
return 'from-slate-500 to-slate-600';
}
};
const getStatusBadge = (status: InvestmentAccount['status']) => {
switch (status) {
case 'active':
return { label: 'Active', className: 'bg-emerald-500/20 text-emerald-400' };
case 'paused':
return { label: 'Paused', className: 'bg-yellow-500/20 text-yellow-400' };
case 'pending':
return { label: 'Pending', className: 'bg-blue-500/20 text-blue-400' };
case 'closed':
return { label: 'Closed', className: 'bg-slate-500/20 text-slate-400' };
default:
return { label: status, className: 'bg-slate-500/20 text-slate-400' };
}
};
const getRiskColor = (riskLevel: InvestmentAccount['riskLevel']) => {
switch (riskLevel) {
case 'low':
return 'text-emerald-400';
case 'medium':
return 'text-yellow-400';
case 'high':
return 'text-red-400';
default:
return 'text-slate-400';
}
};
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value);
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
};
// ============================================================================
// Component
// ============================================================================
export const AccountSummaryCard: React.FC<AccountSummaryCardProps> = ({
account,
onViewDetails,
onManageSettings,
compact = false,
showActions = true,
}) => {
const [showMenu, setShowMenu] = React.useState(false);
const status = getStatusBadge(account.status);
const isPositive = account.totalGains >= 0;
if (compact) {
return (
<div
onClick={() => onViewDetails?.(account.id)}
className="p-4 bg-slate-800/50 rounded-xl border border-slate-700 hover:border-slate-600 cursor-pointer transition-all"
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg bg-gradient-to-br ${getProductColor(account.productType)} flex items-center justify-center`}>
<Wallet className="w-5 h-5 text-white" />
</div>
<div>
<div className="font-medium text-white">{account.productName}</div>
<div className="text-xs text-slate-500">{account.accountNumber}</div>
</div>
</div>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${status.className}`}>
{status.label}
</span>
</div>
<div className="flex items-end justify-between">
<div>
<div className="text-2xl font-bold text-white">{formatCurrency(account.currentBalance)}</div>
<div className={`flex items-center gap-1 text-sm ${isPositive ? 'text-emerald-400' : 'text-red-400'}`}>
{isPositive ? <ArrowUpRight className="w-4 h-4" /> : <ArrowDownRight className="w-4 h-4" />}
{isPositive ? '+' : ''}{formatCurrency(account.totalGains)} ({account.totalGainsPercent.toFixed(2)}%)
</div>
</div>
</div>
</div>
);
}
return (
<div className="bg-slate-800/50 rounded-xl border border-slate-700 overflow-hidden">
{/* Header with gradient */}
<div className={`h-2 bg-gradient-to-r ${getProductColor(account.productType)}`} />
<div className="p-5">
{/* Top Row */}
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className={`w-12 h-12 rounded-xl bg-gradient-to-br ${getProductColor(account.productType)} flex items-center justify-center shadow-lg`}>
<Wallet className="w-6 h-6 text-white" />
</div>
<div>
<h3 className="font-semibold text-white text-lg">{account.productName}</h3>
<div className="flex items-center gap-2">
<span className="text-sm text-slate-400">{account.accountNumber}</span>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${status.className}`}>
{status.label}
</span>
</div>
</div>
</div>
{showActions && (
<div className="relative">
<button
onClick={() => setShowMenu(!showMenu)}
className="p-2 text-slate-400 hover:text-white hover:bg-slate-700 rounded-lg transition-colors"
>
<MoreVertical className="w-5 h-5" />
</button>
{showMenu && (
<div className="absolute right-0 top-full mt-1 w-48 bg-slate-800 border border-slate-700 rounded-lg shadow-xl z-10">
<button
onClick={() => {
setShowMenu(false);
onViewDetails?.(account.id);
}}
className="w-full flex items-center gap-2 px-4 py-2 text-sm text-slate-300 hover:bg-slate-700 transition-colors"
>
<Eye className="w-4 h-4" /> View Details
</button>
<button
onClick={() => {
setShowMenu(false);
onManageSettings?.(account.id);
}}
className="w-full flex items-center gap-2 px-4 py-2 text-sm text-slate-300 hover:bg-slate-700 transition-colors"
>
<Settings className="w-4 h-4" /> Account Settings
</button>
</div>
)}
</div>
)}
</div>
{/* Balance */}
<div className="mb-4">
<div className="text-sm text-slate-400 mb-1">Current Balance</div>
<div className="text-3xl font-bold text-white">{formatCurrency(account.currentBalance)}</div>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-2 gap-4 mb-4">
{/* Total Gains */}
<div className="p-3 bg-slate-900/50 rounded-lg">
<div className="flex items-center gap-2 mb-1">
{isPositive ? (
<TrendingUp className="w-4 h-4 text-emerald-400" />
) : (
<TrendingDown className="w-4 h-4 text-red-400" />
)}
<span className="text-xs text-slate-500">Total Gains</span>
</div>
<div className={`font-semibold ${isPositive ? 'text-emerald-400' : 'text-red-400'}`}>
{isPositive ? '+' : ''}{formatCurrency(account.totalGains)}
</div>
<div className={`text-xs ${isPositive ? 'text-emerald-400/70' : 'text-red-400/70'}`}>
{isPositive ? '+' : ''}{account.totalGainsPercent.toFixed(2)}%
</div>
</div>
{/* Monthly Return */}
<div className="p-3 bg-slate-900/50 rounded-lg">
<div className="flex items-center gap-2 mb-1">
<Percent className="w-4 h-4 text-blue-400" />
<span className="text-xs text-slate-500">Monthly Return</span>
</div>
<div className={`font-semibold ${account.monthlyReturn >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{account.monthlyReturn >= 0 ? '+' : ''}{formatCurrency(account.monthlyReturn)}
</div>
<div className={`text-xs ${account.monthlyReturn >= 0 ? 'text-emerald-400/70' : 'text-red-400/70'}`}>
{account.monthlyReturnPercent >= 0 ? '+' : ''}{account.monthlyReturnPercent.toFixed(2)}%
</div>
</div>
</div>
{/* Footer Info */}
<div className="flex items-center justify-between text-xs text-slate-500 pt-3 border-t border-slate-700">
<div className="flex items-center gap-1">
<Calendar className="w-3.5 h-3.5" />
<span>Opened {formatDate(account.createdAt)}</span>
</div>
<div className={`flex items-center gap-1 ${getRiskColor(account.riskLevel)}`}>
<span className="capitalize">{account.riskLevel} Risk</span>
</div>
</div>
{/* Next Distribution */}
{account.nextDistributionDate && (
<div className="mt-3 p-2 bg-blue-500/10 border border-blue-500/20 rounded-lg">
<div className="flex items-center gap-2 text-xs text-blue-400">
<Clock className="w-3.5 h-3.5" />
<span>Next distribution: {formatDate(account.nextDistributionDate)}</span>
</div>
</div>
)}
</div>
</div>
);
};
export default AccountSummaryCard;

View File

@ -0,0 +1,238 @@
/**
* PerformanceWidgetChart Component
* Compact sparkline-style performance chart for embedding in cards
* OQI-004: Cuentas de Inversion
*/
import React, { useRef, useEffect, useMemo } from 'react';
import { TrendingUp, TrendingDown, Minus } from 'lucide-react';
// ============================================================================
// Types
// ============================================================================
export interface PerformanceDataPoint {
date: string;
value: number;
balance?: number;
}
interface PerformanceWidgetChartProps {
data: PerformanceDataPoint[];
period?: 'week' | 'month' | 'quarter' | 'year' | 'all';
height?: number;
showTrend?: boolean;
showValue?: boolean;
lineColor?: string;
fillColor?: string;
compact?: boolean;
onClick?: () => void;
}
// ============================================================================
// Helper Functions
// ============================================================================
const calculateTrend = (data: PerformanceDataPoint[]) => {
if (data.length < 2) return { direction: 'neutral' as const, change: 0, changePercent: 0 };
const first = data[0].value;
const last = data[data.length - 1].value;
const change = last - first;
const changePercent = first !== 0 ? (change / Math.abs(first)) * 100 : 0;
return {
direction: change > 0 ? 'up' as const : change < 0 ? 'down' as const : 'neutral' as const,
change,
changePercent,
};
};
const formatValue = (value: number) => {
if (Math.abs(value) >= 1000000) {
return `$${(value / 1000000).toFixed(1)}M`;
}
if (Math.abs(value) >= 1000) {
return `$${(value / 1000).toFixed(1)}K`;
}
return `$${value.toFixed(0)}`;
};
// ============================================================================
// Component
// ============================================================================
export const PerformanceWidgetChart: React.FC<PerformanceWidgetChartProps> = ({
data,
period = 'month',
height = 60,
showTrend = true,
showValue = true,
lineColor,
fillColor,
compact = false,
onClick,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const trend = useMemo(() => calculateTrend(data), [data]);
const defaultLineColor = trend.direction === 'up' ? '#10b981' : trend.direction === 'down' ? '#ef4444' : '#6b7280';
const defaultFillColor = trend.direction === 'up' ? 'rgba(16, 185, 129, 0.1)' : trend.direction === 'down' ? 'rgba(239, 68, 68, 0.1)' : 'rgba(107, 114, 128, 0.1)';
const actualLineColor = lineColor || defaultLineColor;
const actualFillColor = fillColor || defaultFillColor;
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || data.length < 2) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
const width = rect.width;
const chartHeight = rect.height;
const padding = 2;
// Calculate min/max with some padding
const values = data.map((d) => d.value);
const minValue = Math.min(...values);
const maxValue = Math.max(...values);
const valueRange = maxValue - minValue || 1;
const paddedMin = minValue - valueRange * 0.1;
const paddedMax = maxValue + valueRange * 0.1;
const paddedRange = paddedMax - paddedMin;
// Map data points to canvas coordinates
const points = data.map((d, i) => ({
x: padding + (i / (data.length - 1)) * (width - 2 * padding),
y: chartHeight - padding - ((d.value - paddedMin) / paddedRange) * (chartHeight - 2 * padding),
}));
// Clear canvas
ctx.clearRect(0, 0, width, chartHeight);
// Draw fill area
ctx.beginPath();
ctx.moveTo(points[0].x, chartHeight);
points.forEach((p) => ctx.lineTo(p.x, p.y));
ctx.lineTo(points[points.length - 1].x, chartHeight);
ctx.closePath();
ctx.fillStyle = actualFillColor;
ctx.fill();
// Draw line
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
// Smooth curve using quadratic bezier
for (let i = 1; i < points.length; i++) {
const prev = points[i - 1];
const curr = points[i];
const midX = (prev.x + curr.x) / 2;
ctx.quadraticCurveTo(prev.x, prev.y, midX, (prev.y + curr.y) / 2);
}
ctx.lineTo(points[points.length - 1].x, points[points.length - 1].y);
ctx.strokeStyle = actualLineColor;
ctx.lineWidth = 2;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.stroke();
// Draw end point dot
const lastPoint = points[points.length - 1];
ctx.beginPath();
ctx.arc(lastPoint.x, lastPoint.y, 3, 0, Math.PI * 2);
ctx.fillStyle = actualLineColor;
ctx.fill();
ctx.strokeStyle = '#1e293b';
ctx.lineWidth = 1.5;
ctx.stroke();
}, [data, actualLineColor, actualFillColor]);
const TrendIcon = trend.direction === 'up' ? TrendingUp : trend.direction === 'down' ? TrendingDown : Minus;
if (compact) {
return (
<div
onClick={onClick}
className={`flex items-center gap-2 ${onClick ? 'cursor-pointer' : ''}`}
>
<canvas
ref={canvasRef}
className="flex-1"
style={{ height: height / 2, width: '100%' }}
/>
{showTrend && (
<span
className={`text-xs font-medium ${
trend.direction === 'up'
? 'text-emerald-400'
: trend.direction === 'down'
? 'text-red-400'
: 'text-slate-400'
}`}
>
{trend.changePercent > 0 ? '+' : ''}{trend.changePercent.toFixed(1)}%
</span>
)}
</div>
);
}
return (
<div
onClick={onClick}
className={`bg-slate-800/50 rounded-lg border border-slate-700 p-3 ${onClick ? 'cursor-pointer hover:border-slate-600 transition-colors' : ''}`}
>
{/* Header */}
{(showValue || showTrend) && (
<div className="flex items-center justify-between mb-2">
{showValue && data.length > 0 && (
<div className="text-lg font-bold text-white">
{formatValue(data[data.length - 1].value)}
</div>
)}
{showTrend && (
<div
className={`flex items-center gap-1 text-sm ${
trend.direction === 'up'
? 'text-emerald-400'
: trend.direction === 'down'
? 'text-red-400'
: 'text-slate-400'
}`}
>
<TrendIcon className="w-4 h-4" />
<span>
{trend.changePercent > 0 ? '+' : ''}{trend.changePercent.toFixed(1)}%
</span>
</div>
)}
</div>
)}
{/* Chart */}
<canvas
ref={canvasRef}
className="w-full"
style={{ height }}
/>
{/* Period Label */}
<div className="mt-2 text-xs text-slate-500 text-center capitalize">
{period === 'all' ? 'All Time' : `Last ${period}`}
</div>
</div>
);
};
export default PerformanceWidgetChart;

View File

@ -0,0 +1,396 @@
/**
* ProductComparisonTable Component
* Side-by-side comparison of investment products
* OQI-004: Cuentas de Inversion
*/
import React, { useState } from 'react';
import {
Check,
X,
ChevronDown,
ChevronUp,
Star,
TrendingUp,
Shield,
DollarSign,
Percent,
Clock,
Users,
Zap,
Info,
} from 'lucide-react';
// ============================================================================
// Types
// ============================================================================
export interface ProductFeature {
name: string;
description?: string;
values: Record<string, string | number | boolean>;
}
export interface InvestmentProduct {
id: string;
name: string;
type: 'atlas' | 'orion' | 'nova' | 'custom';
description: string;
targetReturn: { min: number; max: number };
maxDrawdown: number;
managementFee: number;
performanceFee: number;
minCapital: number;
lockPeriod: number;
riskLevel: 'low' | 'medium' | 'high';
strategies: string[];
features: string[];
isPopular?: boolean;
historicalReturn?: number;
activeAccounts?: number;
}
interface ProductComparisonTableProps {
products: InvestmentProduct[];
selectedProductId?: string;
onSelectProduct?: (productId: string) => void;
onViewDetails?: (productId: string) => void;
compact?: boolean;
}
// ============================================================================
// Helper Functions
// ============================================================================
const getProductColor = (type: InvestmentProduct['type']) => {
switch (type) {
case 'atlas':
return { bg: 'bg-blue-500', text: 'text-blue-400', border: 'border-blue-500', gradient: 'from-blue-500 to-blue-600' };
case 'orion':
return { bg: 'bg-purple-500', text: 'text-purple-400', border: 'border-purple-500', gradient: 'from-purple-500 to-purple-600' };
case 'nova':
return { bg: 'bg-amber-500', text: 'text-amber-400', border: 'border-amber-500', gradient: 'from-amber-500 to-amber-600' };
default:
return { bg: 'bg-slate-500', text: 'text-slate-400', border: 'border-slate-500', gradient: 'from-slate-500 to-slate-600' };
}
};
const getRiskBadge = (riskLevel: InvestmentProduct['riskLevel']) => {
switch (riskLevel) {
case 'low':
return { label: 'Low Risk', className: 'bg-emerald-500/20 text-emerald-400' };
case 'medium':
return { label: 'Medium Risk', className: 'bg-yellow-500/20 text-yellow-400' };
case 'high':
return { label: 'High Risk', className: 'bg-red-500/20 text-red-400' };
default:
return { label: riskLevel, className: 'bg-slate-500/20 text-slate-400' };
}
};
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
// ============================================================================
// Component
// ============================================================================
export const ProductComparisonTable: React.FC<ProductComparisonTableProps> = ({
products,
selectedProductId,
onSelectProduct,
onViewDetails,
compact = false,
}) => {
const [expandedSection, setExpandedSection] = useState<string | null>('returns');
const [hoveredProduct, setHoveredProduct] = useState<string | null>(null);
const toggleSection = (section: string) => {
setExpandedSection(expandedSection === section ? null : section);
};
const comparisonSections = [
{
id: 'returns',
label: 'Returns & Performance',
icon: TrendingUp,
rows: [
{ label: 'Target Return', key: 'targetReturn', format: (p: InvestmentProduct) => `${p.targetReturn.min}% - ${p.targetReturn.max}%` },
{ label: 'Historical Return', key: 'historicalReturn', format: (p: InvestmentProduct) => p.historicalReturn ? `${p.historicalReturn.toFixed(1)}%` : 'N/A' },
{ label: 'Max Drawdown', key: 'maxDrawdown', format: (p: InvestmentProduct) => `${p.maxDrawdown}%` },
],
},
{
id: 'fees',
label: 'Fees & Costs',
icon: DollarSign,
rows: [
{ label: 'Management Fee', key: 'managementFee', format: (p: InvestmentProduct) => `${p.managementFee}%/year` },
{ label: 'Performance Fee', key: 'performanceFee', format: (p: InvestmentProduct) => `${p.performanceFee}%` },
{ label: 'Min Capital', key: 'minCapital', format: (p: InvestmentProduct) => formatCurrency(p.minCapital) },
],
},
{
id: 'terms',
label: 'Terms & Conditions',
icon: Clock,
rows: [
{ label: 'Lock Period', key: 'lockPeriod', format: (p: InvestmentProduct) => `${p.lockPeriod} months` },
{ label: 'Risk Level', key: 'riskLevel', format: (p: InvestmentProduct) => getRiskBadge(p.riskLevel).label },
{ label: 'Active Accounts', key: 'activeAccounts', format: (p: InvestmentProduct) => p.activeAccounts?.toLocaleString() || 'N/A' },
],
},
];
if (compact) {
return (
<div className="overflow-x-auto">
<table className="w-full min-w-[600px]">
<thead>
<tr className="border-b border-slate-700">
<th className="text-left py-3 px-4 text-sm font-medium text-slate-400">Product</th>
<th className="text-center py-3 px-4 text-sm font-medium text-slate-400">Target Return</th>
<th className="text-center py-3 px-4 text-sm font-medium text-slate-400">Min Capital</th>
<th className="text-center py-3 px-4 text-sm font-medium text-slate-400">Risk</th>
<th className="text-center py-3 px-4 text-sm font-medium text-slate-400">Action</th>
</tr>
</thead>
<tbody>
{products.map((product) => {
const colors = getProductColor(product.type);
const risk = getRiskBadge(product.riskLevel);
const isSelected = selectedProductId === product.id;
return (
<tr
key={product.id}
className={`border-b border-slate-700/50 hover:bg-slate-800/50 transition-colors ${isSelected ? 'bg-slate-800/50' : ''}`}
>
<td className="py-3 px-4">
<div className="flex items-center gap-3">
<div className={`w-8 h-8 rounded-lg bg-gradient-to-br ${colors.gradient} flex items-center justify-center`}>
<Zap className="w-4 h-4 text-white" />
</div>
<div>
<div className="font-medium text-white flex items-center gap-2">
{product.name}
{product.isPopular && <Star className="w-3.5 h-3.5 text-amber-400 fill-amber-400" />}
</div>
<div className="text-xs text-slate-500">{product.strategies.length} strategies</div>
</div>
</div>
</td>
<td className="py-3 px-4 text-center">
<span className="text-emerald-400 font-medium">
{product.targetReturn.min}%-{product.targetReturn.max}%
</span>
</td>
<td className="py-3 px-4 text-center text-white">
{formatCurrency(product.minCapital)}
</td>
<td className="py-3 px-4 text-center">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${risk.className}`}>
{risk.label}
</span>
</td>
<td className="py-3 px-4 text-center">
<button
onClick={() => onSelectProduct?.(product.id)}
className={`px-4 py-1.5 rounded-lg text-sm font-medium transition-colors ${
isSelected
? 'bg-emerald-500 text-white'
: 'bg-slate-700 text-white hover:bg-slate-600'
}`}
>
{isSelected ? 'Selected' : 'Select'}
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
return (
<div className="bg-slate-800/50 rounded-xl border border-slate-700 overflow-hidden">
{/* Header Row - Product Names */}
<div className="grid grid-cols-[200px_repeat(auto-fit,minmax(180px,1fr))] border-b border-slate-700">
<div className="p-4 bg-slate-900/50">
<span className="text-sm font-medium text-slate-400">Compare Products</span>
</div>
{products.map((product) => {
const colors = getProductColor(product.type);
const isSelected = selectedProductId === product.id;
const isHovered = hoveredProduct === product.id;
return (
<div
key={product.id}
onMouseEnter={() => setHoveredProduct(product.id)}
onMouseLeave={() => setHoveredProduct(null)}
className={`p-4 text-center border-l border-slate-700 transition-colors ${
isSelected ? 'bg-slate-700/50' : isHovered ? 'bg-slate-800' : ''
}`}
>
<div className={`w-12 h-12 mx-auto rounded-xl bg-gradient-to-br ${colors.gradient} flex items-center justify-center mb-2 shadow-lg`}>
<Zap className="w-6 h-6 text-white" />
</div>
<div className="font-semibold text-white flex items-center justify-center gap-1">
{product.name}
{product.isPopular && <Star className="w-4 h-4 text-amber-400 fill-amber-400" />}
</div>
<div className="text-xs text-slate-500 mt-1">{product.description}</div>
</div>
);
})}
</div>
{/* Comparison Sections */}
{comparisonSections.map((section) => {
const Icon = section.icon;
const isExpanded = expandedSection === section.id;
return (
<div key={section.id} className="border-b border-slate-700 last:border-b-0">
{/* Section Header */}
<button
onClick={() => toggleSection(section.id)}
className="w-full grid grid-cols-[200px_1fr] hover:bg-slate-800/50 transition-colors"
>
<div className="p-3 flex items-center gap-2 text-left">
<Icon className="w-4 h-4 text-slate-400" />
<span className="text-sm font-medium text-slate-300">{section.label}</span>
{isExpanded ? (
<ChevronUp className="w-4 h-4 text-slate-500 ml-auto" />
) : (
<ChevronDown className="w-4 h-4 text-slate-500 ml-auto" />
)}
</div>
<div className="border-l border-slate-700" />
</button>
{/* Section Rows */}
{isExpanded && section.rows.map((row, rowIdx) => (
<div
key={row.key}
className={`grid grid-cols-[200px_repeat(auto-fit,minmax(180px,1fr))] ${
rowIdx < section.rows.length - 1 ? 'border-b border-slate-700/50' : ''
}`}
>
<div className="p-3 pl-10 bg-slate-900/30">
<span className="text-sm text-slate-400">{row.label}</span>
</div>
{products.map((product) => {
const isSelected = selectedProductId === product.id;
const isHovered = hoveredProduct === product.id;
return (
<div
key={product.id}
className={`p-3 text-center border-l border-slate-700 transition-colors ${
isSelected ? 'bg-slate-700/30' : isHovered ? 'bg-slate-800/30' : ''
}`}
>
<span className="text-sm text-white">{row.format(product)}</span>
</div>
);
})}
</div>
))}
</div>
);
})}
{/* Strategies Section */}
<div className="border-b border-slate-700">
<button
onClick={() => toggleSection('strategies')}
className="w-full grid grid-cols-[200px_1fr] hover:bg-slate-800/50 transition-colors"
>
<div className="p-3 flex items-center gap-2 text-left">
<Shield className="w-4 h-4 text-slate-400" />
<span className="text-sm font-medium text-slate-300">Trading Strategies</span>
{expandedSection === 'strategies' ? (
<ChevronUp className="w-4 h-4 text-slate-500 ml-auto" />
) : (
<ChevronDown className="w-4 h-4 text-slate-500 ml-auto" />
)}
</div>
<div className="border-l border-slate-700" />
</button>
{expandedSection === 'strategies' && (
<div className="grid grid-cols-[200px_repeat(auto-fit,minmax(180px,1fr))]">
<div className="p-3 pl-10 bg-slate-900/30">
<span className="text-sm text-slate-400">Included</span>
</div>
{products.map((product) => {
const isSelected = selectedProductId === product.id;
return (
<div
key={product.id}
className={`p-3 border-l border-slate-700 ${isSelected ? 'bg-slate-700/30' : ''}`}
>
<div className="flex flex-wrap gap-1 justify-center">
{product.strategies.map((strategy) => (
<span
key={strategy}
className="px-2 py-0.5 bg-slate-700 text-xs text-slate-300 rounded"
>
{strategy}
</span>
))}
</div>
</div>
);
})}
</div>
)}
</div>
{/* Action Row */}
<div className="grid grid-cols-[200px_repeat(auto-fit,minmax(180px,1fr))] bg-slate-900/30">
<div className="p-4" />
{products.map((product) => {
const isSelected = selectedProductId === product.id;
const colors = getProductColor(product.type);
return (
<div key={product.id} className="p-4 border-l border-slate-700 flex flex-col gap-2">
<button
onClick={() => onSelectProduct?.(product.id)}
className={`w-full py-2 rounded-lg font-medium transition-colors ${
isSelected
? 'bg-emerald-500 text-white'
: `bg-gradient-to-r ${colors.gradient} text-white hover:opacity-90`
}`}
>
{isSelected ? 'Selected' : 'Choose Plan'}
</button>
{onViewDetails && (
<button
onClick={() => onViewDetails(product.id)}
className="w-full py-2 text-sm text-slate-400 hover:text-white transition-colors flex items-center justify-center gap-1"
>
<Info className="w-3.5 h-3.5" /> View Details
</button>
)}
</div>
);
})}
</div>
</div>
);
};
export default ProductComparisonTable;

View File

@ -0,0 +1,21 @@
/**
* Investment Module Components
* Barrel export for all investment-related components
*/
// Form Components
export { DepositForm } from './DepositForm';
export { WithdrawForm } from './WithdrawForm';
// Account Components (OQI-004)
export { default as AccountSummaryCard } from './AccountSummaryCard';
export type { InvestmentAccount } from './AccountSummaryCard';
export { default as ProductComparisonTable } from './ProductComparisonTable';
export type { InvestmentProduct, ProductFeature } from './ProductComparisonTable';
export { default as PerformanceWidgetChart } from './PerformanceWidgetChart';
export type { PerformanceDataPoint } from './PerformanceWidgetChart';
export { default as AccountSettingsPanel } from './AccountSettingsPanel';
export type { AccountSettings, AccountForSettings } from './AccountSettingsPanel';