import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, } from 'recharts'; import { PiggyBank, Upload, X, TrendingUp, Calendar, Percent, Banknote, FileText, Loader2, CheckCircle2, AlertTriangle, } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Badge } from '@/components/ui/badge'; import { Separator } from '@/components/ui/separator'; import { uploadPensionPDFs, getPensionFundSummary, type PensionSnapshot, type PensionUploadResult, } from '@/api'; // ─── Types ──────────────────────────────────────────────────────────────────── type FundKey = 'FCL' | 'ROP' | 'MPAT' | 'MEMP' | 'VOL'; interface FundDef { key: FundKey; name: string; fullName: string; color: string; startBalance: number; monthlyContribution: number; annualRate: number; isDividend: boolean; withdrawalRule: string; defaultTargetAge: number; } interface ProjectionState { contribution: number; rate: number; targetAge: number; } interface ChartDataPoint extends Record { month: string; } interface TooltipEntry { name: string; value: number; color: string; } // ─── Constants ──────────────────────────────────────────────────────────────── const CURRENT_AGE = 30; const FUND_KEYS: FundKey[] = ['FCL', 'ROP', 'MPAT', 'MEMP', 'VOL']; const FUNDS_DEFAULT: Record = { FCL: { key: 'FCL', name: 'FCL', fullName: 'Fondo de Capitalización Laboral', color: '#3b82f6', startBalance: 650_468, monthlyContribution: 150_000, annualRate: 7.5, isDividend: false, withdrawalRule: 'Retirable cada 5 años o al cambio de empleo', defaultTargetAge: 35, }, ROP: { key: 'ROP', name: 'ROP', fullName: 'Régimen Obligatorio de Pensiones', color: '#10b981', startBalance: 18_684_765, monthlyContribution: 120_000, annualRate: 6.0, isDividend: false, withdrawalRule: 'Retirable a los 65 años', defaultTargetAge: 65, }, MPAT: { key: 'MPAT', name: 'MPAT', fullName: 'Ministerio Patronal', color: '#f59e0b', startBalance: 300_000, monthlyContribution: 200_000, annualRate: 3.0, isDividend: true, withdrawalRule: 'Dividendos anuales en marzo', defaultTargetAge: 65, }, MEMP: { key: 'MEMP', name: 'MEMP', fullName: 'Fondo del Empleado', color: '#8b5cf6', startBalance: 300_000, monthlyContribution: 200_000, annualRate: 3.0, isDividend: true, withdrawalRule: '₡100K deducción dos veces al mes · Dividendos en marzo', defaultTargetAge: 65, }, VOL: { key: 'VOL', name: 'VOL', fullName: 'Fondo Voluntario', color: '#f43f5e', startBalance: 2_500_381, monthlyContribution: 400_000, annualRate: 8.0, isDividend: false, withdrawalRule: 'Accesible a los 57 años', defaultTargetAge: 57, }, }; const MONTHS = [ 'Abr 25', 'May 25', 'Jun 25', 'Jul 25', 'Ago 25', 'Sep 25', 'Oct 25', 'Nov 25', 'Dic 25', 'Ene 26', 'Feb 26', 'Mar 26', ]; // ─── Utilities ──────────────────────────────────────────────────────────────── const formatCRC = (amount: number): string => new Intl.NumberFormat('es-CR', { style: 'currency', currency: 'CRC', maximumFractionDigits: 0, }).format(amount); function generateChartData(funds: Record): ChartDataPoint[] { const history: ChartDataPoint[] = new Array(12); let bal = { FCL: funds.FCL.startBalance, ROP: funds.ROP.startBalance, MPAT: funds.MPAT.startBalance, MEMP: funds.MEMP.startBalance, VOL: funds.VOL.startBalance, }; history[11] = { month: MONTHS[11], FCL: Math.round(bal.FCL), ROP: Math.round(bal.ROP), MPAT: Math.round(bal.MPAT), MEMP: Math.round(bal.MEMP), VOL: Math.round(bal.VOL), }; for (let i = 10; i >= 0; i--) { const undoDividend = i === 10; bal = { FCL: Math.max(0, (bal.FCL - 150_000) / (1 + 0.075 / 12)), ROP: Math.max(0, (bal.ROP - 120_000) / (1 + 0.060 / 12)), MPAT: Math.max(0, undoDividend ? bal.MPAT / 1.03 - 200_000 : bal.MPAT - 200_000), MEMP: Math.max(0, undoDividend ? bal.MEMP / 1.03 - 200_000 : bal.MEMP - 200_000), VOL: Math.max(0, (bal.VOL - 400_000) / (1 + 0.08 / 12)), }; history[i] = { month: MONTHS[i], FCL: Math.round(bal.FCL), ROP: Math.round(bal.ROP), MPAT: Math.round(bal.MPAT), MEMP: Math.round(bal.MEMP), VOL: Math.round(bal.VOL), }; } return history; } function calcProjection( currentBalance: number, monthlyContribution: number, annualRate: number, targetAge: number, isDividend: boolean, ): number { const years = Math.max(0, targetAge - CURRENT_AGE); if (years === 0) return currentBalance; if (isDividend) { let balance = currentBalance; const rate = annualRate / 100; for (let y = 0; y < years; y++) { balance = balance + monthlyContribution * 12 + balance * rate; } return balance; } const r = annualRate / 100 / 12; const n = years * 12; if (r === 0) return currentBalance + monthlyContribution * n; return ( currentBalance * Math.pow(1 + r, n) + monthlyContribution * ((Math.pow(1 + r, n) - 1) / r) ); } /** Merge API snapshots into the default fund definitions. */ function applySnapshots( snapshots: PensionSnapshot[], ): Record { const funds = { ...FUNDS_DEFAULT }; for (const snap of snapshots) { const key = snap.fund as FundKey; if (key in funds) { funds[key] = { ...funds[key], startBalance: Math.round(snap.saldo_final) }; } } return funds; } // ─── Chart Tooltip ──────────────────────────────────────────────────────────── function ChartTooltipContent({ active, payload, label, }: { active?: boolean; payload?: TooltipEntry[]; label?: string; }) { if (!active || !payload?.length) return null; return (

