mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 15:28:47 +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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user