diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 707ef76..bdb92fe 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -24,6 +24,7 @@ import Pensions from "./pages/Pensions"; import Proyecciones from "./pages/Proyecciones"; import ServiciosMunicipales from "./pages/ServiciosMunicipales"; import SyncStatus from "./pages/SyncStatus"; +import Planificador from "./pages/Planificador"; function ProtectedRoute({ children }: { children: React.ReactNode }) { const { isAuthenticated, isLoading } = useAuth(); @@ -58,6 +59,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 1477a0d..0175beb 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from "react"; import { Link, Outlet, useLocation, useNavigate } from "react-router-dom"; import { Sparkles, + Telescope, Calculator, BarChart3, Landmark, @@ -55,6 +56,7 @@ const navSections: NavSection[] = [ { to: "/salarios", icon: Landmark, label: "Salarios" }, { to: "/pensions", icon: PiggyBank, label: "Pensiones" }, { to: "/proyecciones", icon: TrendingUp, label: "Proyecciones" }, + { to: "/planificador", icon: Telescope, label: "Planificador" }, { to: "/analytics", icon: BarChart3, label: "Analytics" }, ], }, diff --git a/frontend/src/pages/Planificador.tsx b/frontend/src/pages/Planificador.tsx new file mode 100644 index 0000000..3ba1b3e --- /dev/null +++ b/frontend/src/pages/Planificador.tsx @@ -0,0 +1,207 @@ +import { useMemo, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Line, LineChart, XAxis, YAxis } from 'recharts'; +import { Telescope } from 'lucide-react'; + +import { getYearlyProjection } from '@/lib/api'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + type ChartConfig, +} from '@/components/ui/chart'; + +const MIN_YEAR = 2026; + +const chartConfig = { + baseline: { label: 'Ritmo actual', color: 'var(--chart-2)' }, + scenario: { label: 'Escenario', color: 'var(--chart-1)' }, +} satisfies ChartConfig; + +const formatCRC = (v: number) => + `₡${Math.round(v).toLocaleString('es-CR')}`; + +/** What-if planner (wishlist #7): compare the current savings pace against a + * scenario with extra monthly savings, both compounded monthly. The baseline + * pace comes from the live budget projection (annual net / 12) and is + * editable so scenarios can start from any assumption. */ +export default function Planificador() { + const currentYear = Math.max(MIN_YEAR, new Date().getFullYear()); + const projectionQ = useQuery({ + queryKey: ['budget', 'projection', currentYear], + queryFn: () => getYearlyProjection(currentYear).then((r) => r.data), + }); + const projectedMonthlyNet = projectionQ.data + ? Math.round(projectionQ.data.annual_net / 12) + : null; + + const [baseMonthly, setBaseMonthly] = useState(null); + const [extraMonthly, setExtraMonthly] = useState('50000'); + const [years, setYears] = useState('5'); + const [annualRate, setAnnualRate] = useState('5'); + + // Until the user edits it, the baseline follows the live projection. + const effectiveBase = + baseMonthly !== null + ? parseFloat(baseMonthly) || 0 + : projectedMonthlyNet ?? 0; + + const { series, baselineEnd, scenarioEnd } = useMemo(() => { + const horizon = Math.min(Math.max(parseInt(years, 10) || 0, 1), 40) * 12; + const extra = parseFloat(extraMonthly) || 0; + const r = (parseFloat(annualRate) || 0) / 100 / 12; + + let baseline = 0; + let scenario = 0; + const points: { month: number; label: string; baseline: number; scenario: number }[] = []; + for (let m = 1; m <= horizon; m++) { + baseline = baseline * (1 + r) + effectiveBase; + scenario = scenario * (1 + r) + effectiveBase + extra; + if (m % 3 === 0 || m === horizon) { + points.push({ + month: m, + label: m % 12 === 0 ? `${m / 12} a` : `${m} m`, + baseline: Math.round(baseline), + scenario: Math.round(scenario), + }); + } + } + return { + series: points, + baselineEnd: Math.round(baseline), + scenarioEnd: Math.round(scenario), + }; + }, [effectiveBase, extraMonthly, years, annualRate]); + + return ( +
+
+
+ + + +
+ + setBaseMonthly(e.target.value)} + /> +

+ Prellenado con tu proyección de {currentYear} +

+
+
+ + setExtraMonthly(e.target.value)} + /> +
+
+ + setYears(e.target.value)} + /> +
+
+ + setAnnualRate(e.target.value)} + /> +
+
+
+ +
+ + +

+ Ritmo actual +

+

+ {formatCRC(baselineEnd)} +

+
+
+ + +

+ Escenario +

+

+ {formatCRC(scenarioEnd)} +

+
+
+ + +

+ Diferencia +

+

+ +{formatCRC(scenarioEnd - baselineEnd)} +

+
+
+
+ + + + + Crecimiento comparado + + + + + + + `₡${(v / 1_000_000).toFixed(1)}M`} + /> + formatCRC(Number(value))} /> + } + /> + + + + +

+ Aporte compuesto mensualmente. El ritmo actual usa el balance neto + proyectado de tu presupuesto; ambos escenarios asumen que lo ahorrado + se invierte al rendimiento indicado. +

+
+
+
+ ); +}