mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 14:48:48 +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:
@@ -337,3 +337,10 @@ export type UserSettingsData = Record<string, unknown>;
|
|||||||
export type UserSettingsRead = Schema<'UserSettingsRead'>;
|
export type UserSettingsRead = Schema<'UserSettingsRead'>;
|
||||||
|
|
||||||
export const getUserSettings = () => api.get<UserSettingsRead>('/settings/');
|
export const getUserSettings = () => api.get<UserSettingsRead>('/settings/');
|
||||||
|
|
||||||
|
// --- Exchange Rate ---
|
||||||
|
|
||||||
|
export type ExchangeRatePoint = Schema<'ExchangeRateRead'>;
|
||||||
|
|
||||||
|
export const getRateHistory = (days = 90) =>
|
||||||
|
api.get<ExchangeRatePoint[]>('/exchange-rate/history', { params: { days } });
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import { BarChart3 } from 'lucide-react';
|
import { BarChart3 } from 'lucide-react';
|
||||||
|
|
||||||
import api from '@/lib/api';
|
import api, { getRateHistory } from '@/lib/api';
|
||||||
import ErrorState from '@/components/ErrorState';
|
import ErrorState from '@/components/ErrorState';
|
||||||
import BillingCycleSelector from '@/components/BillingCycleSelector';
|
import BillingCycleSelector from '@/components/BillingCycleSelector';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@@ -63,6 +63,11 @@ const trendChartConfig = {
|
|||||||
},
|
},
|
||||||
} satisfies ChartConfig;
|
} satisfies ChartConfig;
|
||||||
|
|
||||||
|
const rateChartConfig = {
|
||||||
|
sell_rate: { label: 'Venta', color: 'var(--chart-1)' },
|
||||||
|
buy_rate: { label: 'Compra', color: 'var(--chart-2)' },
|
||||||
|
} satisfies ChartConfig;
|
||||||
|
|
||||||
const dailyChartConfig = {
|
const dailyChartConfig = {
|
||||||
total: {
|
total: {
|
||||||
label: 'Gasto Diario',
|
label: 'Gasto Diario',
|
||||||
@@ -103,6 +108,16 @@ export default function Analytics() {
|
|||||||
const byCategory = byCategoryQ.data ?? [];
|
const byCategory = byCategoryQ.data ?? [];
|
||||||
const trend = trendQ.data ?? [];
|
const trend = trendQ.data ?? [];
|
||||||
const daily = dailyQ.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 anyError = byCategoryQ.isError || trendQ.isError || dailyQ.isError;
|
||||||
const retryAll = () => {
|
const retryAll = () => {
|
||||||
byCategoryQ.refetch();
|
byCategoryQ.refetch();
|
||||||
@@ -328,6 +343,67 @@ export default function Analytics() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ import {
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
@@ -40,7 +47,7 @@ import {
|
|||||||
type PensionUploadResult,
|
type PensionUploadResult,
|
||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
import PensionManualEntryModal from '@/components/PensionManualEntryModal';
|
import PensionManualEntryModal from '@/components/PensionManualEntryModal';
|
||||||
import { ClipboardPaste, SlidersHorizontal } from 'lucide-react';
|
import { ClipboardPaste, SlidersHorizontal, Target } from 'lucide-react';
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -317,6 +324,26 @@ export default function Pensions() {
|
|||||||
);
|
);
|
||||||
const [showAssumptions, setShowAssumptions] = useState(false);
|
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
|
// Build a map of fund -> latest snapshot for rendimientos display
|
||||||
const snapshotByFund = useMemo(() => {
|
const snapshotByFund = useMemo(() => {
|
||||||
const map: Partial<Record<FundKey, PensionSnapshot>> = {};
|
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 5: PDF Upload ────────────────────────────────────────── */}
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|||||||
Reference in New Issue
Block a user