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

@@ -1,3 +1,5 @@
from datetime import date
from fastapi import APIRouter, Depends, UploadFile
from pydantic import BaseModel
from sqlmodel import Session, select
@@ -12,11 +14,93 @@ router = APIRouter(prefix="/pensions", tags=["pensions"])
class PensionUploadResult(BaseModel):
imported: int
updated: int
duplicates: int
errors: list[str]
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)
async def upload_pension_pdfs(
files: list[UploadFile],
@@ -24,9 +108,9 @@ async def upload_pension_pdfs(
_user: str = Depends(get_current_user),
):
imported = 0
duplicates = 0
updated = 0
errors: list[str] = []
created: list[PensionSnapshot] = []
results: list[PensionSnapshot] = []
for file in files:
filename = file.filename or "unknown.pdf"
@@ -41,20 +125,9 @@ async def upload_pension_pdfs(
continue
for snap in fund_snapshots:
existing = session.exec(
select(PensionSnapshot).where(
PensionSnapshot.fund == Bank(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,
row, is_new = _upsert_snapshot(
session,
fund=snap.fund,
period_start=snap.period_start,
period_end=snap.period_end,
saldo_anterior=snap.saldo_anterior,
@@ -67,21 +140,72 @@ async def upload_pension_pdfs(
bonificacion=snap.bonificacion,
saldo_final=snap.saldo_final,
source_filename=filename,
contract_number=snap.contract_number,
)
session.add(row)
created.append(row)
results.append(row)
if is_new:
imported += 1
else:
updated += 1
if imported > 0:
if imported > 0 or updated > 0:
session.commit()
for row in created:
for row in results:
session.refresh(row)
return PensionUploadResult(
imported=imported,
duplicates=duplicates,
updated=updated,
duplicates=0,
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],
)

View File

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

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

View 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;
}

View File

@@ -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">
<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 && (