[SPRINT-3] feat(investment,payments): Dashboard, KYC, transactions and alt payments

SUBTASK-005 (Investment):
- Rewrite Investment.tsx with summary cards and performance chart
- Add pagination to Transactions.tsx (10 items per page)
- Add PDF/CSV export dropdown to Reports.tsx
- Fix quick amount buttons in DepositForm.tsx
- Fix Max button in WithdrawForm.tsx
- Add full KYC verification system (3 steps)
- Add KYCVerification page with route /investment/kyc

SUBTASK-006 (Payments):
- Add AlternativePaymentMethods component (OXXO, SPEI, Card)
- Extend payment types for regional methods
- Update PaymentMethodsList exports

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-02-04 00:17:46 -06:00
parent 950d0a7804
commit c9e2727d3b
13 changed files with 2061 additions and 115 deletions

View File

@ -36,6 +36,7 @@ const Withdrawals = lazy(() => import('./modules/investment/pages/Withdrawals'))
const InvestmentTransactions = lazy(() => import('./modules/investment/pages/Transactions'));
const InvestmentReports = lazy(() => import('./modules/investment/pages/Reports'));
const ProductDetail = lazy(() => import('./modules/investment/pages/ProductDetail'));
const KYCVerification = lazy(() => import('./modules/investment/pages/KYCVerification'));
const Settings = lazy(() => import('./modules/settings/pages/Settings'));
const Assistant = lazy(() => import('./modules/assistant/pages/Assistant'));
@ -108,6 +109,7 @@ function App() {
<Route path="/investment/transactions" element={<InvestmentTransactions />} />
<Route path="/investment/reports" element={<InvestmentReports />} />
<Route path="/investment/products/:productId" element={<ProductDetail />} />
<Route path="/investment/kyc" element={<KYCVerification />} />
{/* Portfolio Manager */}
<Route path="/portfolio" element={<PortfolioDashboard />} />

View File

@ -0,0 +1,301 @@
/**
* AlternativePaymentMethods Component
* Display and select alternative payment methods (OXXO, SPEI, etc.)
* Epic: OQI-005 Pagos y Stripe
*/
import React, { useState } from 'react';
import {
Store,
Building2,
CreditCard,
ChevronRight,
Info,
CheckCircle,
Clock,
AlertCircle,
Loader2,
} from 'lucide-react';
import type { PaymentMethodType } from '../../types/payment.types';
export interface AlternativePaymentMethod {
id: PaymentMethodType;
name: string;
description: string;
icon: React.ReactNode;
processingTime: string;
available: boolean;
minAmount?: number;
maxAmount?: number;
fees?: string;
instructions?: string[];
regions?: string[];
}
const ALTERNATIVE_METHODS: AlternativePaymentMethod[] = [
{
id: 'oxxo',
name: 'OXXO Pay',
description: 'Pay with cash at any OXXO convenience store',
icon: <Store className="w-6 h-6" />,
processingTime: '1-3 business days',
available: true,
minAmount: 10,
maxAmount: 10000,
fees: 'No additional fees',
instructions: [
'Complete checkout to receive a payment voucher',
'Visit any OXXO store and show the voucher',
'Pay in cash at the register',
'Your payment will be confirmed within 1-3 days',
],
regions: ['Mexico'],
},
{
id: 'spei',
name: 'SPEI Transfer',
description: 'Instant bank transfer via SPEI (Mexico)',
icon: <Building2 className="w-6 h-6" />,
processingTime: 'Instant - 24 hours',
available: true,
minAmount: 1,
maxAmount: 500000,
fees: 'Bank fees may apply',
instructions: [
'Complete checkout to receive CLABE and reference number',
'Log into your bank app or website',
'Make a SPEI transfer to the provided CLABE',
'Include the reference number in your transfer',
],
regions: ['Mexico'],
},
{
id: 'card',
name: 'Credit/Debit Card',
description: 'Pay with Visa, Mastercard, or Amex',
icon: <CreditCard className="w-6 h-6" />,
processingTime: 'Instant',
available: true,
fees: 'No additional fees',
instructions: ['Enter your card details securely'],
regions: ['Worldwide'],
},
];
interface AlternativePaymentMethodsProps {
selectedMethod: PaymentMethodType | null;
onSelectMethod: (method: PaymentMethodType) => void;
onContinue: () => void;
amount?: number;
currency?: string;
isLoading?: boolean;
showCardOption?: boolean;
region?: string;
}
const AlternativePaymentMethods: React.FC<AlternativePaymentMethodsProps> = ({
selectedMethod,
onSelectMethod,
onContinue,
amount = 0,
currency = 'MXN',
isLoading = false,
showCardOption = true,
region = 'Mexico',
}) => {
const [expandedMethod, setExpandedMethod] = useState<PaymentMethodType | null>(null);
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency,
}).format(value);
};
const availableMethods = ALTERNATIVE_METHODS.filter((method) => {
if (!showCardOption && method.id === 'card') return false;
if (method.regions && !method.regions.includes(region) && !method.regions.includes('Worldwide')) {
return false;
}
if (method.minAmount && amount < method.minAmount) return false;
if (method.maxAmount && amount > method.maxAmount) return false;
return method.available;
});
const toggleExpand = (methodId: PaymentMethodType) => {
setExpandedMethod(expandedMethod === methodId ? null : methodId);
};
const getMethodStatusColor = (method: AlternativePaymentMethod) => {
if (!method.available) return 'text-gray-500';
if (selectedMethod === method.id) return 'text-blue-400';
return 'text-gray-400';
};
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-white">Select Payment Method</h3>
{amount > 0 && (
<span className="text-gray-400">
Total: <span className="text-white font-medium">{formatCurrency(amount)}</span>
</span>
)}
</div>
{/* Payment Methods List */}
<div className="space-y-3">
{availableMethods.map((method) => {
const isSelected = selectedMethod === method.id;
const isExpanded = expandedMethod === method.id;
return (
<div
key={method.id}
className={`rounded-xl border transition-all ${
isSelected
? 'border-blue-500 bg-blue-900/20'
: 'border-gray-700 bg-gray-800/50 hover:border-gray-600'
}`}
>
{/* Method Header */}
<button
onClick={() => onSelectMethod(method.id)}
className="w-full flex items-center justify-between p-4"
>
<div className="flex items-center gap-4">
<div
className={`p-3 rounded-lg ${
isSelected ? 'bg-blue-500/20' : 'bg-gray-900'
} ${getMethodStatusColor(method)}`}
>
{method.icon}
</div>
<div className="text-left">
<div className="flex items-center gap-2">
<span className="text-white font-medium">{method.name}</span>
{method.regions && method.regions[0] !== 'Worldwide' && (
<span className="px-2 py-0.5 bg-gray-700 text-gray-400 text-xs rounded">
{method.regions[0]}
</span>
)}
</div>
<p className="text-gray-400 text-sm">{method.description}</p>
</div>
</div>
<div className="flex items-center gap-3">
{isSelected && (
<CheckCircle className="w-5 h-5 text-blue-400" />
)}
<button
onClick={(e) => {
e.stopPropagation();
toggleExpand(method.id);
}}
className="p-1 text-gray-500 hover:text-white transition-colors"
>
<Info className="w-4 h-4" />
</button>
</div>
</button>
{/* Expanded Details */}
{isExpanded && (
<div className="px-4 pb-4 border-t border-gray-700/50 mt-2 pt-3">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-500 block">Processing Time</span>
<span className="text-white flex items-center gap-1">
<Clock className="w-4 h-4 text-gray-500" />
{method.processingTime}
</span>
</div>
{method.fees && (
<div>
<span className="text-gray-500 block">Fees</span>
<span className="text-white">{method.fees}</span>
</div>
)}
{method.minAmount && (
<div>
<span className="text-gray-500 block">Min Amount</span>
<span className="text-white">{formatCurrency(method.minAmount)}</span>
</div>
)}
{method.maxAmount && (
<div>
<span className="text-gray-500 block">Max Amount</span>
<span className="text-white">{formatCurrency(method.maxAmount)}</span>
</div>
)}
</div>
{method.instructions && method.instructions.length > 0 && (
<div className="mt-4">
<span className="text-gray-500 text-sm block mb-2">How it works:</span>
<ol className="space-y-1.5">
{method.instructions.map((instruction, index) => (
<li key={index} className="flex items-start gap-2 text-sm text-gray-400">
<span className="flex-shrink-0 w-5 h-5 rounded-full bg-gray-700 text-gray-300 flex items-center justify-center text-xs">
{index + 1}
</span>
{instruction}
</li>
))}
</ol>
</div>
)}
</div>
)}
</div>
);
})}
</div>
{/* Amount Limits Warning */}
{amount > 0 && (
<div className="flex items-start gap-2 p-3 bg-gray-800/50 rounded-lg">
<AlertCircle className="w-4 h-4 text-amber-400 flex-shrink-0 mt-0.5" />
<p className="text-gray-400 text-sm">
Some payment methods have minimum and maximum amount limits.
{selectedMethod === 'oxxo' && amount > 10000 && (
<span className="text-amber-400 block mt-1">
OXXO Pay has a maximum limit of $10,000 MXN per transaction.
</span>
)}
</p>
</div>
)}
{/* Continue Button */}
{selectedMethod && (
<button
onClick={onContinue}
disabled={isLoading}
className="w-full flex items-center justify-center gap-2 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Processing...
</>
) : (
<>
Continue with {ALTERNATIVE_METHODS.find((m) => m.id === selectedMethod)?.name}
<ChevronRight className="w-5 h-5" />
</>
)}
</button>
)}
{/* Security Note */}
<div className="flex items-center justify-center gap-2 text-gray-500 text-xs">
<CheckCircle className="w-3 h-3 text-emerald-500" />
All payments are processed securely via Stripe
</div>
</div>
);
};
export default AlternativePaymentMethods;

