mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-05-19 08:48:48 +02:00
Add Pensions page with fund overview, growth chart, and projections
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import Dashboard from './pages/Dashboard';
|
|||||||
import Budget from './pages/Budget';
|
import Budget from './pages/Budget';
|
||||||
import Analytics from './pages/Analytics';
|
import Analytics from './pages/Analytics';
|
||||||
import Salarios from './pages/Salarios';
|
import Salarios from './pages/Salarios';
|
||||||
|
import Pensions from './pages/Pensions';
|
||||||
|
|
||||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
const { isAuthenticated } = useAuth();
|
const { isAuthenticated } = useAuth();
|
||||||
@@ -33,6 +34,7 @@ function AppRoutes() {
|
|||||||
<Route path="/budget" element={<Budget />} />
|
<Route path="/budget" element={<Budget />} />
|
||||||
<Route path="/analytics" element={<Analytics />} />
|
<Route path="/analytics" element={<Analytics />} />
|
||||||
<Route path="/salarios" element={<Salarios />} />
|
<Route path="/salarios" element={<Salarios />} />
|
||||||
|
<Route path="/pensions" element={<Pensions />} />
|
||||||
{/* Redirect old routes */}
|
{/* Redirect old routes */}
|
||||||
<Route path="/transactions" element={<Navigate to="/budget" replace />} />
|
<Route path="/transactions" element={<Navigate to="/budget" replace />} />
|
||||||
<Route path="/transfers" element={<Navigate to="/budget" replace />} />
|
<Route path="/transfers" element={<Navigate to="/budget" replace />} />
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Calculator,
|
Calculator,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Landmark,
|
Landmark,
|
||||||
|
PiggyBank,
|
||||||
LogOut,
|
LogOut,
|
||||||
Wallet,
|
Wallet,
|
||||||
Menu,
|
Menu,
|
||||||
@@ -29,6 +30,7 @@ const navItems = [
|
|||||||
{ to: '/', icon: LayoutDashboard, label: 'Dashboard' },
|
{ to: '/', icon: LayoutDashboard, label: 'Dashboard' },
|
||||||
{ to: '/budget', icon: Calculator, label: 'Presupuesto' },
|
{ to: '/budget', icon: Calculator, label: 'Presupuesto' },
|
||||||
{ to: '/salarios', icon: Landmark, label: 'Salarios' },
|
{ to: '/salarios', icon: Landmark, label: 'Salarios' },
|
||||||
|
{ to: '/pensions', icon: PiggyBank, label: 'Pensiones' },
|
||||||
{ to: '/analytics', icon: BarChart3, label: 'Analytics' },
|
{ to: '/analytics', icon: BarChart3, label: 'Analytics' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
716
frontend/src/pages/Pensions.tsx
Normal file
716
frontend/src/pages/Pensions.tsx
Normal file
@@ -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<FundKey, number> {
|
||||||
|
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<FundKey, FundDef> = {
|
||||||
|
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 (
|
||||||
|
<div className="bg-popover border border-border rounded-lg p-3 shadow-lg text-sm min-w-[220px]">
|
||||||
|
<p className="font-semibold mb-2 text-foreground">{label}</p>
|
||||||
|
{payload.map((entry) => (
|
||||||
|
<div key={entry.name} className="flex items-center justify-between gap-4 py-0.5">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
||||||
|
style={{ background: entry.color }}
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground">{entry.name}</span>
|
||||||
|
</span>
|
||||||
|
<span className="font-mono font-medium text-foreground">{formatCRC(entry.value)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function Pensions() {
|
||||||
|
const [visibleFunds, setVisibleFunds] = useState<Set<FundKey>>(new Set(FUND_KEYS));
|
||||||
|
const [projections, setProjections] = useState<Record<FundKey, ProjectionState>>({
|
||||||
|
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<File[]>([]);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const chartData = useMemo(() => generateChartData(), []);
|
||||||
|
|
||||||
|
const roiEarned = useMemo(() => {
|
||||||
|
return FUND_KEYS.reduce<Record<FundKey, number>>((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<FundKey, number>);
|
||||||
|
}, [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<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
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 (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* ── Page Header ─────────────────────────────────────────────────── */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||||
|
<PiggyBank className="w-5 h-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold font-heading">Pensiones</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Seguimiento de aportes, rendimientos y proyecciones
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Section 1: Fund Overview Cards ──────────────────────────────── */}
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h2 className="text-base font-semibold font-heading flex items-center gap-2 text-muted-foreground uppercase tracking-wider">
|
||||||
|
<Banknote className="w-4 h-4" />
|
||||||
|
Fondos
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
|
||||||
|
{FUND_KEYS.map((key) => {
|
||||||
|
const fund = FUNDS[key];
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={key}
|
||||||
|
className="border-l-4 overflow-hidden"
|
||||||
|
style={{ borderLeftColor: fund.color }}
|
||||||
|
>
|
||||||
|
<CardContent className="p-4 space-y-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between gap-1">
|
||||||
|
<span className="font-bold text-base" style={{ color: fund.color }}>
|
||||||
|
{fund.name}
|
||||||
|
</span>
|
||||||
|
<Badge variant={fund.isDividend ? 'secondary' : 'outline'} className="text-xs">
|
||||||
|
{fund.isDividend ? 'Dividendos' : 'Interés'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground leading-tight mt-0.5">
|
||||||
|
{fund.fullName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground uppercase tracking-wider font-medium">
|
||||||
|
Balance actual
|
||||||
|
</p>
|
||||||
|
<p className="text-xl font-bold font-mono mt-0.5 leading-tight">
|
||||||
|
{formatCRC(fund.startBalance)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="space-y-1.5 text-xs">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Aporte mensual</span>
|
||||||
|
<span className="font-mono font-medium">
|
||||||
|
{formatCRC(fund.monthlyContribution)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Tasa anual</span>
|
||||||
|
<span className="font-mono font-medium">{fund.annualRate}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-1.5 text-xs text-muted-foreground bg-muted/50 rounded-md p-2">
|
||||||
|
<Calendar className="w-3 h-3 flex-shrink-0 mt-0.5" />
|
||||||
|
<span>{fund.withdrawalRule}</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Section 2: Growth Chart ──────────────────────────────────────── */}
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h2 className="text-base font-semibold font-heading flex items-center gap-2 text-muted-foreground uppercase tracking-wider">
|
||||||
|
<TrendingUp className="w-4 h-4" />
|
||||||
|
Evolución del Balance (Abr 2025 — Mar 2026)
|
||||||
|
</h2>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4 space-y-4">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{FUND_KEYS.map((key) => {
|
||||||
|
const fund = FUNDS[key];
|
||||||
|
const active = visibleFunds.has(key);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => toggleFund(key)}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-all border cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
style={{
|
||||||
|
borderColor: fund.color,
|
||||||
|
background: active ? fund.color + '22' : 'transparent',
|
||||||
|
color: active ? fund.color : 'var(--muted-foreground)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="w-2 h-2 rounded-full"
|
||||||
|
style={{ background: active ? fund.color : 'var(--muted-foreground)' }}
|
||||||
|
/>
|
||||||
|
{fund.name}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ResponsiveContainer width="100%" height={320}>
|
||||||
|
<LineChart data={chartData} margin={{ top: 4, right: 8, left: 8, bottom: 4 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="month"
|
||||||
|
tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }}
|
||||||
|
axisLine={{ stroke: 'var(--border)' }}
|
||||||
|
tickLine={false}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tickFormatter={(v: number) => `${(v / 1_000_000).toFixed(1)}M`}
|
||||||
|
tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
width={52}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<ChartTooltipContent />} />
|
||||||
|
<Legend
|
||||||
|
formatter={(value) => (
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--foreground)' }}>{value}</span>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{FUND_KEYS.map((key) =>
|
||||||
|
visibleFunds.has(key) ? (
|
||||||
|
<Line
|
||||||
|
key={key}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={key}
|
||||||
|
name={key}
|
||||||
|
stroke={FUNDS[key].color}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
activeDot={{ r: 4 }}
|
||||||
|
/>
|
||||||
|
) : null,
|
||||||
|
)}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Section 3: ROI Summary ───────────────────────────────────────── */}
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h2 className="text-base font-semibold font-heading flex items-center gap-2 text-muted-foreground uppercase tracking-wider">
|
||||||
|
<Percent className="w-4 h-4" />
|
||||||
|
Rendimiento — Últimos 12 meses
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||||
|
{FUND_KEYS.map((key) => {
|
||||||
|
const fund = FUNDS[key];
|
||||||
|
const earned = roiEarned[key];
|
||||||
|
return (
|
||||||
|
<Card key={key}>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-1.5 mb-2">
|
||||||
|
<span
|
||||||
|
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
||||||
|
style={{ background: fund.color }}
|
||||||
|
/>
|
||||||
|
<span className="font-bold text-sm">{fund.name}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mb-1">
|
||||||
|
{fund.isDividend ? `Dividendos ${fund.annualRate}%` : `${fund.annualRate}% anual`}
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-bold font-mono" style={{ color: fund.color }}>
|
||||||
|
+{formatCRC(earned)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">en rendimientos</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Section 4: Projections ───────────────────────────────────────── */}
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h2 className="text-base font-semibold font-heading flex items-center gap-2 text-muted-foreground uppercase tracking-wider">
|
||||||
|
<TrendingUp className="w-4 h-4" />
|
||||||
|
Proyecciones
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Basado en edad actual de {CURRENT_AGE} años. Edita los campos para simular escenarios.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
|
||||||
|
{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 (
|
||||||
|
<Card key={key} className="border-l-4" style={{ borderLeftColor: fund.color }}>
|
||||||
|
<CardHeader className="pb-2 pt-4 px-4">
|
||||||
|
<CardTitle className="text-sm font-bold" style={{ color: fund.color }}>
|
||||||
|
{fund.name}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-4 pb-4 space-y-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground">Aporte mensual (CRC)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={proj.contribution}
|
||||||
|
onChange={(e) => updateProjection(key, 'contribution', e.target.value)}
|
||||||
|
className="h-8 text-sm font-mono mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground">
|
||||||
|
{fund.isDividend ? 'Tasa dividendo (%)' : 'Tasa anual (%)'}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
value={proj.rate}
|
||||||
|
onChange={(e) => updateProjection(key, 'rate', e.target.value)}
|
||||||
|
className="h-8 text-sm font-mono mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground">Edad objetivo</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={proj.targetAge}
|
||||||
|
onChange={(e) => updateProjection(key, 'targetAge', e.target.value)}
|
||||||
|
className="h-8 text-sm font-mono mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="text-center space-y-0.5">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Valor en {years} {years === 1 ? 'año' : 'años'}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className="text-lg font-bold font-mono leading-tight"
|
||||||
|
style={{ color: fund.color }}
|
||||||
|
>
|
||||||
|
{formatCRC(Math.round(projected))}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Section 5: PDF Upload ────────────────────────────────────────── */}
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h2 className="text-base font-semibold font-heading flex items-center gap-2 text-muted-foreground uppercase tracking-wider">
|
||||||
|
<FileText className="w-4 h-4" />
|
||||||
|
Estados de Cuenta
|
||||||
|
</h2>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6 space-y-4">
|
||||||
|
{/* Drop zone */}
|
||||||
|
<div
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onClick={() => 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(' ')}
|
||||||
|
>
|
||||||
|
<Upload
|
||||||
|
className={`w-8 h-8 ${isDragging ? 'text-primary' : 'text-muted-foreground'}`}
|
||||||
|
/>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{isDragging
|
||||||
|
? 'Suelta los archivos aquí'
|
||||||
|
: 'Arrastra PDFs aquí o toca para seleccionar'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Solo archivos PDF · Múltiples archivos soportados
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="application/pdf"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => handleFiles(e.target.files)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* File list */}
|
||||||
|
{uploadedFiles.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm text-muted-foreground font-medium">
|
||||||
|
{uploadedFiles.length}{' '}
|
||||||
|
{uploadedFiles.length === 1 ? 'archivo en cola' : 'archivos en cola'}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{uploadedFiles.map((file, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-center justify-between gap-3 p-2.5 rounded-lg bg-muted/50 border border-border"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2.5 min-w-0">
|
||||||
|
<FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">{file.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{formatFileSize(file.size)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => removeFile(i)}
|
||||||
|
className="flex-shrink-0 p-1 rounded hover:bg-destructive/10 hover:text-destructive transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
aria-label={`Eliminar ${file.name}`}
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Submit with "Próximamente" tooltip */}
|
||||||
|
<div className="relative group w-full">
|
||||||
|
<Button disabled className="w-full cursor-not-allowed" aria-label="Subir PDFs">
|
||||||
|
<Upload className="w-4 h-4 mr-2" />
|
||||||
|
Subir PDFs
|
||||||
|
</Button>
|
||||||
|
<span className="absolute -top-9 left-1/2 -translate-x-1/2 px-2.5 py-1.5 text-xs rounded-md bg-popover border border-border text-popover-foreground shadow-sm opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-10 font-medium">
|
||||||
|
Próximamente
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user