mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 13:28:46 +02:00
Rate history chart and pension contribution calculator
Analytics gains a 90-day USD/CRC buy/sell chart from the existing history endpoint (wishlist: rate history). Pensiones gains a Calculadora de Aporte: fund + target amount + target age → required monthly contribution via the annuity future-value formula, using the fund's live balance and configured rate (wishlist: contribution calculator). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,7 +13,7 @@ import {
|
||||
} from 'recharts';
|
||||
import { BarChart3 } from 'lucide-react';
|
||||
|
||||
import api from '@/lib/api';
|
||||
import api, { getRateHistory } from '@/lib/api';
|
||||
import ErrorState from '@/components/ErrorState';
|
||||
import BillingCycleSelector from '@/components/BillingCycleSelector';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -63,6 +63,11 @@ const trendChartConfig = {
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const rateChartConfig = {
|
||||
sell_rate: { label: 'Venta', color: 'var(--chart-1)' },
|
||||
buy_rate: { label: 'Compra', color: 'var(--chart-2)' },
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const dailyChartConfig = {
|
||||
total: {
|
||||
label: 'Gasto Diario',
|
||||
@@ -103,6 +108,16 @@ export default function Analytics() {
|
||||
const byCategory = byCategoryQ.data ?? [];
|
||||
const trend = trendQ.data ?? [];
|
||||
const daily = dailyQ.data ?? [];
|
||||
const ratesQ = useQuery({
|
||||
queryKey: ['exchange-rate', 'history'],
|
||||
queryFn: () =>
|
||||
getRateHistory(90).then((r) =>
|
||||
[...r.data]
|
||||
.sort((a, b) => a.date.localeCompare(b.date))
|
||||
.map((p) => ({ ...p, day: p.date.slice(0, 10) })),
|
||||
),
|
||||
staleTime: 60 * 60_000,
|
||||
});
|
||||
const anyError = byCategoryQ.isError || trendQ.isError || dailyQ.isError;
|
||||
const retryAll = () => {
|
||||
byCategoryQ.refetch();
|
||||
@@ -328,6 +343,67 @@ export default function Analytics() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{/* Exchange rate history */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm uppercase tracking-wider text-muted-foreground">
|
||||
Tipo de Cambio USD/CRC (90 días)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{(ratesQ.data?.length ?? 0) < 2 ? (
|
||||
<div className="h-40 flex items-center justify-center text-muted-foreground text-sm">
|
||||
Sin historial suficiente
|
||||
</div>
|
||||
) : (
|
||||
<ChartContainer
|
||||
config={rateChartConfig}
|
||||
className="h-[200px] w-full"
|
||||
>
|
||||
<LineChart data={ratesQ.data}>
|
||||
<XAxis
|
||||
dataKey="day"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickFormatter={(v) =>
|
||||
new Date(v).toLocaleDateString('es-CR', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
domain={['auto', 'auto']}
|
||||
width={44}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
formatter={(value) => `₡${Number(value).toFixed(2)}`}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="sell_rate"
|
||||
stroke="var(--chart-1)"
|
||||
dot={false}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="buy_rate"
|
||||
stroke="var(--chart-2)"
|
||||
dot={false}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,13 @@ import {
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
@@ -40,7 +47,7 @@ import {
|
||||
type PensionUploadResult,
|
||||
} from '@/lib/api';
|
||||
import PensionManualEntryModal from '@/components/PensionManualEntryModal';
|
||||
import { ClipboardPaste, SlidersHorizontal } from 'lucide-react';
|
||||
import { ClipboardPaste, SlidersHorizontal, Target } from 'lucide-react';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -317,6 +324,26 @@ export default function Pensions() {
|
||||
);
|
||||
const [showAssumptions, setShowAssumptions] = useState(false);
|
||||
|
||||
// ── Contribution calculator (wishlist #9): required monthly aporte to
|
||||
// reach a target balance by a target age, given the fund's annual rate.
|
||||
const [calcFund, setCalcFund] = useState<FundKey>('ROP');
|
||||
const [calcTarget, setCalcTarget] = useState('50000000');
|
||||
const [calcAge, setCalcAge] = useState('65');
|
||||
|
||||
const requiredMonthly = useMemo(() => {
|
||||
const fund = FUNDS[calcFund];
|
||||
const target = parseFloat(calcTarget);
|
||||
const targetAge = parseInt(calcAge, 10);
|
||||
const months = (targetAge - CURRENT_AGE) * 12;
|
||||
if (!fund || isNaN(target) || isNaN(targetAge) || months <= 0) return null;
|
||||
const r = fund.annualRate / 100 / 12;
|
||||
const growth = Math.pow(1 + r, months);
|
||||
const fromBalance = fund.startBalance * growth;
|
||||
if (fromBalance >= target) return 0;
|
||||
// FV of an annuity: PMT * ((1+r)^n - 1) / r
|
||||
return ((target - fromBalance) * r) / (growth - 1);
|
||||
}, [FUNDS, calcFund, calcTarget, calcAge]);
|
||||
|
||||
// Build a map of fund -> latest snapshot for rendimientos display
|
||||
const snapshotByFund = useMemo(() => {
|
||||
const map: Partial<Record<FundKey, PensionSnapshot>> = {};
|
||||
@@ -773,6 +800,64 @@ export default function Pensions() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Contribution calculator ──────────────────────────────────────── */}
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-base font-semibold font-heading flex items-center gap-2 text-muted-foreground uppercase tracking-wider">
|
||||
<Target className="w-4 h-4" />
|
||||
Calculadora de Aporte
|
||||
</h2>
|
||||
<Card>
|
||||
<CardContent className="p-4 grid grid-cols-1 sm:grid-cols-4 gap-4 items-end">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Fondo</Label>
|
||||
<Select value={calcFund} onValueChange={(v) => v && setCalcFund(v as FundKey)}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FUND_KEYS.map((k) => (
|
||||
<SelectItem key={k} value={k}>{FUNDS[k].name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Meta (₡)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1000000"
|
||||
value={calcTarget}
|
||||
onChange={(e) => setCalcTarget(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">A los (edad)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={CURRENT_AGE + 1}
|
||||
max="80"
|
||||
value={calcAge}
|
||||
onChange={(e) => setCalcAge(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-right sm:text-left">
|
||||
<p className="text-xs text-muted-foreground">Aporte mensual requerido</p>
|
||||
<p data-sensitive className="text-xl font-bold font-mono text-primary">
|
||||
{requiredMonthly === null
|
||||
? '—'
|
||||
: requiredMonthly === 0
|
||||
? 'Meta ya alcanzable'
|
||||
: formatCRC(Math.ceil(requiredMonthly / 1000) * 1000)}
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
al {FUNDS[calcFund].annualRate}% anual, saldo actual incluido
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* ── Section 5: PDF Upload ────────────────────────────────────────── */}
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
Reference in New Issue
Block a user