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

@@ -31,9 +31,12 @@ import { Separator } from '@/components/ui/separator';
import {
uploadPensionPDFs,
getPensionFundSummary,
getPensionSnapshots,
type PensionSnapshot,
type PensionUploadResult,
} from '@/api';
import PensionManualEntryModal from '@/components/PensionManualEntryModal';
import { ClipboardPaste } from 'lucide-react';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -137,10 +140,9 @@ const FUNDS_DEFAULT: Record<FundKey, FundDef> = {
},
};
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',
const MONTH_NAMES_ES = [
'Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun',
'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic',
];
// ─── Utilities ────────────────────────────────────────────────────────────────
@@ -152,46 +154,39 @@ const formatCRC = (amount: number): string =>
maximumFractionDigits: 0,
}).format(amount);
function generateChartData(funds: Record<FundKey, FundDef>): ChartDataPoint[] {
const history: ChartDataPoint[] = new Array(12);
function buildChartFromSnapshots(snapshots: PensionSnapshot[]): ChartDataPoint[] {
// Group by period_end month key (YYYY-MM)
const byMonth = new Map<string, Record<string, number>>();
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),
};
for (const snap of snapshots) {
const d = new Date(snap.period_end);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
if (!byMonth.has(key)) byMonth.set(key, {});
const entry = byMonth.get(key)!;
const fund = snap.fund as FundKey;
// Keep the latest saldo_final per fund per month
entry[fund] = Math.round(snap.saldo_final);
}
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(
@@ -271,6 +266,7 @@ function ChartTooltipContent({
export default function Pensions() {
const [fundSummary, setFundSummary] = useState<PensionSnapshot[]>([]);
const [allSnapshots, setAllSnapshots] = 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 },
@@ -283,20 +279,25 @@ export default function Pensions() {
const [isDragging, setIsDragging] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [uploadResult, setUploadResult] = useState<PensionUploadResult | null>(null);
const [showManualEntry, setShowManualEntry] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const loadFundSummary = useCallback(async () => {
const loadData = useCallback(async () => {
try {
const { data } = await getPensionFundSummary();
setFundSummary(data);
const [summaryRes, snapshotsRes] = await Promise.all([
getPensionFundSummary(),
getPensionSnapshots(),
]);
setFundSummary(summaryRes.data);
setAllSnapshots(snapshotsRes.data);
} catch {
// API not available or no data yet — use defaults
}
}, []);
useEffect(() => {
loadFundSummary();
}, [loadFundSummary]);
loadData();
}, [loadData]);
const FUNDS = useMemo(() => applySnapshots(fundSummary), [fundSummary]);
@@ -309,7 +310,12 @@ export default function Pensions() {
return map;
}, [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(() => {
return FUND_KEYS.reduce<Record<FundKey, number>>((acc, key) => {
@@ -369,10 +375,11 @@ export default function Pensions() {
setUploadResult(data);
setUploadedFiles([]);
// Refresh fund summary with new data
await loadFundSummary();
await loadData();
} catch (err) {
setUploadResult({
imported: 0,
updated: 0,
duplicates: 0,
errors: [err instanceof Error ? err.message : 'Error al subir archivos'],
snapshots: [],
@@ -521,7 +528,7 @@ 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">
<TrendingUp className="w-4 h-4" />
Evolución del Balance (Abr 2025 Mar 2026)
Evolución del Balance{chartDateRange && ` (${chartDateRange})`}
</h2>
<Card>
<CardContent className="p-4 space-y-4">
@@ -717,12 +724,31 @@ export default function Pensions() {
</div>
</section>
{/* ── Manual Entry Modal ──────────────────────────────────────────── */}
{showManualEntry && (
<PensionManualEntryModal
onClose={() => setShowManualEntry(false)}
onImported={loadData}
/>
)}
{/* ── 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>
<div className="flex items-center justify-between">
<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>
<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>
<CardContent className="p-6 space-y-4">
{/* Drop zone */}
@@ -825,15 +851,16 @@ export default function Pensions() {
: 'border-emerald-500/50 bg-emerald-500/5',
].join(' ')}>
<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" />
) : (
<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'}
{uploadResult.imported > 0 && `${uploadResult.imported} ${uploadResult.imported === 1 ? 'extracto importado' : 'extractos importados'}`}
{uploadResult.imported > 0 && uploadResult.updated > 0 && ' · '}
{uploadResult.updated > 0 && `${uploadResult.updated} actualizado(s)`}
{uploadResult.imported === 0 && uploadResult.updated === 0 && 'Ningún extracto nuevo importado'}
</span>
</div>
{uploadResult.duplicates > 0 && (