[SPRINT-4-5] feat: Education, LLM, Portfolio, Marketplace modules

SPRINT 4 (128 SP):

SUBTASK-007 Education (21 SP):
- Add QuizHistoryCard with stats and trend indicators
- Add EarnedCertificates with search, sort, sharing
- Enhance CourseDetail with "Resume where you left off"

SUBTASK-008 LLM Agent (44 SP):
- Add MemoryManager with conversation history management
- Add LLMToolsPanel with tool enable/disable and execution history
- Add FineTuningPanel with model selection and training config

SUBTASK-009 Portfolio (63 SP):
- Add MonteCarloSimulator with VaR/CVaR calculations
- Add CorrelationMatrix with heatmap visualization
- Add RebalancingPanel with drift thresholds and history
- Add GoalsManager with savings rate calculator
- Add RiskAnalytics with comprehensive metrics

SPRINT 5 (55 SP):

SUBTASK-010 Marketplace (42 SP):
- Create full marketplace module structure
- Add MarketplaceCatalog with grid/list view, filters
- Add SignalPackCard, SignalPackDetail pages
- Add AdvisoryCard, AdvisoryDetail with booking
- Add CourseProductCard, ReviewCard components
- Add marketplace types, service, and store
- Add routes: /marketplace, /marketplace/signals/:slug, etc.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-02-04 00:29:03 -06:00
parent c9e2727d3b
commit 6a8cb3b9cf
28 changed files with 8604 additions and 2 deletions

View File

