From b1c387c41585fef1522306f26694de760411e0ea Mon Sep 17 00:00:00 2001 From: Carlos Escalante Date: Sat, 4 Jul 2026 10:50:18 -0600 Subject: [PATCH] Add Inicio dashboard as homepage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stat cards (gasto del ciclo, próximas cuotas Tasa Cero, pensión), ask-the-assistant box that lands in Asistente and auto-sends via agent.addMessage + runAgent, and últimas transacciones. Index route, login redirects updated; Asistente stays at /asistente. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/App.tsx | 5 +- frontend/src/pages/Asistente.tsx | 31 ++- frontend/src/pages/Inicio.tsx | 346 +++++++++++++++++++++++++++++++ frontend/src/pages/Login.tsx | 2 +- 4 files changed, 380 insertions(+), 4 deletions(-) create mode 100644 frontend/src/pages/Inicio.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b04baab..69ead84 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,6 +16,7 @@ const queryClient = new QueryClient({ }); import Layout from "./components/Layout"; import LoginPage from "./pages/Login"; +import Inicio from "./pages/Inicio"; import Asistente from "./pages/Asistente"; import Analytics from "./pages/Analytics"; import Budget from "./pages/Budget"; @@ -42,7 +43,7 @@ function AppRoutes() { : } + element={isAuthenticated ? : } /> } > - } /> + } /> } /> } /> } /> diff --git a/frontend/src/pages/Asistente.tsx b/frontend/src/pages/Asistente.tsx index 29b6c22..0a9723c 100644 --- a/frontend/src/pages/Asistente.tsx +++ b/frontend/src/pages/Asistente.tsx @@ -1,4 +1,11 @@ -import { CopilotChat, useConfigureSuggestions } from "@copilotkit/react-core/v2"; +import { useEffect, useRef } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; +import { + CopilotChat, + useAgent, + useConfigureSuggestions, + useCopilotKit, +} from "@copilotkit/react-core/v2"; import { useCopilotAction } from "@copilotkit/react-core"; import { Sparkles } from "lucide-react"; import { SpendingSummaryCard, type SpendingSummaryArgs } from "@/components/chat/ChatCards"; @@ -16,6 +23,28 @@ const STATIC_SUGGESTIONS = { export default function Asistente() { useConfigureSuggestions(STATIC_SUGGESTIONS); + // A question typed on the Inicio dashboard arrives via router state: + // append it as a user message and run the agent once. + const location = useLocation(); + const navigate = useNavigate(); + const { agent } = useAgent(); + const { copilotkit } = useCopilotKit(); + const sentRef = useRef(false); + useEffect(() => { + const ask = (location.state as { ask?: string } | null)?.ask; + if (!ask || sentRef.current || !agent) return; + sentRef.current = true; + // Clear the state so back/refresh doesn't re-send the question. + navigate(location.pathname, { replace: true, state: null }); + agent.addMessage({ + id: crypto.randomUUID(), + role: "user", + content: ask, + }); + void copilotkit.runAgent({ agent }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [agent]); + useCopilotAction({ name: "render_spending_summary", description: diff --git a/frontend/src/pages/Inicio.tsx b/frontend/src/pages/Inicio.tsx new file mode 100644 index 0000000..c9d9964 --- /dev/null +++ b/frontend/src/pages/Inicio.tsx @@ -0,0 +1,346 @@ +import { useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { ArrowRight, Sparkles } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; + +import { + getInstallmentPlans, + getMonthlyDetail, + getPensionFundSummary, + getRecentTransactions, +} from "@/lib/api"; +import { formatAmount } from "@/lib/format"; +import { + MONTH_NAMES_ES_SHORT, + currentBudgetCycle, + formatShortDate, +} from "@/lib/dates"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Skeleton } from "@/components/ui/skeleton"; + +const CYCLE = currentBudgetCycle(); + +function StatCardShell({ + to, + ariaLabel, + children, +}: { + to: string; + ariaLabel: string; + children: React.ReactNode; +}) { + return ( + + + {children} + + + ); +} + +function StatValue({ + value, + loading, + className, +}: { + value: string | null; + loading: boolean; + className?: string; +}) { + if (loading) return ; + return ( + + {value ?? "—"} + + ); +} + +function GastoDelCicloCard() { + const q = useQuery({ + queryKey: ["budget", "month", CYCLE.year, CYCLE.month], + queryFn: () => + getMonthlyDetail(CYCLE.year, CYCLE.month).then((r) => r.data), + }); + const spend = q.data + ? q.data.actuals_by_source.reduce((sum, a) => sum + a.net, 0) + : null; + const balance = q.data?.net_balance ?? null; + + return ( + + + + Gasto del ciclo + + + Ciclo al 18 {MONTH_NAMES_ES_SHORT[CYCLE.month].toLowerCase()} + + + + + {balance !== null && ( +

+ Balance proyectado:{" "} + = 0 ? "text-primary" : "text-destructive", + )} + > + {balance >= 0 ? "+" : "-"} + {formatAmount(balance, "CRC")} + +

