Add manual pension data entry and fix chart to use real historical data
All checks were successful
Deploy to VPS / deploy (push) Successful in 23s

- Add paste-and-preview modal for entering pension fund balances from bank website
- Backend upsert logic so n8n PDF uploads overwrite manual entries
- Chart now shows actual snapshot data with dynamic month labels
- New POST /pensions/manual endpoint for JSON-based fund entry

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-04-01 10:03:29 -06:00
parent e011a3adcc
commit 3c9656f416
5 changed files with 548 additions and 80 deletions

View File

@@ -1,3 +1,5 @@
from datetime import date
from fastapi import APIRouter, Depends, UploadFile from fastapi import APIRouter, Depends, UploadFile
from pydantic import BaseModel from pydantic import BaseModel
from sqlmodel import Session, select from sqlmodel import Session, select
@@ -12,11 +14,93 @@ router = APIRouter(prefix="/pensions", tags=["pensions"])
class PensionUploadResult(BaseModel): class PensionUploadResult(BaseModel):
imported: int imported: int
updated: int
duplicates: int duplicates: int
errors: list[str] errors: list[str]
snapshots: list[PensionSnapshotRead] snapshots: list[PensionSnapshotRead]
class PensionManualEntry(BaseModel):
fund: str
period_start: date
period_end: date
saldo_anterior: float
aportes: float
rendimientos: float
retiros: float
traslados: float
comision: float
correccion: float = 0.0
bonificacion: float = 0.0
saldo_final: float
class PensionManualRequest(BaseModel):
entries: list[PensionManualEntry]
def _upsert_snapshot(
session: Session,
fund: 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,
contract_number: str = "",
) -> tuple[PensionSnapshot, bool]:
"""Insert or update a pension snapshot. Returns (row, is_new)."""
existing = session.exec(
select(PensionSnapshot).where(
PensionSnapshot.fund == Bank(fund),
PensionSnapshot.period_start == period_start,
PensionSnapshot.period_end == period_end,
)
).first()
if existing:
existing.saldo_anterior = saldo_anterior
existing.aportes = aportes
existing.rendimientos = rendimientos
existing.retiros = retiros
existing.traslados = traslados
existing.comision = comision
existing.correccion = correccion
existing.bonificacion = bonificacion
existing.saldo_final = saldo_final
existing.source_filename = source_filename
if contract_number:
existing.contract_number = contract_number
session.add(existing)
return existing, False
row = PensionSnapshot(
fund=Bank(fund),
contract_number=contract_number,
period_start=period_start,
period_end=period_end,
saldo_anterior=saldo_anterior,
aportes=aportes,
rendimientos=rendimientos,
retiros=retiros,
traslados=traslados,
comision=comision,
correccion=correccion,
bonificacion=bonificacion,
saldo_final=saldo_final,
source_filename=source_filename,
)
session.add(row)
return row, True
@router.post("/upload", response_model=PensionUploadResult) @router.post("/upload", response_model=PensionUploadResult)
async def upload_pension_pdfs( async def upload_pension_pdfs(
files: list[UploadFile], files: list[UploadFile],
@@ -24,9 +108,9 @@ async def upload_pension_pdfs(
_user: str = Depends(get_current_user), _user: str = Depends(get_current_user),
): ):
imported = 0 imported = 0
duplicates = 0 updated = 0
errors: list[str] = [] errors: list[str] = []
created: list[PensionSnapshot] = [] results: list[PensionSnapshot] = []
for file in files: for file in files:
filename = file.filename or "unknown.pdf" filename = file.filename or "unknown.pdf"
@@ -41,20 +125,9 @@ async def upload_pension_pdfs(
continue continue
for snap in fund_snapshots: for snap in fund_snapshots:
existing = session.exec( row, is_new = _upsert_snapshot(
select(PensionSnapshot).where( session,
PensionSnapshot.fund == Bank(snap.fund), fund=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_start=snap.period_start,
period_end=snap.period_end, period_end=snap.period_end,
saldo_anterior=snap.saldo_anterior, saldo_anterior=snap.saldo_anterior,
@@ -67,21 +140,72 @@ async def upload_pension_pdfs(
bonificacion=snap.bonificacion, bonificacion=snap.bonificacion,
saldo_final=snap.saldo_final, saldo_final=snap.saldo_final,
source_filename=filename, source_filename=filename,
contract_number=snap.contract_number,
) )
session.add(row) results.append(row)
created.append(row) if is_new:
imported += 1 imported += 1
else:
updated += 1
if imported > 0: if imported > 0 or updated > 0:
session.commit() session.commit()
for row in created: for row in results:
session.refresh(row) session.refresh(row)
return PensionUploadResult( return PensionUploadResult(
imported=imported, imported=imported,
duplicates=duplicates, updated=updated,
duplicates=0,
errors=errors, errors=errors,
snapshots=[PensionSnapshotRead.model_validate(r) for r in created], snapshots=[PensionSnapshotRead.model_validate(r) for r in results],
)
@router.post("/manual", response_model=PensionUploadResult)
def submit_manual_entries(
body: PensionManualRequest,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
imported = 0
updated = 0
results: list[PensionSnapshot] = []
for entry in body.entries:
row, is_new = _upsert_snapshot(
session,
fund=entry.fund,
period_start=entry.period_start,
period_end=entry.period_end,
saldo_anterior=entry.saldo_anterior,
aportes=entry.aportes,
rendimientos=entry.rendimientos,
retiros=entry.retiros,
traslados=entry.traslados,
comision=entry.comision,
correccion=entry.correccion,
bonificacion=entry.bonificacion,
saldo_final=entry.saldo_final,
source_filename="manual-entry",
)
results.append(row)
if is_new:
imported += 1
else:
updated += 1
if imported > 0 or updated > 0:
session.commit()
for row in results:
session.refresh(row)
return PensionUploadResult(
imported=imported,
updated=updated,
duplicates=0,
errors=[],
snapshots=[PensionSnapshotRead.model_validate(r) for r in results],
) )

View File

@@ -269,11 +269,27 @@ export interface PensionSnapshot {
export interface PensionUploadResult { export interface PensionUploadResult {
imported: number; imported: number;
updated: number;
duplicates: number; duplicates: number;
errors: string[]; errors: string[];
snapshots: PensionSnapshot[]; snapshots: PensionSnapshot[];
} }
export interface PensionManualEntry {
fund: 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;
}
export const uploadPensionPDFs = (files: File[]) => { export const uploadPensionPDFs = (files: File[]) => {
const form = new FormData(); const form = new FormData();
files.forEach((f) => form.append('files', f)); files.forEach((f) => form.append('files', f));
@@ -285,3 +301,6 @@ export const getPensionSnapshots = () =>
export const getPensionFundSummary = () => export const getPensionFundSummary = () =>
api.get<PensionSnapshot[]>('/pensions/fund-summary'); api.get<PensionSnapshot[]>('/pensions/fund-summary');
export const submitPensionManualEntries = (entries: PensionManualEntry[]) =>
api.post<PensionUploadResult>('/pensions/manual', { entries });

View File

@@ -0,0 +1,165 @@
import { useState } from 'react';
import { ClipboardPaste, CheckCircle, AlertTriangle } from 'lucide-react';
import { type PensionUploadResult, submitPensionManualEntries } from '../api';
import { parsePensionPaste, type PensionParsedEntry } from '@/lib/parsePensionPaste';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
interface Props {
onClose: () => void;
onImported: () => void;
}
const formatCRC = (n: number) =>
new Intl.NumberFormat('es-CR', {
style: 'currency',
currency: 'CRC',
maximumFractionDigits: 0,
}).format(n);
const FUND_LABELS: Record<string, string> = {
ROP: 'ROP',
FCL: 'FCL',
VOL: 'Voluntario',
};
export default function PensionManualEntryModal({ onClose, onImported }: Props) {
const [text, setText] = useState('');
const [parsed, setParsed] = useState<PensionParsedEntry[] | null>(null);
const [parseError, setParseError] = useState('');
const [submitting, setSubmitting] = useState(false);
const [result, setResult] = useState<PensionUploadResult | null>(null);
const handlePreview = () => {
setParseError('');
const entries = parsePensionPaste(text);
if (entries.length === 0) {
setParseError('No se encontraron datos de fondos. Verifica que el texto pegado tenga el formato correcto.');
setParsed(null);
return;
}
setParsed(entries);
};
const handleSubmit = async () => {
if (!parsed) return;
setSubmitting(true);
try {
const { data } = await submitPensionManualEntries(parsed);
setResult(data);
if (data.imported > 0 || data.updated > 0) onImported();
} catch (err) {
console.error(err);
} finally {
setSubmitting(false);
}
};
return (
<Dialog open onOpenChange={(open) => { if (!open) onClose(); }}>
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ClipboardPaste className="w-4 h-4 text-primary" />
Ingresar Datos de Pensión
</DialogTitle>
</DialogHeader>
{result ? (
<div className="space-y-4">
<Alert>
<CheckCircle className="h-4 w-4 text-primary" />
<AlertTitle className="text-primary">Datos Guardados</AlertTitle>
<AlertDescription>
{result.imported > 0 && `${result.imported} nuevo(s)`}
{result.imported > 0 && result.updated > 0 && ' · '}
{result.updated > 0 && `${result.updated} actualizado(s)`}
</AlertDescription>
</Alert>
<Button onClick={onClose} className="w-full">Listo</Button>
</div>
) : !parsed ? (
<div className="space-y-4">
<div className="space-y-2">
<Label>Pegar resumen del período</Label>
<Textarea
className="h-56 font-mono text-xs resize-y"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder={`Pega aquí el texto del resumen de BAC Pensiones.\n\nEjemplo:\nResumen del Período\tROP\tFCL\nSaldo Anterior\t¢ 18,684,764.98\t¢ 650,467.87\nAportes\t¢ 120,012.00\t¢ 60,006.00\n...\n\nSepara ROP+FCL y Voluntario con una línea "---"`}
/>
<p className="text-xs text-muted-foreground">
Pega los bloques de ROP+FCL y Fondo Voluntario. Sepáralos con "---".
</p>
</div>
{parseError && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>{parseError}</AlertDescription>
</Alert>
)}
<DialogFooter>
<Button variant="outline" onClick={onClose}>Cancelar</Button>
<Button onClick={handlePreview} disabled={!text.trim()}>
Vista Previa
</Button>
</DialogFooter>
</div>
) : (
<div className="space-y-4">
<div className="rounded-md border overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="text-left px-3 py-2 font-medium">Fondo</th>
<th className="text-left px-3 py-2 font-medium">Período</th>
<th className="text-right px-3 py-2 font-medium">Saldo Ant.</th>
<th className="text-right px-3 py-2 font-medium">Aportes</th>
<th className="text-right px-3 py-2 font-medium">Rendim.</th>
<th className="text-right px-3 py-2 font-medium">Saldo Final</th>
</tr>
</thead>
<tbody>
{parsed.map((e, i) => (
<tr key={i} className="border-b last:border-0">
<td className="px-3 py-2 font-medium">{FUND_LABELS[e.fund] ?? e.fund}</td>
<td className="px-3 py-2 text-muted-foreground text-xs">
{e.period_start} {e.period_end}
</td>
<td className="px-3 py-2 text-right font-mono text-xs">{formatCRC(e.saldo_anterior)}</td>
<td className="px-3 py-2 text-right font-mono text-xs">{formatCRC(e.aportes)}</td>
<td className={`px-3 py-2 text-right font-mono text-xs ${e.rendimientos < 0 ? 'text-red-500' : 'text-green-600'}`}>
{formatCRC(e.rendimientos)}
</td>
<td className="px-3 py-2 text-right font-mono text-xs font-semibold">{formatCRC(e.saldo_final)}</td>
</tr>
))}
</tbody>
</table>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setParsed(null)}>
Editar
</Button>
<Button onClick={handleSubmit} disabled={submitting}>
{submitting ? 'Guardando...' : 'Confirmar'}
</Button>
</DialogFooter>
</div>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,133 @@
export interface PensionParsedEntry {
fund: string;
period_start: string; // YYYY-MM-DD
period_end: string;
saldo_anterior: number;
aportes: number;
rendimientos: number;
retiros: number;
traslados: number;
comision: number;
correccion: number;
bonificacion: number;
saldo_final: number;
}
function parseAmount(raw: string): number {
// "¢ 18,684,764.98" or "¢ -552,213.24" or just "18,684,764.98"
const cleaned = raw.replace(/[¢\s]/g, '').replace(/,/g, '');
const num = parseFloat(cleaned);
return isNaN(num) ? 0 : num;
}
function parseDateDMY(raw: string): string {
// "01/03/2026" → "2026-03-01"
const m = raw.match(/(\d{2})\/(\d{2})\/(\d{4})/);
if (!m) return '';
return `${m[3]}-${m[2]}-${m[1]}`;
}
function extractAmounts(line: string): number[] {
const matches = line.match(/¢\s*-?[\d,.]+/g);
if (!matches) return [];
return matches.map(parseAmount);
}
interface BlockResult {
funds: string[];
fields: Record<string, number[]>;
period_start: string;
period_end: string;
}
function parseBlock(lines: string[]): BlockResult | null {
const result: BlockResult = {
funds: [],
fields: {},
period_start: '',
period_end: '',
};
// Detect fund columns from header
const headerLine = lines.find((l) => /resumen del per[ií]odo/i.test(l));
if (!headerLine) return null;
if (/\bROP\b/i.test(headerLine) && /\bFCL\b/i.test(headerLine)) {
result.funds = ['ROP', 'FCL'];
} else if (/voluntario/i.test(headerLine) || /\bVOL\b/i.test(headerLine)) {
result.funds = ['VOL'];
} else {
return null;
}
const fieldMap: [RegExp, string][] = [
[/saldo\s*anterior/i, 'saldo_anterior'],
[/aportes/i, 'aportes'],
[/rendimientos/i, 'rendimientos'],
[/retiros/i, 'retiros'],
[/traslados/i, 'traslados'],
[/comisi[oó]n/i, 'comision'],
[/bonificaci[oó]n/i, 'bonificacion'],
[/saldo\s*actual/i, 'saldo_final'],
];
for (const line of lines) {
for (const [regex, key] of fieldMap) {
if (regex.test(line)) {
const amounts = extractAmounts(line);
if (amounts.length > 0) {
result.fields[key] = amounts;
}
}
}
// Period
const periodMatch = line.match(/del\s+(\d{2}\/\d{2}\/\d{4})\s+al\s+(\d{2}\/\d{2}\/\d{4})/i);
if (periodMatch) {
result.period_start = parseDateDMY(periodMatch[1]);
result.period_end = parseDateDMY(periodMatch[2]);
}
}
return result;
}
export function parsePensionPaste(text: string): PensionParsedEntry[] {
// Split into blocks by "---" or multiple blank lines
const blocks = text.split(/(?:^|\n)-{3,}(?:\n|$)|\n{3,}/);
const entries: PensionParsedEntry[] = [];
for (const block of blocks) {
const lines = block.split('\n').filter((l) => l.trim());
if (lines.length < 3) continue;
const parsed = parseBlock(lines);
if (!parsed || !parsed.period_start || !parsed.period_end) continue;
for (let i = 0; i < parsed.funds.length; i++) {
const fund = parsed.funds[i];
const get = (key: string): number => {
const vals = parsed.fields[key];
if (!vals) return 0;
return vals[i] ?? vals[0] ?? 0;
};
entries.push({
fund,
period_start: parsed.period_start,
period_end: parsed.period_end,
saldo_anterior: get('saldo_anterior'),
aportes: get('aportes'),
rendimientos: get('rendimientos'),
retiros: get('retiros'),
traslados: get('traslados'),
comision: get('comision'),
correccion: 0,
bonificacion: get('bonificacion'),
saldo_final: get('saldo_final'),
});
}
}
return entries;
}

View File

@@ -31,9 +31,12 @@ import { Separator } from '@/components/ui/separator';
import { import {
uploadPensionPDFs, uploadPensionPDFs,
getPensionFundSummary, getPensionFundSummary,
getPensionSnapshots,
type PensionSnapshot, type PensionSnapshot,
type PensionUploadResult, type PensionUploadResult,
} from '@/api'; } from '@/api';
import PensionManualEntryModal from '@/components/PensionManualEntryModal';
import { ClipboardPaste } from 'lucide-react';
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@@ -137,10 +140,9 @@ const FUNDS_DEFAULT: Record<FundKey, FundDef> = {
}, },
}; };
const MONTHS = [ const MONTH_NAMES_ES = [
'Abr 25', 'May 25', 'Jun 25', 'Jul 25', 'Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun',
'Ago 25', 'Sep 25', 'Oct 25', 'Nov 25', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic',
'Dic 25', 'Ene 26', 'Feb 26', 'Mar 26',
]; ];
// ─── Utilities ──────────────────────────────────────────────────────────────── // ─── Utilities ────────────────────────────────────────────────────────────────
@@ -152,46 +154,39 @@ const formatCRC = (amount: number): string =>
maximumFractionDigits: 0, maximumFractionDigits: 0,
}).format(amount); }).format(amount);
function generateChartData(funds: Record<FundKey, FundDef>): ChartDataPoint[] { function buildChartFromSnapshots(snapshots: PensionSnapshot[]): ChartDataPoint[] {
const history: ChartDataPoint[] = new Array(12); // Group by period_end month key (YYYY-MM)
const byMonth = new Map<string, Record<string, number>>();
let bal = { for (const snap of snapshots) {
FCL: funds.FCL.startBalance, const d = new Date(snap.period_end);
ROP: funds.ROP.startBalance, const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
MPAT: funds.MPAT.startBalance, if (!byMonth.has(key)) byMonth.set(key, {});
MEMP: funds.MEMP.startBalance, const entry = byMonth.get(key)!;
VOL: funds.VOL.startBalance, const fund = snap.fund as FundKey;
}; // Keep the latest saldo_final per fund per month
entry[fund] = Math.round(snap.saldo_final);
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; // Sort chronologically and take last 12
const sortedKeys = Array.from(byMonth.keys()).sort();
const last12 = sortedKeys.slice(-12);
return last12.map((key) => {
const [yearStr, monthStr] = key.split('-');
const monthIdx = parseInt(monthStr, 10) - 1;
const yearShort = yearStr.slice(2);
const label = `${MONTH_NAMES_ES[monthIdx]} ${yearShort}`;
const values = byMonth.get(key)!;
return {
month: label,
FCL: values.FCL ?? 0,
ROP: values.ROP ?? 0,
MPAT: values.MPAT ?? 0,
MEMP: values.MEMP ?? 0,
VOL: values.VOL ?? 0,
} as ChartDataPoint;
});
} }
function calcProjection( function calcProjection(
@@ -271,6 +266,7 @@ function ChartTooltipContent({
export default function Pensions() { export default function Pensions() {
const [fundSummary, setFundSummary] = useState<PensionSnapshot[]>([]); const [fundSummary, setFundSummary] = useState<PensionSnapshot[]>([]);
const [allSnapshots, setAllSnapshots] = useState<PensionSnapshot[]>([]);
const [visibleFunds, setVisibleFunds] = useState<Set<FundKey>>(new Set(FUND_KEYS)); const [visibleFunds, setVisibleFunds] = useState<Set<FundKey>>(new Set(FUND_KEYS));
const [projections, setProjections] = useState<Record<FundKey, ProjectionState>>({ const [projections, setProjections] = useState<Record<FundKey, ProjectionState>>({
FCL: { contribution: 150_000, rate: 7.5, targetAge: 35 }, FCL: { contribution: 150_000, rate: 7.5, targetAge: 35 },
@@ -283,20 +279,25 @@ export default function Pensions() {
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [uploadResult, setUploadResult] = useState<PensionUploadResult | null>(null); const [uploadResult, setUploadResult] = useState<PensionUploadResult | null>(null);
const [showManualEntry, setShowManualEntry] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const loadFundSummary = useCallback(async () => { const loadData = useCallback(async () => {
try { try {
const { data } = await getPensionFundSummary(); const [summaryRes, snapshotsRes] = await Promise.all([
setFundSummary(data); getPensionFundSummary(),
getPensionSnapshots(),
]);
setFundSummary(summaryRes.data);
setAllSnapshots(snapshotsRes.data);
} catch { } catch {
// API not available or no data yet — use defaults // API not available or no data yet — use defaults
} }
}, []); }, []);
useEffect(() => { useEffect(() => {
loadFundSummary(); loadData();
}, [loadFundSummary]); }, [loadData]);
const FUNDS = useMemo(() => applySnapshots(fundSummary), [fundSummary]); const FUNDS = useMemo(() => applySnapshots(fundSummary), [fundSummary]);
@@ -309,7 +310,12 @@ export default function Pensions() {
return map; return map;
}, [fundSummary]); }, [fundSummary]);
const chartData = useMemo(() => generateChartData(FUNDS), [FUNDS]); const chartData = useMemo(() => buildChartFromSnapshots(allSnapshots), [allSnapshots]);
const chartDateRange = useMemo(() => {
if (chartData.length < 2) return '';
return `${chartData[0].month}${chartData[chartData.length - 1].month}`;
}, [chartData]);
const roiEarned = useMemo(() => { const roiEarned = useMemo(() => {
return FUND_KEYS.reduce<Record<FundKey, number>>((acc, key) => { return FUND_KEYS.reduce<Record<FundKey, number>>((acc, key) => {
@@ -369,10 +375,11 @@ export default function Pensions() {
setUploadResult(data); setUploadResult(data);
setUploadedFiles([]); setUploadedFiles([]);
// Refresh fund summary with new data // Refresh fund summary with new data
await loadFundSummary(); await loadData();
} catch (err) { } catch (err) {
setUploadResult({ setUploadResult({
imported: 0, imported: 0,
updated: 0,
duplicates: 0, duplicates: 0,
errors: [err instanceof Error ? err.message : 'Error al subir archivos'], errors: [err instanceof Error ? err.message : 'Error al subir archivos'],
snapshots: [], snapshots: [],
@@ -521,7 +528,7 @@ export default function Pensions() {
<section className="space-y-3"> <section className="space-y-3">
<h2 className="text-base font-semibold font-heading flex items-center gap-2 text-muted-foreground uppercase tracking-wider"> <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" /> <TrendingUp className="w-4 h-4" />
Evolución del Balance (Abr 2025 Mar 2026) Evolución del Balance{chartDateRange && ` (${chartDateRange})`}
</h2> </h2>
<Card> <Card>
<CardContent className="p-4 space-y-4"> <CardContent className="p-4 space-y-4">
@@ -717,12 +724,31 @@ export default function Pensions() {
</div> </div>
</section> </section>
{/* ── Manual Entry Modal ──────────────────────────────────────────── */}
{showManualEntry && (
<PensionManualEntryModal
onClose={() => setShowManualEntry(false)}
onImported={loadData}
/>
)}
{/* ── Section 5: PDF Upload ────────────────────────────────────────── */} {/* ── Section 5: PDF Upload ────────────────────────────────────────── */}
<section className="space-y-3"> <section className="space-y-3">
<h2 className="text-base font-semibold font-heading flex items-center gap-2 text-muted-foreground uppercase tracking-wider"> <div className="flex items-center justify-between">
<FileText className="w-4 h-4" /> <h2 className="text-base font-semibold font-heading flex items-center gap-2 text-muted-foreground uppercase tracking-wider">
Estados de Cuenta <FileText className="w-4 h-4" />
</h2> Estados de Cuenta
</h2>
<Button
variant="outline"
size="sm"
onClick={() => setShowManualEntry(true)}
className="gap-1.5"
>
<ClipboardPaste className="w-3.5 h-3.5" />
Ingresar manualmente
</Button>
</div>
<Card> <Card>
<CardContent className="p-6 space-y-4"> <CardContent className="p-6 space-y-4">
{/* Drop zone */} {/* Drop zone */}
@@ -825,15 +851,16 @@ export default function Pensions() {
: 'border-emerald-500/50 bg-emerald-500/5', : 'border-emerald-500/50 bg-emerald-500/5',
].join(' ')}> ].join(' ')}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{uploadResult.imported > 0 ? ( {(uploadResult.imported > 0 || uploadResult.updated > 0) ? (
<CheckCircle2 className="w-4 h-4 text-emerald-500" /> <CheckCircle2 className="w-4 h-4 text-emerald-500" />
) : ( ) : (
<AlertTriangle className="w-4 h-4 text-amber-500" /> <AlertTriangle className="w-4 h-4 text-amber-500" />
)} )}
<span className="text-sm font-medium"> <span className="text-sm font-medium">
{uploadResult.imported > 0 {uploadResult.imported > 0 && `${uploadResult.imported} ${uploadResult.imported === 1 ? 'extracto importado' : 'extractos importados'}`}
? `${uploadResult.imported} ${uploadResult.imported === 1 ? 'extracto importado' : 'extractos importados'}` {uploadResult.imported > 0 && uploadResult.updated > 0 && ' · '}
: 'Ningún extracto nuevo importado'} {uploadResult.updated > 0 && `${uploadResult.updated} actualizado(s)`}
{uploadResult.imported === 0 && uploadResult.updated === 0 && 'Ningún extracto nuevo importado'}
</span> </span>
</div> </div>
{uploadResult.duplicates > 0 && ( {uploadResult.duplicates > 0 && (