mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-05-19 08:28:48 +02:00
Compare commits
2 Commits
bd1346f9da
...
eccfd53e0b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eccfd53e0b | ||
|
|
1b90f0c70a |
@@ -1,5 +1,6 @@
|
||||
FROM python:3.11-slim
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends poppler-utils && rm -rf /var/lib/apt/lists/*
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY . .
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
FROM python:3.11-slim
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends poppler-utils && rm -rf /var/lib/apt/lists/*
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY . .
|
||||
|
||||
121
backend/app/api/v1/endpoints/pensions.py
Normal file
121
backend/app/api/v1/endpoints/pensions.py
Normal file
@@ -0,0 +1,121 @@
|
||||
from fastapi import APIRouter, Depends, UploadFile
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.auth import get_current_user
|
||||
from app.db import get_session
|
||||
from app.models.models import Bank, PensionSnapshot, PensionSnapshotRead
|
||||
from app.services.pension_pdf import parse_pension_pdf
|
||||
|
||||
router = APIRouter(prefix="/pensions", tags=["pensions"])
|
||||
|
||||
|
||||
class PensionUploadResult(BaseModel):
|
||||
imported: int
|
||||
duplicates: int
|
||||
errors: list[str]
|
||||
snapshots: list[PensionSnapshotRead]
|
||||
|
||||
|
||||
@router.post("/upload", response_model=PensionUploadResult)
|
||||
async def upload_pension_pdfs(
|
||||
files: list[UploadFile],
|
||||
session: Session = Depends(get_session),
|
||||
_user: str = Depends(get_current_user),
|
||||
):
|
||||
imported = 0
|
||||
duplicates = 0
|
||||
errors: list[str] = []
|
||||
created: list[PensionSnapshot] = []
|
||||
|
||||
for file in files:
|
||||
filename = file.filename or "unknown.pdf"
|
||||
try:
|
||||
pdf_bytes = await file.read()
|
||||
fund_snapshots = parse_pension_pdf(pdf_bytes, filename)
|
||||
except ValueError as e:
|
||||
errors.append(str(e))
|
||||
continue
|
||||
except Exception as e:
|
||||
errors.append(f"{filename}: {e}")
|
||||
continue
|
||||
|
||||
for snap in fund_snapshots:
|
||||
existing = session.exec(
|
||||
select(PensionSnapshot).where(
|
||||
PensionSnapshot.fund == Bank(snap.fund),
|
||||
PensionSnapshot.period_start == snap.period_start,
|
||||
PensionSnapshot.period_end == snap.period_end,
|
||||
)
|
||||
).first()
|
||||
if existing:
|
||||
duplicates += 1
|
||||
continue
|
||||
|
||||
row = PensionSnapshot(
|
||||
fund=Bank(snap.fund),
|
||||
contract_number=snap.contract_number,
|
||||
period_start=snap.period_start,
|
||||
period_end=snap.period_end,
|
||||
saldo_anterior=snap.saldo_anterior,
|
||||
aportes=snap.aportes,
|
||||
rendimientos=snap.rendimientos,
|
||||
retiros=snap.retiros,
|
||||
traslados=snap.traslados,
|
||||
comision=snap.comision,
|
||||
correccion=snap.correccion,
|
||||
bonificacion=snap.bonificacion,
|
||||
saldo_final=snap.saldo_final,
|
||||
source_filename=filename,
|
||||
)
|
||||
session.add(row)
|
||||
created.append(row)
|
||||
imported += 1
|
||||
|
||||
if imported > 0:
|
||||
session.commit()
|
||||
for row in created:
|
||||
session.refresh(row)
|
||||
|
||||
return PensionUploadResult(
|
||||
imported=imported,
|
||||
duplicates=duplicates,
|
||||
errors=errors,
|
||||
snapshots=[PensionSnapshotRead.model_validate(r) for r in created],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/snapshots", response_model=list[PensionSnapshotRead])
|
||||
def get_snapshots(
|
||||
session: Session = Depends(get_session),
|
||||
_user: str = Depends(get_current_user),
|
||||
):
|
||||
rows = session.exec(
|
||||
select(PensionSnapshot).order_by(
|
||||
PensionSnapshot.period_end.desc(), # type: ignore[union-attr]
|
||||
PensionSnapshot.fund,
|
||||
)
|
||||
).all()
|
||||
return rows
|
||||
|
||||
|
||||
@router.get("/fund-summary", response_model=list[PensionSnapshotRead])
|
||||
def get_fund_summary(
|
||||
session: Session = Depends(get_session),
|
||||
_user: str = Depends(get_current_user),
|
||||
):
|
||||
"""Return the latest snapshot per fund (by most recent period_end)."""
|
||||
all_rows = session.exec(
|
||||
select(PensionSnapshot).order_by(
|
||||
PensionSnapshot.period_end.desc(), # type: ignore[union-attr]
|
||||
)
|
||||
).all()
|
||||
|
||||
seen: set[str] = set()
|
||||
latest: list[PensionSnapshot] = []
|
||||
for row in all_rows:
|
||||
if row.fund.value not in seen:
|
||||
seen.add(row.fund.value)
|
||||
latest.append(row)
|
||||
|
||||
return latest
|
||||
@@ -9,6 +9,7 @@ from app.api.v1.endpoints import (
|
||||
exchange_rate,
|
||||
import_transactions,
|
||||
notifications,
|
||||
pensions,
|
||||
salarios,
|
||||
settings,
|
||||
tokens,
|
||||
@@ -28,3 +29,4 @@ api_router.include_router(settings.router)
|
||||
api_router.include_router(budget.router)
|
||||
api_router.include_router(notifications.router)
|
||||
api_router.include_router(salarios.router)
|
||||
api_router.include_router(pensions.router)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import enum
|
||||
from datetime import datetime
|
||||
from datetime import date, datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import JSON, Column
|
||||
from sqlalchemy import JSON, Column, UniqueConstraint
|
||||
from sqlmodel import Field, Relationship, SQLModel
|
||||
|
||||
|
||||
@@ -300,3 +300,36 @@ class PushSubscription(SQLModel, table=True):
|
||||
class PushSubscriptionCreate(SQLModel):
|
||||
endpoint: str
|
||||
keys: dict # {"p256dh": "...", "auth": "..."}
|
||||
|
||||
|
||||
# --- Pension Snapshot ---
|
||||
|
||||
|
||||
class PensionSnapshotBase(SQLModel):
|
||||
fund: Bank
|
||||
contract_number: str
|
||||
period_start: date
|
||||
period_end: date
|
||||
saldo_anterior: float
|
||||
aportes: float
|
||||
rendimientos: float
|
||||
retiros: float
|
||||
traslados: float
|
||||
comision: float
|
||||
correccion: float
|
||||
bonificacion: float
|
||||
saldo_final: float
|
||||
source_filename: str
|
||||
|
||||
|
||||
class PensionSnapshot(PensionSnapshotBase, table=True):
|
||||
__table_args__ = (
|
||||
UniqueConstraint("fund", "period_start", "period_end"),
|
||||
)
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
|
||||
class PensionSnapshotRead(PensionSnapshotBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
|
||||
225
backend/app/services/pension_pdf.py
Normal file
225
backend/app/services/pension_pdf.py
Normal file
@@ -0,0 +1,225 @@
|
||||
"""Parse BAC San José Pensiones PDF statements into structured fund snapshots."""
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
|
||||
|
||||
@dataclass
|
||||
class FundSnapshot:
|
||||
fund: str # "ROP", "FCL", or "VOL"
|
||||
contract_number: str
|
||||
period_start: date
|
||||
period_end: date
|
||||
saldo_anterior: float
|
||||
aportes: float
|
||||
rendimientos: float
|
||||
retiros: float
|
||||
traslados: float
|
||||
comision: float
|
||||
correccion: float
|
||||
bonificacion: float
|
||||
saldo_final: float
|
||||
|
||||
|
||||
def _find_pdftotext() -> str:
|
||||
"""Find pdftotext binary, checking common install paths."""
|
||||
import os
|
||||
|
||||
cmd = shutil.which("pdftotext")
|
||||
if cmd:
|
||||
return cmd
|
||||
for path in ["/opt/homebrew/bin/pdftotext", "/usr/bin/pdftotext", "/usr/local/bin/pdftotext"]:
|
||||
if os.path.isfile(path):
|
||||
return path
|
||||
raise FileNotFoundError("pdftotext not found — install poppler-utils")
|
||||
|
||||
|
||||
def extract_text(pdf_bytes: bytes) -> str:
|
||||
pdftotext_bin = _find_pdftotext()
|
||||
with tempfile.NamedTemporaryFile(suffix=".pdf") as f:
|
||||
f.write(pdf_bytes)
|
||||
f.flush()
|
||||
result = subprocess.run(
|
||||
[pdftotext_bin, "-layout", f.name, "-"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise ValueError(f"pdftotext failed: {result.stderr.strip()}")
|
||||
return result.stdout
|
||||
|
||||
|
||||
def detect_type(text: str) -> str:
|
||||
"""Return 'VOL', 'ROP_FCL', or 'UNKNOWN'."""
|
||||
if any(kw in text for kw in ("MARCA DE TARJETA", "ESTADO DE CUENTA", "PAGO MÍNIMO")):
|
||||
return "CREDIT_CARD"
|
||||
if "FONDO C VOLUNTARIO" in text:
|
||||
return "VOL"
|
||||
if "RÉGIMEN OBLIGATORIO" in text or ("ROP" in text and "FCL" in text):
|
||||
return "ROP_FCL"
|
||||
return "UNKNOWN"
|
||||
|
||||
|
||||
def _parse_amount(s: str) -> float:
|
||||
"""Parse '17,819,176.79' or '-12,693.13' into float."""
|
||||
cleaned = s.replace(",", "")
|
||||
return float(cleaned)
|
||||
|
||||
|
||||
def _find_amounts(line: str) -> list[float]:
|
||||
"""Extract all ¢-prefixed amounts from a line."""
|
||||
return [_parse_amount(m) for m in re.findall(r"¢\s*(-?[\d,]+\.\d{2})", line)]
|
||||
|
||||
|
||||
def _parse_period(text: str) -> tuple[date, date]:
|
||||
m = re.search(r"DEL\s+(\d{2}/\d{2}/\d{4})\s+AL\s+(\d{2}/\d{2}/\d{4})", text)
|
||||
if not m:
|
||||
raise ValueError("Could not find period dates (DEL ... AL ...)")
|
||||
start = date(int(m.group(1)[6:]), int(m.group(1)[3:5]), int(m.group(1)[:2]))
|
||||
end = date(int(m.group(2)[6:]), int(m.group(2)[3:5]), int(m.group(2)[:2]))
|
||||
return start, end
|
||||
|
||||
|
||||
def _extract_summary_value(text: str, label: str) -> list[float]:
|
||||
"""Find a summary line by label and return all ¢ amounts on that line."""
|
||||
pattern = re.compile(re.escape(label) + r".*", re.IGNORECASE)
|
||||
for line in text.split("\n"):
|
||||
if pattern.search(line):
|
||||
amounts = _find_amounts(line)
|
||||
if amounts:
|
||||
return amounts
|
||||
return []
|
||||
|
||||
|
||||
_SUMMARY_FIELDS = [
|
||||
("Saldo Anterior", "saldo_anterior"),
|
||||
("Aportes", "aportes"),
|
||||
("Rendimientos", "rendimientos"),
|
||||
("Retiros", "retiros"),
|
||||
("Traslados", "traslados"),
|
||||
("Comisión de Administración", "comision"),
|
||||
("Corrección de Imputaciones", "correccion"),
|
||||
("Bonificación", "bonificacion"),
|
||||
]
|
||||
|
||||
|
||||
def _find_final_balance(text: str, after_label: str = "Bonificación") -> list[float]:
|
||||
"""Find the standalone balance line after the last summary field.
|
||||
|
||||
After Bonificación (or Corrección for ROP+FCL), there's a line with just
|
||||
the final balance amount(s) and no label.
|
||||
"""
|
||||
lines = text.split("\n")
|
||||
found_label = False
|
||||
for line in lines:
|
||||
if after_label in line:
|
||||
found_label = True
|
||||
continue
|
||||
if found_label:
|
||||
amounts = _find_amounts(line)
|
||||
if amounts:
|
||||
return amounts
|
||||
return []
|
||||
|
||||
|
||||
def parse_vol(text: str) -> list[FundSnapshot]:
|
||||
period_start, period_end = _parse_period(text)
|
||||
|
||||
# Contract number
|
||||
m = re.search(r"N°\s*Contrato:\s*(\S+)", text)
|
||||
contract = m.group(1) if m else ""
|
||||
|
||||
data: dict[str, float] = {}
|
||||
for label, field in _SUMMARY_FIELDS:
|
||||
amounts = _extract_summary_value(text, label)
|
||||
data[field] = amounts[0] if amounts else 0.0
|
||||
|
||||
finals = _find_final_balance(text, "Bonificación")
|
||||
if not finals:
|
||||
# Fallback: look after Corrección
|
||||
finals = _find_final_balance(text, "Corrección de Imputaciones")
|
||||
saldo_final = finals[0] if finals else 0.0
|
||||
|
||||
return [
|
||||
FundSnapshot(
|
||||
fund="VOL",
|
||||
contract_number=contract,
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
saldo_final=saldo_final,
|
||||
**data,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def parse_rop_fcl(text: str) -> list[FundSnapshot]:
|
||||
period_start, period_end = _parse_period(text)
|
||||
|
||||
# Contract numbers
|
||||
m_rop = re.search(r"N°\s*Contrato\s*ROP:\s*(\S+)", text)
|
||||
m_fcl = re.search(r"N°\s*Contrato\s*FCL:\s*(\S+)", text)
|
||||
contract_rop = m_rop.group(1) if m_rop else ""
|
||||
contract_fcl = m_fcl.group(1) if m_fcl else ""
|
||||
|
||||
rop_data: dict[str, float] = {}
|
||||
fcl_data: dict[str, float] = {}
|
||||
|
||||
for label, field in _SUMMARY_FIELDS:
|
||||
amounts = _extract_summary_value(text, label)
|
||||
if len(amounts) >= 2:
|
||||
rop_data[field] = amounts[0]
|
||||
fcl_data[field] = amounts[1]
|
||||
elif len(amounts) == 1:
|
||||
rop_data[field] = amounts[0]
|
||||
fcl_data[field] = 0.0
|
||||
else:
|
||||
rop_data[field] = 0.0
|
||||
fcl_data[field] = 0.0
|
||||
|
||||
# Final balance line (after Corrección since ROP+FCL has no Bonificación)
|
||||
finals = _find_final_balance(text, "Corrección de Imputaciones")
|
||||
rop_final = finals[0] if len(finals) >= 1 else 0.0
|
||||
fcl_final = finals[1] if len(finals) >= 2 else 0.0
|
||||
|
||||
return [
|
||||
FundSnapshot(
|
||||
fund="ROP",
|
||||
contract_number=contract_rop,
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
saldo_final=rop_final,
|
||||
**rop_data,
|
||||
),
|
||||
FundSnapshot(
|
||||
fund="FCL",
|
||||
contract_number=contract_fcl,
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
saldo_final=fcl_final,
|
||||
**fcl_data,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def parse_pension_pdf(pdf_bytes: bytes, filename: str = "") -> list[FundSnapshot]:
|
||||
"""Parse a pension PDF and return fund snapshots.
|
||||
|
||||
Raises ValueError for credit card statements or unrecognized formats.
|
||||
"""
|
||||
text = extract_text(pdf_bytes)
|
||||
doc_type = detect_type(text)
|
||||
|
||||
if doc_type == "CREDIT_CARD":
|
||||
raise ValueError(f"'{filename}' is a credit card statement, not a pension extract")
|
||||
if doc_type == "UNKNOWN":
|
||||
raise ValueError(f"'{filename}' is not a recognized BAC pension statement")
|
||||
|
||||
if doc_type == "VOL":
|
||||
return parse_vol(text)
|
||||
else:
|
||||
return parse_rop_fcl(text)
|
||||
@@ -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() {
|
||||
<Route path="/budget" element={<Budget />} />
|
||||
<Route path="/analytics" element={<Analytics />} />
|
||||
<Route path="/salarios" element={<Salarios />} />
|
||||
<Route path="/pensions" element={<Pensions />} />
|
||||
{/* Redirect old routes */}
|
||||
<Route path="/transactions" element={<Navigate to="/budget" replace />} />
|
||||
<Route path="/transfers" element={<Navigate to="/budget" replace />} />
|
||||
|
||||
@@ -238,3 +238,43 @@ export const getSalarios = (params?: { limit?: number; offset?: number }) =>
|
||||
api.get<Transaction[]>('/salarios/', { params });
|
||||
export const getSalariosSummary = () =>
|
||||
api.get<SalariosSummary>('/salarios/summary');
|
||||
|
||||
// --- Pensions ---
|
||||
|
||||
export interface PensionSnapshot {
|
||||
id: number;
|
||||
fund: string;
|
||||
contract_number: string;
|
||||
period_start: string;
|
||||
period_end: string;
|
||||
saldo_anterior: number;
|
||||
aportes: number;
|
||||
rendimientos: number;
|
||||
retiros: number;
|
||||
traslados: number;
|
||||
comision: number;
|
||||
correccion: number;
|
||||
bonificacion: number;
|
||||
saldo_final: number;
|
||||
source_filename: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface PensionUploadResult {
|
||||
imported: number;
|
||||
duplicates: number;
|
||||
errors: string[];
|
||||
snapshots: PensionSnapshot[];
|
||||
}
|
||||
|
||||
export const uploadPensionPDFs = (files: File[]) => {
|
||||
const form = new FormData();
|
||||
files.forEach((f) => form.append('files', f));
|
||||
return api.post<PensionUploadResult>('/pensions/upload', form);
|
||||
};
|
||||
|
||||
export const getPensionSnapshots = () =>
|
||||
api.get<PensionSnapshot[]>('/pensions/snapshots');
|
||||
|
||||
export const getPensionFundSummary = () =>
|
||||
api.get<PensionSnapshot[]>('/pensions/fund-summary');
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
|
||||
868
frontend/src/pages/Pensions.tsx
Normal file
868
frontend/src/pages/Pensions.tsx
Normal file
@@ -0,0 +1,868 @@
|
||||
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<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_DEFAULT: 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(funds: Record<FundKey, FundDef>): 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<FundKey, FundDef> {
|
||||
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 (
|
||||
<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 [fundSummary, setFundSummary] = useState<PensionSnapshot[]>([]);
|
||||
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 [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadResult, setUploadResult] = useState<PensionUploadResult | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(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<Record<FundKey, PensionSnapshot>> = {};
|
||||
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<Record<FundKey, number>>((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<FundKey, number>);
|
||||
}, [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<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];
|
||||
const snap = snapshotByFund[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>
|
||||
{snap && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
al {new Date(snap.period_end).toLocaleDateString('es-CR')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{snap ? (
|
||||
<div className="space-y-1.5 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Aportes</span>
|
||||
<span className="font-mono font-medium">
|
||||
{formatCRC(snap.aportes)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Rendimientos</span>
|
||||
<span className="font-mono font-medium text-emerald-500">
|
||||
{formatCRC(snap.rendimientos)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Comisión</span>
|
||||
<span className="font-mono font-medium text-destructive">
|
||||
{formatCRC(snap.comision)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<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 — Último Periodo
|
||||
</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 snap = snapshotByFund[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>
|
||||
{snap ? (
|
||||
<p className="text-xs text-muted-foreground mb-1">
|
||||
{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' })}
|
||||
</p>
|
||||
) : (
|
||||
<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 */}
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
disabled={uploadedFiles.length === 0 || isUploading}
|
||||
className="w-full"
|
||||
>
|
||||
{isUploading ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{isUploading ? 'Procesando...' : 'Subir PDFs'}
|
||||
</Button>
|
||||
|
||||
{/* Upload result */}
|
||||
{uploadResult && (
|
||||
<div className={[
|
||||
'rounded-lg border p-4 space-y-2',
|
||||
uploadResult.errors.length > 0 && uploadResult.imported === 0
|
||||
? 'border-destructive/50 bg-destructive/5'
|
||||
: 'border-emerald-500/50 bg-emerald-500/5',
|
||||
].join(' ')}>
|
||||
<div className="flex items-center gap-2">
|
||||
{uploadResult.imported > 0 ? (
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-500" />
|
||||
) : (
|
||||
<AlertTriangle className="w-4 h-4 text-amber-500" />
|
||||
)}
|
||||
<span className="text-sm font-medium">
|
||||
{uploadResult.imported > 0
|
||||
? `${uploadResult.imported} ${uploadResult.imported === 1 ? 'extracto importado' : 'extractos importados'}`
|
||||
: 'Ningún extracto nuevo importado'}
|
||||
</span>
|
||||
</div>
|
||||
{uploadResult.duplicates > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{uploadResult.duplicates} {uploadResult.duplicates === 1 ? 'duplicado omitido' : 'duplicados omitidos'}
|
||||
</p>
|
||||
)}
|
||||
{uploadResult.errors.map((err, i) => (
|
||||
<p key={i} className="text-xs text-destructive">{err}</p>
|
||||
))}
|
||||
{uploadResult.snapshots.length > 0 && (
|
||||
<div className="space-y-1 pt-1">
|
||||
{uploadResult.snapshots.map((snap) => (
|
||||
<div key={snap.id} className="flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{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' })}
|
||||
</span>
|
||||
<span className="font-mono font-medium">{formatCRC(snap.saldo_final)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user