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

@@ -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>
);
}