mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 09:08:46 +02:00
Add Inicio dashboard as homepage
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) <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@ const queryClient = new QueryClient({
|
|||||||
});
|
});
|
||||||
import Layout from "./components/Layout";
|
import Layout from "./components/Layout";
|
||||||
import LoginPage from "./pages/Login";
|
import LoginPage from "./pages/Login";
|
||||||
|
import Inicio from "./pages/Inicio";
|
||||||
import Asistente from "./pages/Asistente";
|
import Asistente from "./pages/Asistente";
|
||||||
import Analytics from "./pages/Analytics";
|
import Analytics from "./pages/Analytics";
|
||||||
import Budget from "./pages/Budget";
|
import Budget from "./pages/Budget";
|
||||||
@@ -42,7 +43,7 @@ function AppRoutes() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route
|
<Route
|
||||||
path="/login"
|
path="/login"
|
||||||
element={isAuthenticated ? <Navigate to="/asistente" replace /> : <LoginPage />}
|
element={isAuthenticated ? <Navigate to="/" replace /> : <LoginPage />}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
element={
|
element={
|
||||||
@@ -51,7 +52,7 @@ function AppRoutes() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route index element={<Navigate to="/asistente" replace />} />
|
<Route index element={<Inicio />} />
|
||||||
<Route path="/asistente" element={<Asistente />} />
|
<Route path="/asistente" element={<Asistente />} />
|
||||||
<Route path="/budget" element={<Budget />} />
|
<Route path="/budget" element={<Budget />} />
|
||||||
<Route path="/analytics" element={<Analytics />} />
|
<Route path="/analytics" element={<Analytics />} />
|
||||||
|
|||||||
@@ -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 { useCopilotAction } from "@copilotkit/react-core";
|
||||||
import { Sparkles } from "lucide-react";
|
import { Sparkles } from "lucide-react";
|
||||||
import { SpendingSummaryCard, type SpendingSummaryArgs } from "@/components/chat/ChatCards";
|
import { SpendingSummaryCard, type SpendingSummaryArgs } from "@/components/chat/ChatCards";
|
||||||
@@ -16,6 +23,28 @@ const STATIC_SUGGESTIONS = {
|
|||||||
export default function Asistente() {
|
export default function Asistente() {
|
||||||
useConfigureSuggestions(STATIC_SUGGESTIONS);
|
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({
|
useCopilotAction({
|
||||||
name: "render_spending_summary",
|
name: "render_spending_summary",
|
||||||
description:
|
description:
|
||||||
|
|||||||
346
frontend/src/pages/Inicio.tsx
Normal file
346
frontend/src/pages/Inicio.tsx
Normal file
@@ -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 (
|
||||||
|
<Link
|
||||||
|
to={to}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
className="block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
<Card className="h-full transition-colors hover:border-primary/40 cursor-pointer">
|
||||||
|
{children}
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatValue({
|
||||||
|
value,
|
||||||
|
loading,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
value: string | null;
|
||||||
|
loading: boolean;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
if (loading) return <Skeleton className="h-8 w-36" />;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-sensitive
|
||||||
|
className={cn("text-2xl font-bold font-mono", className)}
|
||||||
|
>
|
||||||
|
{value ?? "—"}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<StatCardShell to="/budget" ariaLabel="Ver presupuesto">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
Gasto del ciclo
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Ciclo al 18 {MONTH_NAMES_ES_SHORT[CYCLE.month].toLowerCase()}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-1">
|
||||||
|
<StatValue
|
||||||
|
loading={q.isPending}
|
||||||
|
value={
|
||||||
|
q.isError ? null : spend !== null ? formatAmount(spend, "CRC") : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{balance !== null && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Balance proyectado:{" "}
|
||||||
|
<span
|
||||||
|
data-sensitive
|
||||||
|
className={cn(
|
||||||
|
"font-mono font-medium",
|
||||||
|
balance >= 0 ? "text-primary" : "text-destructive",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{balance >= 0 ? "+" : "-"}
|
||||||
|
{formatAmount(balance, "CRC")}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{q.isError && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
No se pudo cargar el ciclo.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</StatCardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<StatCardShell to="/financiamientos" ariaLabel="Ver financiamientos">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
Próximas cuotas
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Tasa Cero en este ciclo</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<StatValue
|
||||||
|
loading={q.isPending}
|
||||||
|
value={q.isError ? null : formatAmount(cycleTotal, "CRC")}
|
||||||
|
/>
|
||||||
|
{upcoming.length > 0 && (
|
||||||
|
<ul className="space-y-0.5 text-xs text-muted-foreground">
|
||||||
|
{upcoming.map((p) => (
|
||||||
|
<li key={p.id} className="flex justify-between gap-2">
|
||||||
|
<span className="truncate">{p.merchant}</span>
|
||||||
|
<span data-sensitive className="font-mono shrink-0">
|
||||||
|
{formatShortDate(p.next_cuota_date!)} ·{" "}
|
||||||
|
{formatAmount(p.installment_amount, p.currency)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{q.data && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Restante total:{" "}
|
||||||
|
<span data-sensitive className="font-mono font-medium">
|
||||||
|
{formatAmount(q.data.total_remaining, "CRC")}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</StatCardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<StatCardShell to="/pensions" ariaLabel="Ver pensiones">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
Pensión
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Saldo total de fondos</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<StatValue
|
||||||
|
loading={q.isPending}
|
||||||
|
value={
|
||||||
|
q.isError ? null : total !== null ? formatAmount(total, "CRC") : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{q.data && q.data.length > 0 && (
|
||||||
|
<ul className="space-y-0.5 text-xs text-muted-foreground">
|
||||||
|
{q.data.map((f) => (
|
||||||
|
<li key={f.fund} className="flex justify-between gap-2">
|
||||||
|
<span>{f.fund}</span>
|
||||||
|
<span data-sensitive className="font-mono">
|
||||||
|
{formatAmount(f.saldo_final, "CRC")}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</StatCardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<form onSubmit={handleSubmit} className="flex items-center gap-3">
|
||||||
|
<Sparkles className="w-5 h-5 text-primary shrink-0" aria-hidden />
|
||||||
|
<Input
|
||||||
|
value={question}
|
||||||
|
onChange={(e) => setQuestion(e.target.value)}
|
||||||
|
placeholder="¿Cuánto gasté este ciclo?"
|
||||||
|
aria-label="Pregúntale al asistente"
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button type="submit">Preguntar</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecentTransactionsCard() {
|
||||||
|
const q = useQuery({
|
||||||
|
queryKey: ["transactions", "recent"],
|
||||||
|
queryFn: () => getRecentTransactions(5).then((r) => r.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
Últimas transacciones
|
||||||
|
</CardTitle>
|
||||||
|
<Link
|
||||||
|
to="/budget"
|
||||||
|
className="flex items-center gap-1 text-xs text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded"
|
||||||
|
>
|
||||||
|
Ver todo
|
||||||
|
<ArrowRight className="w-3 h-3" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{q.isPending ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Array.from({ length: 5 }, (_, i) => (
|
||||||
|
<Skeleton key={i} className="h-6 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : q.isError ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No se pudieron cargar las transacciones.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-border">
|
||||||
|
{(q.data ?? []).map((tx) => (
|
||||||
|
<li
|
||||||
|
key={tx.id}
|
||||||
|
className="flex items-center justify-between gap-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<span className="truncate">{tx.merchant}</span>
|
||||||
|
<span className="flex items-center gap-3 shrink-0">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formatShortDate(tx.date)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
data-sensitive
|
||||||
|
className={cn(
|
||||||
|
"font-mono",
|
||||||
|
tx.transaction_type !== "COMPRA" && "text-primary",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tx.transaction_type === "COMPRA" ? "-" : "+"}
|
||||||
|
{formatAmount(tx.amount, tx.currency)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Inicio() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1
|
||||||
|
className="text-2xl font-bold tracking-tight"
|
||||||
|
style={{ fontFamily: "var(--font-heading)" }}
|
||||||
|
>
|
||||||
|
Inicio
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Tu panorama financiero de hoy.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<GastoDelCicloCard />
|
||||||
|
<ProximasCuotasCard />
|
||||||
|
<PensionCard />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AskAssistantCard />
|
||||||
|
|
||||||
|
<RecentTransactionsCard />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ export default function LoginPage() {
|
|||||||
try {
|
try {
|
||||||
await login(username, password);
|
await login(username, password);
|
||||||
setAuthenticated(true);
|
setAuthenticated(true);
|
||||||
navigate("/asistente", { replace: true });
|
navigate("/", { replace: true });
|
||||||
} catch {
|
} catch {
|
||||||
setError("Invalid credentials");
|
setError("Invalid credentials");
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Reference in New Issue
Block a user