Files
WealthySmart/frontend/src/pages/Pensions.tsx
Carlos Escalante 22334c2129
All checks were successful
Deploy to VPS / deploy (push) Successful in 56s
Remove MPAT and MEMP fund references from pension module
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 10:18:36 -06:00

871 lines
33 KiB
TypeScript

import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
import {
PiggyBank,
Upload,
X,
TrendingUp,
Calendar,
Percent,
Banknote,
FileText,
Loader2,
CheckCircle2,
AlertTriangle,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
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 ────────────────────────────────────────────────────────────────────
type FundKey = 'FCL' | 'ROP' | 'VOL';
interface FundDef {
key: FundKey;
name: string;
fullName: string;
color: string;
startBalance: number;
monthlyContribution: number;
annualRate: number;
isDividend: boolean;
withdrawalRule: string;
defaultTargetAge: number;
}
interface ProjectionState {
contribution: number;
rate: number;
targetAge: number;
}
interface ChartDataPoint extends Record<FundKey, number> {
month: string;
}
interface TooltipEntry {
name: string;
value: number;
color: string;
}
// ─── Constants ────────────────────────────────────────────────────────────────
const CURRENT_AGE = 30;
const FUND_KEYS: FundKey[] = ['FCL', 'ROP', 'VOL'];
const FUNDS_DEFAULT: Record<FundKey, FundDef> = {
FCL: {
key: 'FCL',
name: 'FCL',
fullName: 'Fondo de Capitalización Laboral',
color: '#3b82f6',
startBalance: 650_468,
monthlyContribution: 150_000,
annualRate: 7.5,
isDividend: false,
withdrawalRule: 'Retirable cada 5 años o al cambio de empleo',
defaultTargetAge: 35,
},
ROP: {
key: 'ROP',
name: 'ROP',
fullName: 'Régimen Obligatorio de Pensiones',
color: '#10b981',
startBalance: 18_684_765,
monthlyContribution: 120_000,
annualRate: 6.0,
isDividend: false,
withdrawalRule: 'Retirable a los 65 años',
defaultTargetAge: 65,
},
VOL: {
key: 'VOL',
name: 'VOL',
fullName: 'Fondo Voluntario',
color: '#f43f5e',
startBalance: 2_500_381,
monthlyContribution: 400_000,
annualRate: 8.0,
isDividend: false,
withdrawalRule: 'Accesible a los 57 años',
defaultTargetAge: 57,
},
};
const MONTH_NAMES_ES = [
'Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun',
'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic',
];
// ─── Utilities ────────────────────────────────────────────────────────────────
const formatCRC = (amount: number): string =>
new Intl.NumberFormat('es-CR', {
style: 'currency',
currency: 'CRC',
maximumFractionDigits: 0,
}).format(amount);
function buildChartFromSnapshots(snapshots: PensionSnapshot[]): ChartDataPoint[] {
// Group by period_end month key (YYYY-MM)
const byMonth = new Map<string, Record<string, number>>();
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);
}
// 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,
VOL: values.VOL ?? 0,
} as ChartDataPoint;
});
}
function calcProjection(
currentBalance: number,
monthlyContribution: number,
annualRate: number,
targetAge: number,
isDividend: boolean,
): number {
const years = Math.max(0, targetAge - CURRENT_AGE);
if (years === 0) return currentBalance;
if (isDividend) {
let balance = currentBalance;
const rate = annualRate / 100;
for (let y = 0; y < years; y++) {
balance = balance + monthlyContribution * 12 + balance * rate;
}
return balance;
}
const r = annualRate / 100 / 12;
const n = years * 12;
if (r === 0) return currentBalance + monthlyContribution * n;
return (
currentBalance * Math.pow(1 + r, n) +
monthlyContribution * ((Math.pow(1 + r, n) - 1) / r)
);
}
/** Merge API snapshots into the default fund definitions. */
function applySnapshots(
snapshots: PensionSnapshot[],
): Record<FundKey, FundDef> {
const funds = { ...FUNDS_DEFAULT };
for (const snap of snapshots) {
const key = snap.fund as FundKey;
if (key in funds) {
funds[key] = { ...funds[key], startBalance: Math.round(snap.saldo_final) };
}
}
return funds;
}
// ─── Chart Tooltip ────────────────────────────────────────────────────────────
function ChartTooltipContent({
active,
payload,
label,
}: {
active?: boolean;
payload?: TooltipEntry[];
label?: string;
}) {
if (!active || !payload?.length) return null;
return (
<div className="bg-popover border border-border rounded-lg p-3 shadow-lg text-sm min-w-[220px]">
<p className="font-semibold mb-2 text-foreground">{label}</p>
{payload.map((entry) => (
<div key={entry.name} className="flex items-center justify-between gap-4 py-0.5">
<span className="flex items-center gap-1.5">
<span
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
style={{ background: entry.color }}
/>
<span className="text-muted-foreground">{entry.name}</span>
</span>
<span className="font-mono font-medium text-foreground">{formatCRC(entry.value)}</span>
</div>
))}
</div>
);
}
// ─── Main Component ───────────────────────────────────────────────────────────
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 },
ROP: { contribution: 120_000, rate: 6.0, targetAge: 65 },
VOL: { contribution: 400_000, rate: 8.0, targetAge: 57 },
});
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]);
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 loadData = useCallback(async () => {
try {
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(() => {
loadData();
}, [loadData]);
const FUNDS = useMemo(() => applySnapshots(fundSummary), [fundSummary]);
// Build a map of fund -> latest snapshot for rendimientos display
const snapshotByFund = useMemo(() => {
const map: Partial<Record<FundKey, PensionSnapshot>> = {};
for (const snap of fundSummary) {
map[snap.fund as FundKey] = snap;
}
return map;
}, [fundSummary]);
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) => {
const snap = snapshotByFund[key];
if (snap) {
// Use real rendimientos from the API
acc[key] = Math.round(snap.rendimientos);
} else {
// Fallback: approximate from chart data
const fund = FUNDS[key];
const len = chartData.length;
if (len >= 2 && fund.isDividend) {
acc[key] = Math.max(0, Math.round(
chartData[len - 1][key] - chartData[len - 2][key] - fund.monthlyContribution,
));
} else if (len > 0) {
const activeMonths = chartData.filter((d) => d[key] > 0).length;
acc[key] = Math.round(fund.startBalance * (fund.annualRate / 100) * (activeMonths / 12));
} else {
acc[key] = 0;
}
}
return acc;
}, {} as Record<FundKey, number>);
}, [FUNDS, chartData, snapshotByFund]);
const toggleFund = (key: FundKey) => {
setVisibleFunds((prev) => {
const next = new Set(prev);
if (next.has(key)) {
if (next.size > 1) next.delete(key);
} else {
next.add(key);
}
return next;
});
};
const updateProjection = (key: FundKey, field: keyof ProjectionState, value: string) => {
const num = parseFloat(value);
setProjections((prev) => ({
...prev,
[key]: { ...prev[key], [field]: isNaN(num) ? 0 : num },
}));
};
const handleFiles = useCallback((files: FileList | null) => {
if (!files) return;
const pdfs = Array.from(files).filter((f) => f.type === 'application/pdf');
setUploadedFiles((prev) => [...prev, ...pdfs]);
setUploadResult(null);
}, []);
const handleUpload = async () => {
if (uploadedFiles.length === 0) return;
setIsUploading(true);
setUploadResult(null);
try {
const { data } = await uploadPensionPDFs(uploadedFiles);
setUploadResult(data);
setUploadedFiles([]);
// Refresh fund summary with new data
await loadData();
} catch (err) {
setUploadResult({
imported: 0,
updated: 0,
duplicates: 0,
errors: [err instanceof Error ? err.message : 'Error al subir archivos'],
snapshots: [],
});
} finally {
setIsUploading(false);
}
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(true);
};
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(false);
};
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(false);
handleFiles(e.dataTransfer.files);
};
const removeFile = (index: number) => {
setUploadedFiles((prev) => prev.filter((_, i) => i !== index));
};
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
return (
<div className="space-y-8">
{/* ── Page Header ─────────────────────────────────────────────────── */}
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
<PiggyBank className="w-5 h-5 text-primary" />
</div>
<div>
<h1 className="text-2xl font-bold font-heading">Pensiones</h1>
<p className="text-sm text-muted-foreground">
Seguimiento de aportes, rendimientos y proyecciones
</p>
</div>
</div>
{/* ── Section 1: Fund Overview Cards ──────────────────────────────── */}
<section className="space-y-3">
<h2 className="text-base font-semibold font-heading flex items-center gap-2 text-muted-foreground uppercase tracking-wider">
<Banknote className="w-4 h-4" />
Fondos
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
{FUND_KEYS.map((key) => {
const fund = FUNDS[key];
const snap = snapshotByFund[key];
return (
<Card
key={key}
className="border-l-4 overflow-hidden"
style={{ borderLeftColor: fund.color }}
>
<CardContent className="p-4 space-y-3">
<div>
<div className="flex items-center justify-between gap-1">
<span className="font-bold text-base" style={{ color: fund.color }}>
{fund.name}
</span>
<Badge variant={fund.isDividend ? 'secondary' : 'outline'} className="text-xs">
{fund.isDividend ? 'Dividendos' : 'Interés'}
</Badge>
</div>
<p className="text-xs text-muted-foreground leading-tight mt-0.5">
{fund.fullName}
</p>
</div>
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wider font-medium">
Balance actual
</p>
<p className="text-xl font-bold font-mono mt-0.5 leading-tight">
{formatCRC(fund.startBalance)}
</p>
{snap && (
<p className="text-xs text-muted-foreground mt-0.5">
al {new Date(snap.period_end).toLocaleDateString('es-CR')}
</p>
)}
</div>
<Separator />
{snap ? (
<div className="space-y-1.5 text-xs">
<div className="flex justify-between">
<span className="text-muted-foreground">Aportes</span>
<span className="font-mono font-medium">
{formatCRC(snap.aportes)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Rendimientos</span>
<span className="font-mono font-medium text-emerald-500">
{formatCRC(snap.rendimientos)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Comisión</span>
<span className="font-mono font-medium text-destructive">
{formatCRC(snap.comision)}
</span>
</div>
</div>
) : (
<div className="space-y-1.5 text-xs">
<div className="flex justify-between">
<span className="text-muted-foreground">Aporte mensual</span>
<span className="font-mono font-medium">
{formatCRC(fund.monthlyContribution)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Tasa anual</span>
<span className="font-mono font-medium">{fund.annualRate}%</span>
</div>
</div>
)}
<div className="flex items-start gap-1.5 text-xs text-muted-foreground bg-muted/50 rounded-md p-2">
<Calendar className="w-3 h-3 flex-shrink-0 mt-0.5" />
<span>{fund.withdrawalRule}</span>
</div>
</CardContent>
</Card>
);
})}
</div>
</section>
{/* ── Section 2: Growth Chart ──────────────────────────────────────── */}
<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{chartDateRange && ` (${chartDateRange})`}
</h2>
<Card>
<CardContent className="p-4 space-y-4">
<div className="flex flex-wrap gap-2">
{FUND_KEYS.map((key) => {
const fund = FUNDS[key];
const active = visibleFunds.has(key);
return (
<button
key={key}
onClick={() => toggleFund(key)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-all border cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
style={{
borderColor: fund.color,
background: active ? fund.color + '22' : 'transparent',
color: active ? fund.color : 'var(--muted-foreground)',
}}
>
<span
className="w-2 h-2 rounded-full"
style={{ background: active ? fund.color : 'var(--muted-foreground)' }}
/>
{fund.name}
</button>
);
})}
</div>
<ResponsiveContainer width="100%" height={320}>
<LineChart data={chartData} margin={{ top: 4, right: 8, left: 8, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey="month"
tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }}
axisLine={{ stroke: 'var(--border)' }}
tickLine={false}
/>
<YAxis
tickFormatter={(v: number) => `${(v / 1_000_000).toFixed(1)}M`}
tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }}
axisLine={false}
tickLine={false}
width={52}
/>
<Tooltip content={<ChartTooltipContent />} />
<Legend
formatter={(value) => (
<span style={{ fontSize: 12, color: 'var(--foreground)' }}>{value}</span>
)}
/>
{FUND_KEYS.map((key) =>
visibleFunds.has(key) ? (
<Line
key={key}
type="monotone"
dataKey={key}
name={key}
stroke={FUNDS[key].color}
strokeWidth={2}
dot={false}
activeDot={{ r: 4 }}
/>
) : null,
)}
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
</section>
{/* ── Section 3: ROI Summary ───────────────────────────────────────── */}
<section className="space-y-3">
<h2 className="text-base font-semibold font-heading flex items-center gap-2 text-muted-foreground uppercase tracking-wider">
<Percent className="w-4 h-4" />
Rendimiento Último Periodo
</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
{FUND_KEYS.map((key) => {
const fund = FUNDS[key];
const snap = snapshotByFund[key];
const earned = roiEarned[key];
return (
<Card key={key}>
<CardContent className="p-4">
<div className="flex items-center gap-1.5 mb-2">
<span
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
style={{ background: fund.color }}
/>
<span className="font-bold text-sm">{fund.name}</span>
</div>
{snap ? (
<p className="text-xs text-muted-foreground mb-1">
{new Date(snap.period_start).toLocaleDateString('es-CR', { month: 'short', year: '2-digit' })}
{' — '}
{new Date(snap.period_end).toLocaleDateString('es-CR', { month: 'short', year: '2-digit' })}
</p>
) : (
<p className="text-xs text-muted-foreground mb-1">
{fund.isDividend ? `Dividendos ${fund.annualRate}%` : `${fund.annualRate}% anual`}
</p>
)}
<p className="text-lg font-bold font-mono" style={{ color: fund.color }}>
+{formatCRC(earned)}
</p>
<p className="text-xs text-muted-foreground">en rendimientos</p>
</CardContent>
</Card>
);
})}
</div>
</section>
{/* ── Section 4: Projections ───────────────────────────────────────── */}
<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" />
Proyecciones
</h2>
<p className="text-sm text-muted-foreground">
Basado en edad actual de {CURRENT_AGE} años. Edita los campos para simular escenarios.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
{FUND_KEYS.map((key) => {
const fund = FUNDS[key];
const proj = projections[key];
const projected = calcProjection(
fund.startBalance,
proj.contribution,
proj.rate,
proj.targetAge,
fund.isDividend,
);
const years = Math.max(0, proj.targetAge - CURRENT_AGE);
return (
<Card key={key} className="border-l-4" style={{ borderLeftColor: fund.color }}>
<CardHeader className="pb-2 pt-4 px-4">
<CardTitle className="text-sm font-bold" style={{ color: fund.color }}>
{fund.name}
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 space-y-3">
<div className="space-y-2">
<div>
<Label className="text-xs text-muted-foreground">Aporte mensual (CRC)</Label>
<Input
type="number"
value={proj.contribution}
onChange={(e) => updateProjection(key, 'contribution', e.target.value)}
className="h-8 text-sm font-mono mt-1"
/>
</div>
<div>
<Label className="text-xs text-muted-foreground">
{fund.isDividend ? 'Tasa dividendo (%)' : 'Tasa anual (%)'}
</Label>
<Input
type="number"
step="0.1"
value={proj.rate}
onChange={(e) => updateProjection(key, 'rate', e.target.value)}
className="h-8 text-sm font-mono mt-1"
/>
</div>
<div>
<Label className="text-xs text-muted-foreground">Edad objetivo</Label>
<Input
type="number"
value={proj.targetAge}
onChange={(e) => updateProjection(key, 'targetAge', e.target.value)}
className="h-8 text-sm font-mono mt-1"
/>
</div>
</div>
<Separator />
<div className="text-center space-y-0.5">
<p className="text-xs text-muted-foreground">
Valor en {years} {years === 1 ? 'año' : 'años'}
</p>
<p
className="text-lg font-bold font-mono leading-tight"
style={{ color: fund.color }}
>
{formatCRC(Math.round(projected))}
</p>
</div>
</CardContent>
</Card>
);
})}
</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 */}
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && fileInputRef.current?.click()}
aria-label="Seleccionar archivos PDF"
className={[
'border-2 border-dashed rounded-lg p-8',
'flex flex-col items-center justify-center gap-3',
'cursor-pointer transition-colors select-none',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
isDragging
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50 hover:bg-muted/30',
].join(' ')}
>
<Upload
className={`w-8 h-8 ${isDragging ? 'text-primary' : 'text-muted-foreground'}`}
/>
<div className="text-center">
<p className="text-sm font-medium">
{isDragging
? 'Suelta los archivos aquí'
: 'Arrastra PDFs aquí o toca para seleccionar'}
</p>
<p className="text-xs text-muted-foreground mt-1">
Solo archivos PDF · Múltiples archivos soportados
</p>
</div>
</div>
<input
ref={fileInputRef}
type="file"
accept="application/pdf"
multiple
className="hidden"
onChange={(e) => handleFiles(e.target.files)}
/>
{/* File list */}
{uploadedFiles.length > 0 && (
<div className="space-y-2">
<p className="text-sm text-muted-foreground font-medium">
{uploadedFiles.length}{' '}
{uploadedFiles.length === 1 ? 'archivo en cola' : 'archivos en cola'}
</p>
<div className="space-y-1.5">
{uploadedFiles.map((file, i) => (
<div
key={i}
className="flex items-center justify-between gap-3 p-2.5 rounded-lg bg-muted/50 border border-border"
>
<div className="flex items-center gap-2.5 min-w-0">
<FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<div className="min-w-0">
<p className="text-sm font-medium truncate">{file.name}</p>
<p className="text-xs text-muted-foreground">{formatFileSize(file.size)}</p>
</div>
</div>
<button
onClick={() => removeFile(i)}
className="flex-shrink-0 p-1 rounded hover:bg-destructive/10 hover:text-destructive transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={`Eliminar ${file.name}`}
>
<X className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
</div>
)}
{/* Submit */}
<Button
onClick={handleUpload}
disabled={uploadedFiles.length === 0 || isUploading}
className="w-full"
>
{isUploading ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Upload className="w-4 h-4 mr-2" />
)}
{isUploading ? 'Procesando...' : 'Subir PDFs'}
</Button>
{/* Upload result */}
{uploadResult && (
<div className={[
'rounded-lg border p-4 space-y-2',
uploadResult.errors.length > 0 && uploadResult.imported === 0
? 'border-destructive/50 bg-destructive/5'
: 'border-emerald-500/50 bg-emerald-500/5',
].join(' ')}>
<div className="flex items-center gap-2">
{(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'}`}
{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 && (
<p className="text-xs text-muted-foreground">
{uploadResult.duplicates} {uploadResult.duplicates === 1 ? 'duplicado omitido' : 'duplicados omitidos'}
</p>
)}
{uploadResult.errors.map((err, i) => (
<p key={i} className="text-xs text-destructive">{err}</p>
))}
{uploadResult.snapshots.length > 0 && (
<div className="space-y-1 pt-1">
{uploadResult.snapshots.map((snap) => (
<div key={snap.id} className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">
{snap.fund} · {new Date(snap.period_start).toLocaleDateString('es-CR', { month: 'short', year: '2-digit' })}
{' — '}
{new Date(snap.period_end).toLocaleDateString('es-CR', { month: 'short', year: '2-digit' })}
</span>
<span className="font-mono font-medium">{formatCRC(snap.saldo_final)}</span>
</div>
))}
</div>
)}
</div>
)}
</CardContent>
</Card>
</section>
</div>
);
}