+ )} + {q.isError && ( +

+ No se pudo cargar el ciclo. +

+ )} +
+
+ ); +} + +function ProximasCuotasCard() { + const q = useQuery({ + queryKey: ["installment-plans"], + queryFn: () => getInstallmentPlans().then((r) => r.data), + }); + const active = (q.data?.plans ?? []).filter( + (p) => !p.is_completed && p.next_cuota_date, + ); + const inCycle = active.filter((p) => { + const d = new Date(p.next_cuota_date!); + return d >= CYCLE.start && d < CYCLE.end; + }); + const cycleTotal = inCycle.reduce((s, p) => s + p.installment_amount, 0); + const upcoming = [...active] + .sort( + (a, b) => + new Date(a.next_cuota_date!).getTime() - + new Date(b.next_cuota_date!).getTime(), + ) + .slice(0, 3); + + return ( + + + + Próximas cuotas + + Tasa Cero en este ciclo + + + + {upcoming.length > 0 && ( +
    + {upcoming.map((p) => ( +
  • + {p.merchant} + + {formatShortDate(p.next_cuota_date!)} ·{" "} + {formatAmount(p.installment_amount, p.currency)} + +
  • + ))} +
+ )} + {q.data && ( +

+ Restante total:{" "} + + {formatAmount(q.data.total_remaining, "CRC")} + +

+ )} +
+
+ ); +} + +function PensionCard() { + const q = useQuery({ + queryKey: ["pension-fund-summary"], + queryFn: () => getPensionFundSummary().then((r) => r.data), + }); + const total = q.data + ? q.data.reduce((s, f) => s + f.saldo_final, 0) + : null; + + return ( + + + + Pensión + + Saldo total de fondos + + + + {q.data && q.data.length > 0 && ( +
    + {q.data.map((f) => ( +
  • + {f.fund} + + {formatAmount(f.saldo_final, "CRC")} + +
  • + ))} +
+ )} +
+
+ ); +} + +function AskAssistantCard() { + const [question, setQuestion] = useState(""); + const navigate = useNavigate(); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const ask = question.trim(); + navigate("/asistente", ask ? { state: { ask } } : undefined); + }; + + return ( + + +
+ + setQuestion(e.target.value)} + placeholder="¿Cuánto gasté este ciclo?" + aria-label="Pregúntale al asistente" + className="flex-1" + /> + + +
+
+ ); +} + +function RecentTransactionsCard() { + const q = useQuery({ + queryKey: ["transactions", "recent"], + queryFn: () => getRecentTransactions(5).then((r) => r.data), + }); + + return ( + + + + Últimas transacciones + + + Ver todo + + + + + {q.isPending ? ( +
+ {Array.from({ length: 5 }, (_, i) => ( + + ))} +
+ ) : q.isError ? ( +

+ No se pudieron cargar las transacciones. +

+ ) : ( +
    + {(q.data ?? []).map((tx) => ( +
  • + {tx.merchant} + + + {formatShortDate(tx.date)} + + + {tx.transaction_type === "COMPRA" ? "-" : "+"} + {formatAmount(tx.amount, tx.currency)} + + +
  • + ))} +
+ )} +
+
+ ); +} + +export default function Inicio() { + return ( +
+
+

+ Inicio +

+

+ Tu panorama financiero de hoy. +

+
+ +
+ + + +
+ + + + +
+ ); +} diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index d1386ca..bcc4362 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -23,7 +23,7 @@ export default function LoginPage() { try { await login(username, password); setAuthenticated(true); - navigate("/asistente", { replace: true }); + navigate("/", { replace: true }); } catch { setError("Invalid credentials"); } finally {