diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c55c38f..d395d51 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,6 +7,7 @@ import Dashboard from './pages/Dashboard'; import Budget from './pages/Budget'; import Analytics from './pages/Analytics'; import Salarios from './pages/Salarios'; +import Pensions from './pages/Pensions'; function ProtectedRoute({ children }: { children: React.ReactNode }) { const { isAuthenticated } = useAuth(); @@ -33,6 +34,7 @@ function AppRoutes() { } /> } /> } /> + } /> {/* Redirect old routes */} } /> } /> diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index f8f94a9..0f0e3ac 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -4,6 +4,7 @@ import { Calculator, BarChart3, Landmark, + PiggyBank, LogOut, Wallet, Menu, @@ -29,6 +30,7 @@ const navItems = [ { to: '/', icon: LayoutDashboard, label: 'Dashboard' }, { to: '/budget', icon: Calculator, label: 'Presupuesto' }, { to: '/salarios', icon: Landmark, label: 'Salarios' }, + { to: '/pensions', icon: PiggyBank, label: 'Pensiones' }, { to: '/analytics', icon: BarChart3, label: 'Analytics' }, ]; diff --git a/frontend/src/pages/Pensions.tsx b/frontend/src/pages/Pensions.tsx new file mode 100644 index 0000000..7efa623 --- /dev/null +++ b/frontend/src/pages/Pensions.tsx @@ -0,0 +1,716 @@ +import { useState, 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, +} 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'; + +// ─── 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: 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(): ChartDataPoint[] { + // Work backwards from current balances (end of Mar 2026) to derive 12 months of history. + // history[11] = Mar 26 (current), history[0] = Apr 25. + // Funds that went live recently will show 0 for the months before they started. + // + // Reverse formulas: + // Interest fund: prev = (curr - contribution) / (1 + monthlyRate) + // Dividend month: prev_MPAT/MEMP = curr / 1.03 - contribution (Mar is index 11) + // Dividend other: prev_MPAT/MEMP = curr - contribution + + 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--) { + // i === 10 reverses the Feb→Mar step, which included the annual dividend for MPAT/MEMP + 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) + ); +} + +// ─── 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 [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 fileInputRef = useRef(null); + + const chartData = useMemo(() => generateChartData(), []); + + const roiEarned = useMemo(() => { + return FUND_KEYS.reduce>((acc, key) => { + const fund = FUNDS[key]; + if (fund.isDividend) { + // Dividend earned in March = balance after dividend − balance before dividend + // history[11] = (history[10] + contribution) × 1.03 + // → dividend = history[11] − history[10] − contribution + acc[key] = Math.max(0, Math.round( + chartData[11][key] - chartData[10][key] - fund.monthlyContribution, + )); + } else { + // Approximate interest earned = currentBalance × annualRate × (activeMonths / 12) + const activeMonths = chartData.filter((d) => d[key] > 0).length; + acc[key] = Math.round(fund.startBalance * (fund.annualRate / 100) * (activeMonths / 12)); + } + return acc; + }, {} as Record); + }, [chartData]); + + 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]); + }, []); + + 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]; + return ( + + +
+
+ + {fund.name} + + + {fund.isDividend ? 'Dividendos' : 'Interés'} + +
+

+ {fund.fullName} +

+
+ +
+

+ Balance actual +

+

+ {formatCRC(fund.startBalance)} +

+
+ + + +
+
+ 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 — Últimos 12 meses +

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

+ {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 with "Próximamente" tooltip */} +
+ + + Próximamente + +
+
+
+
+
+ ); +}