[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:
parent
950d0a7804
commit
c9e2727d3b
@ -36,6 +36,7 @@ const Withdrawals = lazy(() => import('./modules/investment/pages/Withdrawals'))
|
|||||||
const InvestmentTransactions = lazy(() => import('./modules/investment/pages/Transactions'));
|
const InvestmentTransactions = lazy(() => import('./modules/investment/pages/Transactions'));
|
||||||
const InvestmentReports = lazy(() => import('./modules/investment/pages/Reports'));
|
const InvestmentReports = lazy(() => import('./modules/investment/pages/Reports'));
|
||||||
const ProductDetail = lazy(() => import('./modules/investment/pages/ProductDetail'));
|
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 Settings = lazy(() => import('./modules/settings/pages/Settings'));
|
||||||
const Assistant = lazy(() => import('./modules/assistant/pages/Assistant'));
|
const Assistant = lazy(() => import('./modules/assistant/pages/Assistant'));
|
||||||
|
|
||||||
@ -108,6 +109,7 @@ function App() {
|
|||||||
<Route path="/investment/transactions" element={<InvestmentTransactions />} />
|
<Route path="/investment/transactions" element={<InvestmentTransactions />} />
|
||||||
<Route path="/investment/reports" element={<InvestmentReports />} />
|
<Route path="/investment/reports" element={<InvestmentReports />} />
|
||||||
<Route path="/investment/products/:productId" element={<ProductDetail />} />
|
<Route path="/investment/products/:productId" element={<ProductDetail />} />
|
||||||
|
<Route path="/investment/kyc" element={<KYCVerification />} />
|
||||||
|
|
||||||
{/* Portfolio Manager */}
|
{/* Portfolio Manager */}
|
||||||
<Route path="/portfolio" element={<PortfolioDashboard />} />
|
<Route path="/portfolio" element={<PortfolioDashboard />} />
|
||||||
|
|||||||
301
src/components/payments/AlternativePaymentMethods.tsx
Normal file
301
src/components/payments/AlternativePaymentMethods.tsx
Normal 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;
|
||||||
@ -14,16 +14,22 @@ import {
|
|||||||
CheckCircle,
|
CheckCircle,
|
||||||
Loader2,
|
Loader2,
|
||||||
Shield,
|
Shield,
|
||||||
|
Building2,
|
||||||
|
Smartphone,
|
||||||
|
Wallet,
|
||||||
|
Store,
|
||||||
|
Banknote,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
getPaymentMethods,
|
getPaymentMethods,
|
||||||
removePaymentMethod,
|
removePaymentMethod,
|
||||||
setDefaultPaymentMethod,
|
setDefaultPaymentMethod,
|
||||||
} from '../../services/payment.service';
|
} from '../../services/payment.service';
|
||||||
|
import type { PaymentMethodType } from '../../types/payment.types';
|
||||||
|
|
||||||
export interface PaymentMethod {
|
export interface PaymentMethod {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'card' | 'bank_account' | 'paypal';
|
type: PaymentMethodType;
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
card?: {
|
card?: {
|
||||||
brand: string;
|
brand: string;
|
||||||
@ -36,6 +42,13 @@ export interface PaymentMethod {
|
|||||||
last4: string;
|
last4: string;
|
||||||
accountType: string;
|
accountType: string;
|
||||||
};
|
};
|
||||||
|
oxxo?: {
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
spei?: {
|
||||||
|
bankName: string;
|
||||||
|
clabe: string;
|
||||||
|
};
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -115,9 +128,80 @@ const PaymentMethodsList: React.FC<PaymentMethodsListProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getCardIcon = (brand: string) => {
|
const getPaymentMethodIcon = (method: PaymentMethod) => {
|
||||||
// In a real app, you'd use card brand icons
|
switch (method.type) {
|
||||||
return <CreditCard className="w-6 h-6" />;
|
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) => {
|
const getCardBrandColor = (brand: string) => {
|
||||||
@ -207,17 +291,19 @@ const PaymentMethodsList: React.FC<PaymentMethodsListProps> = ({
|
|||||||
: 'border-gray-700 hover:border-gray-600'
|
: 'border-gray-700 hover:border-gray-600'
|
||||||
} ${selectable && !expired ? 'cursor-pointer' : ''}`}
|
} ${selectable && !expired ? 'cursor-pointer' : ''}`}
|
||||||
>
|
>
|
||||||
{/* Card Info */}
|
{/* Payment Method Info */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className={`p-3 bg-gray-900 rounded-lg ${getCardBrandColor(method.card?.brand || '')}`}>
|
<div className={`p-3 bg-gray-900 rounded-lg ${getPaymentMethodColor(method)}`}>
|
||||||
{getCardIcon(method.card?.brand || '')}
|
{getPaymentMethodIcon(method)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-white font-medium capitalize">
|
<span className="text-white font-medium capitalize">
|
||||||
{method.card?.brand || method.type}
|
{getPaymentMethodLabel(method)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-gray-400">•••• {method.card?.last4}</span>
|
{getPaymentMethodDetails(method) && (
|
||||||
|
<span className="text-gray-400">{getPaymentMethodDetails(method)}</span>
|
||||||
|
)}
|
||||||
{method.isDefault && (
|
{method.isDefault && (
|
||||||
<span className="flex items-center gap-1 px-2 py-0.5 bg-blue-500/20 text-blue-400 rounded text-xs">
|
<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" />
|
<Star className="w-3 h-3" />
|
||||||
@ -226,10 +312,21 @@ const PaymentMethodsList: React.FC<PaymentMethodsListProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 mt-1">
|
<div className="flex items-center gap-2 mt-1">
|
||||||
<span className={`text-sm ${expired ? 'text-red-400' : expiringSoon ? 'text-yellow-400' : 'text-gray-500'}`}>
|
{method.type === 'card' && method.card && (
|
||||||
{expired ? 'Expired' : `Expires ${method.card?.expMonth}/${method.card?.expYear}`}
|
<span className={`text-sm ${expired ? 'text-red-400' : expiringSoon ? 'text-yellow-400' : 'text-gray-500'}`}>
|
||||||
</span>
|
{expired ? 'Expired' : `Expires ${method.card.expMonth}/${method.card.expYear}`}
|
||||||
{expiringSoon && !expired && (
|
</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>
|
<span className="text-xs text-yellow-400">(expiring soon)</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -41,6 +41,10 @@ export type { RefundEligibility, RefundRequestData, RefundReason } from './Refun
|
|||||||
export { default as RefundList } from './RefundList';
|
export { default as RefundList } from './RefundList';
|
||||||
export type { Refund, RefundStatus } 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)
|
// Stripe Integration (OQI-005)
|
||||||
export { default as StripeElementsWrapper, withStripeElements, useStripeAvailable } from './StripeElementsWrapper';
|
export { default as StripeElementsWrapper, withStripeElements, useStripeAvailable } from './StripeElementsWrapper';
|
||||||
export type { StripeConfig } from './StripeElementsWrapper';
|
export type { StripeConfig } from './StripeElementsWrapper';
|
||||||
|
|||||||
@ -232,17 +232,20 @@ function DepositFormInner({ accounts, onSuccess, onCancel }: DepositFormProps) {
|
|||||||
<p className="mt-1 text-sm text-red-400">{errors.amount.message}</p>
|
<p className="mt-1 text-sm text-red-400">{errors.amount.message}</p>
|
||||||
)}
|
)}
|
||||||
<div className="mt-2 flex gap-2">
|
<div className="mt-2 flex gap-2">
|
||||||
{[100, 500, 1000, 5000].map((amount) => (
|
{[100, 500, 1000, 5000].map((quickAmount) => (
|
||||||
<button
|
<button
|
||||||
key={amount}
|
key={quickAmount}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const event = { target: { value: amount, name: 'amount' } };
|
const input = document.querySelector<HTMLInputElement>('input[name="amount"]');
|
||||||
register('amount').onChange(event);
|
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"
|
className="px-3 py-1 text-sm bg-slate-700 text-slate-300 rounded hover:bg-slate-600 transition-colors"
|
||||||
>
|
>
|
||||||
${amount}
|
${quickAmount}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
101
src/modules/investment/components/KYCStatusBadge.tsx
Normal file
101
src/modules/investment/components/KYCStatusBadge.tsx
Normal 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;
|
||||||
611
src/modules/investment/components/KYCVerificationPanel.tsx
Normal file
611
src/modules/investment/components/KYCVerificationPanel.tsx
Normal 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;
|
||||||
@ -215,8 +215,11 @@ export function WithdrawForm({ accounts, onSuccess, onCancel }: WithdrawFormProp
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const event = { target: { value: maxWithdrawal, name: 'amount' } };
|
const input = document.querySelector<HTMLInputElement>('input[name="amount"]');
|
||||||
register('amount').onChange(event);
|
if (input) {
|
||||||
|
input.value = String(maxWithdrawal);
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
className="text-blue-400 hover:text-blue-300"
|
className="text-blue-400 hover:text-blue-300"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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 = [
|
import React, { useEffect, useState, useRef } from 'react';
|
||||||
{
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
id: 1,
|
import {
|
||||||
name: 'Cuenta Rendimiento Objetivo',
|
TrendingUp,
|
||||||
description: 'Objetivo de 5% mensual con estrategia conservadora',
|
Shield,
|
||||||
agent: 'Atlas',
|
Zap,
|
||||||
profile: 'Conservador',
|
Wallet,
|
||||||
targetReturn: '3-5%',
|
ArrowUpRight,
|
||||||
maxDrawdown: '5%',
|
ArrowDownRight,
|
||||||
icon: Shield,
|
Plus,
|
||||||
},
|
ArrowRight,
|
||||||
{
|
RefreshCw,
|
||||||
id: 2,
|
} from 'lucide-react';
|
||||||
name: 'Cuenta Variable',
|
import { useInvestmentStore } from '../../../stores/investmentStore';
|
||||||
description: 'Rendimiento variable con reparto de utilidades 50/50',
|
|
||||||
agent: 'Orion',
|
// ============================================================================
|
||||||
profile: 'Moderado',
|
// Types
|
||||||
targetReturn: '5-10%',
|
// ============================================================================
|
||||||
maxDrawdown: '10%',
|
|
||||||
icon: TrendingUp,
|
type TimeframeKey = '1W' | '1M' | '3M' | '6M' | '1Y' | 'ALL';
|
||||||
},
|
|
||||||
{
|
interface PerformancePoint {
|
||||||
id: 3,
|
date: string;
|
||||||
name: 'Cuenta Alta Volatilidad',
|
value: number;
|
||||||
description: 'Máximo rendimiento para perfiles agresivos',
|
}
|
||||||
agent: 'Nova',
|
|
||||||
profile: 'Agresivo',
|
// ============================================================================
|
||||||
targetReturn: '10%+',
|
// Performance Chart Component
|
||||||
maxDrawdown: '20%',
|
// ============================================================================
|
||||||
icon: Zap,
|
|
||||||
},
|
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() {
|
export default function Investment() {
|
||||||
return (
|
const navigate = useNavigate();
|
||||||
<div className="space-y-6">
|
const {
|
||||||
<div>
|
accounts,
|
||||||
<h1 className="text-2xl font-bold text-white">Inversión</h1>
|
accountSummary,
|
||||||
<p className="text-gray-400">Gestiona tus cuentas de inversión con agentes IA</p>
|
loadingAccounts,
|
||||||
</div>
|
loadingSummary,
|
||||||
|
fetchAccounts,
|
||||||
|
fetchAccountSummary,
|
||||||
|
} = useInvestmentStore();
|
||||||
|
|
||||||
{/* My Accounts */}
|
const [timeframe, setTimeframe] = useState<TimeframeKey>('1M');
|
||||||
<div className="card">
|
const [performanceData, setPerformanceData] = useState<PerformancePoint[]>([]);
|
||||||
<h2 className="text-lg font-semibold text-white mb-4">Mis Cuentas</h2>
|
const [loadingPerformance, setLoadingPerformance] = useState(false);
|
||||||
<div className="text-center py-8 text-gray-400">
|
|
||||||
<p>No tienes cuentas de inversión activas.</p>
|
const timeframes: { key: TimeframeKey; label: string }[] = [
|
||||||
<button className="btn btn-primary mt-4">Abrir Nueva Cuenta</button>
|
{ 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Available Products */}
|
{/* Account Summary Cards */}
|
||||||
<div>
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<h2 className="text-lg font-semibold text-white mb-4">Productos Disponibles</h2>
|
<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="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<div className="flex items-center gap-3 mb-3">
|
||||||
{products.map((product) => (
|
<div className="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center">
|
||||||
<div key={product.id} className="card hover:border-primary-500 transition-colors">
|
<Wallet className="w-5 h-5 text-blue-400" />
|
||||||
<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>
|
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Risk Warning */}
|
{/* 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">
|
<p className="text-sm text-yellow-400">
|
||||||
<strong>Aviso de Riesgo:</strong> El trading e inversión conlleva riesgos significativos.
|
<strong>Aviso de Riesgo:</strong> El trading e inversion conlleva riesgos significativos.
|
||||||
Los rendimientos objetivo no están garantizados. Puede perder parte o la totalidad de su inversión.
|
Los rendimientos objetivo no estan garantizados. Puede perder parte o la totalidad de su inversion.
|
||||||
|
Solo invierta capital que pueda permitirse perder.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
158
src/modules/investment/pages/KYCVerification.tsx
Normal file
158
src/modules/investment/pages/KYCVerification.tsx
Normal 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;
|
||||||
@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import React, { useEffect, useState, useRef } from 'react';
|
import React, { useEffect, useState, useRef } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
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';
|
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;
|
if (!summary) return;
|
||||||
|
|
||||||
const reportData = {
|
const reportData = {
|
||||||
@ -197,6 +337,7 @@ export const Reports: React.FC = () => {
|
|||||||
link.download = `investment-report-${new Date().toISOString().split('T')[0]}.json`;
|
link.download = `investment-report-${new Date().toISOString().split('T')[0]}.json`;
|
||||||
link.click();
|
link.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
|
setShowExportMenu(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@ -246,13 +387,41 @@ export const Reports: React.FC = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="relative">
|
||||||
onClick={handleExport}
|
<button
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
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
|
<Download className="w-4 h-4" />
|
||||||
</button>
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Period Filter */}
|
{/* Period Filter */}
|
||||||
|
|||||||
@ -3,9 +3,9 @@
|
|||||||
* Global transaction history across all investment accounts
|
* 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 { 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';
|
import investmentService, { Transaction, InvestmentAccount } from '../../../services/investment.service';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -109,6 +109,8 @@ const TransactionRow: React.FC<TransactionRowProps> = ({ transaction }) => {
|
|||||||
// Main Component
|
// Main Component
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
const ITEMS_PER_PAGE = 10;
|
||||||
|
|
||||||
export const Transactions: React.FC = () => {
|
export const Transactions: React.FC = () => {
|
||||||
const [transactions, setTransactions] = useState<TransactionWithAccount[]>([]);
|
const [transactions, setTransactions] = useState<TransactionWithAccount[]>([]);
|
||||||
const [accounts, setAccounts] = useState<InvestmentAccount[]>([]);
|
const [accounts, setAccounts] = useState<InvestmentAccount[]>([]);
|
||||||
@ -117,6 +119,7 @@ export const Transactions: React.FC = () => {
|
|||||||
const [typeFilter, setTypeFilter] = useState<TransactionType>('all');
|
const [typeFilter, setTypeFilter] = useState<TransactionType>('all');
|
||||||
const [selectedAccount, setSelectedAccount] = useState<string>('all');
|
const [selectedAccount, setSelectedAccount] = useState<string>('all');
|
||||||
const [dateRange, setDateRange] = useState<'week' | 'month' | '3months' | 'all'>('month');
|
const [dateRange, setDateRange] = useState<'week' | 'month' | '3months' | 'all'>('month');
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
loadData();
|
||||||
@ -188,6 +191,16 @@ export const Transactions: React.FC = () => {
|
|||||||
fees: transactions.filter(t => t.type === 'fee').reduce((sum, t) => sum + t.amount, 0),
|
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 (
|
return (
|
||||||
<div className="max-w-6xl mx-auto px-4 py-8">
|
<div className="max-w-6xl mx-auto px-4 py-8">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@ -316,9 +329,60 @@ export const Transactions: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
{transactions.map(transaction => (
|
{paginatedTransactions.map(transaction => (
|
||||||
<TransactionRow key={transaction.id} transaction={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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -145,10 +145,19 @@ export interface Payment {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PaymentMethodType =
|
||||||
|
| 'card'
|
||||||
|
| 'bank_account'
|
||||||
|
| 'oxxo'
|
||||||
|
| 'spei'
|
||||||
|
| 'paypal'
|
||||||
|
| 'google_pay'
|
||||||
|
| 'apple_pay';
|
||||||
|
|
||||||
export interface PaymentMethod {
|
export interface PaymentMethod {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
type: 'card' | 'bank_account';
|
type: PaymentMethodType;
|
||||||
brand?: string;
|
brand?: string;
|
||||||
last4: string;
|
last4: string;
|
||||||
expiryMonth?: number;
|
expiryMonth?: number;
|
||||||
@ -156,6 +165,11 @@ export interface PaymentMethod {
|
|||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
stripePaymentMethodId?: string;
|
stripePaymentMethodId?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
metadata?: {
|
||||||
|
bankName?: string;
|
||||||
|
accountType?: string;
|
||||||
|
email?: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InvoiceLineItem {
|
export interface InvoiceLineItem {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user