@ -70,6 +70,11 @@ const MLModelsPage = lazy(() => import('./modules/admin/pages/MLModelsPage'));
const AgentsPage = lazy(() => import('./modules/admin/pages/AgentsPage')); const AgentsPage = lazy(() => import('./modules/admin/pages/AgentsPage'));
const PredictionsPage = lazy(() => import('./modules/admin/pages/PredictionsPage')); const PredictionsPage = lazy(() => import('./modules/admin/pages/PredictionsPage'));
// Lazy load modules - Marketplace
const MarketplaceCatalog = lazy(() => import('./modules/marketplace/pages/MarketplaceCatalog'));
const SignalPackDetail = lazy(() => import('./modules/marketplace/pages/SignalPackDetail'));
const AdvisoryDetail = lazy(() => import('./modules/marketplace/pages/AdvisoryDetail'));
function App() { function App() {
return ( return (
<ErrorBoundary> <ErrorBoundary>
@ -150,6 +155,12 @@ function App() {
<Route path="/admin/models" element={<MLModelsPage />} /> <Route path="/admin/models" element={<MLModelsPage />} />
<Route path="/admin/agents" element={<AgentsPage />} /> <Route path="/admin/agents" element={<AgentsPage />} />
<Route path="/admin/predictions" element={<PredictionsPage />} /> <Route path="/admin/predictions" element={<PredictionsPage />} />
{/* Marketplace */}
<Route path="/marketplace" element={<MarketplaceCatalog />} />
<Route path="/marketplace/signals/:slug" element={<SignalPackDetail />} />
<Route path="/marketplace/advisory/:slug" element={<AdvisoryDetail />} />
<Route path="/marketplace/courses/:slug" element={<CourseDetail />} />
</Route> </Route>
{/* Redirects */} {/* Redirects */}

View File

@ -0,0 +1,653 @@
/**
* FineTuningPanel Component
* Fine-tuning interface for LLM models (UI only, backend mock)
* OQI-007: LLM Strategy Agent - SUBTASK-008
*/
import React, { useState, useRef, useMemo } from 'react';
import {
AcademicCapIcon,
CloudArrowUpIcon,
PlayIcon,
StopIcon,
CheckCircleIcon,
XCircleIcon,
ClockIcon,
ChartBarIcon,
ArrowPathIcon,
DocumentTextIcon,
TrashIcon,
InformationCircleIcon,
ExclamationTriangleIcon,
ArrowDownTrayIcon,
EyeIcon,
ScaleIcon,
} from '@heroicons/react/24/outline';
// ============================================================================
// Types
// ============================================================================
export interface BaseModel {
id: string;
name: string;
provider: string;
description: string;
contextWindow: number;
costPerMToken: number;
supportsFinetuning: boolean;
}
export interface TrainingDataset {
id: string;
name: string;
fileName: string;
sampleCount: number;
sizeKB: number;
format: 'jsonl' | 'csv' | 'json';
uploadedAt: string;
validationStatus: 'pending' | 'valid' | 'invalid';
validationErrors?: string[];
}
export interface FineTunedModel {
id: string;
name: string;
baseModelId: string;
baseModelName: string;
status: 'training' | 'completed' | 'failed' | 'cancelled';
progress: number;
createdAt: string;
completedAt?: string;
trainingLoss?: number;
validationLoss?: number;
epochs: number;
trainingSamples: number;
estimatedCost: number;
metrics?: {
accuracy?: number;
f1Score?: number;
perplexity?: number;
};
}
export interface TrainingConfig {
epochs: number;
batchSize: number;
learningRate: number;
warmupSteps: number;
validationSplit: number;
}
interface FineTuningPanelProps {
baseModels: BaseModel[];
datasets: TrainingDataset[];
fineTunedModels: FineTunedModel[];
onUploadDataset?: (file: File) => void;
onDeleteDataset?: (datasetId: string) => void;
onStartTraining?: (config: {
baseModelId: string;
datasetId: string;
name: string;
config: TrainingConfig;
}) => void;
onCancelTraining?: (modelId: string) => void;
onDownloadModel?: (modelId: string) => void;
onDeleteModel?: (modelId: string) => void;
isLoading?: boolean;
}
// ============================================================================
// Constants
// ============================================================================
const DEFAULT_TRAINING_CONFIG: TrainingConfig = {
epochs: 3,
batchSize: 4,
learningRate: 0.0001,
warmupSteps: 100,
validationSplit: 0.1,
};
// ============================================================================
// Sub-Components
// ============================================================================
interface DatasetCardProps {
dataset: TrainingDataset;
onDelete?: () => void;
isSelected?: boolean;
onSelect?: () => void;
}
const DatasetCard: React.FC<DatasetCardProps> = ({ dataset, onDelete, isSelected, onSelect }) => {
const statusColors = {
pending: 'text-yellow-500 bg-yellow-500/10',
valid: 'text-emerald-500 bg-emerald-500/10',
invalid: 'text-red-500 bg-red-500/10',
};
const StatusIcon = {
pending: ClockIcon,
valid: CheckCircleIcon,
invalid: XCircleIcon,
}[dataset.validationStatus];
return (
<div
onClick={onSelect}
className={`p-3 rounded-lg border-2 cursor-pointer transition-all ${
isSelected
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
<div className="flex items-start gap-3">
<div className={`p-2 rounded-lg ${statusColors[dataset.validationStatus]}`}>
<DocumentTextIcon className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h4 className="font-medium text-gray-900 dark:text-white text-sm truncate">{dataset.name}</h4>
<span className={`px-1.5 py-0.5 text-xs rounded flex items-center gap-1 ${statusColors[dataset.validationStatus]}`}>
<StatusIcon className="w-3 h-3" />
{dataset.validationStatus}
</span>
</div>
<p className="text-xs text-gray-500 mt-0.5">{dataset.fileName}</p>
<div className="flex items-center gap-3 mt-1 text-xs text-gray-400">
<span>{dataset.sampleCount.toLocaleString()} samples</span>
<span>{(dataset.sizeKB / 1024).toFixed(2)} MB</span>
<span>{dataset.format.toUpperCase()}</span>
</div>
{dataset.validationErrors && dataset.validationErrors.length > 0 && (
<div className="mt-2 p-2 bg-red-50 dark:bg-red-900/20 rounded text-xs text-red-600 dark:text-red-400">
{dataset.validationErrors[0]}
{dataset.validationErrors.length > 1 && ` (+${dataset.validationErrors.length - 1} more)`}
</div>
)}
</div>
{onDelete && (
<button
onClick={(e) => { e.stopPropagation(); onDelete(); }}
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg"
>
<TrashIcon className="w-4 h-4" />
</button>
)}
</div>
</div>
);
};
interface ModelComparisonCardProps {
model: FineTunedModel;
onCancel?: () => void;
onDownload?: () => void;
onDelete?: () => void;
}
const ModelComparisonCard: React.FC<ModelComparisonCardProps> = ({ model, onCancel, onDownload, onDelete }) => {
const statusColors = {
training: 'text-blue-500 bg-blue-500/10 border-blue-500/30',
completed: 'text-emerald-500 bg-emerald-500/10 border-emerald-500/30',
failed: 'text-red-500 bg-red-500/10 border-red-500/30',
cancelled: 'text-gray-500 bg-gray-500/10 border-gray-500/30',
};
const StatusIcon = {
training: ArrowPathIcon,
completed: CheckCircleIcon,
failed: XCircleIcon,
cancelled: StopIcon,
}[model.status];
return (
<div className={`rounded-xl border ${statusColors[model.status]} overflow-hidden`}>
{/* Header */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
<div className="flex items-start justify-between">
<div>
<h4 className="font-semibold text-gray-900 dark:text-white">{model.name}</h4>
<p className="text-xs text-gray-500 mt-0.5">Base: {model.baseModelName}</p>
</div>
<div className={`px-2 py-1 rounded-lg flex items-center gap-1.5 text-xs ${statusColors[model.status]}`}>
<StatusIcon className={`w-3.5 h-3.5 ${model.status === 'training' ? 'animate-spin' : ''}`} />
{model.status}
</div>
</div>
{/* Progress Bar (if training) */}
{model.status === 'training' && (
<div className="mt-3">
<div className="flex items-center justify-between text-xs text-gray-500 mb-1">
<span>Training Progress</span>
<span>{model.progress}%</span>
</div>
<div className="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 transition-all"
style={{ width: `${model.progress}%` }}
/>
</div>
</div>
)}
</div>
{/* Metrics */}
<div className="p-4 grid grid-cols-2 gap-3 bg-gray-50 dark:bg-gray-900">
<div>
<div className="text-xs text-gray-500 mb-1">Training Loss</div>
<div className="text-lg font-bold text-gray-900 dark:text-white">
{model.trainingLoss?.toFixed(4) || '-'}
</div>
</div>
<div>
<div className="text-xs text-gray-500 mb-1">Validation Loss</div>
<div className="text-lg font-bold text-gray-900 dark:text-white">
{model.validationLoss?.toFixed(4) || '-'}
</div>
</div>
{model.metrics?.accuracy !== undefined && (
<div>
<div className="text-xs text-gray-500 mb-1">Accuracy</div>
<div className="text-lg font-bold text-emerald-500">
{(model.metrics.accuracy * 100).toFixed(1)}%
</div>
</div>
)}
{model.metrics?.f1Score !== undefined && (
<div>
<div className="text-xs text-gray-500 mb-1">F1 Score</div>
<div className="text-lg font-bold text-blue-500">
{model.metrics.f1Score.toFixed(3)}
</div>
</div>
)}
<div>
<div className="text-xs text-gray-500 mb-1">Epochs</div>
<div className="text-lg font-bold text-gray-900 dark:text-white">{model.epochs}</div>
</div>
<div>
<div className="text-xs text-gray-500 mb-1">Samples</div>
<div className="text-lg font-bold text-gray-900 dark:text-white">
{model.trainingSamples.toLocaleString()}
</div>
</div>
</div>
{/* Footer */}
<div className="p-3 border-t border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 flex items-center justify-between">
<div className="text-xs text-gray-500">
Cost: <span className="font-medium text-gray-900 dark:text-white">${model.estimatedCost.toFixed(2)}</span>
</div>
<div className="flex items-center gap-1">
{model.status === 'training' && onCancel && (
<button
onClick={onCancel}
className="px-2 py-1 text-xs text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
>
Cancel
</button>
)}
{model.status === 'completed' && onDownload && (
<button
onClick={onDownload}
className="p-1.5 text-gray-400 hover:text-primary-500 hover:bg-primary-50 dark:hover:bg-primary-900/20 rounded-lg"
>
<ArrowDownTrayIcon className="w-4 h-4" />
</button>
)}
{(model.status === 'completed' || model.status === 'failed' || model.status === 'cancelled') && onDelete && (
<button
onClick={onDelete}
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg"
>
<TrashIcon className="w-4 h-4" />
</button>
)}
</div>
</div>
</div>
);
};
// ============================================================================
// Main Component
// ============================================================================
export const FineTuningPanel: React.FC<FineTuningPanelProps> = ({
baseModels,
datasets,
fineTunedModels,
onUploadDataset,
onDeleteDataset,
onStartTraining,
onCancelTraining,
onDownloadModel,
onDeleteModel,
isLoading = false,
}) => {
const [activeTab, setActiveTab] = useState<'create' | 'models'>('create');
const [selectedBaseModel, setSelectedBaseModel] = useState<string>('');
const [selectedDataset, setSelectedDataset] = useState<string>('');
const [modelName, setModelName] = useState('');
const [trainingConfig, setTrainingConfig] = useState<TrainingConfig>(DEFAULT_TRAINING_CONFIG);
const [showAdvanced, setShowAdvanced] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const supportedModels = baseModels.filter((m) => m.supportsFinetuning);
const validDatasets = datasets.filter((d) => d.validationStatus === 'valid');
const trainingModels = fineTunedModels.filter((m) => m.status === 'training');
const canStartTraining = selectedBaseModel && selectedDataset && modelName.trim() && !isLoading;
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file && onUploadDataset) {
onUploadDataset(file);
}
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleStartTraining = () => {
if (!canStartTraining || !onStartTraining) return;
onStartTraining({
baseModelId: selectedBaseModel,
datasetId: selectedDataset,
name: modelName.trim(),
config: trainingConfig,
});
setModelName('');
setSelectedDataset('');
};
const estimatedCost = useMemo(() => {
if (!selectedBaseModel || !selectedDataset) return 0;
const model = baseModels.find((m) => m.id === selectedBaseModel);
const dataset = datasets.find((d) => d.id === selectedDataset);
if (!model || !dataset) return 0;
const tokensPerSample = 500;
const totalTokens = dataset.sampleCount * tokensPerSample * trainingConfig.epochs;
return (totalTokens / 1000000) * model.costPerMToken * 10;
}, [selectedBaseModel, selectedDataset, trainingConfig.epochs, baseModels, datasets]);
return (
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
{/* Header */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<AcademicCapIcon className="w-5 h-5 text-primary-500" />
<h3 className="font-semibold text-gray-900 dark:text-white">Fine-Tuning</h3>
</div>
{trainingModels.length > 0 && (
<span className="px-2 py-1 text-xs bg-blue-500/10 text-blue-500 rounded-full flex items-center gap-1">
<ArrowPathIcon className="w-3 h-3 animate-spin" />
{trainingModels.length} training
</span>
)}
</div>
{/* Stats */}
<div className="grid grid-cols-3 gap-3">
<div className="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg text-center">
<div className="text-xl font-bold text-gray-900 dark:text-white">{datasets.length}</div>
<div className="text-xs text-gray-500">Datasets</div>
</div>
<div className="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg text-center">
<div className="text-xl font-bold text-gray-900 dark:text-white">{fineTunedModels.length}</div>
<div className="text-xs text-gray-500">Models</div>
</div>
<div className="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg text-center">
<div className="text-xl font-bold text-emerald-500">
{fineTunedModels.filter((m) => m.status === 'completed').length}
</div>
<div className="text-xs text-gray-500">Ready</div>
</div>
</div>
</div>
{/* Tabs */}
<div className="flex border-b border-gray-200 dark:border-gray-700">
<button
onClick={() => setActiveTab('create')}
className={`flex-1 flex items-center justify-center gap-2 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'create'
? 'border-primary-500 text-primary-500'
: 'border-transparent text-gray-500'
}`}
>
<PlayIcon className="w-4 h-4" />
New Training
</button>
<button
onClick={() => setActiveTab('models')}
className={`flex-1 flex items-center justify-center gap-2 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'models'
? 'border-primary-500 text-primary-500'
: 'border-transparent text-gray-500'
}`}
>
<ScaleIcon className="w-4 h-4" />
Compare Models ({fineTunedModels.length})
</button>
</div>
{/* Create Training Tab */}
{activeTab === 'create' && (
<div className="p-4 space-y-4">
{/* Model Selection */}
<div>
<label className="block text-sm font-medium text-gray-900 dark:text-white mb-2">
Base Model
</label>
<select
value={selectedBaseModel}
onChange={(e) => setSelectedBaseModel(e.target.value)}
className="w-full px-3 py-2.5 bg-gray-100 dark:bg-gray-800 border-0 rounded-lg text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500"
>
<option value="">Select a model...</option>
{supportedModels.map((model) => (
<option key={model.id} value={model.id}>
{model.name} ({model.provider}) - ${model.costPerMToken}/MTok
</option>
))}
</select>
{selectedBaseModel && (
<p className="mt-1 text-xs text-gray-500">
{baseModels.find((m) => m.id === selectedBaseModel)?.description}
</p>
)}
</div>
{/* Dataset Upload & Selection */}
<div>
<label className="block text-sm font-medium text-gray-900 dark:text-white mb-2">
Training Dataset
</label>
<input
type="file"
ref={fileInputRef}
onChange={handleFileUpload}
accept=".jsonl,.csv,.json"
className="hidden"
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={!onUploadDataset}
className="w-full mb-2 p-4 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg flex items-center justify-center gap-2 text-gray-500 hover:border-primary-500 hover:text-primary-500 transition-colors disabled:opacity-50"
>
<CloudArrowUpIcon className="w-5 h-5" />
<span className="text-sm">Upload dataset (JSONL, CSV, JSON)</span>
</button>
{datasets.length > 0 && (
<div className="space-y-2 max-h-48 overflow-y-auto">
{datasets.map((dataset) => (
<DatasetCard
key={dataset.id}
dataset={dataset}
isSelected={selectedDataset === dataset.id}
onSelect={() => setSelectedDataset(dataset.id)}
onDelete={onDeleteDataset ? () => onDeleteDataset(dataset.id) : undefined}
/>
))}
</div>
)}
</div>
{/* Model Name */}
<div>
<label className="block text-sm font-medium text-gray-900 dark:text-white mb-2">
Fine-tuned Model Name
</label>
<input
type="text"
value={modelName}
onChange={(e) => setModelName(e.target.value)}
placeholder="e.g., trading-signals-v1"
className="w-full px-3 py-2.5 bg-gray-100 dark:bg-gray-800 border-0 rounded-lg text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500"
/>
</div>
{/* Advanced Config */}
<div>
<button
onClick={() => setShowAdvanced(!showAdvanced)}
className="flex items-center gap-2 text-sm text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
>
{showAdvanced ? 'Hide' : 'Show'} Advanced Settings
{showAdvanced ? <ChevronUpIcon className="w-4 h-4" /> : <ChevronDownIcon className="w-4 h-4" />}
</button>
{showAdvanced && (
<div className="mt-3 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs text-gray-500 mb-1">Epochs</label>
<input
type="number"
min="1"
max="10"
value={trainingConfig.epochs}
onChange={(e) => setTrainingConfig({ ...trainingConfig, epochs: parseInt(e.target.value) || 1 })}
className="w-full px-3 py-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg text-sm"
/>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Batch Size</label>
<input
type="number"
min="1"
max="32"
value={trainingConfig.batchSize}
onChange={(e) => setTrainingConfig({ ...trainingConfig, batchSize: parseInt(e.target.value) || 1 })}
className="w-full px-3 py-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg text-sm"
/>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Learning Rate</label>
<input
type="number"
step="0.00001"
min="0.00001"
max="0.01"
value={trainingConfig.learningRate}
onChange={(e) => setTrainingConfig({ ...trainingConfig, learningRate: parseFloat(e.target.value) || 0.0001 })}
className="w-full px-3 py-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg text-sm"
/>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Validation Split</label>
<input
type="number"
step="0.05"
min="0.05"
max="0.3"
value={trainingConfig.validationSplit}
onChange={(e) => setTrainingConfig({ ...trainingConfig, validationSplit: parseFloat(e.target.value) || 0.1 })}
className="w-full px-3 py-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg text-sm"
/>
</div>
</div>
</div>
)}
</div>
{/* Estimated Cost */}
{estimatedCost > 0 && (
<div className="p-3 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-yellow-600 dark:text-yellow-400">
<ExclamationTriangleIcon className="w-4 h-4" />
<span>Estimated training cost</span>
</div>
<span className="font-bold text-yellow-600 dark:text-yellow-400">
~${estimatedCost.toFixed(2)}
</span>
</div>
)}
{/* Start Training Button */}
<button
onClick={handleStartTraining}
disabled={!canStartTraining}
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-primary-600 hover:bg-primary-700 text-white rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<PlayIcon className="w-5 h-5" />
Start Fine-Tuning
</button>
{/* Info */}
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg flex items-start gap-2">
<InformationCircleIcon className="w-4 h-4 text-blue-500 mt-0.5 flex-shrink-0" />
<p className="text-xs text-blue-600 dark:text-blue-400">
Fine-tuning customizes a base model on your trading data. This improves accuracy for
your specific use case but may take hours to complete. Training costs are estimates.
</p>
</div>
</div>
)}
{/* Models Comparison Tab */}
{activeTab === 'models' && (
<div className="p-4">
{fineTunedModels.length === 0 ? (
<div className="py-12 text-center">
<AcademicCapIcon className="w-12 h-12 mx-auto text-gray-300 dark:text-gray-600 mb-3" />
<p className="text-gray-500 text-sm">No fine-tuned models yet</p>
<p className="text-gray-400 text-xs mt-1">Start training to create your first model</p>
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{fineTunedModels.map((model) => (
<ModelComparisonCard
key={model.id}
model={model}
onCancel={onCancelTraining ? () => onCancelTraining(model.id) : undefined}
onDownload={onDownloadModel ? () => onDownloadModel(model.id) : undefined}
onDelete={onDeleteModel ? () => onDeleteModel(model.id) : undefined}
/>
))}
</div>
)}
</div>
)}
</div>
);
};
// Missing import fix
const ChevronUpIcon = ({ className }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 15.75 7.5-7.5 7.5 7.5" />
</svg>
);
const ChevronDownIcon = ({ className }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
</svg>
);
export default FineTuningPanel;

View File

@ -0,0 +1,505 @@
/**
* LLMToolsPanel Component
* Tools integration panel with enable/disable toggles and execution history
* OQI-007: LLM Strategy Agent - SUBTASK-008
*/
import React, { useState, useMemo } from 'react';
import {
WrenchScrewdriverIcon,
ChartBarIcon,
CurrencyDollarIcon,
MagnifyingGlassIcon,
CalculatorIcon,
NewspaperIcon,
ClockIcon,
CheckIcon,
XMarkIcon,
ExclamationTriangleIcon,
ChevronDownIcon,
ChevronUpIcon,
PlayIcon,
ArrowPathIcon,
InformationCircleIcon,
Cog6ToothIcon,
} from '@heroicons/react/24/outline';
// ============================================================================
// Types
// ============================================================================
export interface LLMTool {
id: string;
name: string;
description: string;
category: 'market_data' | 'analysis' | 'trading' | 'research' | 'utility';
enabled: boolean;
parameters?: ToolParameter[];
usageCount: number;
avgDuration: number;
lastUsed?: string;
requiredPermissions?: string[];
}
export interface ToolParameter {
name: string;
type: 'string' | 'number' | 'boolean' | 'select';
required: boolean;
default?: string | number | boolean;
options?: string[];
description?: string;
}
export interface ToolExecution {
id: string;
toolId: string;
toolName: string;
status: 'pending' | 'running' | 'success' | 'error';
arguments: Record<string, unknown>;
result?: unknown;
error?: string;
startTime: string;
duration?: number;
}
interface LLMToolsPanelProps {
tools: LLMTool[];
executionHistory: ToolExecution[];
onToggleTool: (toolId: string, enabled: boolean) => void;
onConfigureTool?: (toolId: string) => void;
onRetryExecution?: (executionId: string) => void;
onClearHistory?: () => void;
isLoading?: boolean;
}
// ============================================================================
// Constants
// ============================================================================
const CATEGORY_INFO: Record<string, { label: string; color: string; icon: React.ElementType }> = {
market_data: { label: 'Market Data', color: 'text-blue-500 bg-blue-500/10', icon: CurrencyDollarIcon },
analysis: { label: 'Analysis', color: 'text-purple-500 bg-purple-500/10', icon: ChartBarIcon },
trading: { label: 'Trading', color: 'text-emerald-500 bg-emerald-500/10', icon: CalculatorIcon },
research: { label: 'Research', color: 'text-orange-500 bg-orange-500/10', icon: NewspaperIcon },
utility: { label: 'Utility', color: 'text-gray-500 bg-gray-500/10', icon: WrenchScrewdriverIcon },
};
const STATUS_STYLES: Record<string, { color: string; icon: React.ElementType }> = {
pending: { color: 'text-gray-400 bg-gray-500/10', icon: ClockIcon },
running: { color: 'text-blue-400 bg-blue-500/10', icon: ArrowPathIcon },
success: { color: 'text-emerald-400 bg-emerald-500/10', icon: CheckIcon },
error: { color: 'text-red-400 bg-red-500/10', icon: XMarkIcon },
};
// ============================================================================
// Sub-Components
// ============================================================================
interface ToolCardProps {
tool: LLMTool;
onToggle: (enabled: boolean) => void;
onConfigure?: () => void;
}
const ToolCard: React.FC<ToolCardProps> = ({ tool, onToggle, onConfigure }) => {
const [isExpanded, setIsExpanded] = useState(false);
const category = CATEGORY_INFO[tool.category];
const CategoryIcon = category.icon;
const formatLastUsed = (dateStr?: string) => {
if (!dateStr) return 'Never';
const date = new Date(dateStr);
const now = new Date();
const diff = now.getTime() - date.getTime();
if (diff < 60000) return 'Just now';
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
return date.toLocaleDateString();
};
return (
<div className={`rounded-lg border transition-all ${
tool.enabled
? 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800'
: 'border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-900 opacity-75'
}`}>
<div className="p-3">
<div className="flex items-start gap-3">
{/* Icon */}
<div className={`p-2 rounded-lg ${category.color}`}>
<CategoryIcon className="w-4 h-4" />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h4 className="font-medium text-gray-900 dark:text-white text-sm">{tool.name}</h4>
<span className={`px-1.5 py-0.5 text-xs rounded ${category.color}`}>
{category.label}
</span>
</div>
<p className="text-xs text-gray-500 mt-0.5 line-clamp-2">{tool.description}</p>
{/* Stats */}
<div className="flex items-center gap-4 mt-2 text-xs text-gray-400">
<span title="Usage count">{tool.usageCount} uses</span>
<span title="Average duration">{tool.avgDuration}ms avg</span>
<span title="Last used">{formatLastUsed(tool.lastUsed)}</span>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
{onConfigure && tool.parameters && tool.parameters.length > 0 && (
<button
onClick={onConfigure}
className="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
title="Configure"
>
<Cog6ToothIcon className="w-4 h-4" />
</button>
)}
<button
onClick={() => setIsExpanded(!isExpanded)}
className="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
{isExpanded ? <ChevronUpIcon className="w-4 h-4" /> : <ChevronDownIcon className="w-4 h-4" />}
</button>
{/* Toggle */}
<button
onClick={() => onToggle(!tool.enabled)}
className={`relative w-10 h-6 rounded-full transition-colors ${
tool.enabled ? 'bg-primary-500' : 'bg-gray-300 dark:bg-gray-600'
}`}
>
<span
className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${
tool.enabled ? 'translate-x-5' : 'translate-x-1'
}`}
/>
</button>
</div>
</div>
{/* Expanded Details */}
{isExpanded && (
<div className="mt-3 pt-3 border-t border-gray-100 dark:border-gray-700">
{tool.parameters && tool.parameters.length > 0 && (
<div className="space-y-2">
<h5 className="text-xs font-medium text-gray-500 uppercase">Parameters</h5>
{tool.parameters.map((param) => (
<div key={param.name} className="flex items-center justify-between text-xs">
<div className="flex items-center gap-2">
<span className="text-gray-700 dark:text-gray-300">{param.name}</span>
{param.required && <span className="text-red-400">*</span>}
</div>
<span className="text-gray-400">{param.type}</span>
</div>
))}
</div>
)}
{tool.requiredPermissions && tool.requiredPermissions.length > 0 && (
<div className="mt-2">
<h5 className="text-xs font-medium text-gray-500 uppercase mb-1">Required Permissions</h5>
<div className="flex flex-wrap gap-1">
{tool.requiredPermissions.map((perm) => (
<span
key={perm}
className="px-1.5 py-0.5 bg-yellow-100 dark:bg-yellow-900/30 text-yellow-600 dark:text-yellow-400 text-xs rounded"
>
{perm}
</span>
))}
</div>
</div>
)}
</div>
)}
</div>
</div>
);
};
interface ExecutionHistoryItemProps {
execution: ToolExecution;
onRetry?: () => void;
}
const ExecutionHistoryItem: React.FC<ExecutionHistoryItemProps> = ({ execution, onRetry }) => {
const [isExpanded, setIsExpanded] = useState(false);
const status = STATUS_STYLES[execution.status];
const StatusIcon = status.icon;
return (
<div className="p-3 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg">
<div className="flex items-start gap-3">
<div className={`p-1.5 rounded-lg ${status.color}`}>
<StatusIcon className={`w-4 h-4 ${execution.status === 'running' ? 'animate-spin' : ''}`} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900 dark:text-white text-sm">{execution.toolName}</span>
<span className={`px-1.5 py-0.5 text-xs rounded ${status.color}`}>
{execution.status}
</span>
</div>
<div className="flex items-center gap-3 mt-1 text-xs text-gray-400">
<span>{new Date(execution.startTime).toLocaleTimeString()}</span>
{execution.duration && <span>{execution.duration}ms</span>}
</div>
{execution.error && (
<div className="mt-2 p-2 bg-red-50 dark:bg-red-900/20 rounded text-xs text-red-600 dark:text-red-400 flex items-start gap-2">
<ExclamationTriangleIcon className="w-4 h-4 flex-shrink-0" />
<span>{execution.error}</span>
</div>
)}
</div>
<div className="flex items-center gap-1">
{execution.status === 'error' && onRetry && (
<button
onClick={onRetry}
className="p-1.5 text-gray-400 hover:text-primary-500 hover:bg-primary-50 dark:hover:bg-primary-900/20 rounded-lg"
title="Retry"
>
<ArrowPathIcon className="w-4 h-4" />
</button>
)}
<button
onClick={() => setIsExpanded(!isExpanded)}
className="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
{isExpanded ? <ChevronUpIcon className="w-4 h-4" /> : <ChevronDownIcon className="w-4 h-4" />}
</button>
</div>
</div>
{isExpanded && (
<div className="mt-3 space-y-2">
<div>
<h5 className="text-xs font-medium text-gray-500 uppercase mb-1">Arguments</h5>
<pre className="p-2 bg-gray-100 dark:bg-gray-900 rounded text-xs text-gray-600 dark:text-gray-400 overflow-x-auto">
{JSON.stringify(execution.arguments, null, 2)}
</pre>
</div>
{execution.result !== undefined && execution.result !== null && (
<div>
<h5 className="text-xs font-medium text-gray-500 uppercase mb-1">Result</h5>
<pre className="p-2 bg-gray-100 dark:bg-gray-900 rounded text-xs text-gray-600 dark:text-gray-400 overflow-x-auto max-h-32">
{JSON.stringify(execution.result, null, 2)}
</pre>
</div>
)}
</div>
)}
</div>
);
};
// ============================================================================
// Main Component
// ============================================================================
export const LLMToolsPanel: React.FC<LLMToolsPanelProps> = ({
tools,
executionHistory,
onToggleTool,
onConfigureTool,
onRetryExecution,
onClearHistory,
isLoading = false,
}) => {
const [activeTab, setActiveTab] = useState<'tools' | 'history'>('tools');
const [searchQuery, setSearchQuery] = useState('');
const [categoryFilter, setCategoryFilter] = useState<string>('all');
const filteredTools = useMemo(() => {
let filtered = tools;
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter((t) =>
t.name.toLowerCase().includes(query) ||
t.description.toLowerCase().includes(query)
);
}
if (categoryFilter !== 'all') {
filtered = filtered.filter((t) => t.category === categoryFilter);
}
return filtered;
}, [tools, searchQuery, categoryFilter]);
const enabledCount = tools.filter((t) => t.enabled).length;
const categories = [...new Set(tools.map((t) => t.category))];
const recentExecutions = executionHistory.slice(0, 20);
const successRate = executionHistory.length > 0
? (executionHistory.filter((e) => e.status === 'success').length / executionHistory.length * 100).toFixed(0)
: 0;
return (
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
{/* Header */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<WrenchScrewdriverIcon className="w-5 h-5 text-primary-500" />
<h3 className="font-semibold text-gray-900 dark:text-white">LLM Tools</h3>
</div>
<div className="text-sm text-gray-500">
{enabledCount}/{tools.length} enabled
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-3 gap-3">
<div className="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg text-center">
<div className="text-xl font-bold text-gray-900 dark:text-white">{tools.length}</div>
<div className="text-xs text-gray-500">Total Tools</div>
</div>
<div className="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg text-center">
<div className="text-xl font-bold text-emerald-500">{enabledCount}</div>
<div className="text-xs text-gray-500">Enabled</div>
</div>
<div className="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg text-center">
<div className="text-xl font-bold text-blue-500">{successRate}%</div>
<div className="text-xs text-gray-500">Success Rate</div>
</div>
</div>
</div>
{/* Tabs */}
<div className="flex border-b border-gray-200 dark:border-gray-700">
<button
onClick={() => setActiveTab('tools')}
className={`flex-1 flex items-center justify-center gap-2 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'tools'
? 'border-primary-500 text-primary-500'
: 'border-transparent text-gray-500'
}`}
>
<Cog6ToothIcon className="w-4 h-4" />
Available Tools
</button>
<button
onClick={() => setActiveTab('history')}
className={`flex-1 flex items-center justify-center gap-2 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'history'
? 'border-primary-500 text-primary-500'
: 'border-transparent text-gray-500'
}`}
>
<ClockIcon className="w-4 h-4" />
Execution History ({executionHistory.length})
</button>
</div>
{/* Tools Tab */}
{activeTab === 'tools' && (
<>
{/* Filters */}
<div className="p-3 border-b border-gray-200 dark:border-gray-700 flex items-center gap-3">
<div className="flex-1 relative">
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search tools..."
className="w-full pl-9 pr-3 py-2 text-sm bg-gray-100 dark:bg-gray-800 border-0 rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<select
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
className="px-3 py-2 text-sm bg-gray-100 dark:bg-gray-800 border-0 rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="all">All Categories</option>
{categories.map((cat) => (
<option key={cat} value={cat}>
{CATEGORY_INFO[cat]?.label || cat}
</option>
))}
</select>
</div>
{/* Tools List */}
<div className="max-h-96 overflow-y-auto p-3 space-y-2">
{filteredTools.length === 0 ? (
<div className="p-8 text-center">
<WrenchScrewdriverIcon className="w-12 h-12 mx-auto text-gray-300 dark:text-gray-600 mb-3" />
<p className="text-gray-500 text-sm">No tools found</p>
</div>
) : (
filteredTools.map((tool) => (
<ToolCard
key={tool.id}
tool={tool}
onToggle={(enabled) => onToggleTool(tool.id, enabled)}
onConfigure={onConfigureTool ? () => onConfigureTool(tool.id) : undefined}
/>
))
)}
</div>
</>
)}
{/* History Tab */}
{activeTab === 'history' && (
<>
{/* Clear History */}
{executionHistory.length > 0 && onClearHistory && (
<div className="p-3 border-b border-gray-200 dark:border-gray-700 flex justify-end">
<button
onClick={onClearHistory}
className="text-xs text-gray-500 hover:text-red-500 transition-colors"
>
Clear History
</button>
</div>
)}
{/* Execution List */}
<div className="max-h-96 overflow-y-auto">
{recentExecutions.length === 0 ? (
<div className="p-8 text-center">
<ClockIcon className="w-12 h-12 mx-auto text-gray-300 dark:text-gray-600 mb-3" />
<p className="text-gray-500 text-sm">No execution history</p>
<p className="text-gray-400 text-xs mt-1">Tool executions will appear here</p>
</div>
) : (
<div className="divide-y divide-gray-100 dark:divide-gray-800">
{recentExecutions.map((execution) => (
<ExecutionHistoryItem
key={execution.id}
execution={execution}
onRetry={onRetryExecution ? () => onRetryExecution(execution.id) : undefined}
/>
))}
</div>
)}
</div>
</>
)}
{/* Info Footer */}
<div className="p-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<div className="flex items-start gap-2 text-xs text-gray-500">
<InformationCircleIcon className="w-4 h-4 flex-shrink-0 mt-0.5" />
<p>
Tools extend AI capabilities. Enable tools to allow the assistant to access market data,
analyze charts, execute trades, and more. Some tools may require additional permissions.
</p>
</div>
</div>
</div>
);
};
export default LLMToolsPanel;

View File

@ -0,0 +1,522 @@
/**
* MemoryManager Component
* Enhanced memory management with conversation history, archiving, and visualization
* OQI-007: LLM Strategy Agent - SUBTASK-008
*/
import React, { useState, useMemo, useCallback } from 'react';
import {
CircleStackIcon,
ArchiveBoxIcon,
TrashIcon,
ArrowDownTrayIcon,
ClockIcon,
FolderIcon,
DocumentTextIcon,
ChevronRightIcon,
MagnifyingGlassIcon,
ExclamationTriangleIcon,
CheckCircleIcon,
XMarkIcon,
ArrowPathIcon,
} from '@heroicons/react/24/outline';
// ============================================================================
// Types
// ============================================================================
export interface ConversationRecord {
id: string;
title: string;
preview: string;
messageCount: number;
tokenCount: number;
createdAt: string;
updatedAt: string;
archived: boolean;
tags?: string[];
}
export interface MemoryUsage {
totalTokens: number;
maxTokens: number;
activeConversations: number;
archivedConversations: number;
totalStorageKB: number;
}
export interface ContextWindow {
used: number;
max: number;
reserved: number;
systemPrompt: number;
conversationHistory: number;
tools: number;
}
interface MemoryManagerProps {
conversations: ConversationRecord[];
memoryUsage: MemoryUsage;
contextWindow: ContextWindow;
onClearConversation?: (id: string) => void;
onArchiveConversation?: (id: string) => void;
onRestoreConversation?: (id: string) => void;
onExportConversation?: (id: string) => void;
onDeleteConversation?: (id: string) => void;
onClearAllArchived?: () => void;
onRefreshUsage?: () => void;
isLoading?: boolean;
}
// ============================================================================
// Helper Functions
// ============================================================================
const formatBytes = (kb: number): string => {
if (kb >= 1024) return `${(kb / 1024).toFixed(1)} MB`;
return `${kb.toFixed(1)} KB`;
};
const formatTokens = (tokens: number): string => {
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`;
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`;
return tokens.toString();
};
const formatDate = (dateStr: string): string => {
const date = new Date(dateStr);
const now = new Date();
const diff = now.getTime() - date.getTime();
if (diff < 86400000) {
return date.toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' });
}
if (diff < 604800000) {
return date.toLocaleDateString('es-ES', { weekday: 'short' });
}
return date.toLocaleDateString('es-ES', { month: 'short', day: 'numeric' });
};
// ============================================================================
// Sub-Components
// ============================================================================
interface ContextWindowVisualizationProps {
contextWindow: ContextWindow;
}
const ContextWindowVisualization: React.FC<ContextWindowVisualizationProps> = ({ contextWindow }) => {
const segments = [
{ label: 'System Prompt', value: contextWindow.systemPrompt, color: 'bg-purple-500' },
{ label: 'Conversation', value: contextWindow.conversationHistory, color: 'bg-blue-500' },
{ label: 'Tools', value: contextWindow.tools, color: 'bg-emerald-500' },
{ label: 'Reserved', value: contextWindow.reserved, color: 'bg-gray-400' },
];
const available = contextWindow.max - contextWindow.used;
const usagePercent = (contextWindow.used / contextWindow.max) * 100;
return (
<div className="p-4 bg-gray-50 dark:bg-gray-800 rounded-xl">
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3">Context Window</h4>
{/* Progress Bar */}
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden flex">
{segments.map((segment, i) => (
<div
key={segment.label}
className={`${segment.color} transition-all`}
style={{ width: `${(segment.value / contextWindow.max) * 100}%` }}
title={`${segment.label}: ${formatTokens(segment.value)}`}
/>
))}
</div>
{/* Legend */}
<div className="mt-3 grid grid-cols-2 gap-2">
{segments.map((segment) => (
<div key={segment.label} className="flex items-center gap-2 text-xs">
<div className={`w-3 h-3 rounded ${segment.color}`} />
<span className="text-gray-600 dark:text-gray-400">{segment.label}</span>
<span className="text-gray-900 dark:text-white font-medium">{formatTokens(segment.value)}</span>
</div>
))}
</div>
{/* Summary */}
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700 flex justify-between text-sm">
<div>
<span className="text-gray-500">Used:</span>
<span className={`ml-1 font-medium ${usagePercent > 80 ? 'text-red-500' : 'text-gray-900 dark:text-white'}`}>
{formatTokens(contextWindow.used)} ({usagePercent.toFixed(0)}%)
</span>
</div>
<div>
<span className="text-gray-500">Available:</span>
<span className="ml-1 font-medium text-emerald-500">{formatTokens(available)}</span>
</div>
</div>
{usagePercent > 80 && (
<div className="mt-2 p-2 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg flex items-center gap-2 text-xs text-yellow-600 dark:text-yellow-400">
<ExclamationTriangleIcon className="w-4 h-4" />
<span>Context window is filling up. Consider archiving old conversations.</span>
</div>
)}
</div>
);
};
interface ConversationListItemProps {
conversation: ConversationRecord;
onArchive?: () => void;
onRestore?: () => void;
onExport?: () => void;
onDelete?: () => void;
}
const ConversationListItem: React.FC<ConversationListItemProps> = ({
conversation,
onArchive,
onRestore,
onExport,
onDelete,
}) => {
const [showActions, setShowActions] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
return (
<div
className="group p-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 rounded-lg transition-colors"
onMouseEnter={() => setShowActions(true)}
onMouseLeave={() => { setShowActions(false); setConfirmDelete(false); }}
>
<div className="flex items-start gap-3">
<div className={`p-2 rounded-lg ${conversation.archived ? 'bg-gray-200 dark:bg-gray-700' : 'bg-blue-100 dark:bg-blue-900/30'}`}>
{conversation.archived ? (
<ArchiveBoxIcon className="w-4 h-4 text-gray-500" />
) : (
<DocumentTextIcon className="w-4 h-4 text-blue-500" />
)}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-gray-900 dark:text-white text-sm truncate">
{conversation.title}
</p>
<p className="text-xs text-gray-500 truncate mt-0.5">
{conversation.preview}
</p>
<div className="flex items-center gap-3 mt-1 text-xs text-gray-400">
<span>{conversation.messageCount} messages</span>
<span>{formatTokens(conversation.tokenCount)} tokens</span>
<span>{formatDate(conversation.updatedAt)}</span>
</div>
{conversation.tags && conversation.tags.length > 0 && (
<div className="flex gap-1 mt-1.5">
{conversation.tags.map((tag) => (
<span
key={tag}
className="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-500 text-xs rounded"
>
{tag}
</span>
))}
</div>
)}
</div>
{/* Actions */}
<div className={`flex items-center gap-1 transition-opacity ${showActions ? 'opacity-100' : 'opacity-0'}`}>
{!confirmDelete ? (
<>
{conversation.archived ? (
onRestore && (
<button
onClick={onRestore}
className="p-1.5 text-gray-400 hover:text-emerald-500 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 rounded-lg"
title="Restore"
>
<ArrowPathIcon className="w-4 h-4" />
</button>
)
) : (
onArchive && (
<button
onClick={onArchive}
className="p-1.5 text-gray-400 hover:text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg"
title="Archive"
>
<ArchiveBoxIcon className="w-4 h-4" />
</button>
)
)}
{onExport && (
<button
onClick={onExport}
className="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
title="Export"
>
<ArrowDownTrayIcon className="w-4 h-4" />
</button>
)}
{onDelete && (
<button
onClick={() => setConfirmDelete(true)}
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg"
title="Delete"
>
<TrashIcon className="w-4 h-4" />
</button>
)}
</>
) : (
<>
<button
onClick={onDelete}
className="px-2 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600"
>
Confirm
</button>
<button
onClick={() => setConfirmDelete(false)}
className="p-1 text-gray-400 hover:text-gray-600"
>
<XMarkIcon className="w-4 h-4" />
</button>
</>
)}
</div>
</div>
</div>
);
};
// ============================================================================
// Main Component
// ============================================================================
export const MemoryManager: React.FC<MemoryManagerProps> = ({
conversations,
memoryUsage,
contextWindow,
onClearConversation,
onArchiveConversation,
onRestoreConversation,
onExportConversation,
onDeleteConversation,
onClearAllArchived,
onRefreshUsage,
isLoading = false,
}) => {
const [activeTab, setActiveTab] = useState<'active' | 'archived'>('active');
const [searchQuery, setSearchQuery] = useState('');
const [sortBy, setSortBy] = useState<'recent' | 'tokens' | 'messages'>('recent');
const filteredConversations = useMemo(() => {
let filtered = conversations.filter((c) =>
activeTab === 'archived' ? c.archived : !c.archived
);
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter((c) =>
c.title.toLowerCase().includes(query) ||
c.preview.toLowerCase().includes(query) ||
c.tags?.some((t) => t.toLowerCase().includes(query))
);
}
filtered.sort((a, b) => {
switch (sortBy) {
case 'tokens':
return b.tokenCount - a.tokenCount;
case 'messages':
return b.messageCount - a.messageCount;
default:
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
}
});
return filtered;
}, [conversations, activeTab, searchQuery, sortBy]);
const activeConversations = conversations.filter((c) => !c.archived);
const archivedConversations = conversations.filter((c) => c.archived);
const usagePercent = (memoryUsage.totalTokens / memoryUsage.maxTokens) * 100;
return (
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
{/* Header */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<CircleStackIcon className="w-5 h-5 text-primary-500" />
<h3 className="font-semibold text-gray-900 dark:text-white">Memory Manager</h3>
</div>
{onRefreshUsage && (
<button
onClick={onRefreshUsage}
disabled={isLoading}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg disabled:opacity-50"
title="Refresh"
>
<ArrowPathIcon className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
</button>
)}
</div>
{/* Usage Overview */}
<div className="grid grid-cols-4 gap-3">
<div className="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div className="text-xl font-bold text-gray-900 dark:text-white">
{formatTokens(memoryUsage.totalTokens)}
</div>
<div className="text-xs text-gray-500">Total Tokens</div>
</div>
<div className="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div className="text-xl font-bold text-gray-900 dark:text-white">
{memoryUsage.activeConversations}
</div>
<div className="text-xs text-gray-500">Active</div>
</div>
<div className="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div className="text-xl font-bold text-gray-900 dark:text-white">
{memoryUsage.archivedConversations}
</div>
<div className="text-xs text-gray-500">Archived</div>
</div>
<div className="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div className="text-xl font-bold text-gray-900 dark:text-white">
{formatBytes(memoryUsage.totalStorageKB)}
</div>
<div className="text-xs text-gray-500">Storage</div>
</div>
</div>
{/* Usage Bar */}
<div className="mt-3">
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-gray-500">Memory Usage</span>
<span className={`font-medium ${usagePercent > 80 ? 'text-red-500' : 'text-gray-900 dark:text-white'}`}>
{usagePercent.toFixed(0)}%
</span>
</div>
<div className="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className={`h-full transition-all ${
usagePercent > 80 ? 'bg-red-500' : usagePercent > 60 ? 'bg-yellow-500' : 'bg-emerald-500'
}`}
style={{ width: `${usagePercent}%` }}
/>
</div>
</div>
</div>
{/* Context Window Visualization */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<ContextWindowVisualization contextWindow={contextWindow} />
</div>
{/* Tabs */}
<div className="flex border-b border-gray-200 dark:border-gray-700">
<button
onClick={() => setActiveTab('active')}
className={`flex-1 flex items-center justify-center gap-2 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'active'
? 'border-primary-500 text-primary-500'
: 'border-transparent text-gray-500'
}`}
>
<FolderIcon className="w-4 h-4" />
Active ({activeConversations.length})
</button>
<button
onClick={() => setActiveTab('archived')}
className={`flex-1 flex items-center justify-center gap-2 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'archived'
? 'border-primary-500 text-primary-500'
: 'border-transparent text-gray-500'
}`}
>
<ArchiveBoxIcon className="w-4 h-4" />
Archived ({archivedConversations.length})
</button>
</div>
{/* Search & Sort */}
<div className="p-3 border-b border-gray-200 dark:border-gray-700 flex items-center gap-3">
<div className="flex-1 relative">
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search conversations..."
className="w-full pl-9 pr-3 py-2 text-sm bg-gray-100 dark:bg-gray-800 border-0 rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as typeof sortBy)}
className="px-3 py-2 text-sm bg-gray-100 dark:bg-gray-800 border-0 rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="recent">Most Recent</option>
<option value="tokens">Most Tokens</option>
<option value="messages">Most Messages</option>
</select>
</div>
{/* Conversation List */}
<div className="max-h-80 overflow-y-auto">
{filteredConversations.length === 0 ? (
<div className="p-8 text-center">
<div className="w-12 h-12 mx-auto mb-3 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
{activeTab === 'archived' ? (
<ArchiveBoxIcon className="w-6 h-6 text-gray-400" />
) : (
<DocumentTextIcon className="w-6 h-6 text-gray-400" />
)}
</div>
<p className="text-gray-500 text-sm">
{searchQuery
? 'No conversations match your search'
: activeTab === 'archived'
? 'No archived conversations'
: 'No active conversations'}
</p>
</div>
) : (
<div className="divide-y divide-gray-100 dark:divide-gray-800">
{filteredConversations.map((conversation) => (
<ConversationListItem
key={conversation.id}
conversation={conversation}
onArchive={onArchiveConversation ? () => onArchiveConversation(conversation.id) : undefined}
onRestore={onRestoreConversation ? () => onRestoreConversation(conversation.id) : undefined}
onExport={onExportConversation ? () => onExportConversation(conversation.id) : undefined}
onDelete={onDeleteConversation ? () => onDeleteConversation(conversation.id) : undefined}
/>
))}
</div>
)}
</div>
{/* Footer Actions */}
{activeTab === 'archived' && archivedConversations.length > 0 && onClearAllArchived && (
<div className="p-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<button
onClick={onClearAllArchived}
className="w-full flex items-center justify-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
>
<TrashIcon className="w-4 h-4" />
Clear All Archived ({archivedConversations.length})
</button>
</div>
)}
</div>
);
};
export default MemoryManager;

View File

@ -77,3 +77,15 @@ export type { TokenUsage, TokenCosts, SessionTokenStats, TokenUsageDisplayProps
// Prompt Library (OQI-007) // Prompt Library (OQI-007)
export { default as PromptLibrary } from './PromptLibrary'; export { default as PromptLibrary } from './PromptLibrary';
export type { Prompt, PromptCategory, PromptLibraryProps } from './PromptLibrary'; export type { Prompt, PromptCategory, PromptLibraryProps } from './PromptLibrary';
// Memory Management (OQI-007 - SUBTASK-008)
export { default as MemoryManager } from './MemoryManager';
export type { ConversationRecord, MemoryUsage, ContextWindow } from './MemoryManager';
// LLM Tools Integration (OQI-007 - SUBTASK-008)
export { default as LLMToolsPanel } from './LLMToolsPanel';
export type { LLMTool, ToolParameter, ToolExecution } from './LLMToolsPanel';
// Fine-Tuning Interface (OQI-007 - SUBTASK-008)
export { default as FineTuningPanel } from './FineTuningPanel';
export type { BaseModel, TrainingDataset, FineTunedModel, TrainingConfig } from './FineTuningPanel';

View File

@ -0,0 +1,426 @@
/**
* EarnedCertificates Component
* Displays all earned certificates for user profile
* OQI-002: Modulo Educativo - SUBTASK-007
*/
import React, { useState, useMemo } from 'react';
import {
Award,
Download,
Share2,
ExternalLink,
Calendar,
Clock,
BadgeCheck,
Search,
Grid,
List,
Trophy,
Loader2,
} from 'lucide-react';
export interface EarnedCertificate {
id: string;
credentialId: string;
courseId: string;
courseTitle: string;
courseThumbnail?: string;
instructorName: string;
completedAt: string;
issuedAt: string;
grade?: number;
xpEarned: number;
courseDurationHours: number;
skills?: string[];
verificationUrl?: string;
downloadUrl?: string;
}
interface EarnedCertificatesProps {
certificates: EarnedCertificate[];
loading?: boolean;
onDownload?: (certificateId: string) => void;
onShare?: (certificateId: string, platform: 'linkedin' | 'twitter' | 'email' | 'copy') => void;
onViewCertificate?: (certificateId: string) => void;
compact?: boolean;
}
type ViewMode = 'grid' | 'list';
type SortBy = 'newest' | 'oldest' | 'grade' | 'course';
const EarnedCertificates: React.FC<EarnedCertificatesProps> = ({
certificates,
loading = false,
onDownload,
onShare,
onViewCertificate,
compact = false,
}) => {
const [viewMode, setViewMode] = useState<ViewMode>('grid');
const [searchQuery, setSearchQuery] = useState('');
const [sortBy, setSortBy] = useState<SortBy>('newest');
const [showShareMenu, setShowShareMenu] = useState<string | null>(null);
// Filter and sort certificates
const filteredCertificates = useMemo(() => {
let result = [...certificates];
// Filter by search query
if (searchQuery) {
const query = searchQuery.toLowerCase();
result = result.filter(
(cert) =>
cert.courseTitle.toLowerCase().includes(query) ||
cert.instructorName.toLowerCase().includes(query) ||
cert.skills?.some((skill) => skill.toLowerCase().includes(query))
);
}
// Sort
switch (sortBy) {
case 'oldest':
result.sort((a, b) => new Date(a.issuedAt).getTime() - new Date(b.issuedAt).getTime());
break;
case 'grade':
result.sort((a, b) => (b.grade || 0) - (a.grade || 0));
break;
case 'course':
result.sort((a, b) => a.courseTitle.localeCompare(b.courseTitle));
break;
case 'newest':
default:
result.sort((a, b) => new Date(b.issuedAt).getTime() - new Date(a.issuedAt).getTime());
}
return result;
}, [certificates, searchQuery, sortBy]);
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('es-ES', {
day: 'numeric',
month: 'long',
year: 'numeric',
});
};
const handleShare = (certId: string, platform: 'linkedin' | 'twitter' | 'email' | 'copy') => {
onShare?.(certId, platform);
setShowShareMenu(null);
};
// Loading state
if (loading) {
return (
<div className={`bg-gray-800/50 rounded-xl border border-gray-700 ${compact ? 'p-3' : 'p-4'}`}>
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 text-yellow-400 animate-spin" />
</div>
</div>
);
}
// Empty state
if (certificates.length === 0) {
return (
<div className={`bg-gray-800/50 rounded-xl border border-gray-700 ${compact ? 'p-3' : 'p-4'}`}>
<div className="flex items-center gap-3 mb-4">
<div className="p-2 bg-yellow-500/20 rounded-lg">
<Award className="w-5 h-5 text-yellow-400" />
</div>
<h3 className="font-semibold text-white">Mis Certificados</h3>
</div>
<div className="text-center py-8">
<Trophy className="w-16 h-16 text-gray-600 mx-auto mb-4" />
<p className="text-gray-400">Aun no has obtenido ningun certificado</p>
<p className="text-sm text-gray-500 mt-1">
Completa cursos para obtener certificados
</p>
</div>
</div>
);
}
return (
<div className={`bg-gray-800/50 rounded-xl border border-gray-700 ${compact ? 'p-3' : 'p-4'}`}>
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-yellow-500/20 rounded-lg">
<Award className="w-5 h-5 text-yellow-400" />
</div>
<div>
<h3 className="font-semibold text-white">Mis Certificados</h3>
<p className="text-xs text-gray-500">{certificates.length} certificados obtenidos</p>
</div>
</div>
{/* View Toggle */}
<div className="flex items-center gap-1 bg-gray-700 rounded-lg p-1">
<button
onClick={() => setViewMode('grid')}
className={`p-1.5 rounded ${
viewMode === 'grid' ? 'bg-gray-600 text-white' : 'text-gray-400 hover:text-white'
}`}
>
<Grid className="w-4 h-4" />
</button>
<button
onClick={() => setViewMode('list')}
className={`p-1.5 rounded ${
viewMode === 'list' ? 'bg-gray-600 text-white' : 'text-gray-400 hover:text-white'
}`}
>
<List className="w-4 h-4" />
</button>
</div>
</div>
{/* Search and Sort */}
<div className="flex items-center gap-3 mb-4">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input
type="text"
placeholder="Buscar certificados..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-4 py-2 bg-gray-900/50 border border-gray-700 rounded-lg text-sm text-white placeholder-gray-500 focus:border-yellow-500/50 focus:outline-none"
/>
</div>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortBy)}
className="px-3 py-2 bg-gray-900/50 border border-gray-700 rounded-lg text-sm text-white focus:border-yellow-500/50 focus:outline-none"
>
<option value="newest">Mas recientes</option>
<option value="oldest">Mas antiguos</option>
<option value="grade">Por calificacion</option>
<option value="course">Por curso</option>
</select>
</div>
{/* Certificates Display */}
{filteredCertificates.length === 0 ? (
<div className="text-center py-8">
<p className="text-gray-400">No se encontraron certificados</p>
</div>
) : viewMode === 'grid' ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredCertificates.map((cert) => (
<div
key={cert.id}
className="bg-gradient-to-br from-gray-800 to-gray-900 rounded-xl border border-yellow-500/20 overflow-hidden hover:border-yellow-500/40 transition-colors group"
>
{/* Certificate Preview */}
<div
className="relative aspect-[1.4/1] bg-gradient-to-r from-yellow-600/10 via-amber-500/10 to-yellow-600/10 p-4 cursor-pointer"
onClick={() => onViewCertificate?.(cert.id)}
>
<div className="absolute inset-0 flex flex-col items-center justify-center text-center p-4">
<Award className="w-10 h-10 text-yellow-400 mb-2" />
<p className="text-xs text-yellow-400/70 mb-1">Certificado de Finalizacion</p>
<p className="text-sm font-medium text-white line-clamp-2">{cert.courseTitle}</p>
</div>
<div className="absolute top-2 right-2">
<BadgeCheck className="w-6 h-6 text-yellow-400" />
</div>
</div>
{/* Certificate Info */}
<div className="p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-gray-400 flex items-center gap-1">
<Calendar className="w-3 h-3" />
{formatDate(cert.issuedAt)}
</span>
{cert.grade && (
<span className="text-xs text-green-400 font-medium">{cert.grade}%</span>
)}
</div>
<p className="text-sm text-gray-400 mb-3">{cert.instructorName}</p>
{/* Skills Tags */}
{cert.skills && cert.skills.length > 0 && (
<div className="flex flex-wrap gap-1 mb-3">
{cert.skills.slice(0, 3).map((skill) => (
<span
key={skill}
className="px-2 py-0.5 bg-yellow-500/10 text-yellow-400 text-xs rounded"
>
{skill}
</span>
))}
{cert.skills.length > 3 && (
<span className="text-xs text-gray-500">+{cert.skills.length - 3}</span>
)}
</div>
)}
{/* Actions */}
<div className="flex items-center gap-2">
{onDownload && (
<button
onClick={() => onDownload(cert.id)}
className="flex-1 flex items-center justify-center gap-1 py-1.5 bg-gray-700 hover:bg-gray-600 text-white text-xs rounded transition-colors"
>
<Download className="w-3 h-3" />
PDF
</button>
)}
{onShare && (
<div className="relative">
<button
onClick={() =>
setShowShareMenu(showShareMenu === cert.id ? null : cert.id)
}
className="p-1.5 bg-gray-700 hover:bg-gray-600 text-white rounded transition-colors"
>
<Share2 className="w-3 h-3" />
</button>
{showShareMenu === cert.id && (
<div className="absolute bottom-full right-0 mb-2 w-36 bg-gray-800 border border-gray-700 rounded-lg shadow-xl z-10">
<div className="p-1">
<button
onClick={() => handleShare(cert.id, 'linkedin')}
className="w-full px-3 py-1.5 text-xs text-left text-white hover:bg-gray-700 rounded"
>
LinkedIn
</button>
<button
onClick={() => handleShare(cert.id, 'twitter')}
className="w-full px-3 py-1.5 text-xs text-left text-white hover:bg-gray-700 rounded"
>
Twitter
</button>
<button
onClick={() => handleShare(cert.id, 'copy')}
className="w-full px-3 py-1.5 text-xs text-left text-white hover:bg-gray-700 rounded"
>
Copiar enlace
</button>
</div>
</div>
)}
</div>
)}
{cert.verificationUrl && (
<a
href={cert.verificationUrl}
target="_blank"
rel="noopener noreferrer"
className="p-1.5 bg-gray-700 hover:bg-gray-600 text-white rounded transition-colors"
>
<ExternalLink className="w-3 h-3" />
</a>
)}
</div>
</div>
</div>
))}
</div>
) : (
<div className="space-y-2">
{filteredCertificates.map((cert) => (
<div
key={cert.id}
className="flex items-center gap-4 p-4 bg-gray-900/50 rounded-lg border border-gray-700 hover:border-yellow-500/30 transition-colors"
>
{/* Icon */}
<div className="p-3 bg-yellow-500/20 rounded-lg flex-shrink-0">
<Award className="w-6 h-6 text-yellow-400" />
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<h4
className="font-medium text-white truncate cursor-pointer hover:text-yellow-400 transition-colors"
onClick={() => onViewCertificate?.(cert.id)}
>
{cert.courseTitle}
</h4>
<div className="flex items-center gap-3 text-xs text-gray-400 mt-1">
<span>{cert.instructorName}</span>
<span className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
{formatDate(cert.issuedAt)}
</span>
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{cert.courseDurationHours}h
</span>
</div>
{cert.skills && cert.skills.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{cert.skills.slice(0, 4).map((skill) => (
<span
key={skill}
className="px-2 py-0.5 bg-yellow-500/10 text-yellow-400 text-xs rounded"
>
{skill}
</span>
))}
</div>
)}
</div>
{/* Grade */}
{cert.grade && (
<div className="text-right flex-shrink-0">
<div className="text-xl font-bold text-green-400">{cert.grade}%</div>
<div className="text-xs text-gray-500">Calificacion</div>
</div>
)}
{/* Actions */}
<div className="flex items-center gap-2 flex-shrink-0">
{onDownload && (
<button
onClick={() => onDownload(cert.id)}
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
title="Descargar PDF"
>
<Download className="w-4 h-4" />
</button>
)}
{onShare && (
<button
onClick={() => handleShare(cert.id, 'copy')}
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
title="Compartir"
>
<Share2 className="w-4 h-4" />
</button>
)}
{cert.verificationUrl && (
<a
href={cert.verificationUrl}
target="_blank"
rel="noopener noreferrer"
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
title="Verificar"
>
<ExternalLink className="w-4 h-4" />
</a>
)}
</div>
</div>
))}
</div>
)}
{/* Credential ID Info */}
<div className="mt-4 p-3 bg-gray-900/50 rounded-lg border border-gray-700">
<div className="flex items-center gap-2 text-xs text-gray-400">
<BadgeCheck className="w-4 h-4 text-green-400" />
<span>
Todos los certificados incluyen un ID unico de verificacion para validar su autenticidad
</span>
</div>
</div>
</div>
);
};
export default EarnedCertificates;

View File

@ -0,0 +1,305 @@
/**
* QuizHistoryCard Component
* Displays user's quiz attempt history with scores and trends
* OQI-002: Modulo Educativo - SUBTASK-007
*/
import React, { useState, useMemo } from 'react';
import {
Trophy,
Clock,
Target,
TrendingUp,
TrendingDown,
CheckCircle,
XCircle,
ChevronDown,
ChevronUp,
Calendar,
BarChart3,
RefreshCw,
Award,
Minus,
} from 'lucide-react';
export interface QuizAttemptHistory {
id: string;
quizId: string;
quizTitle: string;
score: number;
maxScore: number;
percentage: number;
passed: boolean;
passingScore: number;
timeSpentMinutes: number;
timeLimitMinutes?: number;
correctAnswers: number;
totalQuestions: number;
xpEarned: number;
attemptNumber: number;
submittedAt: string;
}
interface QuizHistoryCardProps {
attempts: QuizAttemptHistory[];
onRetakeQuiz?: (quizId: string) => void;
onViewDetails?: (attemptId: string) => void;
compact?: boolean;
limit?: number;
showQuizTitle?: boolean;
}
const QuizHistoryCard: React.FC<QuizHistoryCardProps> = ({
attempts,
onRetakeQuiz,
onViewDetails,
compact = false,
limit,
showQuizTitle = true,
}) => {
const [expanded, setExpanded] = useState(false);
// Sort attempts by date (newest first)
const sortedAttempts = useMemo(() => {
return [...attempts].sort(
(a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime()
);
}, [attempts]);
// Calculate statistics
const stats = useMemo(() => {
if (attempts.length === 0) {
return {
totalAttempts: 0,
passedCount: 0,
passRate: 0,
averageScore: 0,
bestScore: 0,
worstScore: 0,
trend: 0,
totalXpEarned: 0,
};
}
const passedCount = attempts.filter((a) => a.passed).length;
const scores = attempts.map((a) => a.percentage);
const averageScore = scores.reduce((sum, s) => sum + s, 0) / scores.length;
const bestScore = Math.max(...scores);
const worstScore = Math.min(...scores);
const totalXpEarned = attempts.reduce((sum, a) => sum + a.xpEarned, 0);
// Calculate trend (last 3 attempts)
let trend = 0;
if (sortedAttempts.length >= 2) {
const recentAttempts = sortedAttempts.slice(0, Math.min(3, sortedAttempts.length));
const latestScore = recentAttempts[0].percentage;
const olderScore = recentAttempts[recentAttempts.length - 1].percentage;
trend = latestScore - olderScore;
}
return {
totalAttempts: attempts.length,
passedCount,
passRate: (passedCount / attempts.length) * 100,
averageScore,
bestScore,
worstScore,
trend,
totalXpEarned,
};
}, [attempts, sortedAttempts]);
const displayedAttempts = limit && !expanded ? sortedAttempts.slice(0, limit) : sortedAttempts;
const hasMore = limit ? sortedAttempts.length > limit : false;
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('es-ES', {
day: 'numeric',
month: 'short',
year: 'numeric',
});
};
const formatTime = (minutes: number) => {
if (minutes < 1) return `${Math.round(minutes * 60)}s`;
return `${Math.floor(minutes)}m ${Math.round((minutes % 1) * 60)}s`;
};
const getScoreColor = (percentage: number, passingScore: number) => {
if (percentage >= passingScore) return 'text-green-400';
if (percentage >= passingScore * 0.8) return 'text-yellow-400';
return 'text-red-400';
};
const getTrendIcon = (trend: number) => {
if (trend > 5) return <TrendingUp className="w-4 h-4 text-green-400" />;
if (trend < -5) return <TrendingDown className="w-4 h-4 text-red-400" />;
return <Minus className="w-4 h-4 text-gray-400" />;
};
if (attempts.length === 0) {
return (
<div className={`bg-gray-800/50 rounded-xl border border-gray-700 ${compact ? 'p-3' : 'p-4'}`}>
<div className="flex items-center gap-3 mb-4">
<div className="p-2 bg-purple-500/20 rounded-lg">
<BarChart3 className="w-5 h-5 text-purple-400" />
</div>
<h3 className="font-semibold text-white">Historial de Quizzes</h3>
</div>
<p className="text-gray-400 text-sm text-center py-6">
Aun no has completado ningun quiz
</p>
</div>
);
}
return (
<div className={`bg-gray-800/50 rounded-xl border border-gray-700 ${compact ? 'p-3' : 'p-4'}`}>
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-purple-500/20 rounded-lg">
<BarChart3 className="w-5 h-5 text-purple-400" />
</div>
<div>
<h3 className="font-semibold text-white">Historial de Quizzes</h3>
<p className="text-xs text-gray-500">{stats.totalAttempts} intentos totales</p>
</div>
</div>
<div className="flex items-center gap-1">
{getTrendIcon(stats.trend)}
<span
className={`text-sm ${
stats.trend > 0 ? 'text-green-400' : stats.trend < 0 ? 'text-red-400' : 'text-gray-400'
}`}
>
{stats.trend > 0 ? '+' : ''}
{stats.trend.toFixed(0)}%
</span>
</div>
</div>
{/* Statistics Summary */}
<div className="grid grid-cols-4 gap-2 mb-4">
<div className="p-2 bg-gray-900/50 rounded-lg text-center">
<div className="text-lg font-bold text-white">{stats.averageScore.toFixed(0)}%</div>
<div className="text-xs text-gray-500">Promedio</div>
</div>
<div className="p-2 bg-gray-900/50 rounded-lg text-center">
<div className="text-lg font-bold text-green-400">{stats.bestScore.toFixed(0)}%</div>
<div className="text-xs text-gray-500">Mejor</div>
</div>
<div className="p-2 bg-gray-900/50 rounded-lg text-center">
<div className="text-lg font-bold text-white">{stats.passRate.toFixed(0)}%</div>
<div className="text-xs text-gray-500">Aprobados</div>
</div>
<div className="p-2 bg-gray-900/50 rounded-lg text-center">
<div className="text-lg font-bold text-purple-400">{stats.totalXpEarned}</div>
<div className="text-xs text-gray-500">XP Total</div>
</div>
</div>
{/* Attempts List */}
<div className="space-y-2">
{displayedAttempts.map((attempt) => (
<div
key={attempt.id}
className={`p-3 rounded-lg border transition-colors ${
attempt.passed
? 'bg-green-500/5 border-green-500/20 hover:border-green-500/40'
: 'bg-red-500/5 border-red-500/20 hover:border-red-500/40'
} ${onViewDetails ? 'cursor-pointer' : ''}`}
onClick={() => onViewDetails?.(attempt.id)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div
className={`p-1.5 rounded-full ${
attempt.passed ? 'bg-green-500/20' : 'bg-red-500/20'
}`}
>
{attempt.passed ? (
<CheckCircle className="w-4 h-4 text-green-400" />
) : (
<XCircle className="w-4 h-4 text-red-400" />
)}
</div>
<div>
{showQuizTitle && (
<p className="text-sm font-medium text-white truncate max-w-[200px]">
{attempt.quizTitle}
</p>
)}
<div className="flex items-center gap-2 text-xs text-gray-500">
<span className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
{formatDate(attempt.submittedAt)}
</span>
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{formatTime(attempt.timeSpentMinutes)}
</span>
<span>Intento #{attempt.attemptNumber}</span>
</div>
</div>
</div>
<div className="text-right">
<div
className={`text-lg font-bold ${getScoreColor(
attempt.percentage,
attempt.passingScore
)}`}
>
{attempt.percentage.toFixed(0)}%
</div>
<div className="text-xs text-gray-500">
{attempt.correctAnswers}/{attempt.totalQuestions}
</div>
{attempt.xpEarned > 0 && (
<div className="text-xs text-purple-400 flex items-center justify-end gap-1">
<Award className="w-3 h-3" />+{attempt.xpEarned} XP
</div>
)}
</div>
</div>
</div>
))}
</div>
{/* Show More / Less */}
{hasMore && (
<button
onClick={() => setExpanded(!expanded)}
className="w-full mt-3 py-2 text-sm text-gray-400 hover:text-white transition-colors flex items-center justify-center gap-1"
>
{expanded ? (
<>
<ChevronUp className="w-4 h-4" />
Mostrar menos
</>
) : (
<>
<ChevronDown className="w-4 h-4" />
Ver {sortedAttempts.length - (limit || 0)} mas
</>
)}
</button>
)}
{/* Retake Button */}
{onRetakeQuiz && sortedAttempts.length > 0 && !sortedAttempts[0].passed && (
<button
onClick={() => onRetakeQuiz(sortedAttempts[0].quizId)}
className="w-full mt-4 py-2.5 bg-purple-600 hover:bg-purple-500 text-white rounded-lg font-medium transition-colors flex items-center justify-center gap-2"
>
<RefreshCw className="w-4 h-4" />
Reintentar ultimo quiz
</button>
)}
</div>
);
};
export default QuizHistoryCard;

View File

@ -33,3 +33,9 @@ export { default as CertificateGenerator } from './CertificateGenerator';
export type { CertificateData, CertificateTemplate } from './CertificateGenerator'; export type { CertificateData, CertificateTemplate } from './CertificateGenerator';
export { default as LiveStreamPlayer } from './LiveStreamPlayer'; export { default as LiveStreamPlayer } from './LiveStreamPlayer';
export type { StreamInfo, ChatMessage, StreamReaction } from './LiveStreamPlayer'; export type { StreamInfo, ChatMessage, StreamReaction } from './LiveStreamPlayer';
// Quiz History & Earned Certificates (OQI-002 - SUBTASK-007)
export { default as QuizHistoryCard } from './QuizHistoryCard';
export type { QuizAttemptHistory } from './QuizHistoryCard';
export { default as EarnedCertificates } from './EarnedCertificates';
export type { EarnedCertificate } from './EarnedCertificates';

View File

@ -127,6 +127,30 @@ export default function CourseDetail() {
const isEnrolled = !!currentCourse.userEnrollment; const isEnrolled = !!currentCourse.userEnrollment;
const enrollment = currentCourse.userEnrollment; const enrollment = currentCourse.userEnrollment;
// Find the next lesson to continue from (first incomplete lesson or last accessed)
const getResumeLessonId = (): string | null => {
if (!currentCourse?.modules) return null;
// Flatten all lessons with their module order
const allLessons: LessonListItem[] = [];
currentCourse.modules
.sort((a, b) => a.sortOrder - b.sortOrder)
.forEach((module) => {
module.lessons
.sort((a, b) => a.sortOrder - b.sortOrder)
.forEach((lesson) => allLessons.push(lesson));
});
// Find first incomplete lesson
const incompleteLesson = allLessons.find((lesson) => !lesson.isCompleted);
if (incompleteLesson) return incompleteLesson.id;
// If all completed, return first lesson for review
return allLessons[0]?.id || null;
};
const resumeLessonId = getResumeLessonId();
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Back Link */} {/* Back Link */}
@ -278,11 +302,11 @@ export default function CourseDetail() {
</div> </div>
<button <button
onClick={() => navigate(`/education/courses/${slug}/lesson/1`)} onClick={() => navigate(`/education/courses/${slug}/lesson/${resumeLessonId || '1'}`)}
className="w-full py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition-colors flex items-center justify-center gap-2" className="w-full py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition-colors flex items-center justify-center gap-2"
> >
<Play className="w-5 h-5" /> <Play className="w-5 h-5" />
{enrollment.progressPercentage > 0 ? 'Continuar Curso' : 'Empezar Curso'} {enrollment.progressPercentage > 0 ? 'Continuar donde lo dejaste' : 'Empezar Curso'}
</button> </button>
</div> </div>
) : ( ) : (

View File

@ -0,0 +1,186 @@
/**
* Advisory Card Component
* Displays advisory service with advisor profile, specializations, and booking options
*/
import React from 'react';
import { Link } from 'react-router-dom';
import {
Star,
CheckCircle,
Calendar,
Clock,
MessageCircle,
Award,
} from 'lucide-react';
import type { MarketplaceProductListItem } from '../../../types/marketplace.types';
interface AdvisoryCardProps {
advisory: MarketplaceProductListItem;
onBook?: (advisoryId: string) => void;
}
export const AdvisoryCard: React.FC<AdvisoryCardProps> = ({ advisory, onBook }) => {
const availabilityColors: Record<string, { bg: string; text: string; dot: string }> = {
available: { bg: 'bg-green-500/10', text: 'text-green-400', dot: 'bg-green-400' },
busy: { bg: 'bg-yellow-500/10', text: 'text-yellow-400', dot: 'bg-yellow-400' },
away: { bg: 'bg-orange-500/10', text: 'text-orange-400', dot: 'bg-orange-400' },
offline: { bg: 'bg-gray-500/10', text: 'text-gray-400', dot: 'bg-gray-400' },
};
// Determine availability from tags (mock implementation)
const getAvailability = () => {
if (advisory.tags.includes('available')) return 'available';
if (advisory.tags.includes('busy')) return 'busy';
return 'available';
};
const availability = getAvailability();
const colors = availabilityColors[availability];
// Extract specializations from tags
const specializations = advisory.tags.filter(
(t) => !['available', 'busy', 'away', 'offline'].includes(t)
).slice(0, 4);
return (
<div className="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden hover:border-gray-600 transition-all duration-200 hover:shadow-lg hover:shadow-purple-500/5">
{/* Advisor Header */}
<div className="p-5 border-b border-gray-700">
<div className="flex items-start gap-4">
{/* Avatar */}
<div className="relative">
{advisory.providerAvatar ? (
<img
src={advisory.providerAvatar}
alt={advisory.providerName}
className="w-16 h-16 rounded-full object-cover"
/>
) : (
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-purple-500 to-blue-500 flex items-center justify-center text-white text-xl font-bold">
{advisory.providerName.charAt(0)}
</div>
)}
{/* Availability indicator */}
<div
className={`absolute -bottom-1 -right-1 w-5 h-5 rounded-full border-2 border-gray-800 ${colors.dot}`}
/>
</div>
{/* Advisor Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-lg font-semibold text-white truncate">
{advisory.providerName}
</h3>
{advisory.providerVerified && (
<CheckCircle className="w-4 h-4 text-blue-400 flex-shrink-0" />
)}
</div>
<p className="text-sm text-gray-400 mb-2">{advisory.name}</p>
<div
className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs ${colors.bg} ${colors.text}`}
>
<span className={`w-1.5 h-1.5 rounded-full ${colors.dot}`} />
{availability.charAt(0).toUpperCase() + availability.slice(1)}
</div>
</div>
</div>
</div>
{/* Content */}
<div className="p-5">
{/* Description */}
<p className="text-sm text-gray-400 mb-4 line-clamp-2">
{advisory.shortDescription || 'Expert trading advisor with years of market experience.'}
</p>
{/* Specializations */}
<div className="mb-4">
<p className="text-xs text-gray-500 mb-2">Specializations</p>
<div className="flex flex-wrap gap-2">
{specializations.length > 0 ? (
specializations.map((spec, idx) => (
<span
key={idx}
className="px-2 py-1 bg-purple-500/10 text-purple-400 text-xs rounded-full"
>
{spec}
</span>
))
) : (
<>
<span className="px-2 py-1 bg-purple-500/10 text-purple-400 text-xs rounded-full">
Technical Analysis
</span>
<span className="px-2 py-1 bg-purple-500/10 text-purple-400 text-xs rounded-full">
Risk Management
</span>
</>
)}
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-3 gap-3 mb-4">
<div className="text-center">
<div className="flex items-center justify-center gap-1 mb-1">
<Star className="w-4 h-4 text-yellow-400" />
<span className="font-semibold text-white">{advisory.rating.toFixed(1)}</span>
</div>
<p className="text-xs text-gray-500">Rating</p>
</div>
<div className="text-center border-x border-gray-700">
<div className="flex items-center justify-center gap-1 mb-1">
<MessageCircle className="w-4 h-4 text-blue-400" />
<span className="font-semibold text-white">{advisory.totalReviews}</span>
</div>
<p className="text-xs text-gray-500">Reviews</p>
</div>
<div className="text-center">
<div className="flex items-center justify-center gap-1 mb-1">
<Award className="w-4 h-4 text-green-400" />
<span className="font-semibold text-white">
{advisory.totalSubscribers || 0}
</span>
</div>
<p className="text-xs text-gray-500">Sessions</p>
</div>
</div>
{/* Response Time */}
<div className="flex items-center gap-2 mb-4 p-2 bg-gray-900/50 rounded-lg">
<Clock className="w-4 h-4 text-gray-400" />
<span className="text-sm text-gray-400">
Typically responds in <span className="text-white">2 hours</span>
</span>
</div>
{/* Price and CTA */}
<div className="flex items-center justify-between pt-3 border-t border-gray-700">
<div>
<span className="text-lg font-bold text-white">${advisory.priceUsd}</span>
<span className="text-gray-400 text-sm">/session</span>
</div>
<div className="flex gap-2">
<Link
to={`/marketplace/advisory/${advisory.slug}`}
className="px-3 py-2 text-gray-400 hover:text-white text-sm font-medium transition-colors"
>
Profile
</Link>
<button
onClick={() => onBook?.(advisory.id)}
className="px-4 py-2 bg-purple-600 hover:bg-purple-500 text-white text-sm font-medium rounded-lg transition-colors flex items-center gap-2"
>
<Calendar className="w-4 h-4" />
Book
</button>
</div>
</div>
</div>
</div>
);
};
export default AdvisoryCard;