View File

@ -14,16 +14,22 @@ import {
CheckCircle,
Loader2,
Shield,
Building2,
Smartphone,
Wallet,
Store,
Banknote,
} from 'lucide-react';
import {
getPaymentMethods,
removePaymentMethod,
setDefaultPaymentMethod,
} from '../../services/payment.service';
import type { PaymentMethodType } from '../../types/payment.types';
export interface PaymentMethod {
id: string;
type: 'card' | 'bank_account' | 'paypal';
type: PaymentMethodType;
isDefault: boolean;
card?: {
brand: string;
@ -36,6 +42,13 @@ export interface PaymentMethod {
last4: string;
accountType: string;
};
oxxo?: {
email: string;
};
spei?: {
bankName: string;
clabe: string;
};
createdAt: string;
}
@ -115,9 +128,80 @@ const PaymentMethodsList: React.FC<PaymentMethodsListProps> = ({
}
};
const getCardIcon = (brand: string) => {
// In a real app, you'd use card brand icons
return <CreditCard className="w-6 h-6" />;
const getPaymentMethodIcon = (method: PaymentMethod) => {
switch (method.type) {
case 'card':
return <CreditCard className="w-6 h-6" />;
case 'bank_account':
case 'spei':
return <Building2 className="w-6 h-6" />;
case 'oxxo':
return <Store className="w-6 h-6" />;
case 'paypal':
return <Wallet className="w-6 h-6" />;
case 'google_pay':
case 'apple_pay':
return <Smartphone className="w-6 h-6" />;
default:
return <Banknote className="w-6 h-6" />;
}
};
const getPaymentMethodColor = (method: PaymentMethod) => {
switch (method.type) {
case 'card':
return getCardBrandColor(method.card?.brand || '');
case 'oxxo':
return 'text-red-400';
case 'spei':
return 'text-blue-500';
case 'paypal':
return 'text-blue-400';
case 'google_pay':
return 'text-green-400';
case 'apple_pay':
return 'text-gray-300';
case 'bank_account':
return 'text-emerald-400';
default:
return 'text-gray-400';
}
};
const getPaymentMethodLabel = (method: PaymentMethod): string => {
switch (method.type) {
case 'card':
return method.card?.brand || 'Card';
case 'oxxo':
return 'OXXO Pay';
case 'spei':
return 'SPEI';
case 'paypal':
return 'PayPal';
case 'google_pay':
return 'Google Pay';
case 'apple_pay':
return 'Apple Pay';
case 'bank_account':
return method.bankAccount?.bankName || 'Bank Account';
default:
return 'Payment Method';
}
};
const getPaymentMethodDetails = (method: PaymentMethod): string => {
switch (method.type) {
case 'card':
return `**** ${method.card?.last4}`;
case 'oxxo':
return method.oxxo?.email || '';
case 'spei':
return method.spei?.clabe ? `CLABE: ****${method.spei.clabe.slice(-4)}` : '';
case 'bank_account':
return `**** ${method.bankAccount?.last4}`;
default:
return '';
}
};
const getCardBrandColor = (brand: string) => {
@ -207,17 +291,19 @@ const PaymentMethodsList: React.FC<PaymentMethodsListProps> = ({
: 'border-gray-700 hover:border-gray-600'
} ${selectable && !expired ? 'cursor-pointer' : ''}`}
>
{/* Card Info */}
{/* Payment Method Info */}
<div className="flex items-center gap-4">
<div className={`p-3 bg-gray-900 rounded-lg ${getCardBrandColor(method.card?.brand || '')}`}>
{getCardIcon(method.card?.brand || '')}
<div className={`p-3 bg-gray-900 rounded-lg ${getPaymentMethodColor(method)}`}>
{getPaymentMethodIcon(method)}
</div>
<div>
<div className="flex items-center gap-2">
<span className="text-white font-medium capitalize">
{method.card?.brand || method.type}
{getPaymentMethodLabel(method)}
</span>
<span className="text-gray-400"> {method.card?.last4}</span>
{getPaymentMethodDetails(method) && (
<span className="text-gray-400">{getPaymentMethodDetails(method)}</span>
)}
{method.isDefault && (
<span className="flex items-center gap-1 px-2 py-0.5 bg-blue-500/20 text-blue-400 rounded text-xs">
<Star className="w-3 h-3" />
@ -226,10 +312,21 @@ const PaymentMethodsList: React.FC<PaymentMethodsListProps> = ({
)}
</div>
<div className="flex items-center gap-2 mt-1">
<span className={`text-sm ${expired ? 'text-red-400' : expiringSoon ? 'text-yellow-400' : 'text-gray-500'}`}>
{expired ? 'Expired' : `Expires ${method.card?.expMonth}/${method.card?.expYear}`}
</span>
{expiringSoon && !expired && (
{method.type === 'card' && method.card && (
<span className={`text-sm ${expired ? 'text-red-400' : expiringSoon ? 'text-yellow-400' : 'text-gray-500'}`}>
{expired ? 'Expired' : `Expires ${method.card.expMonth}/${method.card.expYear}`}
</span>
)}
{method.type === 'oxxo' && (
<span className="text-sm text-gray-500">Pay at any OXXO store</span>
)}
{method.type === 'spei' && (
<span className="text-sm text-gray-500">Bank transfer (Mexico)</span>
)}
{method.type === 'bank_account' && method.bankAccount && (
<span className="text-sm text-gray-500">{method.bankAccount.accountType}</span>
)}
{expiringSoon && !expired && method.type === 'card' && (
<span className="text-xs text-yellow-400">(expiring soon)</span>
)}
</div>

View File

@ -41,6 +41,10 @@ export type { RefundEligibility, RefundRequestData, RefundReason } from './Refun
export { default as RefundList } from './RefundList';
export type { Refund, RefundStatus } from './RefundList';
// Alternative Payment Methods (OQI-005)
export { default as AlternativePaymentMethods } from './AlternativePaymentMethods';
export type { AlternativePaymentMethod } from './AlternativePaymentMethods';
// Stripe Integration (OQI-005)
export { default as StripeElementsWrapper, withStripeElements, useStripeAvailable } from './StripeElementsWrapper';
export type { StripeConfig } from './StripeElementsWrapper';

View File

@ -232,17 +232,20 @@ function DepositFormInner({ accounts, onSuccess, onCancel }: DepositFormProps) {
<p className="mt-1 text-sm text-red-400">{errors.amount.message}</p>
)}
<div className="mt-2 flex gap-2">
{[100, 500, 1000, 5000].map((amount) => (
{[100, 500, 1000, 5000].map((quickAmount) => (
<button
key={amount}
key={quickAmount}
type="button"
onClick={() => {
const event = { target: { value: amount, name: 'amount' } };
register('amount').onChange(event);
const input = document.querySelector<HTMLInputElement>('input[name="amount"]');
if (input) {
input.value = String(quickAmount);
input.dispatchEvent(new Event('input', { bubbles: true }));
}
}}
className="px-3 py-1 text-sm bg-slate-700 text-slate-300 rounded hover:bg-slate-600 transition-colors"
>
${amount}
${quickAmount}
</button>
))}
</div>

View File

@ -0,0 +1,101 @@
/**
* KYCStatusBadge Component
* Displays the current KYC verification status
*/
import React from 'react';
import { CheckCircle, Clock, XCircle, AlertTriangle, Shield } from 'lucide-react';
export type KYCStatus = 'not_started' | 'pending' | 'in_review' | 'approved' | 'rejected';
interface KYCStatusBadgeProps {
status: KYCStatus;
rejectionReason?: string;
size?: 'sm' | 'md' | 'lg';
showLabel?: boolean;
}
const statusConfig: Record<KYCStatus, {
icon: React.ElementType;
label: string;
color: string;
bgColor: string;
description: string;
}> = {
not_started: {
icon: Shield,
label: 'No Iniciado',
color: 'text-gray-500',
bgColor: 'bg-gray-100 dark:bg-gray-800',
description: 'Completa la verificación de identidad para habilitar retiros',
},
pending: {
icon: Clock,
label: 'Pendiente',
color: 'text-yellow-500',
bgColor: 'bg-yellow-100 dark:bg-yellow-900/30',
description: 'Tu documentación ha sido enviada y está pendiente de revisión',
},
in_review: {
icon: Clock,
label: 'En Revisión',
color: 'text-blue-500',
bgColor: 'bg-blue-100 dark:bg-blue-900/30',
description: 'Nuestro equipo está revisando tu documentación',
},
approved: {
icon: CheckCircle,
label: 'Verificado',
color: 'text-green-500',
bgColor: 'bg-green-100 dark:bg-green-900/30',
description: 'Tu identidad ha sido verificada exitosamente',
},
rejected: {
icon: XCircle,
label: 'Rechazado',
color: 'text-red-500',
bgColor: 'bg-red-100 dark:bg-red-900/30',
description: 'La verificación fue rechazada. Por favor, revisa el motivo',
},
};
export const KYCStatusBadge: React.FC<KYCStatusBadgeProps> = ({
status,
rejectionReason,
size = 'md',
showLabel = true,
}) => {
const config = statusConfig[status];
const Icon = config.icon;
const sizeClasses = {
sm: 'px-2 py-0.5 text-xs',
md: 'px-3 py-1 text-sm',
lg: 'px-4 py-2 text-base',
};
const iconSizes = {
sm: 'w-3 h-3',
md: 'w-4 h-4',
lg: 'w-5 h-5',
};
return (
<div className="inline-flex flex-col items-start">
<span
className={`inline-flex items-center gap-1.5 rounded-full font-medium ${sizeClasses[size]} ${config.bgColor} ${config.color}`}
>
<Icon className={iconSizes[size]} />
{showLabel && config.label}
</span>
{status === 'rejected' && rejectionReason && (
<div className="mt-2 flex items-start gap-2 text-sm text-red-500">
<AlertTriangle className="w-4 h-4 flex-shrink-0 mt-0.5" />
<span>{rejectionReason}</span>
</div>
)}
</div>
);
};
export default KYCStatusBadge;

View File

@ -0,0 +1,611 @@
/**
* KYCVerificationPanel Component
* Complete KYC verification flow with document upload
* Epic: OQI-004 Cuentas de Inversion
*/
import React, { useState, useRef } from 'react';
import {
Shield,
User,
FileText,
Upload,
CheckCircle,
AlertCircle,
Camera,
X,
Loader2,
Clock,
MapPin,
Briefcase,
} from 'lucide-react';
import { KYCStatusBadge, KYCStatus } from './KYCStatusBadge';
interface PersonalData {
fullName: string;
dateOfBirth: string;
nationality: string;
address: string;
city: string;
state: string;
postalCode: string;
country: string;
occupation: string;
incomeSource: string;
}
interface Document {
id: string;
type: 'id_front' | 'id_back' | 'proof_of_address' | 'selfie';
fileName: string;
status: 'pending' | 'approved' | 'rejected';
previewUrl?: string;
rejectionReason?: string;
}
interface KYCVerificationPanelProps {
status: KYCStatus;
personalData?: Partial<PersonalData>;
documents?: Document[];
rejectionReason?: string;
onSubmit?: (data: { personalData: PersonalData; documents: File[] }) => Promise<void>;
onCancel?: () => void;
}
type Step = 'personal' | 'documents' | 'review';
const DOCUMENT_TYPES = [
{ id: 'id_front', label: 'ID Frontal', description: 'INE, Pasaporte o Licencia de conducir (frente)', icon: FileText },
{ id: 'id_back', label: 'ID Reverso', description: 'Parte posterior del documento', icon: FileText },
{ id: 'proof_of_address', label: 'Comprobante de Domicilio', description: 'Recibo de luz, agua o estado de cuenta (no mayor a 3 meses)', icon: MapPin },
{ id: 'selfie', label: 'Selfie con ID', description: 'Foto tuya sosteniendo tu identificación', icon: Camera },
];
const NATIONALITY_OPTIONS = [
'Mexico', 'United States', 'Canada', 'Spain', 'Argentina',
'Colombia', 'Chile', 'Peru', 'Brazil', 'United Kingdom', 'Germany', 'France',
];
const OCCUPATION_OPTIONS = [
'Empleado', 'Empresario', 'Profesionista Independiente', 'Comerciante',
'Estudiante', 'Jubilado', 'Otro',
];
const INCOME_SOURCE_OPTIONS = [
'Salario', 'Negocio Propio', 'Inversiones', 'Herencia',
'Ahorros', 'Pensión', 'Otro',
];
export const KYCVerificationPanel: React.FC<KYCVerificationPanelProps> = ({
status,
personalData: initialPersonalData,
documents: existingDocuments = [],
rejectionReason,
onSubmit,
onCancel,
}) => {
const [currentStep, setCurrentStep] = useState<Step>(status === 'not_started' ? 'personal' : 'review');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [currentDocType, setCurrentDocType] = useState<string | null>(null);
const [personalData, setPersonalData] = useState<PersonalData>({
fullName: initialPersonalData?.fullName || '',
dateOfBirth: initialPersonalData?.dateOfBirth || '',
nationality: initialPersonalData?.nationality || '',
address: initialPersonalData?.address || '',
city: initialPersonalData?.city || '',
state: initialPersonalData?.state || '',
postalCode: initialPersonalData?.postalCode || '',
country: initialPersonalData?.country || '',
occupation: initialPersonalData?.occupation || '',
incomeSource: initialPersonalData?.incomeSource || '',
});
const [uploadedDocuments, setUploadedDocuments] = useState<Map<string, { file: File; preview: string }>>(new Map());
const handlePersonalDataChange = (field: keyof PersonalData, value: string) => {
setPersonalData(prev => ({ ...prev, [field]: value }));
};
const isPersonalDataValid = () => {
return (
personalData.fullName.length >= 3 &&
personalData.dateOfBirth !== '' &&
personalData.nationality !== '' &&
personalData.address.length >= 5 &&
personalData.city.length >= 2 &&
personalData.country !== '' &&
personalData.occupation !== '' &&
personalData.incomeSource !== ''
);
};
const handleFileSelect = (docType: string) => {
setCurrentDocType(docType);
fileInputRef.current?.click();
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !currentDocType) return;
if (!file.type.startsWith('image/') && file.type !== 'application/pdf') {
setError('Solo se permiten imágenes o archivos PDF');
return;
}
if (file.size > 10 * 1024 * 1024) {
setError('El archivo no debe exceder 10MB');
return;
}
const preview = URL.createObjectURL(file);
setUploadedDocuments(prev => {
const newMap = new Map(prev);
const existing = prev.get(currentDocType);
if (existing?.preview) {
URL.revokeObjectURL(existing.preview);
}
newMap.set(currentDocType, { file, preview });
return newMap;
});
setError(null);
setCurrentDocType(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const removeDocument = (docType: string) => {
setUploadedDocuments(prev => {
const newMap = new Map(prev);
const existing = prev.get(docType);
if (existing?.preview) {
URL.revokeObjectURL(existing.preview);
}
newMap.delete(docType);
return newMap;
});
};
const isDocumentsComplete = () => {
return DOCUMENT_TYPES.every(doc => uploadedDocuments.has(doc.id) || existingDocuments.some(d => d.type === doc.id && d.status === 'approved'));
};
const handleSubmit = async () => {
if (!onSubmit) return;
setIsSubmitting(true);
setError(null);
try {
const files = Array.from(uploadedDocuments.values()).map(d => d.file);
await onSubmit({ personalData, documents: files });
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al enviar la verificación');
} finally {
setIsSubmitting(false);
}
};
const renderStepIndicator = () => (
<div className="flex items-center justify-center gap-4 mb-8">
{(['personal', 'documents', 'review'] as Step[]).map((step, index) => {
const isActive = step === currentStep;
const isCompleted =
(step === 'personal' && (currentStep === 'documents' || currentStep === 'review')) ||
(step === 'documents' && currentStep === 'review');
return (
<React.Fragment key={step}>
<div className="flex flex-col items-center">
<div
className={`w-10 h-10 rounded-full flex items-center justify-center transition-colors ${
isActive ? 'bg-blue-600 text-white' :
isCompleted ? 'bg-green-600 text-white' :
'bg-gray-200 dark:bg-gray-700 text-gray-500'
}`}
>
{isCompleted ? <CheckCircle className="w-5 h-5" /> : index + 1}
</div>
<span className={`text-xs mt-1 ${isActive ? 'text-white' : 'text-gray-400'}`}>
{step === 'personal' ? 'Datos' : step === 'documents' ? 'Documentos' : 'Revisión'}
</span>
</div>
{index < 2 && (
<div className={`w-16 h-0.5 ${isCompleted ? 'bg-green-600' : 'bg-gray-300 dark:bg-gray-700'}`} />
)}
</React.Fragment>
);
})}
</div>
);
const renderPersonalDataStep = () => (
<div className="space-y-4">
<div className="bg-blue-900/20 border border-blue-800/50 rounded-lg p-4 mb-6">
<div className="flex items-center gap-2">
<Shield className="w-5 h-5 text-blue-400" />
<span className="text-blue-400 font-medium">Información Personal</span>
</div>
<p className="text-slate-400 text-sm mt-2">
Proporciona tus datos personales como aparecen en tu identificación oficial.
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<label className="block text-sm font-medium text-slate-300 mb-1">Nombre Completo</label>
<input
type="text"
value={personalData.fullName}
onChange={(e) => handlePersonalDataChange('fullName', e.target.value)}
placeholder="Como aparece en tu identificación"
className="w-full px-4 py-2.5 bg-slate-800 border border-slate-700 rounded-lg text-white focus:border-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-300 mb-1">Fecha de Nacimiento</label>
<input
type="date"
value={personalData.dateOfBirth}
onChange={(e) => handlePersonalDataChange('dateOfBirth', e.target.value)}
className="w-full px-4 py-2.5 bg-slate-800 border border-slate-700 rounded-lg text-white focus:border-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-300 mb-1">Nacionalidad</label>
<select
value={personalData.nationality}
onChange={(e) => handlePersonalDataChange('nationality', e.target.value)}
className="w-full px-4 py-2.5 bg-slate-800 border border-slate-700 rounded-lg text-white focus:border-blue-500 focus:outline-none"
>
<option value="">Seleccionar</option>
{NATIONALITY_OPTIONS.map(option => (
<option key={option} value={option}>{option}</option>
))}
</select>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-slate-300 mb-1">Dirección</label>
<input
type="text"
value={personalData.address}
onChange={(e) => handlePersonalDataChange('address', e.target.value)}
placeholder="Calle y número"
className="w-full px-4 py-2.5 bg-slate-800 border border-slate-700 rounded-lg text-white focus:border-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-300 mb-1">Ciudad</label>
<input
type="text"
value={personalData.city}
onChange={(e) => handlePersonalDataChange('city', e.target.value)}
className="w-full px-4 py-2.5 bg-slate-800 border border-slate-700 rounded-lg text-white focus:border-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-300 mb-1">Estado/Provincia</label>
<input
type="text"
value={personalData.state}
onChange={(e) => handlePersonalDataChange('state', e.target.value)}
className="w-full px-4 py-2.5 bg-slate-800 border border-slate-700 rounded-lg text-white focus:border-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-300 mb-1">Código Postal</label>
<input
type="text"
value={personalData.postalCode}
onChange={(e) => handlePersonalDataChange('postalCode', e.target.value)}
className="w-full px-4 py-2.5 bg-slate-800 border border-slate-700 rounded-lg text-white focus:border-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-300 mb-1">País</label>
<select
value={personalData.country}
onChange={(e) => handlePersonalDataChange('country', e.target.value)}
className="w-full px-4 py-2.5 bg-slate-800 border border-slate-700 rounded-lg text-white focus:border-blue-500 focus:outline-none"
>
<option value="">Seleccionar</option>
{NATIONALITY_OPTIONS.map(option => (
<option key={option} value={option}>{option}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-300 mb-1">Ocupación</label>
<select
value={personalData.occupation}
onChange={(e) => handlePersonalDataChange('occupation', e.target.value)}
className="w-full px-4 py-2.5 bg-slate-800 border border-slate-700 rounded-lg text-white focus:border-blue-500 focus:outline-none"
>
<option value="">Seleccionar</option>
{OCCUPATION_OPTIONS.map(option => (
<option key={option} value={option}>{option}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-300 mb-1">Fuente de Ingresos</label>
<select
value={personalData.incomeSource}
onChange={(e) => handlePersonalDataChange('incomeSource', e.target.value)}
className="w-full px-4 py-2.5 bg-slate-800 border border-slate-700 rounded-lg text-white focus:border-blue-500 focus:outline-none"
>
<option value="">Seleccionar</option>
{INCOME_SOURCE_OPTIONS.map(option => (
<option key={option} value={option}>{option}</option>
))}
</select>
</div>
</div>
</div>
);
const renderDocumentsStep = () => (
<div className="space-y-4">
<div className="bg-amber-900/20 border border-amber-800/50 rounded-lg p-4 mb-6">
<div className="flex items-center gap-2">
<FileText className="w-5 h-5 text-amber-400" />
<span className="text-amber-400 font-medium">Documentos Requeridos</span>
</div>
<p className="text-slate-400 text-sm mt-2">
Sube fotos claras de tus documentos. Asegúrate de que toda la información sea legible.
</p>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*,application/pdf"
onChange={handleFileChange}
className="hidden"
/>
<div className="grid gap-4">
{DOCUMENT_TYPES.map((docType) => {
const uploaded = uploadedDocuments.get(docType.id);
const existing = existingDocuments.find(d => d.type === docType.id);
const Icon = docType.icon;
return (
<div
key={docType.id}
className={`p-4 rounded-lg border transition-colors ${
uploaded || (existing?.status === 'approved')
? 'border-green-500/50 bg-green-900/10'
: existing?.status === 'rejected'
? 'border-red-500/50 bg-red-900/10'
: 'border-slate-700 bg-slate-800/50 hover:border-slate-600'
}`}
>
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
<div className={`p-2 rounded-lg ${
uploaded || (existing?.status === 'approved')
? 'bg-green-900/30 text-green-400'
: existing?.status === 'rejected'
? 'bg-red-900/30 text-red-400'
: 'bg-slate-700 text-slate-400'
}`}>
<Icon className="w-5 h-5" />
</div>
<div>
<p className="font-medium text-white">{docType.label}</p>
<p className="text-sm text-slate-400">{docType.description}</p>
{existing?.status === 'rejected' && existing.rejectionReason && (
<p className="text-sm text-red-400 mt-1">
<AlertCircle className="w-3 h-3 inline mr-1" />
{existing.rejectionReason}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
{uploaded && (
<>
<img
src={uploaded.preview}
alt="Preview"
className="w-12 h-12 rounded object-cover"
/>
<button
onClick={() => removeDocument(docType.id)}
className="p-1 text-red-400 hover:text-red-300"
>
<X className="w-4 h-4" />
</button>
</>
)}
{!uploaded && existing?.status === 'approved' && (
<CheckCircle className="w-5 h-5 text-green-400" />
)}
{!uploaded && (!existing || existing.status === 'rejected') && (
<button
onClick={() => handleFileSelect(docType.id)}
className="flex items-center gap-2 px-3 py-1.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-500 transition-colors"
>
<Upload className="w-4 h-4" />
Subir
</button>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
);
const renderReviewStep = () => (
<div className="space-y-6">
<div className="text-center mb-6">
<KYCStatusBadge status={status} rejectionReason={rejectionReason} size="lg" />
<p className="text-slate-400 mt-3">
{status === 'pending' && 'Tu documentación ha sido enviada y está pendiente de revisión.'}
{status === 'in_review' && 'Nuestro equipo está revisando tu documentación.'}
{status === 'approved' && 'Tu identidad ha sido verificada exitosamente.'}
{status === 'rejected' && 'Por favor, corrige los documentos rechazados y vuelve a enviar.'}
</p>
</div>
{status === 'approved' && (
<div className="bg-green-900/20 border border-green-800/50 rounded-lg p-6 text-center">
<CheckCircle className="w-12 h-12 text-green-400 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-white mb-2">Verificación Completa</h3>
<p className="text-slate-400">
Ya puedes realizar retiros y acceder a todas las funcionalidades de inversión.
</p>
</div>
)}
{(status === 'pending' || status === 'in_review') && (
<div className="bg-blue-900/20 border border-blue-800/50 rounded-lg p-6 text-center">
<Clock className="w-12 h-12 text-blue-400 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-white mb-2">En Proceso</h3>
<p className="text-slate-400">
La verificación puede tomar entre 24 y 48 horas. Te notificaremos cuando esté lista.
</p>
</div>
)}
{status === 'rejected' && (
<div className="bg-red-900/20 border border-red-800/50 rounded-lg p-6">
<AlertCircle className="w-12 h-12 text-red-400 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-white mb-2 text-center">Verificación Rechazada</h3>
{rejectionReason && (
<p className="text-red-400 text-center mb-4">{rejectionReason}</p>
)}
<button
onClick={() => setCurrentStep('personal')}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-500 transition-colors"
>
Reiniciar Verificación
</button>
</div>
)}
{personalData.fullName && (
<div className="bg-slate-800/50 rounded-lg p-4 border border-slate-700">
<h4 className="text-sm font-medium text-slate-400 mb-3">Datos Personales</h4>
<div className="grid grid-cols-2 gap-2 text-sm">
<div>
<span className="text-slate-500">Nombre:</span>
<span className="text-white ml-2">{personalData.fullName}</span>
</div>
<div>
<span className="text-slate-500">Nacionalidad:</span>
<span className="text-white ml-2">{personalData.nationality}</span>
</div>
<div className="col-span-2">
<span className="text-slate-500">Dirección:</span>
<span className="text-white ml-2">
{personalData.address}, {personalData.city}, {personalData.country}
</span>
</div>
</div>
</div>
)}
</div>
);
return (
<div className="bg-slate-900 rounded-xl border border-slate-800 max-w-2xl mx-auto">
<div className="p-6 border-b border-slate-800">
<h2 className="text-white text-xl font-semibold flex items-center gap-2">
<Shield className="w-5 h-5 text-blue-400" />
Verificación de Identidad (KYC)
</h2>
<p className="text-slate-400 text-sm mt-1">
Completa la verificación para habilitar retiros y acceder a todas las funcionalidades
</p>
</div>
{status === 'not_started' || status === 'rejected' ? (
<>
<div className="p-6 border-b border-slate-800">
{renderStepIndicator()}
</div>
<div className="p-6">
{currentStep === 'personal' && renderPersonalDataStep()}
{currentStep === 'documents' && renderDocumentsStep()}
{currentStep === 'review' && renderReviewStep()}
</div>
{error && (
<div className="px-6 pb-4">
<div className="p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
<p className="text-sm text-red-400">{error}</p>
</div>
</div>
)}
<div className="p-6 border-t border-slate-800 flex justify-between">
<button
onClick={currentStep === 'personal' ? onCancel : () => setCurrentStep(currentStep === 'documents' ? 'personal' : 'documents')}
className="px-4 py-2 text-slate-300 hover:text-white transition-colors"
>
{currentStep === 'personal' ? 'Cancelar' : 'Atrás'}
</button>
{currentStep === 'review' ? (
<button
onClick={handleSubmit}
disabled={isSubmitting}
className="flex items-center gap-2 px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Enviando...
</>
) : (
<>
<CheckCircle className="w-4 h-4" />
Enviar Verificación
</>
)}
</button>
) : (
<button
onClick={() => setCurrentStep(currentStep === 'personal' ? 'documents' : 'review')}
disabled={
(currentStep === 'personal' && !isPersonalDataValid()) ||
(currentStep === 'documents' && !isDocumentsComplete())
}
className="flex items-center gap-2 px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Continuar
</button>
)}
</div>
</>
) : (
<div className="p-6">
{renderReviewStep()}
</div>
)}
</div>
);
};
export default KYCVerificationPanel;

View File

@ -215,8 +215,11 @@ export function WithdrawForm({ accounts, onSuccess, onCancel }: WithdrawFormProp
<button
type="button"
onClick={() => {
const event = { target: { value: maxWithdrawal, name: 'amount' } };
register('amount').onChange(event);
const input = document.querySelector<HTMLInputElement>('input[name="amount"]');
if (input) {
input.value = String(maxWithdrawal);
input.dispatchEvent(new Event('input', { bubbles: true }));
}
}}
className="text-blue-400 hover:text-blue-300"
>

View File

@ -1,98 +1,517 @@
import { TrendingUp, Shield, Zap } from 'lucide-react';
/**
* Investment Dashboard Page
* Main entry point for investment module showing account summary and performance
* Epic: OQI-004 Cuentas de Inversion
*/
const products = [
{
id: 1,
name: 'Cuenta Rendimiento Objetivo',
description: 'Objetivo de 5% mensual con estrategia conservadora',
agent: 'Atlas',
profile: 'Conservador',
targetReturn: '3-5%',
maxDrawdown: '5%',
icon: Shield,
},
{
id: 2,
name: 'Cuenta Variable',
description: 'Rendimiento variable con reparto de utilidades 50/50',
agent: 'Orion',
profile: 'Moderado',
targetReturn: '5-10%',
maxDrawdown: '10%',
icon: TrendingUp,
},
{
id: 3,
name: 'Cuenta Alta Volatilidad',
description: 'Máximo rendimiento para perfiles agresivos',
agent: 'Nova',
profile: 'Agresivo',
targetReturn: '10%+',
maxDrawdown: '20%',
icon: Zap,
},
];
import React, { useEffect, useState, useRef } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import {
TrendingUp,
Shield,
Zap,
Wallet,
ArrowUpRight,
ArrowDownRight,
Plus,
ArrowRight,
RefreshCw,
} from 'lucide-react';
import { useInvestmentStore } from '../../../stores/investmentStore';
// ============================================================================
// Types
// ============================================================================
type TimeframeKey = '1W' | '1M' | '3M' | '6M' | '1Y' | 'ALL';
interface PerformancePoint {
date: string;
value: number;
}
// ============================================================================
// Performance Chart Component
// ============================================================================
interface PortfolioPerformanceChartProps {
data: PerformancePoint[];
height?: number;
}
const PortfolioPerformanceChart: React.FC<PortfolioPerformanceChartProps> = ({ data, height = 200 }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || data.length < 2) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
const width = rect.width;
const chartHeight = rect.height;
const padding = { top: 20, right: 20, bottom: 30, left: 60 };
const chartWidth = width - padding.left - padding.right;
const innerHeight = chartHeight - padding.top - padding.bottom;
ctx.clearRect(0, 0, width, chartHeight);
const values = data.map((d) => d.value);
const minVal = Math.min(...values);
const maxVal = Math.max(...values);
const range = maxVal - minVal || 1;
const paddedMin = minVal - range * 0.1;
const paddedMax = maxVal + range * 0.1;
const paddedRange = paddedMax - paddedMin;
// Draw grid lines
ctx.strokeStyle = '#374151';
ctx.lineWidth = 0.5;
for (let i = 0; i <= 4; i++) {
const y = padding.top + (innerHeight / 4) * i;
ctx.beginPath();
ctx.moveTo(padding.left, y);
ctx.lineTo(width - padding.right, y);
ctx.stroke();
const val = paddedMax - (paddedRange / 4) * i;
ctx.fillStyle = '#9CA3AF';
ctx.font = '11px system-ui';
ctx.textAlign = 'right';
ctx.fillText(`$${val.toLocaleString(undefined, { maximumFractionDigits: 0 })}`, padding.left - 8, y + 4);
}
// Map points
const points = data.map((d, i) => ({
x: padding.left + (i / (data.length - 1)) * chartWidth,
y: padding.top + ((paddedMax - d.value) / paddedRange) * innerHeight,
}));
// Determine color based on performance
const isPositive = data[data.length - 1].value >= data[0].value;
const lineColor = isPositive ? '#10B981' : '#EF4444';
const fillColor = isPositive ? 'rgba(16, 185, 129, 0.15)' : 'rgba(239, 68, 68, 0.15)';
// Draw fill
ctx.beginPath();
ctx.moveTo(points[0].x, chartHeight - padding.bottom);
points.forEach((p) => ctx.lineTo(p.x, p.y));
ctx.lineTo(points[points.length - 1].x, chartHeight - padding.bottom);
ctx.closePath();
ctx.fillStyle = fillColor;
ctx.fill();
// Draw line
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) {
const prev = points[i - 1];
const curr = points[i];
const midX = (prev.x + curr.x) / 2;
ctx.quadraticCurveTo(prev.x, prev.y, midX, (prev.y + curr.y) / 2);
}
ctx.lineTo(points[points.length - 1].x, points[points.length - 1].y);
ctx.strokeStyle = lineColor;
ctx.lineWidth = 2;
ctx.stroke();
// Draw end dot
const lastPt = points[points.length - 1];
ctx.beginPath();
ctx.arc(lastPt.x, lastPt.y, 4, 0, Math.PI * 2);
ctx.fillStyle = lineColor;
ctx.fill();
// X-axis labels
const labelIndices = [0, Math.floor(data.length / 2), data.length - 1];
ctx.fillStyle = '#9CA3AF';
ctx.font = '10px system-ui';
ctx.textAlign = 'center';
labelIndices.forEach((i) => {
if (data[i]) {
const x = padding.left + (i / (data.length - 1)) * chartWidth;
const date = new Date(data[i].date);
ctx.fillText(date.toLocaleDateString('es-ES', { month: 'short', day: 'numeric' }), x, chartHeight - 10);
}
});
}, [data]);
if (data.length < 2) {
return (
<div className="flex items-center justify-center h-48 text-gray-500">
No hay datos de rendimiento disponibles
</div>
);
}
return <canvas ref={canvasRef} className="w-full" style={{ height }} />;
};
// ============================================================================
// Quick Action Button Component
// ============================================================================
interface QuickActionProps {
icon: React.ReactNode;
label: string;
description: string;
to: string;
color: string;
}
const QuickActionButton: React.FC<QuickActionProps> = ({ icon, label, description, to, color }) => (
<Link
to={to}
className={`flex items-center gap-4 p-4 rounded-xl border border-slate-700 bg-slate-800/50 hover:bg-slate-800 hover:border-slate-600 transition-all group`}
>
<div className={`w-12 h-12 rounded-lg ${color} flex items-center justify-center`}>{icon}</div>
<div className="flex-1">
<div className="font-medium text-white group-hover:text-blue-400 transition-colors">{label}</div>
<div className="text-sm text-slate-400">{description}</div>
</div>
<ArrowRight className="w-5 h-5 text-slate-500 group-hover:text-blue-400 transition-colors" />
</Link>
);
// ============================================================================
// Account Card Component
// ============================================================================
interface AccountCardProps {
account: {
id: string;
product: { code: string; name: string; riskProfile: string };
balance: number;
totalDeposited: number;
totalEarnings: number;
unrealizedPnlPercent: number;
status: string;
};
}
const AccountCard: React.FC<AccountCardProps> = ({ account }) => {
const icons: Record<string, React.ReactNode> = {
atlas: <Shield className="w-5 h-5 text-blue-400" />,
orion: <TrendingUp className="w-5 h-5 text-purple-400" />,
nova: <Zap className="w-5 h-5 text-amber-400" />,
};
const isPositive = account.totalEarnings >= 0;
return (
<Link
to={`/investment/accounts/${account.id}`}
className="p-4 rounded-xl border border-slate-700 bg-slate-800/50 hover:bg-slate-800 hover:border-slate-600 transition-all"
>
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 rounded-lg bg-slate-700 flex items-center justify-center">
{icons[account.product.code] || <Wallet className="w-5 h-5 text-slate-400" />}
</div>
<div className="flex-1">
<div className="font-medium text-white">{account.product.name}</div>
<div className="text-xs text-slate-500 capitalize">{account.product.riskProfile}</div>
</div>
<div className={`flex items-center gap-1 text-sm ${isPositive ? 'text-emerald-400' : 'text-red-400'}`}>
{isPositive ? <ArrowUpRight className="w-4 h-4" /> : <ArrowDownRight className="w-4 h-4" />}
{account.unrealizedPnlPercent.toFixed(2)}%
</div>
</div>
<div className="flex items-end justify-between">
<div>
<div className="text-xs text-slate-500">Balance</div>
<div className="text-xl font-bold text-white">
${account.balance.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
</div>
<div className="text-right">
<div className="text-xs text-slate-500">Ganancias</div>
<div className={`font-medium ${isPositive ? 'text-emerald-400' : 'text-red-400'}`}>
{isPositive ? '+' : ''}${Math.abs(account.totalEarnings).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
</div>
</div>
</Link>
);
};
// ============================================================================
// Main Component
// ============================================================================
export default function Investment() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-white">Inversión</h1>
<p className="text-gray-400">Gestiona tus cuentas de inversión con agentes IA</p>
</div>
const navigate = useNavigate();
const {
accounts,
accountSummary,
loadingAccounts,
loadingSummary,
fetchAccounts,
fetchAccountSummary,
} = useInvestmentStore();
{/* My Accounts */}
<div className="card">
<h2 className="text-lg font-semibold text-white mb-4">Mis Cuentas</h2>
<div className="text-center py-8 text-gray-400">
<p>No tienes cuentas de inversión activas.</p>
<button className="btn btn-primary mt-4">Abrir Nueva Cuenta</button>
const [timeframe, setTimeframe] = useState<TimeframeKey>('1M');
const [performanceData, setPerformanceData] = useState<PerformancePoint[]>([]);
const [loadingPerformance, setLoadingPerformance] = useState(false);
const timeframes: { key: TimeframeKey; label: string }[] = [
{ key: '1W', label: '1S' },
{ key: '1M', label: '1M' },
{ key: '3M', label: '3M' },
{ key: '6M', label: '6M' },
{ key: '1Y', label: '1A' },
{ key: 'ALL', label: 'Todo' },
];
useEffect(() => {
fetchAccounts();
fetchAccountSummary();
}, [fetchAccounts, fetchAccountSummary]);
useEffect(() => {
generateMockPerformanceData();
}, [timeframe, accountSummary]);
const generateMockPerformanceData = () => {
if (!accountSummary || accountSummary.totalBalance === 0) {
setPerformanceData([]);
return;
}
setLoadingPerformance(true);
const daysMap: Record<TimeframeKey, number> = {
'1W': 7,
'1M': 30,
'3M': 90,
'6M': 180,
'1Y': 365,
ALL: 730,
};
const days = daysMap[timeframe];
const data: PerformancePoint[] = [];
const baseValue = accountSummary.totalDeposited;
const currentValue = accountSummary.totalBalance;
const growthRate = (currentValue - baseValue) / baseValue;
for (let i = days; i >= 0; i--) {
const date = new Date();
date.setDate(date.getDate() - i);
const progress = (days - i) / days;
const randomVariation = (Math.random() - 0.5) * 0.02;
const value = baseValue * (1 + growthRate * progress + randomVariation);
data.push({
date: date.toISOString(),
value: Math.max(0, value),
});
}
data[data.length - 1].value = currentValue;
setPerformanceData(data);
setLoadingPerformance(false);
};
const handleRefresh = () => {
fetchAccounts();
fetchAccountSummary();
};
const activeAccounts = accounts.filter((a) => a.status === 'active');
const hasAccounts = accounts.length > 0;
const isLoading = loadingAccounts || loadingSummary;
const totalReturn = accountSummary ? accountSummary.overallReturnPercent : 0;
const isPositiveReturn = totalReturn >= 0;
return (
<div className="max-w-7xl mx-auto px-4 py-8 space-y-8">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-white">Dashboard de Inversion</h1>
<p className="text-slate-400 mt-1">Gestiona tus cuentas de inversion con agentes IA</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={handleRefresh}
disabled={isLoading}
className="p-2 rounded-lg bg-slate-800 border border-slate-700 hover:border-slate-600 transition-colors disabled:opacity-50"
>
<RefreshCw className={`w-5 h-5 text-slate-400 ${isLoading ? 'animate-spin' : ''}`} />
</button>
<Link
to="/investment/products"
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors"
>
<Plus className="w-5 h-5" />
Nueva Inversion
</Link>
</div>
</div>
{/* Available Products */}
<div>
<h2 className="text-lg font-semibold text-white mb-4">Productos Disponibles</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{products.map((product) => (
<div key={product.id} className="card hover:border-primary-500 transition-colors">
<div className="w-12 h-12 rounded-lg bg-primary-900/30 flex items-center justify-center mb-4">
<product.icon className="w-6 h-6 text-primary-400" />
</div>
<h3 className="text-lg font-semibold text-white">{product.name}</h3>
<p className="text-sm text-gray-400 mt-2">{product.description}</p>
<div className="mt-4 space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-400">Agente:</span>
<span className="text-white font-medium">{product.agent}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Perfil:</span>
<span className="text-white">{product.profile}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Target mensual:</span>
<span className="text-green-400">{product.targetReturn}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Max Drawdown:</span>
<span className="text-red-400">{product.maxDrawdown}</span>
</div>
</div>
<button className="btn btn-primary w-full mt-4">Abrir Cuenta</button>
{/* Account Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="p-5 rounded-xl bg-gradient-to-br from-blue-600/20 to-blue-800/20 border border-blue-500/30">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center">
<Wallet className="w-5 h-5 text-blue-400" />
</div>
))}
<span className="text-sm text-slate-400">Valor Total</span>
</div>
<div className="text-2xl font-bold text-white">
${(accountSummary?.totalBalance || 0).toLocaleString(undefined, { minimumFractionDigits: 2 })}
</div>
</div>
<div className="p-5 rounded-xl bg-gradient-to-br from-purple-600/20 to-purple-800/20 border border-purple-500/30">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 rounded-lg bg-purple-500/20 flex items-center justify-center">
<ArrowUpRight className="w-5 h-5 text-purple-400" />
</div>
<span className="text-sm text-slate-400">Total Invertido</span>
</div>
<div className="text-2xl font-bold text-white">
${(accountSummary?.totalDeposited || 0).toLocaleString(undefined, { minimumFractionDigits: 2 })}
</div>
</div>
<div className={`p-5 rounded-xl border ${isPositiveReturn ? 'bg-gradient-to-br from-emerald-600/20 to-emerald-800/20 border-emerald-500/30' : 'bg-gradient-to-br from-red-600/20 to-red-800/20 border-red-500/30'}`}>
<div className="flex items-center gap-3 mb-3">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${isPositiveReturn ? 'bg-emerald-500/20' : 'bg-red-500/20'}`}>
<TrendingUp className={`w-5 h-5 ${isPositiveReturn ? 'text-emerald-400' : 'text-red-400'}`} />
</div>
<span className="text-sm text-slate-400">Rendimiento Total</span>
</div>
<div className={`text-2xl font-bold ${isPositiveReturn ? 'text-emerald-400' : 'text-red-400'}`}>
{isPositiveReturn ? '+' : ''}{totalReturn.toFixed(2)}%
</div>
<div className={`text-sm ${isPositiveReturn ? 'text-emerald-400/70' : 'text-red-400/70'}`}>
{isPositiveReturn ? '+' : ''}${Math.abs(accountSummary?.overallReturn || 0).toLocaleString(undefined, { minimumFractionDigits: 2 })}
</div>
</div>
<div className="p-5 rounded-xl bg-gradient-to-br from-amber-600/20 to-amber-800/20 border border-amber-500/30">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 rounded-lg bg-amber-500/20 flex items-center justify-center">
<Shield className="w-5 h-5 text-amber-400" />
</div>
<span className="text-sm text-slate-400">Cuentas Activas</span>
</div>
<div className="text-2xl font-bold text-white">{activeAccounts.length}</div>
<div className="text-sm text-slate-500">de {accounts.length} total</div>
</div>
</div>
{/* Performance Chart */}
<div className="rounded-xl bg-slate-800/50 border border-slate-700 p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-white">Rendimiento del Portfolio</h2>
<div className="flex gap-1">
{timeframes.map((tf) => (
<button
key={tf.key}
onClick={() => setTimeframe(tf.key)}
className={`px-3 py-1.5 text-sm rounded-lg font-medium transition-colors ${
timeframe === tf.key
? 'bg-blue-600 text-white'
: 'bg-slate-700 text-slate-300 hover:bg-slate-600'
}`}
>
{tf.label}
</button>
))}
</div>
</div>
{loadingPerformance ? (
<div className="flex items-center justify-center h-48">
<RefreshCw className="w-8 h-8 text-slate-500 animate-spin" />
</div>
) : (
<PortfolioPerformanceChart data={performanceData} height={200} />
)}
</div>
{/* My Accounts Section */}
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white">Mis Cuentas</h2>
{hasAccounts && (
<Link to="/investment/portfolio" className="text-sm text-blue-400 hover:text-blue-300">
Ver todas
</Link>
)}
</div>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<RefreshCw className="w-8 h-8 text-slate-500 animate-spin" />
</div>
) : hasAccounts ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{activeAccounts.slice(0, 3).map((account) => (
<AccountCard key={account.id} account={account} />
))}
</div>
) : (
<div className="text-center py-12 rounded-xl bg-slate-800/30 border border-slate-700">
<Wallet className="w-12 h-12 text-slate-600 mx-auto mb-4" />
<h3 className="text-lg font-medium text-white mb-2">No tienes cuentas de inversion activas</h3>
<p className="text-slate-400 mb-6">Comienza a invertir con nuestros agentes de IA</p>
<Link
to="/investment/products"
className="inline-flex items-center gap-2 px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors"
>
<Plus className="w-5 h-5" />
Abrir Nueva Cuenta
</Link>
</div>
)}
</div>
{/* Quick Actions */}
<div>
<h2 className="text-lg font-semibold text-white mb-4">Acciones Rapidas</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<QuickActionButton
icon={<Plus className="w-6 h-6 text-blue-400" />}
label="Depositar"
description="Agregar fondos a tus cuentas"
to="/investment/portfolio"
color="bg-blue-500/20"
/>
<QuickActionButton
icon={<ArrowDownRight className="w-6 h-6 text-purple-400" />}
label="Retirar"
description="Solicitar retiro de fondos"
to="/investment/withdrawals"
color="bg-purple-500/20"
/>
<QuickActionButton
icon={<TrendingUp className="w-6 h-6 text-emerald-400" />}
label="Ver Productos"
description="Explorar opciones de inversion"
to="/investment/products"
color="bg-emerald-500/20"
/>
</div>
</div>
{/* Risk Warning */}
<div className="card bg-yellow-900/20 border-yellow-800">
<div className="p-4 rounded-xl bg-yellow-900/20 border border-yellow-800/50">
<p className="text-sm text-yellow-400">
<strong>Aviso de Riesgo:</strong> El trading e inversión conlleva riesgos significativos.
Los rendimientos objetivo no están garantizados. Puede perder parte o la totalidad de su inversión.
<strong>Aviso de Riesgo:</strong> El trading e inversion conlleva riesgos significativos.
Los rendimientos objetivo no estan garantizados. Puede perder parte o la totalidad de su inversion.
Solo invierta capital que pueda permitirse perder.
</p>
</div>
</div>

