Add pension PDF upload, parsing, and fund summary API
All checks were successful
Deploy to VPS / deploy (push) Successful in 48s

Backend: parse BAC pension statement PDFs (VOL, ROP, FCL) via
pdftotext, store snapshots with duplicate detection, reject
credit card statements. Endpoints: POST /upload, GET /snapshots,
GET /fund-summary.

Frontend: wire up drag-and-drop upload, load real balances and
rendimientos from API, show upload results with error/duplicate
feedback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-03-28 22:24:42 -06:00
parent 1b90f0c70a
commit eccfd53e0b
8 changed files with 631 additions and 56 deletions

View File

@@ -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 . .

View File

@@ -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 . .

View 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

View File

@@ -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)

View File

@@ -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

View 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"\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"\s*Contrato\s*ROP:\s*(\S+)", text)
m_fcl = re.search(r"\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)

View File

@@ -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');

View File

@@ -1,4 +1,4 @@
import { useState, useMemo, useCallback, useRef } from 'react';
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import {
LineChart,
Line,
@@ -18,6 +18,9 @@ import {
Percent,
Banknote,
FileText,
Loader2,
CheckCircle2,
AlertTriangle,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -25,6 +28,12 @@ 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 ────────────────────────────────────────────────────────────────────
@@ -65,7 +74,7 @@ const CURRENT_AGE = 30;
const FUND_KEYS: FundKey[] = ['FCL', 'ROP', 'MPAT', 'MEMP', 'VOL'];
const FUNDS: Record<FundKey, FundDef> = {
const FUNDS_DEFAULT: Record<FundKey, FundDef> = {
FCL: {
key: 'FCL',
name: 'FCL',
@@ -143,24 +152,15 @@ const formatCRC = (amount: number): string =>
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
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,
FCL: funds.FCL.startBalance,
ROP: funds.ROP.startBalance,
MPAT: funds.MPAT.startBalance,
MEMP: funds.MEMP.startBalance,
VOL: funds.VOL.startBalance,
};
history[11] = {
@@ -173,7 +173,6 @@ function generateChartData(): ChartDataPoint[] {
};
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)),
@@ -223,6 +222,20 @@ function calcProjection(
);
}
/** 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({
@@ -257,6 +270,7 @@ function ChartTooltipContent({
// ─── 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 },
@@ -267,28 +281,57 @@ export default function Pensions() {
});
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 chartData = useMemo(() => generateChartData(), []);
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) {
// 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]);
}, [FUNDS, chartData, snapshotByFund]);
const toggleFund = (key: FundKey) => {
setVisibleFunds((prev) => {
@@ -314,8 +357,31 @@ export default function Pensions() {
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);
@@ -366,6 +432,7 @@ export default function Pensions() {
<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}
@@ -394,10 +461,37 @@ export default function Pensions() {
<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>
@@ -410,6 +504,7 @@ export default function Pensions() {
<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" />
@@ -501,11 +596,12 @@ export default function Pensions() {
<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
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}>
@@ -517,9 +613,17 @@ export default function Pensions() {
/>
<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>
@@ -698,16 +802,64 @@ export default function Pensions() {
</div>
)}
{/* Submit with "Próximamente" tooltip */}
<div className="relative group w-full">
<Button disabled className="w-full cursor-not-allowed" aria-label="Subir PDFs">
{/* 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" />
Subir PDFs
)}
{isUploading ? 'Procesando...' : '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
{/* 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>