mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-05-19 11:08: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:
@@ -269,11 +269,27 @@ export interface PensionSnapshot {
|
||||
|
||||
export interface PensionUploadResult {
|
||||
imported: number;
|
||||
updated: number;
|
||||
duplicates: number;
|
||||
errors: string[];
|
||||
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[]) => {
|
||||
const form = new FormData();
|
||||
files.forEach((f) => form.append('files', f));
|
||||
@@ -285,3 +301,6 @@ export const getPensionSnapshots = () =>
|
||||
|
||||
export const getPensionFundSummary = () =>
|
||||
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 {
|
||||
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