[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:
parent
c9e2727d3b
commit
6a8cb3b9cf
11
src/App.tsx
11
src/App.tsx
@ -70,6 +70,11 @@ const MLModelsPage = lazy(() => import('./modules/admin/pages/MLModelsPage'));
|
||||
const AgentsPage = lazy(() => import('./modules/admin/pages/AgentsPage'));
|
||||
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() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
@ -150,6 +155,12 @@ function App() {
|
||||
<Route path="/admin/models" element={<MLModelsPage />} />
|
||||
<Route path="/admin/agents" element={<AgentsPage />} />
|
||||
<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>
|
||||
|
||||
{/* Redirects */}
|
||||
|
||||
653
src/modules/assistant/components/FineTuningPanel.tsx
Normal file
653
src/modules/assistant/components/FineTuningPanel.tsx
Normal 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;
|
||||
505
src/modules/assistant/components/LLMToolsPanel.tsx
Normal file
505
src/modules/assistant/components/LLMToolsPanel.tsx
Normal 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;
|
||||
522
src/modules/assistant/components/MemoryManager.tsx
Normal file
522
src/modules/assistant/components/MemoryManager.tsx
Normal 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;
|
||||
@ -77,3 +77,15 @@ export type { TokenUsage, TokenCosts, SessionTokenStats, TokenUsageDisplayProps
|
||||
// Prompt Library (OQI-007)
|
||||
export { default as PromptLibrary } 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';
|
||||
|
||||
426
src/modules/education/components/EarnedCertificates.tsx
Normal file
426
src/modules/education/components/EarnedCertificates.tsx
Normal 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;
|
||||
305
src/modules/education/components/QuizHistoryCard.tsx
Normal file
305
src/modules/education/components/QuizHistoryCard.tsx
Normal 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;
|
||||
@ -33,3 +33,9 @@ export { default as CertificateGenerator } from './CertificateGenerator';
|
||||
export type { CertificateData, CertificateTemplate } from './CertificateGenerator';
|
||||
export { default as LiveStreamPlayer } 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';
|
||||
|
||||
@ -127,6 +127,30 @@ export default function CourseDetail() {
|
||||
const isEnrolled = !!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 (
|
||||
<div className="space-y-6">
|
||||
{/* Back Link */}
|
||||
@ -278,11 +302,11 @@ export default function CourseDetail() {
|
||||
</div>
|
||||
|
||||
<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"
|
||||
>
|
||||
<Play className="w-5 h-5" />
|
||||
{enrollment.progressPercentage > 0 ? 'Continuar Curso' : 'Empezar Curso'}
|
||||
{enrollment.progressPercentage > 0 ? 'Continuar donde lo dejaste' : 'Empezar Curso'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
186
src/modules/marketplace/components/AdvisoryCard.tsx
Normal file
186
src/modules/marketplace/components/AdvisoryCard.tsx
Normal 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;
|
||||
179
src/modules/marketplace/components/CourseProductCard.tsx
Normal file
179
src/modules/marketplace/components/CourseProductCard.tsx
Normal 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;
|
||||
30
src/modules/marketplace/components/ProductCard.tsx
Normal file
30
src/modules/marketplace/components/ProductCard.tsx
Normal 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;
|
||||
123
src/modules/marketplace/components/ReviewCard.tsx
Normal file
123
src/modules/marketplace/components/ReviewCard.tsx
Normal 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;
|
||||
174
src/modules/marketplace/components/SignalPackCard.tsx
Normal file
174
src/modules/marketplace/components/SignalPackCard.tsx
Normal 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;
|
||||
9
src/modules/marketplace/components/index.ts
Normal file
9
src/modules/marketplace/components/index.ts
Normal 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';
|
||||
453
src/modules/marketplace/pages/AdvisoryDetail.tsx
Normal file
453
src/modules/marketplace/pages/AdvisoryDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
396
src/modules/marketplace/pages/MarketplaceCatalog.tsx
Normal file
396
src/modules/marketplace/pages/MarketplaceCatalog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
399
src/modules/marketplace/pages/SignalPackDetail.tsx
Normal file
399
src/modules/marketplace/pages/SignalPackDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
7
src/modules/marketplace/pages/index.ts
Normal file
7
src/modules/marketplace/pages/index.ts
Normal 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';
|
||||
379
src/modules/portfolio/components/CorrelationMatrix.tsx
Normal file
379
src/modules/portfolio/components/CorrelationMatrix.tsx
Normal 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;
|
||||
739
src/modules/portfolio/components/GoalsManager.tsx
Normal file
739
src/modules/portfolio/components/GoalsManager.tsx
Normal 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;
|
||||
591
src/modules/portfolio/components/MonteCarloSimulator.tsx
Normal file
591
src/modules/portfolio/components/MonteCarloSimulator.tsx
Normal 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;
|
||||
613
src/modules/portfolio/components/RebalancingPanel.tsx
Normal file
613
src/modules/portfolio/components/RebalancingPanel.tsx
Normal 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)}% → {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;
|
||||
690
src/modules/portfolio/components/RiskAnalytics.tsx
Normal file
690
src/modules/portfolio/components/RiskAnalytics.tsx
Normal 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;
|
||||
@ -6,9 +6,14 @@
|
||||
export { AllocationChart } from './AllocationChart';
|
||||
export { AllocationTable } from './AllocationTable';
|
||||
export { AllocationsCard } from './AllocationsCard';
|
||||
export { CorrelationMatrix } from './CorrelationMatrix';
|
||||
export { GoalCard } from './GoalCard';
|
||||
export { GoalProgressCard } from './GoalProgressCard';
|
||||
export { GoalsManager } from './GoalsManager';
|
||||
export { MonteCarloSimulator } from './MonteCarloSimulator';
|
||||
export { PerformanceChart } from './PerformanceChart';
|
||||
export { PerformanceMetricsCard } from './PerformanceMetricsCard';
|
||||
export { RebalanceCard } from './RebalanceCard';
|
||||
export { RebalanceModal } from './RebalanceModal';
|
||||
export { RebalancingPanel } from './RebalancingPanel';
|
||||
export { RiskAnalytics } from './RiskAnalytics';
|
||||
|
||||
302
src/services/marketplace.service.ts
Normal file
302
src/services/marketplace.service.ts
Normal 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,
|
||||
};
|
||||
525
src/stores/marketplaceStore.ts
Normal file
525
src/stores/marketplaceStore.ts
Normal 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;
|
||||
338
src/types/marketplace.types.ts
Normal file
338
src/types/marketplace.types.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user