mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-05-19 08:48: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:
@@ -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],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
165
frontend/src/components/PensionManualEntryModal.tsx
Normal file
165
frontend/src/components/PensionManualEntryModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
133
frontend/src/lib/parsePensionPaste.ts
Normal file
133
frontend/src/lib/parsePensionPaste.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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 && (
|
||||||
|
|||||||
Reference in New Issue
Block a user