[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:
parent
e9aa29fccd
commit
7d9e8d2da9
524
src/modules/investment/components/AccountSettingsPanel.tsx
Normal file
524
src/modules/investment/components/AccountSettingsPanel.tsx
Normal 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;
|
||||
285
src/modules/investment/components/AccountSummaryCard.tsx
Normal file
285
src/modules/investment/components/AccountSummaryCard.tsx
Normal 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;
|
||||
238
src/modules/investment/components/PerformanceWidgetChart.tsx
Normal file
238
src/modules/investment/components/PerformanceWidgetChart.tsx
Normal 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;
|
||||
396
src/modules/investment/components/ProductComparisonTable.tsx
Normal file
396
src/modules/investment/components/ProductComparisonTable.tsx
Normal 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;
|
||||
21
src/modules/investment/components/index.ts
Normal file
21
src/modules/investment/components/index.ts
Normal 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';
|
||||
Loading…
Reference in New Issue
Block a user