mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-05-19 11:28:49 +02:00
Add pension PDF upload, parsing, and fund summary API
All checks were successful
Deploy to VPS / deploy (push) Successful in 48s
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:
@@ -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 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,
|
||||
));
|
||||
const snap = snapshotByFund[key];
|
||||
if (snap) {
|
||||
// Use real rendimientos from the API
|
||||
acc[key] = Math.round(snap.rendimientos);
|
||||
} 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));
|
||||
// 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>);
|
||||
}, [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,22 +461,50 @@ 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 />
|
||||
|
||||
<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>
|
||||
{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="flex justify-between">
|
||||
<span className="text-muted-foreground">Tasa anual</span>
|
||||
<span className="font-mono font-medium">{fund.annualRate}%</span>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
<p className="text-xs text-muted-foreground mb-1">
|
||||
{fund.isDividend ? `Dividendos ${fund.annualRate}%` : `${fund.annualRate}% anual`}
|
||||
</p>
|
||||
{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
|
||||
</Button>
|
||||
<span className="absolute -top-9 left-1/2 -translate-x-1/2 px-2.5 py-1.5 text-xs rounded-md bg-popover border border-border text-popover-foreground shadow-sm opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-10 font-medium">
|
||||
Próximamente
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{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>
|
||||
|
||||
Reference in New Issue
Block a user