View File

@ -0,0 +1,158 @@
/**
* KYCVerification Page
* Standalone page for KYC verification process
* Epic: OQI-004 Cuentas de Inversion
*/
import React, { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { ArrowLeft, AlertCircle } from 'lucide-react';
import { KYCVerificationPanel } from '../components/KYCVerificationPanel';
import { KYCStatus } from '../components/KYCStatusBadge';
interface KYCData {
status: KYCStatus;
personalData?: {
fullName: string;
dateOfBirth: string;
nationality: string;
address: string;
city: string;
state: string;
postalCode: string;
country: string;
occupation: string;
incomeSource: string;
};
documents?: Array<{
id: string;
type: 'id_front' | 'id_back' | 'proof_of_address' | 'selfie';
fileName: string;
status: 'pending' | 'approved' | 'rejected';
rejectionReason?: string;
}>;
rejectionReason?: string;
}
export const KYCVerification: React.FC = () => {
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [kycData, setKycData] = useState<KYCData>({
status: 'not_started',
});
useEffect(() => {
loadKYCStatus();
}, []);
const loadKYCStatus = async () => {
try {
setLoading(true);
setError(null);
const token = localStorage.getItem('token');
if (!token) {
navigate('/login');
return;
}
const response = await fetch('/api/v1/investment/kyc/status', {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (response.ok) {
const data = await response.json();
setKycData(data.data || { status: 'not_started' });
} else if (response.status === 404) {
setKycData({ status: 'not_started' });
} else {
throw new Error('Error loading KYC status');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Error loading KYC status');
setKycData({ status: 'not_started' });
} finally {
setLoading(false);
}
};
const handleSubmit = async (data: { personalData: { fullName: string; dateOfBirth: string; nationality: string; address: string; city: string; state: string; postalCode: string; country: string; occupation: string; incomeSource: string }; documents: File[] }) => {
const token = localStorage.getItem('token');
if (!token) {
throw new Error('Please log in to continue');
}
const formData = new FormData();
formData.append('personalData', JSON.stringify(data.personalData));
data.documents.forEach((file, index) => {
formData.append(`document_${index}`, file);
});
const response = await fetch('/api/v1/investment/kyc/submit', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
body: formData,
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to submit KYC');
}
setKycData(prev => ({ ...prev, status: 'pending' }));
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-96">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
);
}
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="flex items-center gap-4 mb-8">
<Link
to="/investment/portfolio"
className="p-2 rounded-lg bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700"
>
<ArrowLeft className="w-5 h-5 text-gray-600 dark:text-gray-400" />
</Link>
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Verificación de Identidad
</h1>
<p className="text-gray-500 dark:text-gray-400">
Completa tu verificación KYC para habilitar retiros
</p>
</div>
</div>
{error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-6 mb-6">
<div className="flex items-center gap-2">
<AlertCircle className="w-5 h-5 text-red-500" />
<p className="text-red-600 dark:text-red-400">{error}</p>
</div>
</div>
)}
<KYCVerificationPanel
status={kycData.status}
personalData={kycData.personalData}
documents={kycData.documents}
rejectionReason={kycData.rejectionReason}
onSubmit={handleSubmit}
onCancel={() => navigate('/investment/portfolio')}
/>
</div>
);
};
export default KYCVerification;

View File

@ -5,7 +5,7 @@
import React, { useEffect, useState, useRef } from 'react';
import { Link } from 'react-router-dom';
import { ArrowLeft, TrendingUp, TrendingDown, PieChart, BarChart3, Download, Calendar, AlertCircle } from 'lucide-react';
import { ArrowLeft, TrendingUp, TrendingDown, PieChart, BarChart3, Download, Calendar, AlertCircle, FileText, FileSpreadsheet, ChevronDown } from 'lucide-react';
import investmentService, { AccountSummary, InvestmentAccount } from '../../../services/investment.service';
// ============================================================================
@ -169,7 +169,147 @@ export const Reports: React.FC = () => {
};
}) || [];
const handleExport = () => {
const [showExportMenu, setShowExportMenu] = useState(false);
const handleExportCSV = () => {
if (!summary) return;
const headers = ['Cuenta', 'Balance', 'Invertido', 'Retirado', 'Ganancias', 'Retorno %'];
const rows = summary.accounts.map(a => {
const totalReturn = a.balance - a.totalDeposited + a.totalWithdrawn;
const returnPercent = a.totalDeposited > 0 ? (totalReturn / a.totalDeposited) * 100 : 0;
return [
a.product.name,
a.balance.toFixed(2),
a.totalDeposited.toFixed(2),
a.totalWithdrawn.toFixed(2),
a.totalEarnings.toFixed(2),
returnPercent.toFixed(2) + '%',
];
});
const summaryRow = [
'TOTAL',
summary.totalBalance.toFixed(2),
summary.totalDeposited.toFixed(2),
summary.totalWithdrawn.toFixed(2),
summary.overallReturn.toFixed(2),
summary.overallReturnPercent.toFixed(2) + '%',
];
const csvContent = [
headers.join(','),
...rows.map(row => row.join(',')),
'',
summaryRow.join(','),
].join('\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `investment-report-${new Date().toISOString().split('T')[0]}.csv`;
link.click();
URL.revokeObjectURL(url);
setShowExportMenu(false);
};
const handleExportPDF = () => {
if (!summary) return;
const printWindow = window.open('', '_blank');
if (!printWindow) return;
const htmlContent = `
<!DOCTYPE html>
<html>
<head>
<title>Investment Report - ${new Date().toLocaleDateString()}</title>
<style>
body { font-family: Arial, sans-serif; padding: 40px; color: #333; }
h1 { color: #1e40af; border-bottom: 2px solid #1e40af; padding-bottom: 10px; }
.summary { display: flex; gap: 20px; margin: 20px 0; }
.summary-card { background: #f3f4f6; padding: 15px; border-radius: 8px; flex: 1; }
.summary-card h3 { margin: 0 0 5px 0; font-size: 12px; color: #666; }
.summary-card p { margin: 0; font-size: 20px; font-weight: bold; }
.positive { color: #10b981; }
.negative { color: #ef4444; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
th, td { padding: 12px; text-align: left; border-bottom: 1px solid #e5e7eb; }
th { background: #f9fafb; font-weight: 600; }
.right { text-align: right; }
.footer { margin-top: 40px; font-size: 12px; color: #999; }
</style>
</head>
<body>
<h1>Investment Report</h1>
<p>Generated: ${new Date().toLocaleString()}</p>
<div class="summary">
<div class="summary-card">
<h3>Total Balance</h3>
<p>$${summary.totalBalance.toLocaleString(undefined, { minimumFractionDigits: 2 })}</p>
</div>
<div class="summary-card">
<h3>Total Invested</h3>
<p>$${summary.totalDeposited.toLocaleString(undefined, { minimumFractionDigits: 2 })}</p>
</div>
<div class="summary-card">
<h3>Overall Return</h3>
<p class="${summary.overallReturn >= 0 ? 'positive' : 'negative'}">
${summary.overallReturn >= 0 ? '+' : ''}$${Math.abs(summary.overallReturn).toLocaleString(undefined, { minimumFractionDigits: 2 })}
(${summary.overallReturnPercent >= 0 ? '+' : ''}${summary.overallReturnPercent.toFixed(2)}%)
</p>
</div>
</div>
<h2>Account Details</h2>
<table>
<thead>
<tr>
<th>Account</th>
<th class="right">Balance</th>
<th class="right">Invested</th>
<th class="right">Earnings</th>
<th class="right">Return</th>
</tr>
</thead>
<tbody>
${summary.accounts.map(a => {
const totalReturn = a.balance - a.totalDeposited + a.totalWithdrawn;
const returnPercent = a.totalDeposited > 0 ? (totalReturn / a.totalDeposited) * 100 : 0;
return `
<tr>
<td>${a.product.name}</td>
<td class="right">$${a.balance.toLocaleString(undefined, { minimumFractionDigits: 2 })}</td>
<td class="right">$${a.totalDeposited.toLocaleString(undefined, { minimumFractionDigits: 2 })}</td>
<td class="right ${totalReturn >= 0 ? 'positive' : 'negative'}">
${totalReturn >= 0 ? '+' : ''}$${Math.abs(totalReturn).toLocaleString(undefined, { minimumFractionDigits: 2 })}
</td>
<td class="right ${returnPercent >= 0 ? 'positive' : 'negative'}">
${returnPercent >= 0 ? '+' : ''}${returnPercent.toFixed(2)}%
</td>
</tr>
`;
}).join('')}
</tbody>
</table>
<div class="footer">
<p>This report is for informational purposes only. Past performance does not guarantee future results.</p>
<p>Trading Platform - Investment Reports</p>
</div>
</body>
</html>
`;
printWindow.document.write(htmlContent);
printWindow.document.close();
printWindow.print();
setShowExportMenu(false);
};
const handleExportJSON = () => {
if (!summary) return;
const reportData = {
@ -197,6 +337,7 @@ export const Reports: React.FC = () => {
link.download = `investment-report-${new Date().toISOString().split('T')[0]}.json`;
link.click();
URL.revokeObjectURL(url);
setShowExportMenu(false);
};
if (loading) {
@ -246,13 +387,41 @@ export const Reports: React.FC = () => {
</p>
</div>
</div>
<button
onClick={handleExport}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Download className="w-4 h-4" />
Exportar
</button>
<div className="relative">
<button
onClick={() => setShowExportMenu(!showExportMenu)}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Download className="w-4 h-4" />
Exportar
<ChevronDown className="w-4 h-4" />
</button>
{showExportMenu && (
<div className="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1 z-10">
<button
onClick={handleExportPDF}
className="w-full flex items-center gap-3 px-4 py-2 text-left text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<FileText className="w-4 h-4 text-red-500" />
Exportar PDF
</button>
<button
onClick={handleExportCSV}
className="w-full flex items-center gap-3 px-4 py-2 text-left text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<FileSpreadsheet className="w-4 h-4 text-green-500" />
Exportar CSV
</button>
<button
onClick={handleExportJSON}
className="w-full flex items-center gap-3 px-4 py-2 text-left text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<Download className="w-4 h-4 text-blue-500" />
Exportar JSON
</button>
</div>
)}
</div>
</div>
{/* Period Filter */}

View File

@ -3,9 +3,9 @@
* Global transaction history across all investment accounts
*/
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { ArrowLeft, ArrowDownCircle, ArrowUpCircle, Gift, Receipt, RefreshCw, Filter, Calendar, AlertCircle } from 'lucide-react';
import { ArrowLeft, ArrowDownCircle, ArrowUpCircle, Gift, Receipt, RefreshCw, Filter, Calendar, AlertCircle, ChevronLeft, ChevronRight } from 'lucide-react';
import investmentService, { Transaction, InvestmentAccount } from '../../../services/investment.service';
// ============================================================================
@ -109,6 +109,8 @@ const TransactionRow: React.FC<TransactionRowProps> = ({ transaction }) => {
// Main Component
// ============================================================================
const ITEMS_PER_PAGE = 10;
export const Transactions: React.FC = () => {
const [transactions, setTransactions] = useState<TransactionWithAccount[]>([]);
const [accounts, setAccounts] = useState<InvestmentAccount[]>([]);
@ -117,6 +119,7 @@ export const Transactions: React.FC = () => {
const [typeFilter, setTypeFilter] = useState<TransactionType>('all');
const [selectedAccount, setSelectedAccount] = useState<string>('all');
const [dateRange, setDateRange] = useState<'week' | 'month' | '3months' | 'all'>('month');
const [currentPage, setCurrentPage] = useState(1);
useEffect(() => {
loadData();
@ -188,6 +191,16 @@ export const Transactions: React.FC = () => {
fees: transactions.filter(t => t.type === 'fee').reduce((sum, t) => sum + t.amount, 0),
};
const totalPages = Math.ceil(transactions.length / ITEMS_PER_PAGE);
const paginatedTransactions = useMemo(() => {
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
return transactions.slice(startIndex, startIndex + ITEMS_PER_PAGE);
}, [transactions, currentPage]);
useEffect(() => {
setCurrentPage(1);
}, [typeFilter, selectedAccount]);
return (
<div className="max-w-6xl mx-auto px-4 py-8">
{/* Header */}
@ -316,9 +329,60 @@ export const Transactions: React.FC = () => {
</div>
) : (
<div>
{transactions.map(transaction => (
{paginatedTransactions.map(transaction => (
<TransactionRow key={transaction.id} transaction={transaction} />
))}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
<div className="text-sm text-gray-500 dark:text-gray-400">
Mostrando {((currentPage - 1) * ITEMS_PER_PAGE) + 1} - {Math.min(currentPage * ITEMS_PER_PAGE, transactions.length)} de {transactions.length} transacciones
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
disabled={currentPage === 1}
className="p-2 rounded-lg bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronLeft className="w-5 h-5 text-gray-600 dark:text-gray-400" />
</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={() => setCurrentPage(pageNum)}
className={`w-10 h-10 rounded-lg text-sm font-medium transition-colors ${
currentPage === pageNum
? 'bg-blue-600 text-white'
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
>
{pageNum}
</button>
);
})}
</div>
<button
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
disabled={currentPage === totalPages}
className="p-2 rounded-lg bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronRight className="w-5 h-5 text-gray-600 dark:text-gray-400" />
</button>
</div>
</div>
)}
</div>
)}
</div>

View File

@ -145,10 +145,19 @@ export interface Payment {
createdAt: string;
}
export type PaymentMethodType =
| 'card'
| 'bank_account'
| 'oxxo'
| 'spei'
| 'paypal'
| 'google_pay'
| 'apple_pay';
export interface PaymentMethod {
id: string;
userId: string;
type: 'card' | 'bank_account';
type: PaymentMethodType;
brand?: string;
last4: string;
expiryMonth?: number;
@ -156,6 +165,11 @@ export interface PaymentMethod {
isDefault: boolean;
stripePaymentMethodId?: string;
createdAt: string;
metadata?: {
bankName?: string;
accountType?: string;
email?: string;
};
}
export interface InvoiceLineItem {