{label}

{payload.map((entry) => (
{entry.name} {formatCRC(entry.value)}
))}
); } // ─── Main Component ─────────────────────────────────────────────────────────── export default function Pensions() { const [fundSummary, setFundSummary] = useState([]); const [visibleFunds, setVisibleFunds] = useState>(new Set(FUND_KEYS)); const [projections, setProjections] = useState>({ FCL: { contribution: 150_000, rate: 7.5, targetAge: 35 }, ROP: { contribution: 120_000, rate: 6.0, targetAge: 65 }, MPAT: { contribution: 200_000, rate: 3.0, targetAge: 65 }, MEMP: { contribution: 200_000, rate: 3.0, targetAge: 65 }, VOL: { contribution: 400_000, rate: 8.0, targetAge: 57 }, }); const [uploadedFiles, setUploadedFiles] = useState([]); const [isDragging, setIsDragging] = useState(false); const [isUploading, setIsUploading] = useState(false); const [uploadResult, setUploadResult] = useState(null); const fileInputRef = useRef(null); const loadFundSummary = useCallback(async () => { try { const { data } = await getPensionFundSummary(); setFundSummary(data); } catch { // API not available or no data yet — use defaults } }, []); useEffect(() => { loadFundSummary(); }, [loadFundSummary]); const FUNDS = useMemo(() => applySnapshots(fundSummary), [fundSummary]); // Build a map of fund -> latest snapshot for rendimientos display const snapshotByFund = useMemo(() => { const map: Partial> = {}; for (const snap of fundSummary) { map[snap.fund as FundKey] = snap; } return map; }, [fundSummary]); const chartData = useMemo(() => generateChartData(FUNDS), [FUNDS]); const roiEarned = useMemo(() => { return FUND_KEYS.reduce>((acc, key) => { const snap = snapshotByFund[key]; if (snap) { // Use real rendimientos from the API acc[key] = Math.round(snap.rendimientos); } else { // Fallback: approximate from hardcoded data const fund = FUNDS[key]; if (fund.isDividend) { acc[key] = Math.max(0, Math.round( chartData[11][key] - chartData[10][key] - fund.monthlyContribution, )); } else { const activeMonths = chartData.filter((d) => d[key] > 0).length; acc[key] = Math.round(fund.startBalance * (fund.annualRate / 100) * (activeMonths / 12)); } } return acc; }, {} as Record); }, [FUNDS, chartData, snapshotByFund]); const toggleFund = (key: FundKey) => { setVisibleFunds((prev) => { const next = new Set(prev); if (next.has(key)) { if (next.size > 1) next.delete(key); } else { next.add(key); } return next; }); }; const updateProjection = (key: FundKey, field: keyof ProjectionState, value: string) => { const num = parseFloat(value); setProjections((prev) => ({ ...prev, [key]: { ...prev[key], [field]: isNaN(num) ? 0 : num }, })); }; const handleFiles = useCallback((files: FileList | null) => { if (!files) return; const pdfs = Array.from(files).filter((f) => f.type === 'application/pdf'); setUploadedFiles((prev) => [...prev, ...pdfs]); setUploadResult(null); }, []); const handleUpload = async () => { if (uploadedFiles.length === 0) return; setIsUploading(true); setUploadResult(null); try { const { data } = await uploadPensionPDFs(uploadedFiles); setUploadResult(data); setUploadedFiles([]); // Refresh fund summary with new data await loadFundSummary(); } catch (err) { setUploadResult({ imported: 0, duplicates: 0, errors: [err instanceof Error ? err.message : 'Error al subir archivos'], snapshots: [], }); } finally { setIsUploading(false); } }; const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(true); }; const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); }; const handleDrop = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); handleFiles(e.dataTransfer.files); }; const removeFile = (index: number) => { setUploadedFiles((prev) => prev.filter((_, i) => i !== index)); }; const formatFileSize = (bytes: number): string => { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; }; return (
{/* ── Page Header ─────────────────────────────────────────────────── */}