View File

@ -0,0 +1,179 @@
/**
* Course Product Card Component
* Displays course product for marketplace with enrollment options
*/
import React from 'react';
import { Link } from 'react-router-dom';
import {
Star,
Users,
Clock,
BookOpen,
CheckCircle,
Play,
} from 'lucide-react';
import type { MarketplaceProductListItem } from '../../../types/marketplace.types';
interface CourseProductCardProps {
course: MarketplaceProductListItem;
onEnroll?: (courseId: string) => void;
}
export const CourseProductCard: React.FC<CourseProductCardProps> = ({ course, onEnroll }) => {
const difficultyColors: Record<string, string> = {
beginner: 'bg-green-500/20 text-green-400',
intermediate: 'bg-yellow-500/20 text-yellow-400',
advanced: 'bg-red-500/20 text-red-400',
};
// Extract difficulty from tags
const getDifficulty = () => {
const level = course.tags.find((t) =>
['beginner', 'intermediate', 'advanced'].includes(t.toLowerCase())
);
return level?.toLowerCase() || 'beginner';
};
const difficulty = getDifficulty();
return (
<div className="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden hover:border-gray-600 transition-all duration-200 hover:shadow-lg hover:shadow-green-500/5">
{/* Thumbnail */}
<div className="relative h-40 bg-gradient-to-br from-green-600/20 to-teal-600/20">
{course.thumbnailUrl ? (
<img
src={course.thumbnailUrl}
alt={course.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<BookOpen className="w-16 h-16 text-green-500/50" />
</div>
)}
{/* Play icon overlay */}
<div className="absolute inset-0 flex items-center justify-center bg-black/20 opacity-0 hover:opacity-100 transition-opacity">
<div className="w-14 h-14 rounded-full bg-white/90 flex items-center justify-center">
<Play className="w-6 h-6 text-gray-900 ml-1" />
</div>
</div>
{/* Free badge */}
{course.priceUsd === 0 && (
<div className="absolute top-3 left-3 px-2 py-1 bg-green-500 text-white text-xs font-medium rounded-full">
FREE
</div>
)}
{/* Difficulty badge */}
<div
className={`absolute top-3 right-3 px-2 py-1 text-xs font-medium rounded-full ${
difficultyColors[difficulty]
}`}
>
{difficulty.charAt(0).toUpperCase() + difficulty.slice(1)}
</div>
</div>
{/* Content */}
<div className="p-4">
{/* Instructor info */}
<div className="flex items-center gap-2 mb-3">
{course.providerAvatar ? (
<img
src={course.providerAvatar}
alt={course.providerName}
className="w-7 h-7 rounded-full"
/>
) : (
<div className="w-7 h-7 rounded-full bg-gray-700 flex items-center justify-center text-gray-400 text-xs">
{course.providerName.charAt(0)}
</div>
)}
<span className="text-sm text-gray-400 truncate flex items-center gap-1">
{course.providerName}
{course.providerVerified && (
<CheckCircle className="w-3.5 h-3.5 text-blue-400" />
)}
</span>
</div>
{/* Title */}
<h3 className="text-lg font-semibold text-white mb-2 line-clamp-2 min-h-[3.5rem]">
{course.name}
</h3>
{/* Meta info */}
<div className="flex items-center gap-4 text-sm text-gray-400 mb-4">
<span className="flex items-center gap-1">
<Clock className="w-4 h-4" />
{Math.floor(Math.random() * 10) + 2}h
</span>
<span className="flex items-center gap-1">
<BookOpen className="w-4 h-4" />
{Math.floor(Math.random() * 30) + 5} lessons
</span>
</div>
{/* Rating and Enrollments */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-1">
<Star className="w-4 h-4 text-yellow-400 fill-yellow-400" />
<span className="font-semibold text-white">{course.rating.toFixed(1)}</span>
<span className="text-gray-500 text-sm">({course.totalReviews})</span>
</div>
<div className="flex items-center gap-1 text-gray-400 text-sm">
<Users className="w-4 h-4" />
{(course.totalSubscribers || 0).toLocaleString()} enrolled
</div>
</div>
{/* Topics/Tags */}
<div className="flex flex-wrap gap-1 mb-4">
{course.tags
.filter((t) => !['beginner', 'intermediate', 'advanced'].includes(t.toLowerCase()))
.slice(0, 3)
.map((tag, idx) => (
<span
key={idx}
className="px-2 py-0.5 bg-gray-700 text-gray-300 text-xs rounded"
>
{tag}
</span>
))}
</div>
{/* Price and CTA */}
<div className="flex items-center justify-between pt-3 border-t border-gray-700">
<div>
{course.priceUsd === 0 ? (
<span className="text-xl font-bold text-green-400">Free</span>
) : (
<>
<span className="text-xl font-bold text-white">${course.priceUsd}</span>
</>
)}
</div>
<div className="flex gap-2">
<Link
to={`/marketplace/courses/${course.slug}`}
className="px-3 py-2 text-gray-400 hover:text-white text-sm font-medium transition-colors"
>
Preview
</Link>
<button
onClick={() => onEnroll?.(course.id)}
className="px-4 py-2 bg-green-600 hover:bg-green-500 text-white text-sm font-medium rounded-lg transition-colors"
>
{course.priceUsd === 0 ? 'Enroll Free' : 'Buy Course'}
</button>
</div>
</div>
</div>
</div>
);
};
export default CourseProductCard;

View File

@ -0,0 +1,30 @@
/**
* Product Card Component
* Generic product card that adapts to different product types
*/
import React from 'react';
import type { MarketplaceProductListItem } from '../../../types/marketplace.types';
import { SignalPackCard } from './SignalPackCard';
import { AdvisoryCard } from './AdvisoryCard';
import { CourseProductCard } from './CourseProductCard';
interface ProductCardProps {
product: MarketplaceProductListItem;
onAction?: (productId: string) => void;
}
export const ProductCard: React.FC<ProductCardProps> = ({ product, onAction }) => {
switch (product.productType) {
case 'signals':
return <SignalPackCard pack={product} onSubscribe={onAction} />;
case 'advisory':
return <AdvisoryCard advisory={product} onBook={onAction} />;
case 'courses':
return <CourseProductCard course={product} onEnroll={onAction} />;
default:
return <SignalPackCard pack={product} onSubscribe={onAction} />;
}
};
export default ProductCard;

View File

@ -0,0 +1,123 @@
/**
* Review Card Component
* Displays product review with rating, comment, and helpful count
*/
import React, { useState } from 'react';
import { Star, ThumbsUp, CheckCircle, MessageCircle } from 'lucide-react';
import type { ProductReview } from '../../../types/marketplace.types';
interface ReviewCardProps {
review: ProductReview;
onMarkHelpful?: (reviewId: string) => void;
}
export const ReviewCard: React.FC<ReviewCardProps> = ({ review, onMarkHelpful }) => {
const [isHelpful, setIsHelpful] = useState(false);
const handleMarkHelpful = () => {
if (!isHelpful) {
setIsHelpful(true);
onMarkHelpful?.(review.id);
}
};
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
return (
<div className="bg-gray-800/50 rounded-xl border border-gray-700 p-5">
{/* Header */}
<div className="flex items-start gap-4 mb-4">
{/* Avatar */}
{review.userAvatar ? (
<img
src={review.userAvatar}
alt={review.userName}
className="w-10 h-10 rounded-full object-cover"
/>
) : (
<div className="w-10 h-10 rounded-full bg-gray-700 flex items-center justify-center text-gray-400 font-medium">
{review.userName.charAt(0).toUpperCase()}
</div>
)}
{/* User info and rating */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h4 className="font-medium text-white">{review.userName}</h4>
{review.verified && (
<span className="flex items-center gap-1 text-xs text-green-400">
<CheckCircle className="w-3.5 h-3.5" />
Verified Purchase
</span>
)}
</div>
{/* Rating and date */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-1">
{[1, 2, 3, 4, 5].map((star) => (
<Star
key={star}
className={`w-4 h-4 ${
star <= review.rating
? 'text-yellow-400 fill-yellow-400'
: 'text-gray-600'
}`}
/>
))}
</div>
<span className="text-sm text-gray-500">{formatDate(review.createdAt)}</span>
</div>
</div>
</div>
{/* Review title */}
{review.title && (
<h5 className="font-semibold text-white mb-2">{review.title}</h5>
)}
{/* Review content */}
<p className="text-gray-300 mb-4 leading-relaxed">{review.comment}</p>
{/* Provider response */}
{review.response && (
<div className="bg-gray-900/50 rounded-lg p-4 mb-4 border-l-2 border-blue-500">
<div className="flex items-center gap-2 mb-2">
<MessageCircle className="w-4 h-4 text-blue-400" />
<span className="text-sm font-medium text-blue-400">Provider Response</span>
<span className="text-xs text-gray-500">
{formatDate(review.response.respondedAt)}
</span>
</div>
<p className="text-gray-400 text-sm">{review.response.comment}</p>
</div>
)}
{/* Actions */}
<div className="flex items-center justify-between pt-3 border-t border-gray-700">
<button
onClick={handleMarkHelpful}
disabled={isHelpful}
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm transition-colors ${
isHelpful
? 'bg-blue-500/20 text-blue-400 cursor-not-allowed'
: 'text-gray-400 hover:text-white hover:bg-gray-700'
}`}
>
<ThumbsUp className={`w-4 h-4 ${isHelpful ? 'fill-current' : ''}`} />
Helpful ({review.helpful + (isHelpful ? 1 : 0)})
</button>
</div>
</div>
);
};
export default ReviewCard;

View File

@ -0,0 +1,174 @@
/**
* Signal Pack Card Component
* Displays signal pack summary with provider info, stats, and subscription options
*/
import React from 'react';
import { Link } from 'react-router-dom';
import {
TrendingUp,
TrendingDown,
Star,
Users,
BarChart2,
CheckCircle,
Zap,
} from 'lucide-react';
import type { MarketplaceProductListItem } from '../../../types/marketplace.types';
interface SignalPackCardProps {
pack: MarketplaceProductListItem;
onSubscribe?: (packId: string) => void;
}
export const SignalPackCard: React.FC<SignalPackCardProps> = ({ pack, onSubscribe }) => {
const riskLevelColors: Record<string, string> = {
low: 'bg-green-500/20 text-green-400 border-green-500/30',
medium: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
high: 'bg-red-500/20 text-red-400 border-red-500/30',
};
const getRiskTag = () => {
const tag = pack.tags.find((t) => ['low', 'medium', 'high'].includes(t.toLowerCase()));
return tag || 'medium';
};
return (
<div className="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden hover:border-gray-600 transition-all duration-200 hover:shadow-lg hover:shadow-blue-500/5">
{/* Header with thumbnail */}
<div className="relative h-40 bg-gradient-to-br from-blue-600/20 to-purple-600/20">
{pack.thumbnailUrl ? (
<img
src={pack.thumbnailUrl}
alt={pack.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<BarChart2 className="w-16 h-16 text-blue-500/50" />
</div>
)}
{/* Free trial badge */}
{pack.hasFreeTrial && (
<div className="absolute top-3 left-3 px-2 py-1 bg-green-500 text-white text-xs font-medium rounded-full flex items-center gap-1">
<Zap className="w-3 h-3" />
Free Trial
</div>
)}
{/* Risk level badge */}
<div
className={`absolute top-3 right-3 px-2 py-1 text-xs font-medium rounded-full border ${
riskLevelColors[getRiskTag()]
}`}
>
{getRiskTag().charAt(0).toUpperCase() + getRiskTag().slice(1)} Risk
</div>
</div>
{/* Content */}
<div className="p-4">
{/* Provider info */}
<div className="flex items-center gap-2 mb-3">
{pack.providerAvatar ? (
<img
src={pack.providerAvatar}
alt={pack.providerName}
className="w-8 h-8 rounded-full"
/>
) : (
<div className="w-8 h-8 rounded-full bg-gray-700 flex items-center justify-center text-gray-400 text-sm">
{pack.providerName.charAt(0)}
</div>
)}
<div className="flex-1 min-w-0">
<p className="text-sm text-gray-400 truncate flex items-center gap-1">
{pack.providerName}
{pack.providerVerified && (
<CheckCircle className="w-3.5 h-3.5 text-blue-400" />
)}
</p>
</div>
</div>
{/* Title */}
<h3 className="text-lg font-semibold text-white mb-2 line-clamp-1">
{pack.name}
</h3>
{/* Description */}
<p className="text-sm text-gray-400 mb-4 line-clamp-2">
{pack.shortDescription || 'Professional trading signals for consistent profits.'}
</p>
{/* Stats grid */}
<div className="grid grid-cols-2 gap-3 mb-4">
<div className="bg-gray-900/50 rounded-lg p-2.5">
<div className="flex items-center gap-1.5 text-gray-400 text-xs mb-1">
<TrendingUp className="w-3.5 h-3.5" />
Win Rate
</div>
<p className="text-green-400 font-semibold">
{(Math.random() * 20 + 70).toFixed(1)}%
</p>
</div>
<div className="bg-gray-900/50 rounded-lg p-2.5">
<div className="flex items-center gap-1.5 text-gray-400 text-xs mb-1">
<Users className="w-3.5 h-3.5" />
Subscribers
</div>
<p className="text-white font-semibold">
{pack.totalSubscribers?.toLocaleString() || '0'}
</p>
</div>
</div>
{/* Rating */}
<div className="flex items-center gap-2 mb-4">
<div className="flex items-center gap-1">
{[1, 2, 3, 4, 5].map((star) => (
<Star
key={star}
className={`w-4 h-4 ${
star <= Math.round(pack.rating)
? 'text-yellow-400 fill-yellow-400'
: 'text-gray-600'
}`}
/>
))}
</div>
<span className="text-sm text-gray-400">
({pack.totalReviews} reviews)
</span>
</div>
{/* Price and CTA */}
<div className="flex items-center justify-between pt-3 border-t border-gray-700">
<div>
<span className="text-2xl font-bold text-white">
${pack.priceUsd}
</span>
<span className="text-gray-400 text-sm">/mo</span>
</div>
<div className="flex gap-2">
<Link
to={`/marketplace/signals/${pack.slug}`}
className="px-3 py-2 text-gray-400 hover:text-white text-sm font-medium transition-colors"
>
Details
</Link>
<button
onClick={() => onSubscribe?.(pack.id)}
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium rounded-lg transition-colors"
>
Subscribe
</button>
</div>
</div>
</div>
</div>
);
};
export default SignalPackCard;

View File

@ -0,0 +1,9 @@
/**
* Marketplace Components Index
*/
export { SignalPackCard } from './SignalPackCard';
export { AdvisoryCard } from './AdvisoryCard';
export { CourseProductCard } from './CourseProductCard';
export { ProductCard } from './ProductCard';
export { ReviewCard } from './ReviewCard';

View File

@ -0,0 +1,453 @@
/**
* Advisory Service Detail Page
* Full details of an advisory service with advisor profile, availability, and booking
*/
import React, { useEffect, useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import {
ArrowLeft,
Star,
CheckCircle,
Calendar,
Clock,
MessageCircle,
Award,
Globe,
Shield,
Video,
AlertCircle,
Loader2,
ChevronLeft,
ChevronRight,
} from 'lucide-react';
import { useMarketplaceStore } from '../../../stores/marketplaceStore';
import { ReviewCard } from '../components';
import type { ServiceType } from '../../../types/marketplace.types';
export default function AdvisoryDetail() {
const { slug } = useParams<{ slug: string }>();
const {
currentAdvisory: advisory,
reviews,
totalReviews,
availableSlots,
availabilityTimezone,
loadingDetail,
loadingReviews,
loadingAvailability,
error,
fetchAdvisoryBySlug,
fetchReviews,
fetchAdvisorAvailability,
bookConsultation,
markReviewHelpful,
resetCurrentProduct,
} = useMarketplaceStore();
const [selectedService, setSelectedService] = useState<ServiceType | null>(null);
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
const [selectedSlot, setSelectedSlot] = useState<string | null>(null);
const [notes, setNotes] = useState('');
const [booking, setBooking] = useState(false);
useEffect(() => {
if (slug) {
fetchAdvisoryBySlug(slug);
}
return () => {
resetCurrentProduct();
};
}, [slug, fetchAdvisoryBySlug, resetCurrentProduct]);
useEffect(() => {
if (advisory) {
fetchReviews(advisory.id, 'advisory', { page: 1, pageSize: 5, sortBy: 'helpful' });
// Set default service
if (advisory.serviceTypes.length > 0 && !selectedService) {
setSelectedService(advisory.serviceTypes[0]);
}
}
}, [advisory, fetchReviews, selectedService]);
useEffect(() => {
if (advisory && selectedDate) {
const dateStr = selectedDate.toISOString().split('T')[0];
fetchAdvisorAvailability(advisory.id, dateStr);
}
}, [advisory, selectedDate, fetchAdvisorAvailability]);
const handleDateChange = (direction: 'prev' | 'next') => {
const newDate = new Date(selectedDate);
if (direction === 'next') {
newDate.setDate(newDate.getDate() + 1);
} else {
newDate.setDate(newDate.getDate() - 1);
}
setSelectedDate(newDate);
setSelectedSlot(null);
};
const handleBook = async () => {
if (!advisory || !selectedService || !selectedSlot) return;
setBooking(true);
try {
await bookConsultation(advisory.id, {
serviceTypeId: selectedService.id,
scheduledAt: `${selectedDate.toISOString().split('T')[0]}T${selectedSlot}:00.000Z`,
notes: notes || undefined,
});
// Show success or redirect
} catch (err) {
console.error('Failed to book:', err);
} finally {
setBooking(false);
}
};
if (loadingDetail) {
return (
<div className="flex items-center justify-center min-h-96">
<Loader2 className="w-10 h-10 text-blue-500 animate-spin" />
</div>
);
}
if (error || !advisory) {
return (
<div className="flex flex-col items-center justify-center min-h-96">
<AlertCircle className="w-12 h-12 text-red-400 mb-4" />
<h2 className="text-xl font-semibold text-white mb-2">Advisory Not Found</h2>
<p className="text-gray-400 mb-4">{error || 'The requested advisory does not exist.'}</p>
<Link
to="/marketplace"
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg"
>
Back to Marketplace
</Link>
</div>
);
}
const formatDate = (date: Date) => {
return date.toLocaleDateString('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric',
});
};
const isToday = (date: Date) => {
const today = new Date();
return date.toDateString() === today.toDateString();
};
return (
<div className="space-y-8">
{/* Back link */}
<Link
to="/marketplace"
className="inline-flex items-center gap-2 text-gray-400 hover:text-white transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Back to Marketplace
</Link>
{/* Main Content */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Left Column - Advisor Info */}
<div className="lg:col-span-2 space-y-6">
{/* Advisor Header */}
<div className="bg-gray-800 rounded-xl border border-gray-700 p-6">
<div className="flex items-start gap-6">
{/* Avatar */}
<div className="relative flex-shrink-0">
{advisory.advisor.avatarUrl ? (
<img
src={advisory.advisor.avatarUrl}
alt={advisory.advisor.name}
className="w-24 h-24 rounded-full object-cover"
/>
) : (
<div className="w-24 h-24 rounded-full bg-gradient-to-br from-purple-500 to-blue-500 flex items-center justify-center text-white text-2xl font-bold">
{advisory.advisor.name.charAt(0)}
</div>
)}
{/* Online indicator */}
{advisory.availability === 'available' && (
<div className="absolute -bottom-1 -right-1 w-6 h-6 rounded-full bg-green-500 border-4 border-gray-800" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-2">
<h1 className="text-2xl font-bold text-white">{advisory.advisor.name}</h1>
{advisory.advisor.verified && (
<CheckCircle className="w-6 h-6 text-blue-400" />
)}
</div>
{advisory.advisor.title && (
<p className="text-lg text-gray-400 mb-2">{advisory.advisor.title}</p>
)}
{/* Quick Stats */}
<div className="flex items-center gap-4 mb-4">
<div className="flex items-center gap-1">
<Star className="w-5 h-5 text-yellow-400 fill-yellow-400" />
<span className="text-white font-medium">{advisory.rating.toFixed(1)}</span>
<span className="text-gray-500">({advisory.totalReviews} reviews)</span>
</div>
<span className="text-gray-600">|</span>
<div className="flex items-center gap-1 text-gray-400">
<MessageCircle className="w-4 h-4" />
<span>{advisory.totalConsultations} consultations</span>
</div>
</div>
{/* Experience */}
<p className="text-gray-300">{advisory.advisor.experience}</p>
</div>
</div>
</div>
{/* About */}
<div className="bg-gray-800 rounded-xl border border-gray-700 p-6">
<h2 className="text-lg font-semibold text-white mb-4">About</h2>
<p className="text-gray-300 mb-6">{advisory.description}</p>
{/* Credentials */}
{advisory.advisor.credentials.length > 0 && (
<div className="mb-6">
<h3 className="text-sm font-medium text-gray-400 mb-3">Credentials</h3>
<div className="flex flex-wrap gap-2">
{advisory.advisor.credentials.map((cred, idx) => (
<span
key={idx}
className="px-3 py-1.5 bg-blue-500/10 text-blue-400 rounded-lg text-sm flex items-center gap-2"
>
<Award className="w-4 h-4" />
{cred}
</span>
))}
</div>
</div>
)}
{/* Specializations */}
<div className="mb-6">
<h3 className="text-sm font-medium text-gray-400 mb-3">Specializations</h3>
<div className="flex flex-wrap gap-2">
{advisory.specializations.map((spec, idx) => (
<span
key={idx}
className="px-3 py-1.5 bg-purple-500/10 text-purple-400 rounded-lg text-sm"
>
{spec}
</span>
))}
</div>
</div>
{/* Languages */}
<div>
<h3 className="text-sm font-medium text-gray-400 mb-3">Languages</h3>
<div className="flex flex-wrap gap-2">
{advisory.languages.map((lang, idx) => (
<span
key={idx}
className="px-3 py-1.5 bg-gray-700 text-gray-300 rounded-lg text-sm flex items-center gap-2"
>
<Globe className="w-4 h-4" />
{lang}
</span>
))}
</div>
</div>
</div>
{/* Service Types */}
<div className="bg-gray-800 rounded-xl border border-gray-700 p-6">
<h2 className="text-lg font-semibold text-white mb-4">Services Offered</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{advisory.serviceTypes.map((service) => (
<div
key={service.id}
className={`p-4 rounded-lg border-2 cursor-pointer transition-colors ${
selectedService?.id === service.id
? 'border-purple-500 bg-purple-500/10'
: 'border-gray-700 hover:border-gray-600'
}`}
onClick={() => setSelectedService(service)}
>
<div className="flex items-start justify-between mb-2">
<h3 className="font-medium text-white">{service.name}</h3>
<p className="text-lg font-bold text-white">${service.priceUsd}</p>
</div>
<p className="text-sm text-gray-400 mb-2">{service.description}</p>
<p className="text-sm text-gray-500 flex items-center gap-1">
<Clock className="w-4 h-4" />
{service.durationMinutes} minutes
</p>
</div>
))}
</div>
</div>
{/* Reviews Section */}
<div className="bg-gray-800 rounded-xl border border-gray-700 p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white">
Reviews ({totalReviews})
</h2>
</div>
{loadingReviews ? (
<div className="flex justify-center py-8">
<Loader2 className="w-6 h-6 text-blue-500 animate-spin" />
</div>
) : reviews.length > 0 ? (
<div className="space-y-4">
{reviews.map((review) => (
<ReviewCard
key={review.id}
review={review}
onMarkHelpful={markReviewHelpful}
/>
))}
</div>
) : (
<p className="text-gray-400 text-center py-8">No reviews yet.</p>
)}
</div>
</div>
{/* Right Column - Booking */}
<div className="space-y-6">
{/* Booking Card */}
<div className="bg-gray-800 rounded-xl border border-gray-700 p-6 sticky top-6">
<h2 className="text-lg font-semibold text-white mb-4">Book a Consultation</h2>
{/* Selected Service */}
{selectedService && (
<div className="mb-4 p-3 bg-gray-900/50 rounded-lg">
<p className="text-sm text-gray-400 mb-1">Selected Service</p>
<p className="font-medium text-white">{selectedService.name}</p>
<p className="text-sm text-gray-400">
{selectedService.durationMinutes} min - ${selectedService.priceUsd}
</p>
</div>
)}
{/* Date Picker */}
<div className="mb-4">
<div className="flex items-center justify-between mb-3">
<button
onClick={() => handleDateChange('prev')}
disabled={isToday(selectedDate)}
className="p-2 text-gray-400 hover:text-white disabled:opacity-30 disabled:cursor-not-allowed"
>
<ChevronLeft className="w-5 h-5" />
</button>
<div className="text-center">
<p className="font-medium text-white">{formatDate(selectedDate)}</p>
<p className="text-xs text-gray-500">{availabilityTimezone}</p>
</div>
<button
onClick={() => handleDateChange('next')}
className="p-2 text-gray-400 hover:text-white"
>
<ChevronRight className="w-5 h-5" />
</button>
</div>
</div>
{/* Time Slots */}
<div className="mb-4">
<p className="text-sm text-gray-400 mb-3">Available Times</p>
{loadingAvailability ? (
<div className="flex justify-center py-4">
<Loader2 className="w-5 h-5 text-blue-500 animate-spin" />
</div>
) : availableSlots.length > 0 ? (
<div className="grid grid-cols-3 gap-2">
{availableSlots.map((slot) => (
<button
key={slot}
onClick={() => setSelectedSlot(slot)}
className={`py-2 px-3 rounded-lg text-sm font-medium transition-colors ${
selectedSlot === slot
? 'bg-purple-600 text-white'
: 'bg-gray-900 text-gray-400 hover:text-white hover:bg-gray-700'
}`}
>
{slot}
</button>
))}
</div>
) : (
<p className="text-gray-500 text-center py-4">No slots available</p>
)}
</div>
{/* Notes */}
<div className="mb-6">
<label className="block text-sm text-gray-400 mb-2">
Notes (optional)
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="What would you like to discuss?"
rows={3}
className="w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:border-purple-500 focus:outline-none resize-none"
/>
</div>
{/* Book Button */}
<button
onClick={handleBook}
disabled={booking || !selectedService || !selectedSlot}
className="w-full py-3 bg-purple-600 hover:bg-purple-500 text-white font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{booking ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<>
<Calendar className="w-5 h-5" />
Book Consultation
</>
)}
</button>
{/* Price Summary */}
{selectedService && (
<div className="mt-4 pt-4 border-t border-gray-700">
<div className="flex items-center justify-between text-lg">
<span className="text-gray-400">Total</span>
<span className="font-bold text-white">${selectedService.priceUsd}</span>
</div>
</div>
)}
{/* Trust Signals */}
<div className="mt-6 pt-6 border-t border-gray-700 space-y-3">
<div className="flex items-center gap-3 text-sm text-gray-400">
<Video className="w-5 h-5 text-blue-400" />
<span>Video call via Zoom/Meet</span>
</div>
<div className="flex items-center gap-3 text-sm text-gray-400">
<Shield className="w-5 h-5 text-green-400" />
<span>Full refund if cancelled 24h before</span>
</div>
<div className="flex items-center gap-3 text-sm text-gray-400">
<Clock className="w-5 h-5 text-purple-400" />
<span>Responds in ~{advisory.responseTimeHours}h</span>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,396 @@
/**
* Marketplace Catalog Page
* Main marketplace page with grid/list view, filters, search, and sorting
*/
import React, { useEffect, useState } from 'react';
import {
Search,
Filter,
X,
Loader2,
Grid,
List,
SlidersHorizontal,
ChevronDown,
} from 'lucide-react';
import { useMarketplaceStore } from '../../../stores/marketplaceStore';
import { ProductCard } from '../components';
import type { ProductCategory, MarketplaceFilters } from '../../../types/marketplace.types';
const categoryOptions: { value: ProductCategory | ''; label: string; icon: string }[] = [
{ value: '', label: 'All Products', icon: '📦' },
{ value: 'signals', label: 'Signal Packs', icon: '📊' },
{ value: 'courses', label: 'Courses', icon: '📚' },
{ value: 'advisory', label: 'Advisory Services', icon: '👨‍💼' },
{ value: 'tools', label: 'Trading Tools', icon: '🛠️' },
];
const sortOptions = [
{ value: 'newest', label: 'Newest' },
{ value: 'popular', label: 'Most Popular' },
{ value: 'rating', label: 'Top Rated' },
{ value: 'price_asc', label: 'Price: Low to High' },
{ value: 'price_desc', label: 'Price: High to Low' },
];
export default function MarketplaceCatalog() {
const {
products,
featuredProducts,
totalProducts,
currentPage,
pageSize,
loadingProducts,
fetchProducts,
fetchFeaturedProducts,
setFilters,
filters,
} = useMarketplaceStore();
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [searchTerm, setSearchTerm] = useState('');
const [selectedCategory, setSelectedCategory] = useState<ProductCategory | ''>('');
const [sortBy, setSortBy] = useState<MarketplaceFilters['sortBy']>('popular');
const [showFilters, setShowFilters] = useState(false);
const [minRating, setMinRating] = useState<number | undefined>(undefined);
const [verifiedOnly, setVerifiedOnly] = useState(false);
const [hasFreeTrial, setHasFreeTrial] = useState(false);
// Load initial data
useEffect(() => {
fetchProducts();
fetchFeaturedProducts();
}, [fetchProducts, fetchFeaturedProducts]);
// Apply filters when they change
useEffect(() => {
const newFilters: MarketplaceFilters = {
search: searchTerm || undefined,
category: selectedCategory || undefined,
sortBy,
minRating,
verified: verifiedOnly || undefined,
hasFreeTrial: hasFreeTrial || undefined,
page: 1,
pageSize: 12,
};
setFilters(newFilters);
fetchProducts(newFilters);
}, [searchTerm, selectedCategory, sortBy, minRating, verifiedOnly, hasFreeTrial, setFilters, fetchProducts]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
fetchProducts({ ...filters, search: searchTerm, page: 1 });
};
const handlePageChange = (page: number) => {
fetchProducts({ ...filters, page });
};
const clearFilters = () => {
setSearchTerm('');
setSelectedCategory('');
setSortBy('popular');
setMinRating(undefined);
setVerifiedOnly(false);
setHasFreeTrial(false);
};
const hasActiveFilters =
searchTerm || selectedCategory || minRating || verifiedOnly || hasFreeTrial || sortBy !== 'popular';
const totalPages = Math.ceil(totalProducts / pageSize);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-white">Marketplace</h1>
<p className="text-gray-400">
Discover premium signals, expert advisors, and trading courses
</p>
</div>
{/* View mode toggle */}
<div className="flex items-center gap-2 bg-gray-800 rounded-lg p-1">
<button
onClick={() => setViewMode('grid')}
className={`p-2 rounded-md transition-colors ${
viewMode === 'grid'
? 'bg-gray-700 text-white'
: 'text-gray-400 hover:text-white'
}`}
title="Grid view"
>
<Grid className="w-5 h-5" />
</button>
<button
onClick={() => setViewMode('list')}
className={`p-2 rounded-md transition-colors ${
viewMode === 'list'
? 'bg-gray-700 text-white'
: 'text-gray-400 hover:text-white'
}`}
title="List view"
>
<List className="w-5 h-5" />
</button>
</div>
</div>
{/* Category Pills */}
<div className="flex flex-wrap gap-2">
{categoryOptions.map((cat) => (
<button
key={cat.value}
onClick={() => setSelectedCategory(cat.value as ProductCategory | '')}
className={`px-4 py-2 rounded-full font-medium transition-colors flex items-center gap-2 ${
selectedCategory === cat.value
? 'bg-blue-600 text-white'
: 'bg-gray-800 text-gray-400 hover:text-white hover:bg-gray-700'
}`}
>
<span>{cat.icon}</span>
<span>{cat.label}</span>
</button>
))}
</div>
{/* Search and Filters */}
<div className="bg-gray-800 rounded-xl border border-gray-700 p-4">
<form onSubmit={handleSearch} className="flex flex-col lg:flex-row gap-4">
{/* Search Input */}
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search products, providers..."
className="w-full pl-10 pr-4 py-2.5 bg-gray-900 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:border-blue-500 focus:outline-none"
/>
</div>
{/* Sort and Filters */}
<div className="flex flex-wrap gap-3">
{/* Sort */}
<div className="relative">
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as MarketplaceFilters['sortBy'])}
className="appearance-none px-4 py-2.5 pr-10 bg-gray-900 border border-gray-700 rounded-lg text-white focus:border-blue-500 focus:outline-none cursor-pointer"
>
{sortOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
</div>
{/* Filter toggle */}
<button
type="button"
onClick={() => setShowFilters(!showFilters)}
className={`px-4 py-2.5 rounded-lg border font-medium transition-colors flex items-center gap-2 ${
showFilters || hasActiveFilters
? 'bg-blue-500/20 border-blue-500/50 text-blue-400'
: 'bg-gray-900 border-gray-700 text-gray-400 hover:text-white'
}`}
>
<SlidersHorizontal className="w-4 h-4" />
Filters
{hasActiveFilters && (
<span className="w-2 h-2 rounded-full bg-blue-400" />
)}
</button>
{/* Clear Filters */}
{hasActiveFilters && (
<button
type="button"
onClick={clearFilters}
className="px-4 py-2.5 text-gray-400 hover:text-white flex items-center gap-2"
>
<X className="w-4 h-4" />
Clear
</button>
)}
</div>
</form>
{/* Expandable Filters */}
{showFilters && (
<div className="mt-4 pt-4 border-t border-gray-700 grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Min Rating */}
<div>
<label className="block text-sm text-gray-400 mb-2">Minimum Rating</label>
<div className="flex gap-2">
{[4, 4.5, 5].map((rating) => (
<button
key={rating}
onClick={() => setMinRating(minRating === rating ? undefined : rating)}
className={`px-3 py-1.5 rounded-lg text-sm transition-colors ${
minRating === rating
? 'bg-yellow-500/20 text-yellow-400 border border-yellow-500/50'
: 'bg-gray-900 text-gray-400 border border-gray-700 hover:text-white'
}`}
>
{rating}+ Stars
</button>
))}
</div>
</div>
{/* Verified Only */}
<div>
<label className="block text-sm text-gray-400 mb-2">Provider Status</label>
<button
onClick={() => setVerifiedOnly(!verifiedOnly)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
verifiedOnly
? 'bg-blue-500/20 text-blue-400 border border-blue-500/50'
: 'bg-gray-900 text-gray-400 border border-gray-700 hover:text-white'
}`}
>
Verified Only
</button>
</div>
{/* Free Trial */}
<div>
<label className="block text-sm text-gray-400 mb-2">Pricing</label>
<button
onClick={() => setHasFreeTrial(!hasFreeTrial)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
hasFreeTrial
? 'bg-green-500/20 text-green-400 border border-green-500/50'
: 'bg-gray-900 text-gray-400 border border-gray-700 hover:text-white'
}`}
>
Free Trial Available
</button>
</div>
</div>
)}
{/* Results Count */}
<div className="mt-4 flex items-center justify-between text-sm text-gray-400">
<span>
{loadingProducts ? 'Searching...' : `${totalProducts} products found`}
</span>
{hasActiveFilters && (
<span className="flex items-center gap-1">
<Filter className="w-4 h-4" />
Filters active
</span>
)}
</div>
</div>
{/* Featured Section (only on first page without filters) */}
{!hasActiveFilters && currentPage === 1 && featuredProducts.length > 0 && (
<div className="mb-8">
<h2 className="text-xl font-semibold text-white mb-4">Featured</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{featuredProducts.slice(0, 3).map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
</div>
)}
{/* Product Grid/List */}
{loadingProducts ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 text-blue-500 animate-spin" />
</div>
) : products.length > 0 ? (
<>
<div
className={
viewMode === 'grid'
? 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6'
: 'flex flex-col gap-4'
}
>
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 mt-8">
<button
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage <= 1}
className="px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
let pageNum: number;
if (totalPages <= 5) {
pageNum = i + 1;
} else if (currentPage <= 3) {
pageNum = i + 1;
} else if (currentPage >= totalPages - 2) {
pageNum = totalPages - 4 + i;
} else {
pageNum = currentPage - 2 + i;
}
return (
<button
key={pageNum}
onClick={() => handlePageChange(pageNum)}
className={`w-10 h-10 rounded-lg font-medium transition-colors ${
currentPage === pageNum
? 'bg-blue-600 text-white'
: 'bg-gray-800 text-gray-400 hover:text-white'
}`}
>
{pageNum}
</button>
);
})}
</div>
<button
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage >= totalPages}
className="px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
)}
</>
) : (
<div className="text-center py-12">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-gray-800 mb-4">
<Search className="w-8 h-8 text-gray-500" />
</div>
<h3 className="text-lg font-medium text-white mb-2">No products found</h3>
<p className="text-gray-400 mb-4">
Try adjusting your filters or search with different terms
</p>
{hasActiveFilters && (
<button
onClick={clearFilters}
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-colors"
>
Clear Filters
</button>
)}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,399 @@
/**
* Signal Pack Detail Page
* Full details of a signal pack with stats, performance history, and subscription
*/
import React, { useEffect, useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import {
ArrowLeft,
Star,
Users,
TrendingUp,
CheckCircle,
BarChart2,
Clock,
Calendar,
Shield,
Zap,
AlertCircle,
Loader2,
} from 'lucide-react';
import { useMarketplaceStore } from '../../../stores/marketplaceStore';
import { ReviewCard } from '../components';
import type { SignalPackPricing } from '../../../types/marketplace.types';
export default function SignalPackDetail() {
const { slug } = useParams<{ slug: string }>();
const {
currentSignalPack: pack,
reviews,
totalReviews,
relatedProducts,
loadingDetail,
loadingReviews,
error,
fetchSignalPackBySlug,
fetchReviews,
fetchRelatedProducts,
subscribeToSignalPack,
markReviewHelpful,
resetCurrentProduct,
} = useMarketplaceStore();
const [selectedPricing, setSelectedPricing] = useState<SignalPackPricing | null>(null);
const [subscribing, setSubscribing] = useState(false);
useEffect(() => {
if (slug) {
fetchSignalPackBySlug(slug);
}
return () => {
resetCurrentProduct();
};
}, [slug, fetchSignalPackBySlug, resetCurrentProduct]);
useEffect(() => {
if (pack) {
fetchReviews(pack.id, 'signals', { page: 1, pageSize: 5, sortBy: 'helpful' });
fetchRelatedProducts(pack.id);
// Set default pricing (monthly or first available)
const defaultPricing = pack.pricing.find((p) => p.period === 'monthly') || pack.pricing[0];
setSelectedPricing(defaultPricing);
}
}, [pack, fetchReviews, fetchRelatedProducts]);
const handleSubscribe = async () => {
if (!pack || !selectedPricing) return;
setSubscribing(true);
try {
await subscribeToSignalPack(pack.id, selectedPricing.id);
// Show success or redirect
} catch (err) {
console.error('Failed to subscribe:', err);
} finally {
setSubscribing(false);
}
};
if (loadingDetail) {
return (
<div className="flex items-center justify-center min-h-96">
<Loader2 className="w-10 h-10 text-blue-500 animate-spin" />
</div>
);
}
if (error || !pack) {
return (
<div className="flex flex-col items-center justify-center min-h-96">
<AlertCircle className="w-12 h-12 text-red-400 mb-4" />
<h2 className="text-xl font-semibold text-white mb-2">Signal Pack Not Found</h2>
<p className="text-gray-400 mb-4">{error || 'The requested signal pack does not exist.'}</p>
<Link
to="/marketplace"
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg"
>
Back to Marketplace
</Link>
</div>
);
}
const periodLabels: Record<string, string> = {
monthly: '/month',
quarterly: '/3 months',
yearly: '/year',
lifetime: 'one-time',
};
return (
<div className="space-y-8">
{/* Back link */}
<Link
to="/marketplace"
className="inline-flex items-center gap-2 text-gray-400 hover:text-white transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Back to Marketplace
</Link>
{/* Main Content */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Left Column - Main Info */}
<div className="lg:col-span-2 space-y-6">
{/* Header */}
<div className="bg-gray-800 rounded-xl border border-gray-700 p-6">
<div className="flex items-start gap-6">
{/* Thumbnail */}
<div className="w-24 h-24 rounded-xl bg-gradient-to-br from-blue-600/20 to-purple-600/20 flex items-center justify-center flex-shrink-0">
{pack.thumbnailUrl ? (
<img
src={pack.thumbnailUrl}
alt={pack.name}
className="w-full h-full object-cover rounded-xl"
/>
) : (
<BarChart2 className="w-12 h-12 text-blue-500" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-2">
<h1 className="text-2xl font-bold text-white">{pack.name}</h1>
{pack.hasFreeTrial && (
<span className="px-2 py-1 bg-green-500 text-white text-xs font-medium rounded-full flex items-center gap-1">
<Zap className="w-3 h-3" />
{pack.freeTrialDays} Days Free
</span>
)}
</div>
{/* Provider */}
<div className="flex items-center gap-3 mb-4">
{pack.provider.avatarUrl ? (
<img
src={pack.provider.avatarUrl}
alt={pack.provider.name}
className="w-8 h-8 rounded-full"
/>
) : (
<div className="w-8 h-8 rounded-full bg-gray-700 flex items-center justify-center text-gray-400 text-sm">
{pack.provider.name.charAt(0)}
</div>
)}
<span className="text-gray-400 flex items-center gap-1">
{pack.provider.name}
{pack.provider.verified && (
<CheckCircle className="w-4 h-4 text-blue-400" />
)}
</span>
<span className="text-gray-600">|</span>
<div className="flex items-center gap-1">
<Star className="w-4 h-4 text-yellow-400 fill-yellow-400" />
<span className="text-white font-medium">{pack.rating.toFixed(1)}</span>
<span className="text-gray-500">({pack.totalReviews} reviews)</span>
</div>
</div>
{/* Description */}
<p className="text-gray-300">{pack.description}</p>
</div>
</div>
</div>
{/* Performance Stats */}
<div className="bg-gray-800 rounded-xl border border-gray-700 p-6">
<h2 className="text-lg font-semibold text-white mb-4">Performance Statistics</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-gray-900/50 rounded-lg p-4 text-center">
<TrendingUp className="w-6 h-6 text-green-400 mx-auto mb-2" />
<p className="text-2xl font-bold text-green-400">{pack.winRate}%</p>
<p className="text-sm text-gray-400">Win Rate</p>
</div>
<div className="bg-gray-900/50 rounded-lg p-4 text-center">
<BarChart2 className="w-6 h-6 text-blue-400 mx-auto mb-2" />
<p className="text-2xl font-bold text-white">+{pack.avgProfitPercent}%</p>
<p className="text-sm text-gray-400">Avg Profit</p>
</div>
<div className="bg-gray-900/50 rounded-lg p-4 text-center">
<Calendar className="w-6 h-6 text-purple-400 mx-auto mb-2" />
<p className="text-2xl font-bold text-white">{pack.avgSignalsPerWeek}</p>
<p className="text-sm text-gray-400">Signals/Week</p>
</div>
<div className="bg-gray-900/50 rounded-lg p-4 text-center">
<Users className="w-6 h-6 text-yellow-400 mx-auto mb-2" />
<p className="text-2xl font-bold text-white">{pack.totalSubscribers.toLocaleString()}</p>
<p className="text-sm text-gray-400">Subscribers</p>
</div>
</div>
</div>
{/* Symbols Covered */}
<div className="bg-gray-800 rounded-xl border border-gray-700 p-6">
<h2 className="text-lg font-semibold text-white mb-4">Symbols Covered</h2>
<div className="flex flex-wrap gap-2">
{pack.symbolsCovered.map((symbol) => (
<span
key={symbol}
className="px-3 py-1.5 bg-gray-900 text-gray-300 rounded-lg text-sm font-medium"
>
{symbol}
</span>
))}
</div>
</div>
{/* Recent Signals Preview */}
{pack.recentSignals.length > 0 && (
<div className="bg-gray-800 rounded-xl border border-gray-700 p-6">
<h2 className="text-lg font-semibold text-white mb-4">Recent Signals (Preview)</h2>
<div className="space-y-3">
{pack.recentSignals.slice(0, 5).map((signal) => (
<div
key={signal.id}
className="flex items-center justify-between p-3 bg-gray-900/50 rounded-lg"
>
<div className="flex items-center gap-3">
<span
className={`px-2 py-1 text-xs font-medium rounded ${
signal.direction === 'long'
? 'bg-green-500/20 text-green-400'
: 'bg-red-500/20 text-red-400'
}`}
>
{signal.direction.toUpperCase()}
</span>
<span className="font-medium text-white">{signal.symbol}</span>
</div>
<div className="flex items-center gap-4">
<span className="text-gray-400 text-sm">
Entry: ${signal.entryPrice.toFixed(2)}
</span>
{signal.status === 'closed' && signal.profitLossPercent !== undefined && (
<span
className={`font-medium ${
signal.profitLossPercent >= 0 ? 'text-green-400' : 'text-red-400'
}`}
>
{signal.profitLossPercent >= 0 ? '+' : ''}
{signal.profitLossPercent.toFixed(2)}%
</span>
)}
<span
className={`px-2 py-1 text-xs rounded ${
signal.status === 'active'
? 'bg-blue-500/20 text-blue-400'
: signal.status === 'closed'
? 'bg-gray-500/20 text-gray-400'
: 'bg-yellow-500/20 text-yellow-400'
}`}
>
{signal.status}
</span>
</div>
</div>
))}
</div>
</div>
)}
{/* Reviews Section */}
<div className="bg-gray-800 rounded-xl border border-gray-700 p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white">
Reviews ({totalReviews})
</h2>
</div>
{loadingReviews ? (
<div className="flex justify-center py-8">
<Loader2 className="w-6 h-6 text-blue-500 animate-spin" />
</div>
) : reviews.length > 0 ? (
<div className="space-y-4">
{reviews.map((review) => (
<ReviewCard
key={review.id}
review={review}
onMarkHelpful={markReviewHelpful}
/>
))}
</div>
) : (
<p className="text-gray-400 text-center py-8">No reviews yet.</p>
)}
</div>
</div>
{/* Right Column - Subscription */}
<div className="space-y-6">
{/* Pricing Card */}
<div className="bg-gray-800 rounded-xl border border-gray-700 p-6 sticky top-6">
<h2 className="text-lg font-semibold text-white mb-4">Subscribe</h2>
{/* Pricing Options */}
<div className="space-y-3 mb-6">
{pack.pricing.map((pricing) => (
<button
key={pricing.id}
onClick={() => setSelectedPricing(pricing)}
className={`w-full p-4 rounded-lg border-2 transition-colors text-left ${
selectedPricing?.id === pricing.id
? 'border-blue-500 bg-blue-500/10'
: 'border-gray-700 hover:border-gray-600'
}`}
>
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-white capitalize">{pricing.period}</p>
{pricing.discountPercent && (
<p className="text-sm text-green-400">
Save {pricing.discountPercent}%
</p>
)}
</div>
<div className="text-right">
<p className="text-xl font-bold text-white">
${pricing.priceUsd}
<span className="text-sm font-normal text-gray-400">
{periodLabels[pricing.period]}
</span>
</p>
{pricing.originalPriceUsd && (
<p className="text-sm text-gray-500 line-through">
${pricing.originalPriceUsd}
</p>
)}
</div>
</div>
{pricing.isPopular && (
<span className="inline-block mt-2 px-2 py-0.5 bg-yellow-500/20 text-yellow-400 text-xs rounded-full">
Most Popular
</span>
)}
</button>
))}
</div>
{/* Subscribe Button */}
<button
onClick={handleSubscribe}
disabled={subscribing || !selectedPricing}
className="w-full py-3 bg-blue-600 hover:bg-blue-500 text-white font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{subscribing ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<>
Subscribe Now
{pack.hasFreeTrial && (
<span className="text-sm opacity-80">
({pack.freeTrialDays} days free)
</span>
)}
</>
)}
</button>
{/* Trust Signals */}
<div className="mt-6 pt-6 border-t border-gray-700 space-y-3">
<div className="flex items-center gap-3 text-sm text-gray-400">
<Shield className="w-5 h-5 text-green-400" />
<span>7-day money-back guarantee</span>
</div>
<div className="flex items-center gap-3 text-sm text-gray-400">
<Clock className="w-5 h-5 text-blue-400" />
<span>Cancel anytime</span>
</div>
<div className="flex items-center gap-3 text-sm text-gray-400">
<CheckCircle className="w-5 h-5 text-purple-400" />
<span>Instant access to all signals</span>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,7 @@
/**
* Marketplace Pages Index
*/
export { default as MarketplaceCatalog } from './MarketplaceCatalog';
export { default as SignalPackDetail } from './SignalPackDetail';
export { default as AdvisoryDetail } from './AdvisoryDetail';

View File

@ -0,0 +1,379 @@
/**
* Correlation Matrix Component
* Displays asset correlations in a heatmap format
* with tooltips and color-coded values
*/
import React, { useMemo, useState } from 'react';
import {
TableCellsIcon,
InformationCircleIcon,
ArrowPathIcon,
} from '@heroicons/react/24/solid';
import type { PortfolioAllocation } from '../../../services/portfolio.service';
interface CorrelationMatrixProps {
allocations: PortfolioAllocation[];
compact?: boolean;
}
interface CorrelationData {
matrix: number[][];
assets: string[];
}
// Generate realistic correlation values based on asset types
const generateCorrelation = (asset1: string, asset2: string): number => {
if (asset1 === asset2) return 1;
// Crypto assets tend to be highly correlated
const cryptoPairs: Record<string, Record<string, number>> = {
BTC: { ETH: 0.85, SOL: 0.78, LINK: 0.72, AVAX: 0.75, ADA: 0.68, DOT: 0.70, MATIC: 0.73, UNI: 0.65, USDT: 0.05, USDC: 0.05 },
ETH: { BTC: 0.85, SOL: 0.82, LINK: 0.78, AVAX: 0.80, ADA: 0.72, DOT: 0.75, MATIC: 0.85, UNI: 0.82, USDT: 0.03, USDC: 0.03 },
SOL: { BTC: 0.78, ETH: 0.82, LINK: 0.70, AVAX: 0.78, ADA: 0.65, DOT: 0.68, MATIC: 0.75, UNI: 0.70, USDT: 0.02, USDC: 0.02 },
LINK: { BTC: 0.72, ETH: 0.78, SOL: 0.70, AVAX: 0.72, ADA: 0.62, DOT: 0.65, MATIC: 0.68, UNI: 0.75, USDT: 0.01, USDC: 0.01 },
AVAX: { BTC: 0.75, ETH: 0.80, SOL: 0.78, LINK: 0.72, ADA: 0.68, DOT: 0.70, MATIC: 0.72, UNI: 0.68, USDT: 0.02, USDC: 0.02 },
ADA: { BTC: 0.68, ETH: 0.72, SOL: 0.65, LINK: 0.62, AVAX: 0.68, DOT: 0.72, MATIC: 0.65, UNI: 0.60, USDT: 0.01, USDC: 0.01 },
DOT: { BTC: 0.70, ETH: 0.75, SOL: 0.68, LINK: 0.65, AVAX: 0.70, ADA: 0.72, MATIC: 0.68, UNI: 0.62, USDT: 0.02, USDC: 0.02 },
MATIC: { BTC: 0.73, ETH: 0.85, SOL: 0.75, LINK: 0.68, AVAX: 0.72, ADA: 0.65, DOT: 0.68, UNI: 0.75, USDT: 0.02, USDC: 0.02 },
UNI: { BTC: 0.65, ETH: 0.82, SOL: 0.70, LINK: 0.75, AVAX: 0.68, ADA: 0.60, DOT: 0.62, MATIC: 0.75, USDT: 0.01, USDC: 0.01 },
USDT: { BTC: 0.05, ETH: 0.03, SOL: 0.02, LINK: 0.01, AVAX: 0.02, ADA: 0.01, DOT: 0.02, MATIC: 0.02, UNI: 0.01, USDC: 0.98 },
USDC: { BTC: 0.05, ETH: 0.03, SOL: 0.02, LINK: 0.01, AVAX: 0.02, ADA: 0.01, DOT: 0.02, MATIC: 0.02, UNI: 0.01, USDT: 0.98 },
};
const corr1 = cryptoPairs[asset1]?.[asset2];
const corr2 = cryptoPairs[asset2]?.[asset1];
if (corr1 !== undefined) return corr1;
if (corr2 !== undefined) return corr2;
// Default moderate correlation for unknown pairs
return 0.5 + (Math.random() * 0.3 - 0.15);
};
// Get color based on correlation value
const getCorrelationColor = (value: number): string => {
if (value >= 0.8) return 'bg-red-500';
if (value >= 0.6) return 'bg-orange-400';
if (value >= 0.4) return 'bg-yellow-400';
if (value >= 0.2) return 'bg-green-300';
if (value >= 0) return 'bg-green-500';
if (value >= -0.2) return 'bg-cyan-400';
if (value >= -0.4) return 'bg-blue-400';
if (value >= -0.6) return 'bg-blue-500';
return 'bg-indigo-600';
};
const getCorrelationTextColor = (value: number): string => {
if (Math.abs(value) >= 0.6) return 'text-white';
return 'text-gray-900';
};
const getCorrelationLabel = (value: number): string => {
if (value === 1) return 'Perfecta';
if (value >= 0.8) return 'Muy Alta';
if (value >= 0.6) return 'Alta';
if (value >= 0.4) return 'Moderada';
if (value >= 0.2) return 'Baja';
if (value >= 0) return 'Muy Baja';
if (value >= -0.2) return 'Inversa Baja';
if (value >= -0.4) return 'Inversa Moderada';
if (value >= -0.6) return 'Inversa Alta';
return 'Inversa Muy Alta';
};
export const CorrelationMatrix: React.FC<CorrelationMatrixProps> = ({
allocations,
compact = false,
}) => {
const [hoveredCell, setHoveredCell] = useState<{ row: number; col: number } | null>(null);
const [isLoading, setIsLoading] = useState(false);
// Generate correlation data
const correlationData = useMemo((): CorrelationData => {
const assets = allocations.map((a) => a.asset);
const matrix: number[][] = [];
for (let i = 0; i < assets.length; i++) {
const row: number[] = [];
for (let j = 0; j < assets.length; j++) {
row.push(generateCorrelation(assets[i], assets[j]));
}
matrix.push(row);
}
return { matrix, assets };
}, [allocations]);
// Calculate portfolio diversification score
const diversificationScore = useMemo(() => {
const { matrix } = correlationData;
if (matrix.length < 2) return 100;
let totalCorr = 0;
let count = 0;
for (let i = 0; i < matrix.length; i++) {
for (let j = i + 1; j < matrix.length; j++) {
totalCorr += matrix[i][j];
count++;
}
}
const avgCorr = count > 0 ? totalCorr / count : 0;
// Score inversely related to average correlation
return Math.max(0, Math.min(100, (1 - avgCorr) * 100));
}, [correlationData]);
// Find highly correlated pairs
const highCorrelationPairs = useMemo(() => {
const pairs: Array<{ asset1: string; asset2: string; correlation: number }> = [];
const { matrix, assets } = correlationData;
for (let i = 0; i < matrix.length; i++) {
for (let j = i + 1; j < matrix.length; j++) {
if (matrix[i][j] >= 0.7) {
pairs.push({
asset1: assets[i],
asset2: assets[j],
correlation: matrix[i][j],
});
}
}
}
return pairs.sort((a, b) => b.correlation - a.correlation).slice(0, 3);
}, [correlationData]);
if (compact) {
return (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-cyan-100 dark:bg-cyan-900/30 rounded-lg">
<TableCellsIcon className="w-5 h-5 text-cyan-600" />
</div>
<h3 className="font-bold text-gray-900 dark:text-white">Correlaciones</h3>
</div>
</div>
{/* Diversification Score */}
<div className="mb-4">
<div className="flex items-center justify-between mb-1">
<span className="text-sm text-gray-500 dark:text-gray-400">Diversificacion</span>
<span className={`font-bold ${
diversificationScore >= 60 ? 'text-green-500' :
diversificationScore >= 40 ? 'text-yellow-500' : 'text-red-500'
}`}>
{diversificationScore.toFixed(0)}%
</span>
</div>
<div className="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${
diversificationScore >= 60 ? 'bg-green-500' :
diversificationScore >= 40 ? 'bg-yellow-500' : 'bg-red-500'
}`}
style={{ width: `${diversificationScore}%` }}
/>
</div>
</div>
{/* High Correlation Warning */}
{highCorrelationPairs.length > 0 && (
<div className="text-xs text-orange-600 dark:text-orange-400">
{highCorrelationPairs.length} par(es) altamente correlacionado(s)
</div>
)}
</div>
);
}
return (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-cyan-100 dark:bg-cyan-900/30 rounded-lg">
<TableCellsIcon className="w-6 h-6 text-cyan-600" />
</div>
<div>
<h3 className="font-bold text-gray-900 dark:text-white">Matriz de Correlacion</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
Relaciones entre activos del portfolio
</p>
</div>
</div>
{/* Diversification Score Badge */}
<div className={`px-3 py-1.5 rounded-lg ${
diversificationScore >= 60 ? 'bg-green-100 dark:bg-green-900/30' :
diversificationScore >= 40 ? 'bg-yellow-100 dark:bg-yellow-900/30' :
'bg-red-100 dark:bg-red-900/30'
}`}>
<span className="text-xs text-gray-600 dark:text-gray-400">Diversificacion: </span>
<span className={`font-bold ${
diversificationScore >= 60 ? 'text-green-600' :
diversificationScore >= 40 ? 'text-yellow-600' : 'text-red-600'
}`}>
{diversificationScore.toFixed(0)}%
</span>
</div>
</div>
{/* Correlation Matrix Table */}
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr>
<th className="p-2 text-left text-sm font-medium text-gray-500 dark:text-gray-400">
Activo
</th>
{correlationData.assets.map((asset) => (
<th
key={asset}
className="p-2 text-center text-sm font-medium text-gray-900 dark:text-white"
>
{asset}
</th>
))}
</tr>
</thead>
<tbody>
{correlationData.matrix.map((row, i) => (
<tr key={correlationData.assets[i]}>
<td className="p-2 text-sm font-medium text-gray-900 dark:text-white">
{correlationData.assets[i]}
</td>
{row.map((value, j) => {
const isHovered = hoveredCell?.row === i && hoveredCell?.col === j;
const isHighlighted = hoveredCell !== null && (hoveredCell.row === i || hoveredCell.col === j);
return (
<td
key={j}
className="p-1"
onMouseEnter={() => setHoveredCell({ row: i, col: j })}
onMouseLeave={() => setHoveredCell(null)}
>
<div
className={`
w-12 h-12 flex items-center justify-center rounded-lg cursor-pointer
transition-all duration-150
${getCorrelationColor(value)}
${isHovered ? 'ring-2 ring-gray-900 dark:ring-white scale-110 z-10' : ''}
${isHighlighted && !isHovered ? 'opacity-80' : ''}
`}
>
<span className={`text-xs font-medium ${getCorrelationTextColor(value)}`}>
{value.toFixed(2)}
</span>
</div>
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
{/* Tooltip for hovered cell */}
{hoveredCell && (
<div className="mt-4 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<p className="text-sm text-gray-700 dark:text-gray-300">
<span className="font-bold">{correlationData.assets[hoveredCell.row]}</span>
{' / '}
<span className="font-bold">{correlationData.assets[hoveredCell.col]}</span>
{': '}
<span className={`font-bold ${
correlationData.matrix[hoveredCell.row][hoveredCell.col] >= 0.7 ? 'text-red-500' :
correlationData.matrix[hoveredCell.row][hoveredCell.col] >= 0.4 ? 'text-yellow-500' :
'text-green-500'
}`}>
{correlationData.matrix[hoveredCell.row][hoveredCell.col].toFixed(3)}
</span>
{' - '}
{getCorrelationLabel(correlationData.matrix[hoveredCell.row][hoveredCell.col])}
</p>
</div>
)}
{/* Color Legend */}
<div className="mt-6">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
Escala de Correlacion
</h4>
<div className="flex items-center gap-1">
<span className="text-xs text-gray-500 dark:text-gray-400 w-8">-1</span>
<div className="flex-1 h-4 rounded-full overflow-hidden flex">
<div className="flex-1 bg-indigo-600" />
<div className="flex-1 bg-blue-500" />
<div className="flex-1 bg-blue-400" />
<div className="flex-1 bg-cyan-400" />
<div className="flex-1 bg-green-500" />
<div className="flex-1 bg-green-300" />
<div className="flex-1 bg-yellow-400" />
<div className="flex-1 bg-orange-400" />
<div className="flex-1 bg-red-500" />
</div>
<span className="text-xs text-gray-500 dark:text-gray-400 w-4">+1</span>
</div>
<div className="flex justify-between text-xs text-gray-500 dark:text-gray-400 mt-1">
<span>Inversa Perfecta</span>
<span>Sin Correlacion</span>
<span>Correlacion Perfecta</span>
</div>
</div>
{/* High Correlation Warnings */}
{highCorrelationPairs.length > 0 && (
<div className="mt-6 p-4 bg-orange-50 dark:bg-orange-900/20 rounded-lg border border-orange-200 dark:border-orange-800">
<div className="flex items-start gap-3">
<InformationCircleIcon className="w-5 h-5 text-orange-500 flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-orange-800 dark:text-orange-200">
Pares Altamente Correlacionados
</p>
<p className="text-sm text-orange-700 dark:text-orange-300 mt-1">
Los siguientes pares de activos se mueven juntos, lo que puede reducir la diversificacion:
</p>
<ul className="mt-2 space-y-1">
{highCorrelationPairs.map((pair, index) => (
<li key={index} className="text-sm text-orange-600 dark:text-orange-400">
<span className="font-medium">{pair.asset1}</span> - <span className="font-medium">{pair.asset2}</span>
{': '}
<span className="font-bold">{(pair.correlation * 100).toFixed(0)}%</span>
</li>
))}
</ul>
</div>
</div>
</div>
)}
{/* Interpretation Guide */}
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
Interpretacion
</h4>
<div className="grid md:grid-cols-3 gap-4 text-xs text-gray-600 dark:text-gray-400">
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<p className="font-medium text-green-600 mb-1">Correlacion Baja (0-0.3)</p>
<p>Excelente para diversificacion. Los activos se mueven independientemente.</p>
</div>
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<p className="font-medium text-yellow-600 mb-1">Correlacion Moderada (0.3-0.7)</p>
<p>Diversificacion parcial. Algunos movimientos compartidos.</p>
</div>
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<p className="font-medium text-red-600 mb-1">Correlacion Alta (0.7-1.0)</p>
<p>Poca diversificacion. Los activos tienden a moverse juntos.</p>
</div>
</div>
</div>
</div>
);
};
export default CorrelationMatrix;

View File

@ -0,0 +1,739 @@
/**
* Goals Manager Component
* Comprehensive goal management with CRUD operations,
* progress tracking, required savings rate calculator,
* and goal timeline visualization
*/
import React, { useState, useEffect, useMemo } from 'react';
import { Link } from 'react-router-dom';
import {
FlagIcon,
PlusIcon,
PencilIcon,
TrashIcon,
ChartBarIcon,
CalendarIcon,
BanknotesIcon,
CheckCircleIcon,
ExclamationTriangleIcon,
ClockIcon,
ArrowTrendingUpIcon,
CalculatorIcon,
XMarkIcon,
} from '@heroicons/react/24/solid';
import type { PortfolioGoal } from '../../../services/portfolio.service';
interface GoalsManagerProps {
goals: PortfolioGoal[];
onCreateGoal?: () => void;
onEditGoal?: (goalId: string) => void;
onDeleteGoal?: (goalId: string) => void;
onUpdateProgress?: (goalId: string, amount: number) => void;
compact?: boolean;
}
// Goal templates
const GOAL_TEMPLATES = [
{ name: 'Fondo de Emergencia', icon: '🛡️', defaultAmount: 5000, description: '3-6 meses de gastos' },
{ name: 'Vacaciones', icon: '✈️', defaultAmount: 3000, description: 'Viaje de ensueño' },
{ name: 'Auto', icon: '🚗', defaultAmount: 20000, description: 'Vehiculo nuevo o usado' },
{ name: 'Casa', icon: '🏠', defaultAmount: 50000, description: 'Enganche de vivienda' },
{ name: 'Retiro', icon: '🌴', defaultAmount: 100000, description: 'Fondo de retiro' },
{ name: 'Educacion', icon: '🎓', defaultAmount: 15000, description: 'Estudios o capacitacion' },
];
interface StatusConfig {
icon: React.ElementType;
color: string;
bgColor: string;
label: string;
}
const STATUS_CONFIGS: Record<string, StatusConfig> = {
on_track: {
icon: CheckCircleIcon,
color: 'text-green-500',
bgColor: 'bg-green-100 dark:bg-green-900/30',
label: 'En Camino',
},
at_risk: {
icon: ClockIcon,
color: 'text-yellow-500',
bgColor: 'bg-yellow-100 dark:bg-yellow-900/30',
label: 'En Riesgo',
},
behind: {
icon: ExclamationTriangleIcon,
color: 'text-red-500',
bgColor: 'bg-red-100 dark:bg-red-900/30',
label: 'Atrasado',
},
};
// Savings Rate Calculator Modal
interface SavingsCalculatorModalProps {
isOpen: boolean;
onClose: () => void;
goal?: PortfolioGoal;
}
const SavingsCalculatorModal: React.FC<SavingsCalculatorModalProps> = ({
isOpen,
onClose,
goal,
}) => {
const [targetAmount, setTargetAmount] = useState(goal?.targetAmount?.toString() || '10000');
const [currentAmount, setCurrentAmount] = useState(goal?.currentAmount?.toString() || '0');
const [targetMonths, setTargetMonths] = useState('12');
const [expectedReturn, setExpectedReturn] = useState('8');
const calculation = useMemo(() => {
const target = parseFloat(targetAmount) || 0;
const current = parseFloat(currentAmount) || 0;
const months = parseInt(targetMonths) || 12;
const returnRate = (parseFloat(expectedReturn) || 0) / 100 / 12;
const remaining = target - current;
// Simple calculation without compound interest
const simpleMonthly = remaining / months;
// With compound interest (future value of annuity formula)
let compoundMonthly = simpleMonthly;
if (returnRate > 0) {
const fvFactor = (Math.pow(1 + returnRate, months) - 1) / returnRate;
compoundMonthly = remaining / fvFactor;
}
// Project final value with different savings rates
const scenarios = [0.5, 0.75, 1, 1.25, 1.5].map((multiplier) => {
const monthly = simpleMonthly * multiplier;
let projected = current;
for (let i = 0; i < months; i++) {
projected = projected * (1 + returnRate) + monthly;
}
return {
multiplier,
monthly: monthly,
projected,
reachesGoal: projected >= target,
};
});
return {
remaining,
simpleMonthly,
compoundMonthly,
scenarios,
};
}, [targetAmount, currentAmount, targetMonths, expectedReturn]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
<div className="relative min-h-screen flex items-center justify-center p-4">
<div className="relative bg-white dark:bg-gray-800 rounded-2xl shadow-xl w-full max-w-lg">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
<CalculatorIcon className="w-6 h-6 text-blue-600" />
</div>
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
Calculadora de Ahorro
</h2>
</div>
<button
onClick={onClose}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
>
<XMarkIcon className="w-6 h-6" />
</button>
</div>
{/* Content */}
<div className="p-6 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Monto Objetivo ($)
</label>
<input
type="number"
value={targetAmount}
onChange={(e) => setTargetAmount(e.target.value)}
className="w-full px-3 py-2 bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Ahorro Actual ($)
</label>
<input
type="number"
value={currentAmount}
onChange={(e) => setCurrentAmount(e.target.value)}
className="w-full px-3 py-2 bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Plazo (meses)
</label>
<input
type="number"
value={targetMonths}
onChange={(e) => setTargetMonths(e.target.value)}
className="w-full px-3 py-2 bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Rendimiento Anual (%)
</label>
<input
type="number"
value={expectedReturn}
onChange={(e) => setExpectedReturn(e.target.value)}
className="w-full px-3 py-2 bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white"
/>
</div>
</div>
{/* Results */}
<div className="mt-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<h4 className="font-medium text-blue-800 dark:text-blue-200 mb-3">
Ahorro Mensual Requerido
</h4>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-xs text-blue-600 dark:text-blue-400">Sin rendimiento</p>
<p className="text-xl font-bold text-blue-700 dark:text-blue-300">
${calculation.simpleMonthly.toLocaleString(undefined, { maximumFractionDigits: 0 })}
</p>
</div>
<div>
<p className="text-xs text-blue-600 dark:text-blue-400">Con {expectedReturn}% anual</p>
<p className="text-xl font-bold text-blue-700 dark:text-blue-300">
${calculation.compoundMonthly.toLocaleString(undefined, { maximumFractionDigits: 0 })}
</p>
</div>
</div>
</div>
{/* Scenarios */}
<div className="mt-4">
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
Escenarios de Ahorro
</h4>
<div className="space-y-2">
{calculation.scenarios.map((scenario) => (
<div
key={scenario.multiplier}
className={`flex items-center justify-between p-2 rounded-lg ${
scenario.reachesGoal
? 'bg-green-50 dark:bg-green-900/20'
: 'bg-gray-50 dark:bg-gray-700/50'
}`}
>
<div className="flex items-center gap-2">
{scenario.reachesGoal ? (
<CheckCircleIcon className="w-4 h-4 text-green-500" />
) : (
<ExclamationTriangleIcon className="w-4 h-4 text-gray-400" />
)}
<span className="text-sm text-gray-700 dark:text-gray-300">
${scenario.monthly.toLocaleString(undefined, { maximumFractionDigits: 0 })}/mes
</span>
</div>
<span className={`text-sm font-medium ${
scenario.reachesGoal ? 'text-green-600' : 'text-gray-500'
}`}>
${scenario.projected.toLocaleString(undefined, { maximumFractionDigits: 0 })}
</span>
</div>
))}
</div>
</div>
</div>
</div>
</div>
</div>
);
};
// Progress Update Modal
interface ProgressModalProps {
isOpen: boolean;
onClose: () => void;
goal: PortfolioGoal;
onSave: (amount: number) => void;
}
const ProgressModal: React.FC<ProgressModalProps> = ({
isOpen,
onClose,
goal,
onSave,
}) => {
const [amount, setAmount] = useState(goal.currentAmount.toString());
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
<div className="relative min-h-screen flex items-center justify-center p-4">
<div className="relative bg-white dark:bg-gray-800 rounded-2xl shadow-xl w-full max-w-md">
<div className="p-6">
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-4">
Actualizar Progreso: {goal.name}
</h3>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Monto Actual ($)
</label>
<input
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
className="w-full px-4 py-3 bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white"
/>
</div>
<div className="flex gap-3">
<button
onClick={onClose}
className="flex-1 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 font-medium rounded-lg"
>
Cancelar
</button>
<button
onClick={() => {
onSave(parseFloat(amount));
onClose();
}}
className="flex-1 py-2 bg-blue-600 text-white font-medium rounded-lg"
>
Guardar
</button>
</div>
</div>
</div>
</div>
</div>
);
};
// Goal Timeline Component
interface GoalTimelineProps {
goals: PortfolioGoal[];
}
const GoalTimeline: React.FC<GoalTimelineProps> = ({ goals }) => {
// Sort goals by target date
const sortedGoals = [...goals].sort(
(a, b) => new Date(a.targetDate).getTime() - new Date(b.targetDate).getTime()
);
const now = Date.now();
const maxDate = Math.max(...sortedGoals.map((g) => new Date(g.targetDate).getTime()));
const minDate = now;
const range = maxDate - minDate;
return (
<div className="relative">
<div className="h-2 bg-gray-200 dark:bg-gray-700 rounded-full"></div>
{sortedGoals.map((goal, index) => {
const position = ((new Date(goal.targetDate).getTime() - minDate) / range) * 100;
const statusConfig = STATUS_CONFIGS[goal.status];
return (
<div
key={goal.id}
className="absolute top-1/2 transform -translate-y-1/2"
style={{ left: `${Math.min(95, Math.max(5, position))}%` }}
>
<div
className={`w-4 h-4 rounded-full border-2 border-white ${statusConfig.bgColor} cursor-pointer`}
title={`${goal.name}: ${goal.progress.toFixed(0)}%`}
/>
</div>
);
})}
</div>
);
};
export const GoalsManager: React.FC<GoalsManagerProps> = ({
goals,
onCreateGoal,
onEditGoal,
onDeleteGoal,
onUpdateProgress,
compact = false,
}) => {
const [showCalculator, setShowCalculator] = useState(false);
const [selectedGoal, setSelectedGoal] = useState<PortfolioGoal | null>(null);
const [showProgressModal, setShowProgressModal] = useState(false);
const [progressGoal, setProgressGoal] = useState<PortfolioGoal | null>(null);
// Summary stats
const stats = useMemo(() => {
const total = goals.length;
const onTrack = goals.filter((g) => g.status === 'on_track').length;
const atRisk = goals.filter((g) => g.status === 'at_risk').length;
const behind = goals.filter((g) => g.status === 'behind').length;
const totalTarget = goals.reduce((sum, g) => sum + g.targetAmount, 0);
const totalCurrent = goals.reduce((sum, g) => sum + g.currentAmount, 0);
const avgProgress = total > 0 ? (totalCurrent / totalTarget) * 100 : 0;
return { total, onTrack, atRisk, behind, totalTarget, totalCurrent, avgProgress };
}, [goals]);
const handleProgressUpdate = (goalId: string, amount: number) => {
if (onUpdateProgress) {
onUpdateProgress(goalId, amount);
}
};
if (compact) {
return (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-green-100 dark:bg-green-900/30 rounded-lg">
<FlagIcon className="w-5 h-5 text-green-600" />
</div>
<h3 className="font-bold text-gray-900 dark:text-white">Metas</h3>
</div>
{onCreateGoal && (
<button
onClick={onCreateGoal}
className="p-1.5 bg-green-100 dark:bg-green-900/30 rounded-lg text-green-600 hover:bg-green-200 dark:hover:bg-green-900/50"
>
<PlusIcon className="w-4 h-4" />
</button>
)}
</div>
{goals.length > 0 ? (
<>
<div className="space-y-3">
{goals.slice(0, 3).map((goal) => {
const statusConfig = STATUS_CONFIGS[goal.status];
return (
<div key={goal.id} className="flex items-center gap-3">
<div className={`p-1 rounded-full ${statusConfig.bgColor}`}>
<statusConfig.icon className={`w-3 h-3 ${statusConfig.color}`} />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
{goal.name}
</p>
<div className="h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${
goal.status === 'on_track' ? 'bg-green-500' :
goal.status === 'at_risk' ? 'bg-yellow-500' : 'bg-red-500'
}`}
style={{ width: `${Math.min(100, goal.progress)}%` }}
/>
</div>
</div>
<span className="text-xs font-medium text-gray-500 dark:text-gray-400">
{goal.progress.toFixed(0)}%
</span>
</div>
);
})}
</div>
{goals.length > 3 && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-3 text-center">
+{goals.length - 3} metas mas
</p>
)}
</>
) : (
<div className="text-center py-4">
<p className="text-sm text-gray-500 dark:text-gray-400">
No hay metas definidas
</p>
</div>
)}
</div>
);
}
return (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<div className="p-2 bg-green-100 dark:bg-green-900/30 rounded-lg">
<FlagIcon className="w-6 h-6 text-green-600" />
</div>
<div>
<h3 className="font-bold text-gray-900 dark:text-white">Gestor de Metas</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
{stats.total} meta(s) | {stats.avgProgress.toFixed(0)}% progreso promedio
</p>
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => {
setSelectedGoal(null);
setShowCalculator(true);
}}
className="p-2 bg-gray-100 dark:bg-gray-700 rounded-lg text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600"
title="Calculadora de Ahorro"
>
<CalculatorIcon className="w-5 h-5" />
</button>
{onCreateGoal && (
<button
onClick={onCreateGoal}
className="px-4 py-2 bg-green-600 hover:bg-green-700 text-white font-medium rounded-lg flex items-center gap-2"
>
<PlusIcon className="w-5 h-5" />
Nueva Meta
</button>
)}
</div>
</div>
{/* Content */}
<div className="p-6">
{goals.length > 0 ? (
<>
{/* Summary Stats */}
<div className="grid grid-cols-4 gap-4 mb-6">
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg text-center">
<p className="text-xs text-gray-500 dark:text-gray-400">Total Metas</p>
<p className="text-xl font-bold text-gray-900 dark:text-white">{stats.total}</p>
</div>
<div className="p-3 bg-green-50 dark:bg-green-900/20 rounded-lg text-center">
<p className="text-xs text-green-600">En Camino</p>
<p className="text-xl font-bold text-green-600">{stats.onTrack}</p>
</div>
<div className="p-3 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg text-center">
<p className="text-xs text-yellow-600">En Riesgo</p>
<p className="text-xl font-bold text-yellow-600">{stats.atRisk}</p>
</div>
<div className="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg text-center">
<p className="text-xs text-red-600">Atrasados</p>
<p className="text-xl font-bold text-red-600">{stats.behind}</p>
</div>
</div>
{/* Timeline */}
<div className="mb-6">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
Linea de Tiempo
</h4>
<GoalTimeline goals={goals} />
<div className="flex justify-between text-xs text-gray-500 dark:text-gray-400 mt-2">
<span>Hoy</span>
<span>
{new Date(Math.max(...goals.map(g => new Date(g.targetDate).getTime()))).toLocaleDateString()}
</span>
</div>
</div>
{/* Goals List */}
<div className="space-y-4">
{goals.map((goal) => {
const statusConfig = STATUS_CONFIGS[goal.status];
const StatusIcon = statusConfig.icon;
const daysRemaining = Math.ceil(
(new Date(goal.targetDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24)
);
return (
<div
key={goal.id}
className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg"
>
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${statusConfig.bgColor}`}>
<StatusIcon className={`w-5 h-5 ${statusConfig.color}`} />
</div>
<div>
<h4 className="font-bold text-gray-900 dark:text-white">
{goal.name}
</h4>
<span className={`text-xs ${statusConfig.color}`}>
{statusConfig.label}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => {
setSelectedGoal(goal);
setShowCalculator(true);
}}
className="p-1.5 text-gray-400 hover:text-blue-600 transition-colors"
title="Calculadora"
>
<CalculatorIcon className="w-4 h-4" />
</button>
{onEditGoal && (
<button
onClick={() => onEditGoal(goal.id)}
className="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
title="Editar"
>
<PencilIcon className="w-4 h-4" />
</button>
)}
{onDeleteGoal && (
<button
onClick={() => {
if (confirm('¿Eliminar esta meta?')) {
onDeleteGoal(goal.id);
}
}}
className="p-1.5 text-gray-400 hover:text-red-500 transition-colors"
title="Eliminar"
>
<TrashIcon className="w-4 h-4" />
</button>
)}
</div>
</div>
{/* Progress Bar */}
<div className="mb-3">
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-500 dark:text-gray-400">
${goal.currentAmount.toLocaleString()} de ${goal.targetAmount.toLocaleString()}
</span>
<span className="font-medium text-gray-900 dark:text-white">
{goal.progress.toFixed(1)}%
</span>
</div>
<div className="h-2 bg-gray-200 dark:bg-gray-600 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
goal.status === 'on_track' ? 'bg-green-500' :
goal.status === 'at_risk' ? 'bg-yellow-500' : 'bg-red-500'
}`}
style={{ width: `${Math.min(100, goal.progress)}%` }}
/>
</div>
</div>
{/* Details */}
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<p className="text-gray-500 dark:text-gray-400">Fecha Objetivo</p>
<p className="font-medium text-gray-900 dark:text-white">
{new Date(goal.targetDate).toLocaleDateString()}
</p>
</div>
<div>
<p className="text-gray-500 dark:text-gray-400">Dias Restantes</p>
<p className={`font-medium ${daysRemaining < 30 ? 'text-red-500' : 'text-gray-900 dark:text-white'}`}>
{daysRemaining > 0 ? daysRemaining : 'Vencido'}
</p>
</div>
<div>
<p className="text-gray-500 dark:text-gray-400">Aporte Mensual</p>
<p className="font-medium text-gray-900 dark:text-white">
${goal.monthlyContribution.toLocaleString()}
</p>
</div>
</div>
{/* Update Progress Button */}
{onUpdateProgress && (
<button
onClick={() => {
setProgressGoal(goal);
setShowProgressModal(true);
}}
className="mt-3 w-full py-2 bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 text-sm font-medium rounded-lg hover:bg-blue-100 dark:hover:bg-blue-900/50 transition-colors"
>
Actualizar Progreso
</button>
)}
</div>
);
})}
</div>
</>
) : (
<div className="text-center py-12">
<FlagIcon className="w-12 h-12 text-gray-400 mx-auto mb-3" />
<h4 className="font-medium text-gray-900 dark:text-white mb-1">
No hay metas definidas
</h4>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
Establece tus objetivos financieros y monitorea tu progreso
</p>
{onCreateGoal && (
<button
onClick={onCreateGoal}
className="px-6 py-2 bg-green-600 hover:bg-green-700 text-white font-medium rounded-lg"
>
Crear Primera Meta
</button>
)}
</div>
)}
{/* Goal Templates */}
{goals.length === 0 && (
<div className="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
Plantillas de Metas
</h4>
<div className="grid grid-cols-3 gap-3">
{GOAL_TEMPLATES.map((template) => (
<button
key={template.name}
className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-left"
>
<span className="text-2xl">{template.icon}</span>
<p className="text-sm font-medium text-gray-900 dark:text-white mt-1">
{template.name}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
~${template.defaultAmount.toLocaleString()}
</p>
</button>
))}
</div>
</div>
)}
</div>
{/* Savings Calculator Modal */}
<SavingsCalculatorModal
isOpen={showCalculator}
onClose={() => setShowCalculator(false)}
goal={selectedGoal || undefined}
/>
{/* Progress Update Modal */}
{progressGoal && (
<ProgressModal
isOpen={showProgressModal}
onClose={() => {
setShowProgressModal(false);
setProgressGoal(null);
}}
goal={progressGoal}
onSave={(amount) => handleProgressUpdate(progressGoal.id, amount)}
/>
)}
</div>
);
};
export default GoalsManager;

View File

@ -0,0 +1,591 @@
/**
* Monte Carlo Simulator Component
* Simulates portfolio performance using Monte Carlo method
* with probability distributions and VaR calculations
*/
import React, { useState, useMemo, useCallback } from 'react';
import {
ChartBarIcon,
PlayIcon,
ArrowPathIcon,
InformationCircleIcon,
ExclamationTriangleIcon,
} from '@heroicons/react/24/solid';
interface MonteCarloSimulatorProps {
portfolioId: string;
currentValue: number;
annualReturn?: number;
volatility?: number;
compact?: boolean;
}
interface SimulationResult {
paths: number[][];
finalValues: number[];
percentiles: Record<string, number>;
var95: number;
var99: number;
cvar95: number;
expectedValue: number;
probabilityOfLoss: number;
probabilityOfTarget: number;
medianValue: number;
}
interface ConfidenceInterval {
level: string;
lower: number;
upper: number;
color: string;
}
const TIME_HORIZONS = [
{ value: 30, label: '1 Mes' },
{ value: 90, label: '3 Meses' },
{ value: 180, label: '6 Meses' },
{ value: 365, label: '1 Año' },
{ value: 730, label: '2 Años' },
{ value: 1825, label: '5 Años' },
];
const formatCurrency = (value: number): string => {
return `$${value.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`;
};
const formatPercent = (value: number): string => {
return `${value.toFixed(1)}%`;
};
export const MonteCarloSimulator: React.FC<MonteCarloSimulatorProps> = ({
portfolioId,
currentValue,
annualReturn = 0.12,
volatility = 0.25,
compact = false,
}) => {
const [numSimulations, setNumSimulations] = useState(5000);
const [timeHorizon, setTimeHorizon] = useState(365);
const [targetReturn, setTargetReturn] = useState(10);
const [isSimulating, setIsSimulating] = useState(false);
const [result, setResult] = useState<SimulationResult | null>(null);
const [showAdvanced, setShowAdvanced] = useState(false);
// Custom return and volatility overrides
const [customReturn, setCustomReturn] = useState<string>(String(annualReturn * 100));
const [customVolatility, setCustomVolatility] = useState<string>(String(volatility * 100));
// Run Monte Carlo simulation
const runSimulation = useCallback(() => {
setIsSimulating(true);
// Use requestAnimationFrame to prevent UI blocking
requestAnimationFrame(() => {
const usedReturn = parseFloat(customReturn) / 100;
const usedVolatility = parseFloat(customVolatility) / 100;
// Daily parameters
const tradingDays = timeHorizon;
const dailyReturn = usedReturn / 252;
const dailyVolatility = usedVolatility / Math.sqrt(252);
const paths: number[][] = [];
const finalValues: number[] = [];
// Run simulations
for (let sim = 0; sim < numSimulations; sim++) {
const path: number[] = [currentValue];
let value = currentValue;
for (let day = 1; day <= tradingDays; day++) {
// Generate random normal using Box-Muller transform
const u1 = Math.random();
const u2 = Math.random();
const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
// Geometric Brownian Motion
const drift = (dailyReturn - 0.5 * dailyVolatility * dailyVolatility);
const diffusion = dailyVolatility * z;
value = value * Math.exp(drift + diffusion);
// Store path points at intervals (for visualization)
if (sim < 100 && day % Math.max(1, Math.floor(tradingDays / 50)) === 0) {
path.push(value);
}
}
if (sim < 100) {
paths.push(path);
}
finalValues.push(value);
}
// Sort final values for percentile calculations
finalValues.sort((a, b) => a - b);
// Calculate percentiles
const getPercentile = (p: number): number => {
const index = Math.floor((p / 100) * finalValues.length);
return finalValues[Math.min(index, finalValues.length - 1)];
};
const percentiles: Record<string, number> = {
p5: getPercentile(5),
p10: getPercentile(10),
p25: getPercentile(25),
p50: getPercentile(50),
p75: getPercentile(75),
p90: getPercentile(90),
p95: getPercentile(95),
};
// Calculate VaR (Value at Risk)
const var95 = currentValue - getPercentile(5);
const var99 = currentValue - getPercentile(1);
// Calculate CVaR (Conditional VaR / Expected Shortfall)
const var95Index = Math.floor(0.05 * finalValues.length);
const worstValues = finalValues.slice(0, var95Index);
const cvar95 = currentValue - (worstValues.reduce((a, b) => a + b, 0) / worstValues.length);
// Calculate statistics
const expectedValue = finalValues.reduce((a, b) => a + b, 0) / finalValues.length;
const probabilityOfLoss = (finalValues.filter(v => v < currentValue).length / finalValues.length) * 100;
const targetValue = currentValue * (1 + targetReturn / 100);
const probabilityOfTarget = (finalValues.filter(v => v >= targetValue).length / finalValues.length) * 100;
setResult({
paths,
finalValues,
percentiles,
var95,
var99,
cvar95,
expectedValue,
probabilityOfLoss,
probabilityOfTarget,
medianValue: percentiles.p50,
});
setIsSimulating(false);
});
}, [currentValue, numSimulations, timeHorizon, targetReturn, customReturn, customVolatility]);
// Confidence intervals for display
const confidenceIntervals: ConfidenceInterval[] = useMemo(() => {
if (!result) return [];
return [
{ level: '90%', lower: result.percentiles.p5, upper: result.percentiles.p95, color: 'bg-blue-100 dark:bg-blue-900/30' },
{ level: '50%', lower: result.percentiles.p25, upper: result.percentiles.p75, color: 'bg-blue-200 dark:bg-blue-800/40' },
];
}, [result]);
// Distribution chart data
const distributionData = useMemo(() => {
if (!result) return null;
const min = result.finalValues[0];
const max = result.finalValues[result.finalValues.length - 1];
const range = max - min;
const bucketCount = 40;
const bucketSize = range / bucketCount;
const buckets = new Array(bucketCount).fill(0);
result.finalValues.forEach(value => {
const bucketIndex = Math.min(Math.floor((value - min) / bucketSize), bucketCount - 1);
buckets[bucketIndex]++;
});
const maxCount = Math.max(...buckets);
return {
buckets,
maxCount,
min,
max,
bucketSize,
};
}, [result]);
if (compact) {
return (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-indigo-100 dark:bg-indigo-900/30 rounded-lg">
<ChartBarIcon className="w-5 h-5 text-indigo-600" />
</div>
<h3 className="font-bold text-gray-900 dark:text-white">Monte Carlo</h3>
</div>
<button
onClick={runSimulation}
disabled={isSimulating}
className="px-3 py-1.5 bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-400 text-white text-sm font-medium rounded-lg transition-colors flex items-center gap-1"
>
{isSimulating ? (
<ArrowPathIcon className="w-4 h-4 animate-spin" />
) : (
<PlayIcon className="w-4 h-4" />
)}
Simular
</button>
</div>
{result ? (
<div className="grid grid-cols-2 gap-3">
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg text-center">
<p className="text-xs text-gray-500 dark:text-gray-400">VaR 95%</p>
<p className="text-lg font-bold text-red-500">
{formatCurrency(result.var95)}
</p>
</div>
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg text-center">
<p className="text-xs text-gray-500 dark:text-gray-400">Esperado</p>
<p className={`text-lg font-bold ${result.expectedValue >= currentValue ? 'text-green-500' : 'text-red-500'}`}>
{formatCurrency(result.expectedValue)}
</p>
</div>
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg text-center">
<p className="text-xs text-gray-500 dark:text-gray-400">Prob. Perdida</p>
<p className="text-lg font-bold text-orange-500">
{formatPercent(result.probabilityOfLoss)}
</p>
</div>
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg text-center">
<p className="text-xs text-gray-500 dark:text-gray-400">Prob. +{targetReturn}%</p>
<p className="text-lg font-bold text-green-500">
{formatPercent(result.probabilityOfTarget)}
</p>
</div>
</div>
) : (
<div className="text-center py-6">
<p className="text-sm text-gray-500 dark:text-gray-400">
Ejecuta una simulacion para ver resultados
</p>
</div>
)}
</div>
);
}
return (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-indigo-100 dark:bg-indigo-900/30 rounded-lg">
<ChartBarIcon className="w-6 h-6 text-indigo-600" />
</div>
<div>
<h3 className="font-bold text-gray-900 dark:text-white">Simulador Monte Carlo</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
Analisis de escenarios probabilisticos
</p>
</div>
</div>
</div>
{/* Configuration */}
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{/* Number of Simulations */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Simulaciones
</label>
<select
value={numSimulations}
onChange={(e) => setNumSimulations(Number(e.target.value))}
className="w-full px-3 py-2 bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 text-gray-900 dark:text-white"
>
<option value={1000}>1,000</option>
<option value={5000}>5,000</option>
<option value={10000}>10,000</option>
</select>
</div>
{/* Time Horizon */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Horizonte Temporal
</label>
<select
value={timeHorizon}
onChange={(e) => setTimeHorizon(Number(e.target.value))}
className="w-full px-3 py-2 bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 text-gray-900 dark:text-white"
>
{TIME_HORIZONS.map((h) => (
<option key={h.value} value={h.value}>{h.label}</option>
))}
</select>
</div>
{/* Target Return */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Retorno Objetivo (%)
</label>
<input
type="number"
value={targetReturn}
onChange={(e) => setTargetReturn(Number(e.target.value))}
min={-50}
max={200}
className="w-full px-3 py-2 bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 text-gray-900 dark:text-white"
/>
</div>
{/* Run Button */}
<div className="flex items-end">
<button
onClick={runSimulation}
disabled={isSimulating}
className="w-full px-4 py-2 bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-400 text-white font-medium rounded-lg transition-colors flex items-center justify-center gap-2"
>
{isSimulating ? (
<>
<ArrowPathIcon className="w-5 h-5 animate-spin" />
Simulando...
</>
) : (
<>
<PlayIcon className="w-5 h-5" />
Ejecutar
</>
)}
</button>
</div>
</div>
{/* Advanced Settings Toggle */}
<button
onClick={() => setShowAdvanced(!showAdvanced)}
className="text-sm text-indigo-600 dark:text-indigo-400 hover:text-indigo-700 mb-4"
>
{showAdvanced ? 'Ocultar parametros avanzados' : 'Mostrar parametros avanzados'}
</button>
{/* Advanced Settings */}
{showAdvanced && (
<div className="grid md:grid-cols-2 gap-4 mb-6 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Retorno Anual Esperado (%)
</label>
<input
type="number"
value={customReturn}
onChange={(e) => setCustomReturn(e.target.value)}
step="0.5"
className="w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 text-gray-900 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Volatilidad Anual (%)
</label>
<input
type="number"
value={customVolatility}
onChange={(e) => setCustomVolatility(e.target.value)}
step="0.5"
className="w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 text-gray-900 dark:text-white"
/>
</div>
</div>
)}
{/* Results */}
{result && (
<>
{/* Distribution Chart */}
{distributionData && (
<div className="mb-6">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
Distribucion de Valores Finales
</h4>
<div className="relative h-40 bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
{/* Chart bars */}
<div className="flex items-end h-full gap-px">
{distributionData.buckets.map((count, index) => {
const height = (count / distributionData.maxCount) * 100;
const value = distributionData.min + (index * distributionData.bucketSize);
const isLoss = value < currentValue;
const isTarget = value >= currentValue * (1 + targetReturn / 100);
return (
<div
key={index}
className={`flex-1 rounded-t transition-all ${
isLoss
? 'bg-red-400 dark:bg-red-500'
: isTarget
? 'bg-green-400 dark:bg-green-500'
: 'bg-indigo-400 dark:bg-indigo-500'
}`}
style={{ height: `${Math.max(1, height)}%` }}
title={`${formatCurrency(value)}: ${count} simulaciones`}
/>
);
})}
</div>
{/* Current value marker */}
<div
className="absolute bottom-0 w-0.5 h-full bg-gray-900 dark:bg-white"
style={{
left: `${((currentValue - distributionData.min) / (distributionData.max - distributionData.min)) * 100}%`,
}}
/>
</div>
{/* Legend */}
<div className="flex justify-center gap-6 mt-3 text-xs">
<div className="flex items-center gap-1">
<div className="w-3 h-3 bg-red-400 rounded" />
<span className="text-gray-600 dark:text-gray-400">Perdida</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 bg-indigo-400 rounded" />
<span className="text-gray-600 dark:text-gray-400">Ganancia</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 bg-green-400 rounded" />
<span className="text-gray-600 dark:text-gray-400">Objetivo +{targetReturn}%</span>
</div>
</div>
</div>
)}
{/* VaR Display */}
<div className="grid md:grid-cols-3 gap-4 mb-6">
<div className="p-4 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800">
<div className="flex items-center gap-2 mb-2">
<ExclamationTriangleIcon className="w-5 h-5 text-red-500" />
<span className="text-sm font-medium text-red-700 dark:text-red-400">VaR 95%</span>
</div>
<p className="text-2xl font-bold text-red-600">{formatCurrency(result.var95)}</p>
<p className="text-xs text-red-600 dark:text-red-400 mt-1">
Perdida maxima con 95% de confianza
</p>
</div>
<div className="p-4 bg-orange-50 dark:bg-orange-900/20 rounded-lg border border-orange-200 dark:border-orange-800">
<div className="flex items-center gap-2 mb-2">
<ExclamationTriangleIcon className="w-5 h-5 text-orange-500" />
<span className="text-sm font-medium text-orange-700 dark:text-orange-400">CVaR 95%</span>
</div>
<p className="text-2xl font-bold text-orange-600">{formatCurrency(result.cvar95)}</p>
<p className="text-xs text-orange-600 dark:text-orange-400 mt-1">
Perdida esperada en el peor 5%
</p>
</div>
<div className="p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
<div className="flex items-center gap-2 mb-2">
<ExclamationTriangleIcon className="w-5 h-5 text-purple-500" />
<span className="text-sm font-medium text-purple-700 dark:text-purple-400">VaR 99%</span>
</div>
<p className="text-2xl font-bold text-purple-600">{formatCurrency(result.var99)}</p>
<p className="text-xs text-purple-600 dark:text-purple-400 mt-1">
Perdida maxima con 99% de confianza
</p>
</div>
</div>
{/* Confidence Intervals */}
<div className="mb-6">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
Intervalos de Confianza
</h4>
<div className="space-y-3">
{confidenceIntervals.map((interval) => (
<div key={interval.level} className={`p-3 rounded-lg ${interval.color}`}>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
Intervalo {interval.level}
</span>
<span className="text-sm text-gray-600 dark:text-gray-400">
{formatCurrency(interval.lower)} - {formatCurrency(interval.upper)}
</span>
</div>
</div>
))}
</div>
</div>
{/* Summary Statistics */}
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg text-center">
<p className="text-xs text-gray-500 dark:text-gray-400">Valor Esperado</p>
<p className={`text-xl font-bold ${result.expectedValue >= currentValue ? 'text-green-500' : 'text-red-500'}`}>
{formatCurrency(result.expectedValue)}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
({((result.expectedValue / currentValue - 1) * 100).toFixed(1)}%)
</p>
</div>
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg text-center">
<p className="text-xs text-gray-500 dark:text-gray-400">Valor Mediano</p>
<p className={`text-xl font-bold ${result.medianValue >= currentValue ? 'text-green-500' : 'text-red-500'}`}>
{formatCurrency(result.medianValue)}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
({((result.medianValue / currentValue - 1) * 100).toFixed(1)}%)
</p>
</div>
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg text-center">
<p className="text-xs text-gray-500 dark:text-gray-400">Prob. de Perdida</p>
<p className="text-xl font-bold text-orange-500">
{formatPercent(result.probabilityOfLoss)}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Valor {'<'} {formatCurrency(currentValue)}
</p>
</div>
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg text-center">
<p className="text-xs text-gray-500 dark:text-gray-400">Prob. Objetivo</p>
<p className="text-xl font-bold text-green-500">
{formatPercent(result.probabilityOfTarget)}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Retorno {'>='} +{targetReturn}%
</p>
</div>
</div>
{/* Info Footer */}
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
<div className="flex items-start gap-2">
<InformationCircleIcon className="w-5 h-5 text-gray-400 flex-shrink-0" />
<p className="text-xs text-gray-500 dark:text-gray-400">
Simulacion basada en {numSimulations.toLocaleString()} escenarios usando movimiento browniano
geometrico con retorno anualizado de {customReturn}% y volatilidad de {customVolatility}%.
Los resultados son estimaciones y no garantizan rendimientos futuros.
</p>
</div>
</div>
</>
)}
{/* Empty State */}
{!result && !isSimulating && (
<div className="text-center py-12 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<ChartBarIcon className="w-12 h-12 text-gray-400 mx-auto mb-3" />
<h4 className="font-medium text-gray-900 dark:text-white mb-1">
Configura y Ejecuta la Simulacion
</h4>
<p className="text-sm text-gray-500 dark:text-gray-400 max-w-md mx-auto">
El simulador Monte Carlo genera miles de escenarios posibles para estimar
el rango de valores futuros de tu portfolio y el riesgo asociado.
</p>
</div>
)}
</div>
);
};
export default MonteCarloSimulator;

View File

@ -0,0 +1,613 @@
/**
* Rebalancing Panel Component
* Enhanced rebalancing UI with history, drift settings,
* target vs current comparison, and one-click suggestions
*/
import React, { useState, useEffect, useMemo } from 'react';
import {
ArrowsRightLeftIcon,
ClockIcon,
CogIcon,
ArrowUpIcon,
ArrowDownIcon,
CheckCircleIcon,
ExclamationTriangleIcon,
CalendarIcon,
ChartBarIcon,
AdjustmentsHorizontalIcon,
} from '@heroicons/react/24/solid';
import type { PortfolioAllocation, RebalanceRecommendation } from '../../../services/portfolio.service';
interface RebalancingPanelProps {
portfolioId: string;
allocations: PortfolioAllocation[];
recommendations?: RebalanceRecommendation[];
onRebalance?: () => void;
isExecuting?: boolean;
lastRebalanced?: string | null;
}
interface RebalanceHistoryItem {
id: string;
date: string;
tradesCount: number;
totalVolume: number;
driftBefore: number;
driftAfter: number;
status: 'completed' | 'partial' | 'failed';
}
interface DriftSettings {
threshold: number;
autoRebalance: boolean;
frequency: 'manual' | 'weekly' | 'monthly' | 'quarterly';
notifyOnDrift: boolean;
}
// Mock history data
const generateMockHistory = (): RebalanceHistoryItem[] => {
return [
{
id: '1',
date: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
tradesCount: 4,
totalVolume: 2500,
driftBefore: 8.5,
driftAfter: 0.3,
status: 'completed',
},
{
id: '2',
date: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000).toISOString(),
tradesCount: 3,
totalVolume: 1800,
driftBefore: 6.2,
driftAfter: 0.5,
status: 'completed',
},
{
id: '3',
date: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000).toISOString(),
tradesCount: 5,
totalVolume: 3200,
driftBefore: 12.1,
driftAfter: 0.2,
status: 'completed',
},
{
id: '4',
date: new Date(Date.now() - 120 * 24 * 60 * 60 * 1000).toISOString(),
tradesCount: 2,
totalVolume: 950,
driftBefore: 5.8,
driftAfter: 1.2,
status: 'partial',
},
];
};
// Allocation comparison bar component
interface AllocationBarProps {
asset: string;
current: number;
target: number;
color: string;
}
const AllocationBar: React.FC<AllocationBarProps> = ({ asset, current, target, color }) => {
const deviation = current - target;
const maxPercent = Math.max(current, target, 50);
return (
<div className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="font-medium text-gray-900 dark:text-white">{asset}</span>
<div className="flex items-center gap-2">
<span className="text-gray-500 dark:text-gray-400">
{current.toFixed(1)}% / {target.toFixed(1)}%
</span>
<span
className={`text-xs font-medium px-1.5 py-0.5 rounded ${
Math.abs(deviation) > 5
? 'bg-red-100 dark:bg-red-900/30 text-red-600'
: Math.abs(deviation) > 2
? 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-600'
: 'bg-green-100 dark:bg-green-900/30 text-green-600'
}`}
>
{deviation > 0 ? '+' : ''}{deviation.toFixed(1)}%
</span>
</div>
</div>
<div className="relative h-4 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
{/* Current allocation bar */}
<div
className="absolute top-0 left-0 h-full rounded-full transition-all duration-300"
style={{
width: `${(current / maxPercent) * 100}%`,
backgroundColor: color,
}}
/>
{/* Target marker */}
<div
className="absolute top-0 h-full w-0.5 bg-gray-900 dark:bg-white"
style={{ left: `${(target / maxPercent) * 100}%` }}
/>
</div>
</div>
);
};
// Asset colors
const ASSET_COLORS: Record<string, string> = {
BTC: '#F7931A',
ETH: '#627EEA',
USDT: '#26A17B',
USDC: '#2775CA',
SOL: '#9945FF',
LINK: '#2A5ADA',
AVAX: '#E84142',
ADA: '#0033AD',
DOT: '#E6007A',
MATIC: '#8247E5',
};
const DEFAULT_COLOR = '#6366F1';
export const RebalancingPanel: React.FC<RebalancingPanelProps> = ({
portfolioId,
allocations,
recommendations = [],
onRebalance,
isExecuting = false,
lastRebalanced,
}) => {
const [activeTab, setActiveTab] = useState<'overview' | 'history' | 'settings'>('overview');
const [history, setHistory] = useState<RebalanceHistoryItem[]>([]);
const [loadingHistory, setLoadingHistory] = useState(false);
const [settings, setSettings] = useState<DriftSettings>({
threshold: 5,
autoRebalance: false,
frequency: 'manual',
notifyOnDrift: true,
});
// Load history on mount
useEffect(() => {
if (activeTab === 'history') {
setLoadingHistory(true);
// Simulate API call
setTimeout(() => {
setHistory(generateMockHistory());
setLoadingHistory(false);
}, 500);
}
}, [activeTab]);
// Calculate max drift
const maxDrift = useMemo(() => {
return Math.max(...allocations.map((a) => Math.abs(a.deviation)));
}, [allocations]);
// Calculate days since last rebalance
const daysSinceRebalance = useMemo(() => {
if (!lastRebalanced) return null;
const diff = Date.now() - new Date(lastRebalanced).getTime();
return Math.floor(diff / (1000 * 60 * 60 * 24));
}, [lastRebalanced]);
// Check if rebalance is needed based on settings
const needsRebalance = maxDrift > settings.threshold;
// Active recommendations count
const activeRecommendations = recommendations.filter((r) => r.action !== 'hold');
// Calculate total trade volume
const totalTradeVolume = activeRecommendations.reduce((sum, r) => sum + r.amountUSD, 0);
return (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg">
{/* Header with Tabs */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
<ArrowsRightLeftIcon className="w-6 h-6 text-purple-600" />
</div>
<div>
<h3 className="font-bold text-gray-900 dark:text-white">Sistema de Rebalanceo</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
{needsRebalance
? `Drift maximo: ${maxDrift.toFixed(1)}% (umbral: ${settings.threshold}%)`
: 'Portfolio balanceado'}
</p>
</div>
</div>
{/* Tabs */}
<div className="flex gap-1 bg-gray-100 dark:bg-gray-700 rounded-lg p-1">
<button
onClick={() => setActiveTab('overview')}
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
activeTab === 'overview'
? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
<ChartBarIcon className="w-4 h-4 inline mr-1" />
Vista
</button>
<button
onClick={() => setActiveTab('history')}
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
activeTab === 'history'
? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
<ClockIcon className="w-4 h-4 inline mr-1" />
Historial
</button>
<button
onClick={() => setActiveTab('settings')}
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
activeTab === 'settings'
? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
<CogIcon className="w-4 h-4 inline mr-1" />
Config
</button>
</div>
</div>
{/* Content */}
<div className="p-6">
{/* Overview Tab */}
{activeTab === 'overview' && (
<>
{/* Status Cards */}
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg text-center">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Drift Maximo</p>
<p className={`text-2xl font-bold ${
maxDrift > settings.threshold ? 'text-red-500' :
maxDrift > settings.threshold * 0.5 ? 'text-yellow-500' : 'text-green-500'
}`}>
{maxDrift.toFixed(1)}%
</p>
</div>
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg text-center">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Operaciones</p>
<p className="text-2xl font-bold text-gray-900 dark:text-white">
{activeRecommendations.length}
</p>
</div>
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg text-center">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Volumen Est.</p>
<p className="text-2xl font-bold text-gray-900 dark:text-white">
${totalTradeVolume.toLocaleString(undefined, { maximumFractionDigits: 0 })}
</p>
</div>
</div>
{/* Allocation Comparison */}
<div className="mb-6">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-4">
Actual vs Objetivo
</h4>
<div className="space-y-4">
{allocations.map((alloc) => (
<AllocationBar
key={alloc.asset}
asset={alloc.asset}
current={alloc.currentPercent}
target={alloc.targetPercent}
color={ASSET_COLORS[alloc.asset] || DEFAULT_COLOR}
/>
))}
</div>
</div>
{/* Recommendations */}
{activeRecommendations.length > 0 ? (
<div className="mb-6">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
Acciones Sugeridas
</h4>
<div className="space-y-2">
{activeRecommendations
.sort((a, b) => {
const priorityOrder = { high: 0, medium: 1, low: 2 };
return priorityOrder[a.priority] - priorityOrder[b.priority];
})
.map((rec, index) => (
<div
key={index}
className={`flex items-center justify-between p-3 rounded-lg ${
rec.priority === 'high'
? 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800'
: rec.priority === 'medium'
? 'bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800'
: 'bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600'
}`}
>
<div className="flex items-center gap-3">
{rec.action === 'buy' ? (
<div className="p-1.5 bg-green-100 dark:bg-green-900/30 rounded-full">
<ArrowUpIcon className="w-4 h-4 text-green-600" />
</div>
) : (
<div className="p-1.5 bg-red-100 dark:bg-red-900/30 rounded-full">
<ArrowDownIcon className="w-4 h-4 text-red-600" />
</div>
)}
<div>
<span className="font-medium text-gray-900 dark:text-white">
{rec.asset}
</span>
<span className="text-sm text-gray-500 dark:text-gray-400 ml-2">
{rec.currentPercent.toFixed(1)}% &rarr; {rec.targetPercent.toFixed(1)}%
</span>
</div>
</div>
<div className="text-right">
<p className={`font-medium ${rec.action === 'buy' ? 'text-green-600' : 'text-red-600'}`}>
{rec.action === 'buy' ? 'Comprar' : 'Vender'}
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
~${rec.amountUSD.toLocaleString(undefined, { maximumFractionDigits: 0 })}
</p>
</div>
</div>
))}
</div>
</div>
) : (
<div className="mb-6 text-center py-8 bg-green-50 dark:bg-green-900/20 rounded-lg">
<CheckCircleIcon className="w-12 h-12 text-green-500 mx-auto mb-2" />
<p className="font-medium text-green-700 dark:text-green-300">
Portfolio Balanceado
</p>
<p className="text-sm text-green-600 dark:text-green-400 mt-1">
No se requieren ajustes en este momento
</p>
</div>
)}
{/* Rebalance Button */}
{activeRecommendations.length > 0 && onRebalance && (
<button
onClick={onRebalance}
disabled={isExecuting}
className="w-full py-3 bg-purple-600 hover:bg-purple-700 disabled:bg-purple-400 text-white font-medium rounded-lg transition-colors flex items-center justify-center gap-2"
>
{isExecuting ? (
<>
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
Ejecutando...
</>
) : (
<>
<ArrowsRightLeftIcon className="w-5 h-5" />
Ejecutar Rebalanceo
</>
)}
</button>
)}
{/* Last Rebalance Info */}
{lastRebalanced && (
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-500 dark:text-gray-400">
<div className="flex items-center gap-2">
<CalendarIcon className="w-4 h-4" />
Ultimo rebalanceo: {new Date(lastRebalanced).toLocaleDateString()}
{daysSinceRebalance !== null && (
<span className="text-gray-400">
(hace {daysSinceRebalance} dias)
</span>
)}
</div>
</div>
)}
</>
)}
{/* History Tab */}
{activeTab === 'history' && (
<>
{loadingHistory ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600"></div>
</div>
) : history.length > 0 ? (
<div className="space-y-4">
{history.map((item) => (
<div
key={item.id}
className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg"
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className={`p-1 rounded-full ${
item.status === 'completed'
? 'bg-green-100 dark:bg-green-900/30'
: item.status === 'partial'
? 'bg-yellow-100 dark:bg-yellow-900/30'
: 'bg-red-100 dark:bg-red-900/30'
}`}>
{item.status === 'completed' ? (
<CheckCircleIcon className="w-4 h-4 text-green-500" />
) : (
<ExclamationTriangleIcon className={`w-4 h-4 ${
item.status === 'partial' ? 'text-yellow-500' : 'text-red-500'
}`} />
)}
</span>
<span className="font-medium text-gray-900 dark:text-white">
{new Date(item.date).toLocaleDateString()}
</span>
</div>
<span className={`text-xs px-2 py-1 rounded-full ${
item.status === 'completed'
? 'bg-green-100 dark:bg-green-900/30 text-green-600'
: item.status === 'partial'
? 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-600'
: 'bg-red-100 dark:bg-red-900/30 text-red-600'
}`}>
{item.status === 'completed' ? 'Completado' :
item.status === 'partial' ? 'Parcial' : 'Fallido'}
</span>
</div>
<div className="grid grid-cols-4 gap-4 text-sm">
<div>
<p className="text-gray-500 dark:text-gray-400">Operaciones</p>
<p className="font-medium text-gray-900 dark:text-white">
{item.tradesCount}
</p>
</div>
<div>
<p className="text-gray-500 dark:text-gray-400">Volumen</p>
<p className="font-medium text-gray-900 dark:text-white">
${item.totalVolume.toLocaleString()}
</p>
</div>
<div>
<p className="text-gray-500 dark:text-gray-400">Drift Antes</p>
<p className="font-medium text-red-500">
{item.driftBefore.toFixed(1)}%
</p>
</div>
<div>
<p className="text-gray-500 dark:text-gray-400">Drift Despues</p>
<p className="font-medium text-green-500">
{item.driftAfter.toFixed(1)}%
</p>
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-12">
<ClockIcon className="w-12 h-12 text-gray-400 mx-auto mb-3" />
<p className="text-gray-500 dark:text-gray-400">
No hay historial de rebalanceos
</p>
</div>
)}
</>
)}
{/* Settings Tab */}
{activeTab === 'settings' && (
<div className="space-y-6">
{/* Drift Threshold */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Umbral de Drift (%)
</label>
<div className="flex items-center gap-4">
<input
type="range"
min="1"
max="20"
value={settings.threshold}
onChange={(e) => setSettings({ ...settings, threshold: Number(e.target.value) })}
className="flex-1"
/>
<span className="w-12 text-center font-medium text-gray-900 dark:text-white">
{settings.threshold}%
</span>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Se notificara cuando un activo se desvie mas de este porcentaje
</p>
</div>
{/* Auto Rebalance */}
<div>
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
Rebalanceo Automatico
</label>
<p className="text-xs text-gray-500 dark:text-gray-400">
Ejecutar rebalanceo automaticamente
</p>
</div>
<button
onClick={() => setSettings({ ...settings, autoRebalance: !settings.autoRebalance })}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
settings.autoRebalance ? 'bg-purple-600' : 'bg-gray-200 dark:bg-gray-700'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
settings.autoRebalance ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
</div>
{/* Frequency */}
{settings.autoRebalance && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Frecuencia de Revision
</label>
<select
value={settings.frequency}
onChange={(e) => setSettings({ ...settings, frequency: e.target.value as DriftSettings['frequency'] })}
className="w-full px-3 py-2 bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-purple-500 text-gray-900 dark:text-white"
>
<option value="manual">Manual</option>
<option value="weekly">Semanal</option>
<option value="monthly">Mensual</option>
<option value="quarterly">Trimestral</option>
</select>
</div>
)}
{/* Notifications */}
<div>
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
Notificaciones de Drift
</label>
<p className="text-xs text-gray-500 dark:text-gray-400">
Recibir alertas cuando se supere el umbral
</p>
</div>
<button
onClick={() => setSettings({ ...settings, notifyOnDrift: !settings.notifyOnDrift })}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
settings.notifyOnDrift ? 'bg-purple-600' : 'bg-gray-200 dark:bg-gray-700'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
settings.notifyOnDrift ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
</div>
{/* Save Button */}
<button
className="w-full py-2 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 font-medium rounded-lg transition-colors"
>
Guardar Configuracion
</button>
</div>
)}
</div>
</div>
);
};
export default RebalancingPanel;

View File

@ -0,0 +1,690 @@
/**
* Risk Analytics Component
* Comprehensive risk analysis dashboard with Sharpe ratio,
* Beta calculation, drawdown analysis, and risk metrics
*/
import React, { useState, useEffect, useMemo } from 'react';
import {
ShieldExclamationIcon,
ChartBarIcon,
ArrowTrendingDownIcon,
InformationCircleIcon,
ArrowPathIcon,
ExclamationTriangleIcon,
} from '@heroicons/react/24/solid';
import type { PortfolioMetrics, PortfolioAllocation } from '../../../services/portfolio.service';
interface RiskAnalyticsProps {
portfolioId: string;
allocations?: PortfolioAllocation[];
metrics?: PortfolioMetrics | null;
compact?: boolean;
}
interface DrawdownPeriod {
startDate: string;
endDate: string;
duration: number;
peakValue: number;
troughValue: number;
drawdownPercent: number;
recovered: boolean;
}
interface RiskRating {
level: 'low' | 'moderate' | 'high' | 'very-high';
score: number;
label: string;
color: string;
bgColor: string;
}
// Generate mock drawdown history
const generateMockDrawdowns = (): DrawdownPeriod[] => {
return [
{
startDate: '2025-11-01',
endDate: '2025-11-15',
duration: 14,
peakValue: 32000,
troughValue: 29120,
drawdownPercent: -9.0,
recovered: true,
},
{
startDate: '2025-09-15',
endDate: '2025-10-05',
duration: 20,
peakValue: 28500,
troughValue: 26505,
drawdownPercent: -7.0,
recovered: true,
},
{
startDate: '2025-07-20',
endDate: '2025-08-10',
duration: 21,
peakValue: 25000,
troughValue: 23250,
drawdownPercent: -7.0,
recovered: true,
},
];
};
// Calculate risk rating based on metrics
const calculateRiskRating = (metrics: PortfolioMetrics | null): RiskRating => {
if (!metrics) {
return {
level: 'moderate',
score: 50,
label: 'Moderado',
color: 'text-yellow-500',
bgColor: 'bg-yellow-100 dark:bg-yellow-900/30',
};
}
let score = 50;
// Volatility impact (higher = more risk)
if (metrics.volatility > 30) score += 20;
else if (metrics.volatility > 20) score += 10;
else if (metrics.volatility < 10) score -= 15;
// Sharpe ratio impact (higher = less risk)
if (metrics.sharpeRatio > 2) score -= 20;
else if (metrics.sharpeRatio > 1) score -= 10;
else if (metrics.sharpeRatio < 0.5) score += 15;
// Max drawdown impact
const absDrawdown = Math.abs(metrics.maxDrawdownPercent);
if (absDrawdown > 25) score += 20;
else if (absDrawdown > 15) score += 10;
else if (absDrawdown < 5) score -= 10;
// Beta impact
if (metrics.beta > 1.5) score += 15;
else if (metrics.beta > 1.2) score += 5;
else if (metrics.beta < 0.8) score -= 10;
score = Math.max(0, Math.min(100, score));
if (score >= 70) {
return {
level: 'very-high',
score,
label: 'Muy Alto',
color: 'text-red-600',
bgColor: 'bg-red-100 dark:bg-red-900/30',
};
} else if (score >= 50) {
return {
level: 'high',
score,
label: 'Alto',
color: 'text-orange-500',
bgColor: 'bg-orange-100 dark:bg-orange-900/30',
};
} else if (score >= 30) {
return {
level: 'moderate',
score,
label: 'Moderado',
color: 'text-yellow-500',
bgColor: 'bg-yellow-100 dark:bg-yellow-900/30',
};
} else {
return {
level: 'low',
score,
label: 'Bajo',
color: 'text-green-500',
bgColor: 'bg-green-100 dark:bg-green-900/30',
};
}
};
// Metric Card Component
interface MetricCardProps {
label: string;
value: string | number;
tooltip: string;
type?: 'positive' | 'negative' | 'neutral' | 'info';
suffix?: string;
benchmark?: number;
benchmarkLabel?: string;
}
const MetricCard: React.FC<MetricCardProps> = ({
label,
value,
tooltip,
type = 'neutral',
suffix = '',
benchmark,
benchmarkLabel,
}) => {
const [showTooltip, setShowTooltip] = useState(false);
const valueColorClass =
type === 'positive'
? 'text-green-500'
: type === 'negative'
? 'text-red-500'
: type === 'info'
? 'text-blue-500'
: 'text-gray-900 dark:text-white';
return (
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg relative">
<div className="flex items-center justify-between mb-1">
<span className="text-xs text-gray-500 dark:text-gray-400">{label}</span>
<div
className="relative"
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
>
<InformationCircleIcon className="w-4 h-4 text-gray-400 cursor-help" />
{showTooltip && (
<div className="absolute bottom-full right-0 mb-2 px-3 py-2 bg-gray-900 text-white text-xs rounded-lg w-48 z-10">
{tooltip}
</div>
)}
</div>
</div>
<p className={`text-xl font-bold ${valueColorClass}`}>
{typeof value === 'number' ? value.toFixed(2) : value}
{suffix}
</p>
{benchmark !== undefined && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{benchmarkLabel || 'Benchmark'}: {benchmark.toFixed(2)}{suffix}
</p>
)}
</div>
);
};
export const RiskAnalytics: React.FC<RiskAnalyticsProps> = ({
portfolioId,
allocations = [],
metrics = null,
compact = false,
}) => {
const [drawdowns, setDrawdowns] = useState<DrawdownPeriod[]>([]);
const [loading, setLoading] = useState(false);
const [activeTab, setActiveTab] = useState<'overview' | 'drawdown' | 'details'>('overview');
// Load drawdown data
useEffect(() => {
setDrawdowns(generateMockDrawdowns());
}, [portfolioId]);
// Calculate risk rating
const riskRating = useMemo(() => calculateRiskRating(metrics), [metrics]);
// Use mock metrics if none provided
const displayMetrics: PortfolioMetrics = metrics || {
totalReturn: 2543.67,
totalReturnPercent: 25.44,
annualizedReturn: 1850.25,
annualizedReturnPercent: 18.5,
volatility: 22.35,
sharpeRatio: 1.25,
sortinoRatio: 1.62,
maxDrawdown: -2856.32,
maxDrawdownPercent: -9.52,
maxDrawdownDate: '2025-11-15',
beta: 1.15,
alpha: 2.8,
rSquared: 0.82,
trackingError: 5.2,
informationRatio: 0.54,
calmarRatio: 1.94,
updatedAt: new Date().toISOString(),
};
if (compact) {
return (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-red-100 dark:bg-red-900/30 rounded-lg">
<ShieldExclamationIcon className="w-5 h-5 text-red-600" />
</div>
<h3 className="font-bold text-gray-900 dark:text-white">Riesgo</h3>
</div>
<div className={`px-2 py-1 rounded-full ${riskRating.bgColor}`}>
<span className={`text-xs font-medium ${riskRating.color}`}>
{riskRating.label}
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="text-center p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<p className="text-xs text-gray-500 dark:text-gray-400">Sharpe</p>
<p className={`text-lg font-bold ${
displayMetrics.sharpeRatio >= 1 ? 'text-green-500' :
displayMetrics.sharpeRatio >= 0.5 ? 'text-yellow-500' : 'text-red-500'
}`}>
{displayMetrics.sharpeRatio.toFixed(2)}
</p>
</div>
<div className="text-center p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<p className="text-xs text-gray-500 dark:text-gray-400">Beta</p>
<p className="text-lg font-bold text-gray-900 dark:text-white">
{displayMetrics.beta.toFixed(2)}
</p>
</div>
<div className="text-center p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<p className="text-xs text-gray-500 dark:text-gray-400">Volatilidad</p>
<p className="text-lg font-bold text-gray-900 dark:text-white">
{displayMetrics.volatility.toFixed(1)}%
</p>
</div>
<div className="text-center p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<p className="text-xs text-gray-500 dark:text-gray-400">Max DD</p>
<p className="text-lg font-bold text-red-500">
{displayMetrics.maxDrawdownPercent.toFixed(1)}%
</p>
</div>
</div>
</div>
);
}
return (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<div className="p-2 bg-red-100 dark:bg-red-900/30 rounded-lg">
<ShieldExclamationIcon className="w-6 h-6 text-red-600" />
</div>
<div>
<h3 className="font-bold text-gray-900 dark:text-white">Analisis de Riesgo</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
Metricas y evaluacion de riesgo del portfolio
</p>
</div>
</div>
{/* Risk Rating Badge */}
<div className={`px-4 py-2 rounded-lg ${riskRating.bgColor}`}>
<span className="text-xs text-gray-500 dark:text-gray-400">Nivel de Riesgo: </span>
<span className={`font-bold ${riskRating.color}`}>
{riskRating.label} ({riskRating.score}/100)
</span>
</div>
</div>
{/* Tabs */}
<div className="flex gap-1 p-2 mx-6 mt-4 bg-gray-100 dark:bg-gray-700 rounded-lg">
<button
onClick={() => setActiveTab('overview')}
className={`flex-1 px-3 py-2 text-sm font-medium rounded-md transition-colors ${
activeTab === 'overview'
? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900'
}`}
>
Resumen
</button>
<button
onClick={() => setActiveTab('drawdown')}
className={`flex-1 px-3 py-2 text-sm font-medium rounded-md transition-colors ${
activeTab === 'drawdown'
? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900'
}`}
>
Drawdown
</button>
<button
onClick={() => setActiveTab('details')}
className={`flex-1 px-3 py-2 text-sm font-medium rounded-md transition-colors ${
activeTab === 'details'
? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900'
}`}
>
Detalles
</button>
</div>
{/* Content */}
<div className="p-6">
{/* Overview Tab */}
{activeTab === 'overview' && (
<>
{/* Risk Score Visualization */}
<div className="mb-6">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
Puntuacion de Riesgo
</h4>
<div className="relative h-4 bg-gradient-to-r from-green-500 via-yellow-500 to-red-500 rounded-full">
<div
className="absolute top-1/2 transform -translate-y-1/2 -translate-x-1/2 w-6 h-6 bg-white border-2 border-gray-900 rounded-full shadow-lg"
style={{ left: `${riskRating.score}%` }}
/>
</div>
<div className="flex justify-between text-xs text-gray-500 dark:text-gray-400 mt-2">
<span>Bajo Riesgo</span>
<span>Alto Riesgo</span>
</div>
</div>
{/* Key Metrics Grid */}
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<MetricCard
label="Sharpe Ratio"
value={displayMetrics.sharpeRatio}
tooltip="Retorno en exceso por unidad de riesgo. Mayor a 1 es bueno, mayor a 2 es excelente."
type={displayMetrics.sharpeRatio >= 1 ? 'positive' : displayMetrics.sharpeRatio >= 0.5 ? 'neutral' : 'negative'}
benchmark={1.0}
benchmarkLabel="Objetivo"
/>
<MetricCard
label="Beta"
value={displayMetrics.beta}
tooltip="Sensibilidad al mercado. Beta 1 = igual que mercado, >1 = mas volatil, <1 = menos volatil."
type={displayMetrics.beta > 1.3 ? 'negative' : displayMetrics.beta < 0.7 ? 'info' : 'neutral'}
benchmark={1.0}
benchmarkLabel="Mercado"
/>
<MetricCard
label="Volatilidad Anual"
value={displayMetrics.volatility}
suffix="%"
tooltip="Desviacion estandar anualizada de los retornos. Mide la variabilidad del portfolio."
type={displayMetrics.volatility > 30 ? 'negative' : displayMetrics.volatility < 15 ? 'positive' : 'neutral'}
/>
<MetricCard
label="Max Drawdown"
value={displayMetrics.maxDrawdownPercent}
suffix="%"
tooltip="Maxima caida desde un pico. Menor es mejor."
type="negative"
/>
</div>
{/* Risk-Adjusted Returns */}
<div className="mb-6">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
Retornos Ajustados por Riesgo
</h4>
<div className="grid md:grid-cols-3 gap-4">
<MetricCard
label="Alpha"
value={displayMetrics.alpha}
suffix="%"
tooltip="Rendimiento en exceso sobre el benchmark. Alpha positivo indica gestion superior."
type={displayMetrics.alpha >= 0 ? 'positive' : 'negative'}
/>
<MetricCard
label="Sortino Ratio"
value={displayMetrics.sortinoRatio}
tooltip="Similar al Sharpe pero solo considera la volatilidad a la baja. Mayor es mejor."
type={displayMetrics.sortinoRatio >= 1 ? 'positive' : 'neutral'}
/>
<MetricCard
label="Calmar Ratio"
value={displayMetrics.calmarRatio}
tooltip="Retorno anualizado dividido por max drawdown. Mayor es mejor."
type={displayMetrics.calmarRatio >= 1 ? 'positive' : 'neutral'}
/>
</div>
</div>
{/* Risk Interpretation */}
<div className={`p-4 rounded-lg border ${
riskRating.level === 'low' ? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800' :
riskRating.level === 'moderate' ? 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800' :
'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800'
}`}>
<h4 className={`font-medium ${riskRating.color} mb-2`}>
Interpretacion: Riesgo {riskRating.label}
</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">
{riskRating.level === 'low' && 'Tu portfolio tiene un perfil de riesgo conservador con buena diversificacion y volatilidad controlada.'}
{riskRating.level === 'moderate' && 'Tu portfolio tiene un nivel de riesgo equilibrado, adecuado para inversores con tolerancia moderada.'}
{riskRating.level === 'high' && 'Tu portfolio presenta volatilidad elevada. Considera rebalancear para reducir la exposicion al riesgo.'}
{riskRating.level === 'very-high' && 'Tu portfolio tiene alta exposicion al riesgo. Se recomienda diversificar y reducir posiciones volatiles.'}
</p>
</div>
</>
)}
{/* Drawdown Tab */}
{activeTab === 'drawdown' && (
<>
{/* Current Drawdown Status */}
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800">
<div className="flex items-center gap-3 mb-3">
<ArrowTrendingDownIcon className="w-6 h-6 text-red-500" />
<div>
<h4 className="font-bold text-red-700 dark:text-red-300">
Max Drawdown: {displayMetrics.maxDrawdownPercent.toFixed(2)}%
</h4>
<p className="text-sm text-red-600 dark:text-red-400">
Registrado el {displayMetrics.maxDrawdownDate ? new Date(displayMetrics.maxDrawdownDate).toLocaleDateString() : 'N/A'}
</p>
</div>
</div>
<p className="text-sm text-red-600 dark:text-red-400">
Perdida maxima: ${Math.abs(displayMetrics.maxDrawdown).toLocaleString(undefined, { minimumFractionDigits: 2 })}
</p>
</div>
{/* Drawdown History */}
<div className="mb-6">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
Historial de Drawdowns
</h4>
<div className="space-y-3">
{drawdowns.map((dd, index) => (
<div
key={index}
className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg"
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className={`p-1 rounded-full ${dd.recovered ? 'bg-green-100 dark:bg-green-900/30' : 'bg-red-100 dark:bg-red-900/30'}`}>
{dd.recovered ? (
<ChartBarIcon className="w-4 h-4 text-green-500" />
) : (
<ArrowTrendingDownIcon className="w-4 h-4 text-red-500" />
)}
</span>
<span className="font-medium text-gray-900 dark:text-white">
{new Date(dd.startDate).toLocaleDateString()} - {new Date(dd.endDate).toLocaleDateString()}
</span>
</div>
<span className={`font-bold ${dd.recovered ? 'text-green-500' : 'text-red-500'}`}>
{dd.drawdownPercent.toFixed(1)}%
</span>
</div>
<div className="grid grid-cols-4 gap-4 text-sm">
<div>
<p className="text-gray-500 dark:text-gray-400">Duracion</p>
<p className="font-medium text-gray-900 dark:text-white">{dd.duration} dias</p>
</div>
<div>
<p className="text-gray-500 dark:text-gray-400">Pico</p>
<p className="font-medium text-gray-900 dark:text-white">${dd.peakValue.toLocaleString()}</p>
</div>
<div>
<p className="text-gray-500 dark:text-gray-400">Valle</p>
<p className="font-medium text-gray-900 dark:text-white">${dd.troughValue.toLocaleString()}</p>
</div>
<div>
<p className="text-gray-500 dark:text-gray-400">Estado</p>
<p className={`font-medium ${dd.recovered ? 'text-green-500' : 'text-red-500'}`}>
{dd.recovered ? 'Recuperado' : 'En progreso'}
</p>
</div>
</div>
</div>
))}
</div>
</div>
{/* Drawdown Guidelines */}
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<div className="flex items-start gap-3">
<InformationCircleIcon className="w-5 h-5 text-blue-500 flex-shrink-0 mt-0.5" />
<div>
<h4 className="font-medium text-blue-800 dark:text-blue-200">
Guia de Drawdown
</h4>
<ul className="mt-2 text-sm text-blue-700 dark:text-blue-300 space-y-1">
<li> {'<'}5%: Normal, parte de la volatilidad del mercado</li>
<li> 5-10%: Moderado, considerar revisar posiciones</li>
<li> 10-20%: Significativo, evaluar estrategia de riesgo</li>
<li> {'>'}20%: Severo, tomar acciones correctivas</li>
</ul>
</div>
</div>
</div>
</>
)}
{/* Details Tab */}
{activeTab === 'details' && (
<>
{/* All Metrics */}
<div className="grid md:grid-cols-2 gap-4">
{/* Returns Section */}
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<h4 className="font-semibold text-gray-700 dark:text-gray-300 mb-3">
Retornos
</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-500 dark:text-gray-400">Retorno Total</span>
<span className={`font-medium ${displayMetrics.totalReturnPercent >= 0 ? 'text-green-500' : 'text-red-500'}`}>
{displayMetrics.totalReturnPercent >= 0 ? '+' : ''}{displayMetrics.totalReturnPercent.toFixed(2)}%
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500 dark:text-gray-400">Retorno Anualizado</span>
<span className={`font-medium ${displayMetrics.annualizedReturnPercent >= 0 ? 'text-green-500' : 'text-red-500'}`}>
{displayMetrics.annualizedReturnPercent >= 0 ? '+' : ''}{displayMetrics.annualizedReturnPercent.toFixed(2)}%
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500 dark:text-gray-400">Alpha</span>
<span className={`font-medium ${displayMetrics.alpha >= 0 ? 'text-green-500' : 'text-red-500'}`}>
{displayMetrics.alpha >= 0 ? '+' : ''}{displayMetrics.alpha.toFixed(2)}%
</span>
</div>
</div>
</div>
{/* Risk Section */}
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<h4 className="font-semibold text-gray-700 dark:text-gray-300 mb-3">
Metricas de Riesgo
</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-500 dark:text-gray-400">Volatilidad</span>
<span className="font-medium text-gray-900 dark:text-white">
{displayMetrics.volatility.toFixed(2)}%
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500 dark:text-gray-400">Beta</span>
<span className="font-medium text-gray-900 dark:text-white">
{displayMetrics.beta.toFixed(2)}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500 dark:text-gray-400">Max Drawdown</span>
<span className="font-medium text-red-500">
{displayMetrics.maxDrawdownPercent.toFixed(2)}%
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500 dark:text-gray-400">Tracking Error</span>
<span className="font-medium text-gray-900 dark:text-white">
{displayMetrics.trackingError.toFixed(2)}%
</span>
</div>
</div>
</div>
{/* Risk-Adjusted Section */}
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<h4 className="font-semibold text-gray-700 dark:text-gray-300 mb-3">
Ratios Ajustados
</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-500 dark:text-gray-400">Sharpe Ratio</span>
<span className={`font-medium ${displayMetrics.sharpeRatio >= 1 ? 'text-green-500' : 'text-gray-900 dark:text-white'}`}>
{displayMetrics.sharpeRatio.toFixed(2)}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500 dark:text-gray-400">Sortino Ratio</span>
<span className={`font-medium ${displayMetrics.sortinoRatio >= 1 ? 'text-green-500' : 'text-gray-900 dark:text-white'}`}>
{displayMetrics.sortinoRatio.toFixed(2)}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500 dark:text-gray-400">Information Ratio</span>
<span className="font-medium text-gray-900 dark:text-white">
{displayMetrics.informationRatio.toFixed(2)}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500 dark:text-gray-400">Calmar Ratio</span>
<span className={`font-medium ${displayMetrics.calmarRatio >= 1 ? 'text-green-500' : 'text-gray-900 dark:text-white'}`}>
{displayMetrics.calmarRatio.toFixed(2)}
</span>
</div>
</div>
</div>
{/* Correlation Section */}
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<h4 className="font-semibold text-gray-700 dark:text-gray-300 mb-3">
Correlacion con Mercado
</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-500 dark:text-gray-400">R-Squared</span>
<span className="font-medium text-gray-900 dark:text-white">
{(displayMetrics.rSquared * 100).toFixed(0)}%
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500 dark:text-gray-400">Beta</span>
<span className="font-medium text-gray-900 dark:text-white">
{displayMetrics.beta.toFixed(2)}
</span>
</div>
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
R-squared de {(displayMetrics.rSquared * 100).toFixed(0)}% indica que{' '}
{displayMetrics.rSquared >= 0.7 ? 'alta' : displayMetrics.rSquared >= 0.4 ? 'moderada' : 'baja'}{' '}
correlacion con el mercado de referencia.
</div>
</div>
</div>
</div>
{/* Last Update */}
<div className="mt-4 text-xs text-gray-500 dark:text-gray-400 text-right">
Ultima actualizacion: {new Date(displayMetrics.updatedAt).toLocaleString()}
</div>
</>
)}
</div>
</div>
);
};
export default RiskAnalytics;

View File

@ -6,9 +6,14 @@
export { AllocationChart } from './AllocationChart'; export { AllocationChart } from './AllocationChart';
export { AllocationTable } from './AllocationTable'; export { AllocationTable } from './AllocationTable';
export { AllocationsCard } from './AllocationsCard'; export { AllocationsCard } from './AllocationsCard';
export { CorrelationMatrix } from './CorrelationMatrix';
export { GoalCard } from './GoalCard'; export { GoalCard } from './GoalCard';
export { GoalProgressCard } from './GoalProgressCard'; export { GoalProgressCard } from './GoalProgressCard';
export { GoalsManager } from './GoalsManager';
export { MonteCarloSimulator } from './MonteCarloSimulator';
export { PerformanceChart } from './PerformanceChart'; export { PerformanceChart } from './PerformanceChart';
export { PerformanceMetricsCard } from './PerformanceMetricsCard'; export { PerformanceMetricsCard } from './PerformanceMetricsCard';
export { RebalanceCard } from './RebalanceCard'; export { RebalanceCard } from './RebalanceCard';
export { RebalanceModal } from './RebalanceModal'; export { RebalanceModal } from './RebalanceModal';
export { RebalancingPanel } from './RebalancingPanel';
export { RiskAnalytics } from './RiskAnalytics';

View File

@ -0,0 +1,302 @@
/**
* Marketplace Service
* API client for marketplace products, signal packs, advisory services, and subscriptions
*/
import { apiClient as api } from '../lib/apiClient';
import type {
MarketplaceProductListItem,
SignalPack,
AdvisoryService,
CourseProduct,
ProductReview,
UserSubscription,
ConsultationBooking,
MarketplaceFilters,
PaginatedResponse,
ApiResponse,
ProductCategory,
} from '../types/marketplace.types';
// ============================================================================
// Products (General)
// ============================================================================
export async function getProducts(
filters?: MarketplaceFilters
): Promise<PaginatedResponse<MarketplaceProductListItem>> {
const response = await api.get<ApiResponse<PaginatedResponse<MarketplaceProductListItem>>>(
'/marketplace/products',
{ params: filters }
);
return response.data.data;
}
export async function getFeaturedProducts(limit = 6): Promise<MarketplaceProductListItem[]> {
const response = await api.get<ApiResponse<MarketplaceProductListItem[]>>(
'/marketplace/products/featured',
{ params: { limit } }
);
return response.data.data;
}
export async function getPopularProducts(limit = 6): Promise<MarketplaceProductListItem[]> {
const response = await api.get<ApiResponse<MarketplaceProductListItem[]>>(
'/marketplace/products/popular',
{ params: { limit } }
);
return response.data.data;
}
export async function getRelatedProducts(
productId: string,
limit = 4
): Promise<MarketplaceProductListItem[]> {
const response = await api.get<ApiResponse<MarketplaceProductListItem[]>>(
`/marketplace/products/${productId}/related`,
{ params: { limit } }
);
return response.data.data;
}
// ============================================================================
// Signal Packs
// ============================================================================
export async function getSignalPacks(
filters?: MarketplaceFilters
): Promise<PaginatedResponse<MarketplaceProductListItem>> {
const response = await api.get<ApiResponse<PaginatedResponse<MarketplaceProductListItem>>>(
'/marketplace/signals',
{ params: filters }
);
return response.data.data;
}
export async function getSignalPackById(id: string): Promise<SignalPack> {
const response = await api.get<ApiResponse<SignalPack>>(`/marketplace/signals/${id}`);
return response.data.data;
}
export async function getSignalPackBySlug(slug: string): Promise<SignalPack> {
const response = await api.get<ApiResponse<SignalPack>>(`/marketplace/signals/slug/${slug}`);
return response.data.data;
}
export async function subscribeToSignalPack(
packId: string,
pricingId: string
): Promise<UserSubscription> {
const response = await api.post<ApiResponse<UserSubscription>>(
`/marketplace/signals/${packId}/subscribe`,
{ pricingId }
);
return response.data.data;
}
// ============================================================================
// Advisory Services
// ============================================================================
export async function getAdvisoryServices(
filters?: MarketplaceFilters
): Promise<PaginatedResponse<MarketplaceProductListItem>> {
const response = await api.get<ApiResponse<PaginatedResponse<MarketplaceProductListItem>>>(
'/marketplace/advisory',
{ params: filters }
);
return response.data.data;
}
export async function getAdvisoryServiceById(id: string): Promise<AdvisoryService> {
const response = await api.get<ApiResponse<AdvisoryService>>(`/marketplace/advisory/${id}`);
return response.data.data;
}
export async function getAdvisoryServiceBySlug(slug: string): Promise<AdvisoryService> {
const response = await api.get<ApiResponse<AdvisoryService>>(
`/marketplace/advisory/slug/${slug}`
);
return response.data.data;
}
export async function getAdvisorAvailability(
advisoryId: string,
date: string
): Promise<{ slots: string[]; timezone: string }> {
const response = await api.get<ApiResponse<{ slots: string[]; timezone: string }>>(
`/marketplace/advisory/${advisoryId}/availability`,
{ params: { date } }
);
return response.data.data;
}
export async function bookConsultation(
advisoryId: string,
data: {
serviceTypeId: string;
scheduledAt: string;
notes?: string;
}
): Promise<ConsultationBooking> {
const response = await api.post<ApiResponse<ConsultationBooking>>(
`/marketplace/advisory/${advisoryId}/book`,
data
);
return response.data.data;
}
// ============================================================================
// Course Products
// ============================================================================
export async function getCourseProducts(
filters?: MarketplaceFilters
): Promise<PaginatedResponse<MarketplaceProductListItem>> {
const response = await api.get<ApiResponse<PaginatedResponse<MarketplaceProductListItem>>>(
'/marketplace/courses',
{ params: filters }
);
return response.data.data;
}
export async function getCourseProductById(id: string): Promise<CourseProduct> {
const response = await api.get<ApiResponse<CourseProduct>>(`/marketplace/courses/${id}`);
return response.data.data;
}
export async function getCourseProductBySlug(slug: string): Promise<CourseProduct> {
const response = await api.get<ApiResponse<CourseProduct>>(`/marketplace/courses/slug/${slug}`);
return response.data.data;
}
// ============================================================================
// Reviews
// ============================================================================
export async function getProductReviews(
productId: string,
productType: ProductCategory,
params?: { page?: number; pageSize?: number; sortBy?: 'newest' | 'helpful' | 'rating' }
): Promise<PaginatedResponse<ProductReview>> {
const response = await api.get<ApiResponse<PaginatedResponse<ProductReview>>>(
`/marketplace/${productType}/${productId}/reviews`,
{ params }
);
return response.data.data;
}
export async function createReview(
productId: string,
productType: ProductCategory,
data: { rating: number; title?: string; comment: string }
): Promise<ProductReview> {
const response = await api.post<ApiResponse<ProductReview>>(
`/marketplace/${productType}/${productId}/reviews`,
data
);
return response.data.data;
}
export async function markReviewHelpful(reviewId: string): Promise<void> {
await api.post(`/marketplace/reviews/${reviewId}/helpful`);
}
// ============================================================================
// Subscriptions
// ============================================================================
export async function getMySubscriptions(): Promise<UserSubscription[]> {
const response = await api.get<ApiResponse<UserSubscription[]>>('/marketplace/subscriptions');
return response.data.data;
}
export async function getSubscriptionById(id: string): Promise<UserSubscription> {
const response = await api.get<ApiResponse<UserSubscription>>(`/marketplace/subscriptions/${id}`);
return response.data.data;
}
export async function cancelSubscription(id: string): Promise<UserSubscription> {
const response = await api.post<ApiResponse<UserSubscription>>(
`/marketplace/subscriptions/${id}/cancel`
);
return response.data.data;
}
export async function renewSubscription(
id: string,
pricingId: string
): Promise<UserSubscription> {
const response = await api.post<ApiResponse<UserSubscription>>(
`/marketplace/subscriptions/${id}/renew`,
{ pricingId }
);
return response.data.data;
}
// ============================================================================
// Bookings
// ============================================================================
export async function getMyBookings(): Promise<ConsultationBooking[]> {
const response = await api.get<ApiResponse<ConsultationBooking[]>>('/marketplace/bookings');
return response.data.data;
}
export async function getBookingById(id: string): Promise<ConsultationBooking> {
const response = await api.get<ApiResponse<ConsultationBooking>>(`/marketplace/bookings/${id}`);
return response.data.data;
}
export async function cancelBooking(id: string): Promise<ConsultationBooking> {
const response = await api.post<ApiResponse<ConsultationBooking>>(
`/marketplace/bookings/${id}/cancel`
);
return response.data.data;
}
// ============================================================================
// Export service object
// ============================================================================
export const marketplaceService = {
// Products
getProducts,
getFeaturedProducts,
getPopularProducts,
getRelatedProducts,
// Signal Packs
getSignalPacks,
getSignalPackById,
getSignalPackBySlug,
subscribeToSignalPack,
// Advisory Services
getAdvisoryServices,
getAdvisoryServiceById,
getAdvisoryServiceBySlug,
getAdvisorAvailability,
bookConsultation,
// Course Products
getCourseProducts,
getCourseProductById,
getCourseProductBySlug,
// Reviews
getProductReviews,
createReview,
markReviewHelpful,
// Subscriptions
getMySubscriptions,
getSubscriptionById,
cancelSubscription,
renewSubscription,
// Bookings
getMyBookings,
getBookingById,
cancelBooking,
};

View File

@ -0,0 +1,525 @@
/**
* Marketplace Store
* Zustand store for marketplace module state management
*/
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import type {
MarketplaceProductListItem,
SignalPack,
AdvisoryService,
CourseProduct,
ProductReview,
UserSubscription,
ConsultationBooking,
MarketplaceFilters,
ProductCategory,
} from '../types/marketplace.types';
import { marketplaceService } from '../services/marketplace.service';
// ============================================================================
// State Interface
// ============================================================================
interface MarketplaceState {
// Products (all categories)
products: MarketplaceProductListItem[];
featuredProducts: MarketplaceProductListItem[];
popularProducts: MarketplaceProductListItem[];
relatedProducts: MarketplaceProductListItem[];
totalProducts: number;
currentPage: number;
pageSize: number;
loadingProducts: boolean;
filters: MarketplaceFilters;
// Current Product Detail
currentSignalPack: SignalPack | null;
currentAdvisory: AdvisoryService | null;
currentCourseProduct: CourseProduct | null;
loadingDetail: boolean;
// Reviews
reviews: ProductReview[];
totalReviews: number;
loadingReviews: boolean;
submittingReview: boolean;
// User subscriptions and bookings
mySubscriptions: UserSubscription[];
myBookings: ConsultationBooking[];
loadingSubscriptions: boolean;
loadingBookings: boolean;
// Availability (for advisory)
availableSlots: string[];
availabilityTimezone: string;
loadingAvailability: boolean;
// General
error: string | null;
// Actions - Products
fetchProducts: (filters?: MarketplaceFilters) => Promise<void>;
fetchFeaturedProducts: () => Promise<void>;
fetchPopularProducts: () => Promise<void>;
fetchRelatedProducts: (productId: string) => Promise<void>;
// Actions - Signal Packs
fetchSignalPackById: (id: string) => Promise<void>;
fetchSignalPackBySlug: (slug: string) => Promise<void>;
subscribeToSignalPack: (packId: string, pricingId: string) => Promise<void>;
// Actions - Advisory
fetchAdvisoryById: (id: string) => Promise<void>;
fetchAdvisoryBySlug: (slug: string) => Promise<void>;
fetchAdvisorAvailability: (advisoryId: string, date: string) => Promise<void>;
bookConsultation: (
advisoryId: string,
data: { serviceTypeId: string; scheduledAt: string; notes?: string }
) => Promise<void>;
// Actions - Course Products
fetchCourseProductById: (id: string) => Promise<void>;
fetchCourseProductBySlug: (slug: string) => Promise<void>;
// Actions - Reviews
fetchReviews: (
productId: string,
productType: ProductCategory,
params?: { page?: number; pageSize?: number; sortBy?: 'newest' | 'helpful' | 'rating' }
) => Promise<void>;
createReview: (
productId: string,
productType: ProductCategory,
data: { rating: number; title?: string; comment: string }
) => Promise<void>;
markReviewHelpful: (reviewId: string) => Promise<void>;
// Actions - Subscriptions
fetchMySubscriptions: () => Promise<void>;
cancelSubscription: (id: string) => Promise<void>;
// Actions - Bookings
fetchMyBookings: () => Promise<void>;
cancelBooking: (id: string) => Promise<void>;
// Utility
setFilters: (filters: MarketplaceFilters) => void;
clearError: () => void;
resetCurrentProduct: () => void;
}
// ============================================================================
// Initial State
// ============================================================================
const initialState = {
// Products
products: [],
featuredProducts: [],
popularProducts: [],
relatedProducts: [],
totalProducts: 0,
currentPage: 1,
pageSize: 12,
loadingProducts: false,
filters: {},
// Current Product
currentSignalPack: null,
currentAdvisory: null,
currentCourseProduct: null,
loadingDetail: false,
// Reviews
reviews: [],
totalReviews: 0,
loadingReviews: false,
submittingReview: false,
// User data
mySubscriptions: [],
myBookings: [],
loadingSubscriptions: false,
loadingBookings: false,
// Availability
availableSlots: [],
availabilityTimezone: 'UTC',
loadingAvailability: false,
// General
error: null,
};
// ============================================================================
// Store
// ============================================================================
export const useMarketplaceStore = create<MarketplaceState>()(
devtools(
(set, get) => ({
...initialState,
// ========================================================================
// Product Actions
// ========================================================================
fetchProducts: async (filters?: MarketplaceFilters) => {
const state = get();
const mergedFilters = { ...state.filters, ...filters };
set({ loadingProducts: true, error: null, filters: mergedFilters });
try {
const result = await marketplaceService.getProducts(mergedFilters);
set({
products: result.data,
totalProducts: result.total,
currentPage: result.page,
pageSize: result.pageSize,
loadingProducts: false,
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to fetch products';
set({ error: errorMessage, loadingProducts: false, products: [] });
console.error('Error fetching products:', error);
}
},
fetchFeaturedProducts: async () => {
try {
const products = await marketplaceService.getFeaturedProducts(6);
set({ featuredProducts: products });
} catch (error) {
console.error('Error fetching featured products:', error);
}
},
fetchPopularProducts: async () => {
try {
const products = await marketplaceService.getPopularProducts(6);
set({ popularProducts: products });
} catch (error) {
console.error('Error fetching popular products:', error);
}
},
fetchRelatedProducts: async (productId: string) => {
try {
const products = await marketplaceService.getRelatedProducts(productId, 4);
set({ relatedProducts: products });
} catch (error) {
console.error('Error fetching related products:', error);
}
},
// ========================================================================
// Signal Pack Actions
// ========================================================================
fetchSignalPackById: async (id: string) => {
set({ loadingDetail: true, error: null, currentSignalPack: null });
try {
const signalPack = await marketplaceService.getSignalPackById(id);
set({ currentSignalPack: signalPack, loadingDetail: false });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to fetch signal pack';
set({ error: errorMessage, loadingDetail: false });
console.error('Error fetching signal pack:', error);
}
},
fetchSignalPackBySlug: async (slug: string) => {
set({ loadingDetail: true, error: null, currentSignalPack: null });
try {
const signalPack = await marketplaceService.getSignalPackBySlug(slug);
set({ currentSignalPack: signalPack, loadingDetail: false });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to fetch signal pack';
set({ error: errorMessage, loadingDetail: false });
console.error('Error fetching signal pack:', error);
}
},
subscribeToSignalPack: async (packId: string, pricingId: string) => {
set({ error: null });
try {
const subscription = await marketplaceService.subscribeToSignalPack(packId, pricingId);
// Add to subscriptions list
const state = get();
set({ mySubscriptions: [...state.mySubscriptions, subscription] });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to subscribe';
set({ error: errorMessage });
console.error('Error subscribing to signal pack:', error);
throw error;
}
},
// ========================================================================
// Advisory Actions
// ========================================================================
fetchAdvisoryById: async (id: string) => {
set({ loadingDetail: true, error: null, currentAdvisory: null });
try {
const advisory = await marketplaceService.getAdvisoryServiceById(id);
set({ currentAdvisory: advisory, loadingDetail: false });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to fetch advisory';
set({ error: errorMessage, loadingDetail: false });
console.error('Error fetching advisory:', error);
}
},
fetchAdvisoryBySlug: async (slug: string) => {
set({ loadingDetail: true, error: null, currentAdvisory: null });
try {
const advisory = await marketplaceService.getAdvisoryServiceBySlug(slug);
set({ currentAdvisory: advisory, loadingDetail: false });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to fetch advisory';
set({ error: errorMessage, loadingDetail: false });
console.error('Error fetching advisory:', error);
}
},
fetchAdvisorAvailability: async (advisoryId: string, date: string) => {
set({ loadingAvailability: true });
try {
const result = await marketplaceService.getAdvisorAvailability(advisoryId, date);
set({
availableSlots: result.slots,
availabilityTimezone: result.timezone,
loadingAvailability: false,
});
} catch (error) {
console.error('Error fetching availability:', error);
set({ loadingAvailability: false, availableSlots: [] });
}
},
bookConsultation: async (advisoryId, data) => {
set({ error: null });
try {
const booking = await marketplaceService.bookConsultation(advisoryId, data);
const state = get();
set({ myBookings: [...state.myBookings, booking] });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to book consultation';
set({ error: errorMessage });
console.error('Error booking consultation:', error);
throw error;
}
},
// ========================================================================
// Course Product Actions
// ========================================================================
fetchCourseProductById: async (id: string) => {
set({ loadingDetail: true, error: null, currentCourseProduct: null });
try {
const course = await marketplaceService.getCourseProductById(id);
set({ currentCourseProduct: course, loadingDetail: false });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to fetch course';
set({ error: errorMessage, loadingDetail: false });
console.error('Error fetching course:', error);
}
},
fetchCourseProductBySlug: async (slug: string) => {
set({ loadingDetail: true, error: null, currentCourseProduct: null });
try {
const course = await marketplaceService.getCourseProductBySlug(slug);
set({ currentCourseProduct: course, loadingDetail: false });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to fetch course';
set({ error: errorMessage, loadingDetail: false });
console.error('Error fetching course:', error);
}
},
// ========================================================================
// Review Actions
// ========================================================================
fetchReviews: async (productId, productType, params) => {
set({ loadingReviews: true });
try {
const result = await marketplaceService.getProductReviews(productId, productType, params);
set({
reviews: result.data,
totalReviews: result.total,
loadingReviews: false,
});
} catch (error) {
console.error('Error fetching reviews:', error);
set({ loadingReviews: false, reviews: [] });
}
},
createReview: async (productId, productType, data) => {
set({ submittingReview: true, error: null });
try {
const review = await marketplaceService.createReview(productId, productType, data);
const state = get();
set({
reviews: [review, ...state.reviews],
totalReviews: state.totalReviews + 1,
submittingReview: false,
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to create review';
set({ error: errorMessage, submittingReview: false });
console.error('Error creating review:', error);
throw error;
}
},
markReviewHelpful: async (reviewId: string) => {
try {
await marketplaceService.markReviewHelpful(reviewId);
// Update local state
const state = get();
set({
reviews: state.reviews.map((r) =>
r.id === reviewId ? { ...r, helpful: r.helpful + 1 } : r
),
});
} catch (error) {
console.error('Error marking review helpful:', error);
}
},
// ========================================================================
// Subscription Actions
// ========================================================================
fetchMySubscriptions: async () => {
set({ loadingSubscriptions: true });
try {
const subscriptions = await marketplaceService.getMySubscriptions();
set({ mySubscriptions: subscriptions, loadingSubscriptions: false });
} catch (error) {
console.error('Error fetching subscriptions:', error);
set({ loadingSubscriptions: false, mySubscriptions: [] });
}
},
cancelSubscription: async (id: string) => {
set({ error: null });
try {
const updated = await marketplaceService.cancelSubscription(id);
const state = get();
set({
mySubscriptions: state.mySubscriptions.map((s) => (s.id === id ? updated : s)),
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to cancel subscription';
set({ error: errorMessage });
console.error('Error cancelling subscription:', error);
throw error;
}
},
// ========================================================================
// Booking Actions
// ========================================================================
fetchMyBookings: async () => {
set({ loadingBookings: true });
try {
const bookings = await marketplaceService.getMyBookings();
set({ myBookings: bookings, loadingBookings: false });
} catch (error) {
console.error('Error fetching bookings:', error);
set({ loadingBookings: false, myBookings: [] });
}
},
cancelBooking: async (id: string) => {
set({ error: null });
try {
const updated = await marketplaceService.cancelBooking(id);
const state = get();
set({
myBookings: state.myBookings.map((b) => (b.id === id ? updated : b)),
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to cancel booking';
set({ error: errorMessage });
console.error('Error cancelling booking:', error);
throw error;
}
},
// ========================================================================
// Utility Actions
// ========================================================================
setFilters: (filters: MarketplaceFilters) => {
set({ filters });
},
clearError: () => {
set({ error: null });
},
resetCurrentProduct: () => {
set({
currentSignalPack: null,
currentAdvisory: null,
currentCourseProduct: null,
loadingDetail: false,
reviews: [],
totalReviews: 0,
relatedProducts: [],
});
},
}),
{
name: 'marketplace-store',
}
)
);
// ============================================================================
// Selectors
// ============================================================================
export const useProducts = () => useMarketplaceStore((state) => state.products);
export const useFeaturedProducts = () => useMarketplaceStore((state) => state.featuredProducts);
export const usePopularProducts = () => useMarketplaceStore((state) => state.popularProducts);
export const useRelatedProducts = () => useMarketplaceStore((state) => state.relatedProducts);
export const useCurrentSignalPack = () => useMarketplaceStore((state) => state.currentSignalPack);
export const useCurrentAdvisory = () => useMarketplaceStore((state) => state.currentAdvisory);
export const useCurrentCourseProduct = () => useMarketplaceStore((state) => state.currentCourseProduct);
export const useProductReviews = () => useMarketplaceStore((state) => state.reviews);
export const useMySubscriptions = () => useMarketplaceStore((state) => state.mySubscriptions);
export const useMyBookings = () => useMarketplaceStore((state) => state.myBookings);
export const useMarketplaceFilters = () => useMarketplaceStore((state) => state.filters);
export const useMarketplaceError = () => useMarketplaceStore((state) => state.error);
export const useLoadingProducts = () => useMarketplaceStore((state) => state.loadingProducts);
export const useLoadingDetail = () => useMarketplaceStore((state) => state.loadingDetail);
export default useMarketplaceStore;

View File

@ -0,0 +1,338 @@
/**
* Marketplace Module Types
* Type definitions for signal packs, advisory services, courses, and marketplace products
*/
// ============================================================================
// Enums
// ============================================================================
export type ProductCategory = 'signals' | 'courses' | 'advisory' | 'tools';
export type ProductStatus = 'active' | 'inactive' | 'draft' | 'archived';
export type SubscriptionPeriod = 'monthly' | 'quarterly' | 'yearly' | 'lifetime';
export type AdvisoryAvailability = 'available' | 'busy' | 'away' | 'offline';
// ============================================================================
// Provider Types
// ============================================================================
export interface MarketplaceProvider {
id: string;
name: string;
avatarUrl?: string;
bio?: string;
verified: boolean;
rating: number;
totalReviews: number;
totalSubscribers: number;
joinedAt: string;
specializations: string[];
socialLinks?: {
twitter?: string;
telegram?: string;
website?: string;
};
}
// ============================================================================
// Signal Pack Types
// ============================================================================
export interface SignalPack {
id: string;
name: string;
slug: string;
description: string;
shortDescription?: string;
thumbnailUrl?: string;
provider: MarketplaceProvider;
category: ProductCategory;
status: ProductStatus;
// Signal Stats
winRate: number;
avgProfitPercent: number;
totalSignals: number;
avgSignalsPerWeek: number;
symbolsCovered: string[];
tradingStyle: string;
riskLevel: 'low' | 'medium' | 'high';
// Pricing
pricing: SignalPackPricing[];
hasFreeTrial: boolean;
freeTrialDays?: number;
// Performance
monthlyReturns: MonthlyReturn[];
recentSignals: SignalPreview[];
// Meta
totalSubscribers: number;
rating: number;
totalReviews: number;
createdAt: string;
updatedAt: string;
}
export interface SignalPackPricing {
id: string;
period: SubscriptionPeriod;
priceUsd: number;
originalPriceUsd?: number;
discountPercent?: number;
isPopular?: boolean;
}
export interface MonthlyReturn {
month: string;
year: number;
returnPercent: number;
totalSignals: number;
winRate: number;
}
export interface SignalPreview {
id: string;
symbol: string;
direction: 'long' | 'short';
entryPrice: number;
takeProfitPrice?: number;
stopLossPrice?: number;
status: 'pending' | 'active' | 'closed';
profitLossPercent?: number;
createdAt: string;
closedAt?: string;
}
// ============================================================================
// Advisory Service Types
// ============================================================================
export interface AdvisoryService {
id: string;
name: string;
slug: string;
description: string;
shortDescription?: string;
thumbnailUrl?: string;
advisor: AdvisorProfile;
category: ProductCategory;
status: ProductStatus;
// Service Details
specializations: string[];
serviceTypes: ServiceType[];
languages: string[];
// Availability
availability: AdvisoryAvailability;
availableSlots: AvailabilitySlot[];
timezone: string;
responseTimeHours: number;
// Pricing
pricing: AdvisoryPricing[];
// Meta
totalConsultations: number;
rating: number;
totalReviews: number;
createdAt: string;
updatedAt: string;
}
export interface AdvisorProfile extends MarketplaceProvider {
title?: string;
experience: string;
credentials: string[];
portfolio?: string;
tradingExperienceYears: number;
}
export interface ServiceType {
id: string;
name: string;
description: string;
durationMinutes: number;
priceUsd: number;
}
export interface AvailabilitySlot {
id: string;
dayOfWeek: number;
startTime: string;
endTime: string;
isAvailable: boolean;
}
export interface AdvisoryPricing {
id: string;
serviceTypeId: string;
serviceType: ServiceType;
priceUsd: number;
discountedPriceUsd?: number;
}
// ============================================================================
// Course Product Types (Marketplace view)
// ============================================================================
export interface CourseProduct {
id: string;
title: string;
slug: string;
description: string;
shortDescription?: string;
thumbnailUrl?: string;
instructor: MarketplaceProvider;
category: ProductCategory;
status: ProductStatus;
// Course Details
difficultyLevel: 'beginner' | 'intermediate' | 'advanced';
totalLessons: number;
totalDuration: number;
topics: string[];
// Pricing
priceUsd: number;
originalPriceUsd?: number;
isFree: boolean;
// Meta
totalEnrollments: number;
rating: number;
totalReviews: number;
createdAt: string;
updatedAt: string;
}
// ============================================================================
// Unified Marketplace Product
// ============================================================================
export type MarketplaceProduct =
| (SignalPack & { productType: 'signals' })
| (AdvisoryService & { productType: 'advisory' })
| (CourseProduct & { productType: 'courses' });
export interface MarketplaceProductListItem {
id: string;
name: string;
slug: string;
shortDescription?: string;
thumbnailUrl?: string;
productType: ProductCategory;
providerName: string;
providerAvatar?: string;
providerVerified: boolean;
priceUsd: number;
hasFreeTrial?: boolean;
rating: number;
totalReviews: number;
totalSubscribers?: number;
tags: string[];
createdAt: string;
}
// ============================================================================
// Review Types
// ============================================================================
export interface ProductReview {
id: string;
productId: string;
productType: ProductCategory;
userId: string;
userName: string;
userAvatar?: string;
rating: number;
title?: string;
comment: string;
helpful: number;
verified: boolean;
createdAt: string;
updatedAt: string;
response?: ProviderResponse;
}
export interface ProviderResponse {
comment: string;
respondedAt: string;
}
// ============================================================================
// Subscription Types
// ============================================================================
export interface UserSubscription {
id: string;
userId: string;
productId: string;
productType: ProductCategory;
product: MarketplaceProductListItem;
status: 'active' | 'cancelled' | 'expired' | 'paused';
period: SubscriptionPeriod;
priceUsd: number;
startDate: string;
endDate: string;
autoRenew: boolean;
createdAt: string;
}
// ============================================================================
// Filter Types
// ============================================================================
export interface MarketplaceFilters {
category?: ProductCategory;
search?: string;
minRating?: number;
maxPrice?: number;
minPrice?: number;
sortBy?: 'newest' | 'popular' | 'rating' | 'price_asc' | 'price_desc';
hasFreeTrial?: boolean;
verified?: boolean;
page?: number;
pageSize?: number;
}
// ============================================================================
// API Response Types
// ============================================================================
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
export interface ApiResponse<T> {
success: boolean;
data: T;
error?: {
message: string;
code: string;
};
}
// ============================================================================
// Booking Types
// ============================================================================
export interface ConsultationBooking {
id: string;
userId: string;
advisoryId: string;
serviceTypeId: string;
scheduledAt: string;
durationMinutes: number;
status: 'pending' | 'confirmed' | 'completed' | 'cancelled';
meetingLink?: string;
notes?: string;
priceUsd: number;
createdAt: string;
}