mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-05-19 10:28:48 +02:00
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
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:
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user