Pensiones

Seguimiento de aportes, rendimientos y proyecciones

{/* ── Section 1: Fund Overview Cards ──────────────────────────────── */}

Fondos

{FUND_KEYS.map((key) => { const fund = FUNDS[key]; const snap = snapshotByFund[key]; return (
{fund.name} {fund.isDividend ? 'Dividendos' : 'Interés'}

{fund.fullName}

Balance actual

{formatCRC(fund.startBalance)}

{snap && (

al {new Date(snap.period_end).toLocaleDateString('es-CR')}

)}
{snap ? (
Aportes {formatCRC(snap.aportes)}
Rendimientos {formatCRC(snap.rendimientos)}
Comisión {formatCRC(snap.comision)}
) : (
Aporte mensual {formatCRC(fund.monthlyContribution)}
Tasa anual {fund.annualRate}%
)}
{fund.withdrawalRule}
); })}
{/* ── Section 2: Growth Chart ──────────────────────────────────────── */}

Evolución del Balance (Abr 2025 — Mar 2026)

{FUND_KEYS.map((key) => { const fund = FUNDS[key]; const active = visibleFunds.has(key); return ( ); })}
`${(v / 1_000_000).toFixed(1)}M`} tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }} axisLine={false} tickLine={false} width={52} /> } /> ( {value} )} /> {FUND_KEYS.map((key) => visibleFunds.has(key) ? ( ) : null, )}
{/* ── Section 3: ROI Summary ───────────────────────────────────────── */}

Rendimiento — Último Periodo

{FUND_KEYS.map((key) => { const fund = FUNDS[key]; const snap = snapshotByFund[key]; const earned = roiEarned[key]; return (
{fund.name}
{snap ? (

{new Date(snap.period_start).toLocaleDateString('es-CR', { month: 'short', year: '2-digit' })} {' — '} {new Date(snap.period_end).toLocaleDateString('es-CR', { month: 'short', year: '2-digit' })}

) : (

{fund.isDividend ? `Dividendos ${fund.annualRate}%` : `${fund.annualRate}% anual`}

)}

+{formatCRC(earned)}

en rendimientos

); })}
{/* ── Section 4: Projections ───────────────────────────────────────── */}

Proyecciones

Basado en edad actual de {CURRENT_AGE} años. Edita los campos para simular escenarios.

{FUND_KEYS.map((key) => { const fund = FUNDS[key]; const proj = projections[key]; const projected = calcProjection( fund.startBalance, proj.contribution, proj.rate, proj.targetAge, fund.isDividend, ); const years = Math.max(0, proj.targetAge - CURRENT_AGE); return ( {fund.name}
updateProjection(key, 'contribution', e.target.value)} className="h-8 text-sm font-mono mt-1" />
updateProjection(key, 'rate', e.target.value)} className="h-8 text-sm font-mono mt-1" />
updateProjection(key, 'targetAge', e.target.value)} className="h-8 text-sm font-mono mt-1" />

Valor en {years} {years === 1 ? 'año' : 'años'}

{formatCRC(Math.round(projected))}

); })}
{/* ── Section 5: PDF Upload ────────────────────────────────────────── */}

Estados de Cuenta

{/* Drop zone */}
fileInputRef.current?.click()} role="button" tabIndex={0} onKeyDown={(e) => e.key === 'Enter' && fileInputRef.current?.click()} aria-label="Seleccionar archivos PDF" className={[ 'border-2 border-dashed rounded-lg p-8', 'flex flex-col items-center justify-center gap-3', 'cursor-pointer transition-colors select-none', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', isDragging ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/50 hover:bg-muted/30', ].join(' ')} >

{isDragging ? 'Suelta los archivos aquí' : 'Arrastra PDFs aquí o toca para seleccionar'}

Solo archivos PDF · Múltiples archivos soportados

handleFiles(e.target.files)} /> {/* File list */} {uploadedFiles.length > 0 && (

{uploadedFiles.length}{' '} {uploadedFiles.length === 1 ? 'archivo en cola' : 'archivos en cola'}

{uploadedFiles.map((file, i) => (

{file.name}

{formatFileSize(file.size)}

))}
)} {/* Submit */} {/* Upload result */} {uploadResult && (
0 && uploadResult.imported === 0 ? 'border-destructive/50 bg-destructive/5' : 'border-emerald-500/50 bg-emerald-500/5', ].join(' ')}>
{uploadResult.imported > 0 ? ( ) : ( )} {uploadResult.imported > 0 ? `${uploadResult.imported} ${uploadResult.imported === 1 ? 'extracto importado' : 'extractos importados'}` : 'Ningún extracto nuevo importado'}
{uploadResult.duplicates > 0 && (

{uploadResult.duplicates} {uploadResult.duplicates === 1 ? 'duplicado omitido' : 'duplicados omitidos'}

)} {uploadResult.errors.map((err, i) => (

{err}

))} {uploadResult.snapshots.length > 0 && (
{uploadResult.snapshots.map((snap) => (
{snap.fund} · {new Date(snap.period_start).toLocaleDateString('es-CR', { month: 'short', year: '2-digit' })} {' — '} {new Date(snap.period_end).toLocaleDateString('es-CR', { month: 'short', year: '2-digit' })} {formatCRC(snap.saldo_final)}
))}
)}
)}
); }