Compare commits

..

10 Commits

Author SHA1 Message Date
Carlos Escalante
10d9bd209f Fix dashboard data findings from browser verification
All checks were successful
Deploy to VPS / test (push) Successful in 1m32s
Deploy to VPS / deploy (push) Successful in 1m2s
- Próximas cuotas: headline is the upcoming billing batch (sum of every
  active plan's next cuota, using the rounding-absorbing last cuota
  when it's the pending one) — the in-cycle formula was ~always zero
  since cuotas bill on the 19th, day one of the next cycle.
- useAgent must name the 'wealthysmart' agent (default crashed the page).
- /transactions/recent excludes future-dated Tasa Cero cuotas.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 11:02:26 -06:00
Carlos Escalante
b1c387c415 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>
2026-07-04 10:50:18 -06:00
Carlos Escalante
7ba70ae524 Asistente: fill viewport via flex chain instead of vh math
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 10:47:26 -06:00
Carlos Escalante
d9039c76b9 Replace hand-rolled shell with shadcn sidebar + breadcrumb header
AppSidebar (icon-collapsible, sync warning badge, brand header),
NavUser footer (avatar + privacy/theme/logout dropdown), and a thin
header with SidebarTrigger, breadcrumbs, and quick toggles. Mobile
drawer and cmd+B come from the sidebar primitives.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 10:46:34 -06:00
Carlos Escalante
d117fb79ea Add nav config module, budget-cycle helper, me/recent API helpers
navigation.ts becomes the single source of truth for sidebar and
breadcrumbs (Inicio entry added; '/' is exact-match).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 10:43:26 -06:00
Carlos Escalante
d2610b2eef Add shadcn sidebar/breadcrumb/collapsible/avatar/tooltip (base-nova)
Registry-installed Base UI flavor; existing ui components untouched.
Also drops the meaningless empty registries key from components.json.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 10:42:20 -06:00
Carlos Escalante
3d71234a61 Budget: unify transactions section into one card
Toolbar (source tabs, search, CSV, add) and a summary strip (billing
cycle range, count, per-currency total) now live inside the table card
instead of five loose control rows. Cycle range 18-to-18 is finally
visible. Stray English strings translated; back-button spacing fixed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 09:38:36 -06:00
Carlos Escalante
72a5840752 Proyecciones: clicking a month row opens that month in Budget
The clicked year/month now travel in the navigation state; Budget
seeds useBudget with them instead of always defaulting to today.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 20:40:09 -06:00
Carlos Escalante
f832138b41 Pensions: compute current age from birthdate instead of hardcoded 30
FCL projection target defaults to the next 5-year withdrawal window
(CURRENT_AGE + 5) rather than a fixed 35.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 20:38:44 -06:00
Carlos Escalante
5296230416 Show net total of listed transactions above the table
Compras minus devoluciones, one figure per currency, excluding Tasa
Cero anchors (their cuotas are what count). Tooltip explains the math.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 20:17:55 -06:00
24 changed files with 1985 additions and 277 deletions

View File

@@ -253,9 +253,15 @@ def recent_transactions(
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
from app.timeutil import utcnow
query = (
select(Transaction)
.where(Transaction.source == TransactionSource.CREDIT_CARD)
.where(
Transaction.source == TransactionSource.CREDIT_CARD,
# Tasa Cero generates future-dated cuotas; "recent" means billed.
Transaction.date <= utcnow(),
)
.order_by(col(Transaction.date).desc(), col(Transaction.id).desc())
.limit(limit)
)

View File

@@ -20,6 +20,5 @@
"hooks": "@/hooks"
},
"menuColor": "default-translucent",
"menuAccent": "subtle",
"registries": {}
"menuAccent": "subtle"
}

View File

@@ -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() {
<Routes>
<Route
path="/login"
element={isAuthenticated ? <Navigate to="/asistente" replace /> : <LoginPage />}
element={isAuthenticated ? <Navigate to="/" replace /> : <LoginPage />}
/>
<Route
element={
@@ -51,7 +52,7 @@ function AppRoutes() {
</ProtectedRoute>
}
>
<Route index element={<Navigate to="/asistente" replace />} />
<Route index element={<Inicio />} />
<Route path="/asistente" element={<Asistente />} />
<Route path="/budget" element={<Budget />} />
<Route path="/analytics" element={<Analytics />} />

View File

@@ -1,239 +1,100 @@
import { useState, useEffect } from "react";
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
import {
Sparkles,
Telescope,
Calculator,
BarChart3,
Landmark,
PiggyBank,
Droplets,
Layers,
LogOut,
TrendingUp,
Wallet,
Menu,
RefreshCw,
Sun,
Moon,
Eye,
EyeOff,
type LucideIcon,
} from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { Link, Outlet, useLocation } from "react-router-dom";
import { Eye, EyeOff, Moon, Sun } from "lucide-react";
import { getSyncStatus } from "@/lib/api";
import { titleFor } from "@/lib/navigation";
import { useTheme } from "@/contexts/theme-context";
import { usePrivacy } from "@/contexts/privacy-context";
import { useAuth } from "@/AuthContext";
import { AppSidebar } from "@/components/app-sidebar";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetClose,
} from "@/components/ui/sheet";
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
interface NavSection {
label: string;
items: { to: string; icon: LucideIcon; label: string }[];
}
const navSections: NavSection[] = [
{
label: "General",
items: [
{ to: "/asistente", icon: Sparkles, label: "Asistente" },
{ to: "/sync", icon: RefreshCw, label: "Sincronización" },
],
},
{
label: "Finanzas",
items: [
{ to: "/budget", icon: Calculator, label: "Presupuesto" },
{ to: "/salarios", icon: Landmark, label: "Salarios" },
{ to: "/financiamientos", icon: Layers, label: "Financiamientos" },
{ to: "/pensions", icon: PiggyBank, label: "Pensiones" },
{ to: "/proyecciones", icon: TrendingUp, label: "Proyecciones" },
{ to: "/planificador", icon: Telescope, label: "Planificador" },
{ to: "/analytics", icon: BarChart3, label: "Analytics" },
],
},
{
label: "Servicios",
items: [
{ to: "/servicios-municipales", icon: Droplets, label: "Municipalidad" },
],
},
];
function SidebarNav({ onNavigate }: { onNavigate?: () => void }) {
const { pathname } = useLocation();
const isActive = (to: string) =>
pathname === to || pathname.startsWith(`${to}/`);
// Surface stalled ingestion sources as a dot on the Sincronización item.
const syncQ = useQuery({
queryKey: ['sync-status'],
queryFn: () => getSyncStatus().then((r) => r.data),
staleTime: 5 * 60_000,
});
const syncWarnings = syncQ.data?.warnings ?? 0;
return (
<nav className="flex flex-col gap-0.5 px-3">
{navSections.map((section) => (
<div key={section.label}>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-3 pt-4 pb-1">
{section.label}
</p>
{section.items.map(({ to, icon: Icon, label }) => (
<Link
key={to}
to={to}
onClick={onNavigate}
className={cn(
"flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors",
isActive(to)
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
>
<Icon className="w-4 h-4" />
{label}
{to === "/sync" && syncWarnings > 0 && (
<span
className="ml-auto w-2 h-2 rounded-full bg-amber-500"
title={`${syncWarnings} fuente(s) atrasada(s)`}
aria-label={`${syncWarnings} fuentes de datos atrasadas`}
/>
)}
</Link>
))}
</div>
))}
</nav>
);
}
import {
SidebarInset,
SidebarProvider,
SidebarTrigger,
} from "@/components/ui/sidebar";
export default function Layout() {
const { theme, toggleTheme } = useTheme();
const { privacyMode, togglePrivacy } = usePrivacy();
const { logout } = useAuth();
const navigate = useNavigate();
const [mobileOpen, setMobileOpen] = useState(false);
const handleLogout = async () => {
await logout();
navigate("/login", { replace: true });
};
const { pathname } = useLocation();
const title = titleFor(pathname);
return (
<div className="min-h-screen bg-background text-foreground">
<header className="border-b border-border backdrop-blur-sm sticky top-0 z-50 bg-background/90">
<div className="px-4 sm:px-6 py-3 flex items-center justify-between">
<div className="flex items-center gap-2.5">
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<header className="sticky top-0 z-40 flex h-14 shrink-0 items-center gap-2 border-b border-border bg-background/90 px-4 backdrop-blur-sm">
<SidebarTrigger className="-ml-1" />
<Separator
orientation="vertical"
className="mr-2 data-[orientation=vertical]:h-4"
/>
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
{pathname === "/" ? (
<BreadcrumbPage>Inicio</BreadcrumbPage>
) : (
<BreadcrumbLink render={<Link to="/" />}>
Inicio
</BreadcrumbLink>
)}
</BreadcrumbItem>
{pathname !== "/" && (
<>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>{title}</BreadcrumbPage>
</BreadcrumbItem>
</>
)}
</BreadcrumbList>
</Breadcrumb>
<div className="ml-auto flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => setMobileOpen(true)}
title="Open menu"
aria-label="Open menu"
className="md:hidden"
onClick={togglePrivacy}
title="Toggle privacy mode"
aria-label="Toggle privacy mode"
aria-pressed={privacyMode}
>
<Menu className="w-5 h-5" />
</Button>
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center">
<Wallet className="w-4 h-4 text-primary-foreground" strokeWidth={2.5} />
</div>
<span className="text-lg font-bold tracking-tight hidden sm:inline" style={{ fontFamily: "var(--font-heading)" }}>
Wealthy<span className="text-primary">Smart</span>
</span>
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" onClick={togglePrivacy} title="Toggle privacy mode" aria-label="Toggle privacy mode" aria-pressed={privacyMode}>
{privacyMode ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</Button>
<Button variant="ghost" size="icon" onClick={toggleTheme} title="Toggle theme" aria-label="Toggle theme" aria-pressed={theme === "dark"}>
{theme === "dark" ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
{privacyMode ? (
<EyeOff className="w-4 h-4" />
) : (
<Eye className="w-4 h-4" />
)}
</Button>
<Button
variant="ghost"
size="icon"
onClick={handleLogout}
title="Sign out"
aria-label="Sign out"
className="hidden md:inline-flex"
onClick={toggleTheme}
title="Toggle theme"
aria-label="Toggle theme"
aria-pressed={theme === "dark"}
>
<LogOut className="w-4 h-4" />
{theme === "dark" ? (
<Sun className="w-4 h-4" />
) : (
<Moon className="w-4 h-4" />
)}
</Button>
</div>
</div>
</header>
<div className="flex">
<aside className="hidden md:flex md:flex-col md:w-56 md:flex-shrink-0 border-r border-border sticky top-[57px] h-[calc(100vh-57px)] overflow-y-auto bg-background">
<div className="flex-1">
<SidebarNav />
</div>
<div className="px-3 pb-4">
<Separator className="mb-2" />
<button
onClick={handleLogout}
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted w-full cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<LogOut className="w-4 h-4" />
Cerrar sesión
</button>
</div>
</aside>
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetContent side="left" className="p-0 w-64">
<SheetHeader className="p-4">
<SheetTitle className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center">
<Wallet className="w-4 h-4 text-primary-foreground" strokeWidth={2.5} />
</div>
<span style={{ fontFamily: "var(--font-heading)" }}>
Wealthy<span className="text-primary">Smart</span>
</span>
</SheetTitle>
</SheetHeader>
<Separator />
<div className="flex flex-col h-[calc(100%-65px)]">
<div className="flex-1 overflow-y-auto">
<SidebarNav onNavigate={() => setMobileOpen(false)} />
</div>
<div className="px-3 pb-4">
<Separator className="mb-2" />
<SheetClose render={<span />}>
<button
onClick={() => {
setMobileOpen(false);
void handleLogout();
}}
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted w-full cursor-pointer"
>
<LogOut className="w-4 h-4" />
Cerrar sesión
</button>
</SheetClose>
</div>
</div>
</SheetContent>
</Sheet>
<main className="flex-1 min-w-0 px-4 sm:px-6 lg:px-8 py-6">
<div className="max-w-6xl mx-auto">
<main className="flex min-h-0 flex-1 flex-col px-4 py-6 sm:px-6 lg:px-8">
<div className="mx-auto flex min-h-0 w-full max-w-6xl flex-1 flex-col">
<Outlet />
</div>
</main>
</div>
</div>
</SidebarInset>
</SidebarProvider>
);
}

View File

@@ -48,6 +48,11 @@ export interface TransactionListProps {
addLabel?: string;
onToggleDeferred?: (tx: Transaction) => void;
onConvertTasaCero?: (tx: Transaction) => void;
/** Extra toolbar content, e.g. source tabs (left) and the CSV button (right). */
toolbarLeft?: React.ReactNode;
toolbarRight?: React.ReactNode;
/** Period hint shown in the summary strip, e.g. "Ciclo 18 jun 18 jul". */
summaryLabel?: string;
}
export default function TransactionList({
@@ -61,9 +66,12 @@ export default function TransactionList({
emptyMessage = 'No transactions found',
showCategory = true,
showSourceIcon = false,
addLabel = 'Add Transaction',
addLabel = 'Agregar transacción',
onToggleDeferred,
onConvertTasaCero,
toolbarLeft,
toolbarRight,
summaryLabel,
}: TransactionListProps) {
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<Transaction | null>(null);
@@ -194,28 +202,76 @@ export default function TransactionList({
: transactions;
const empty = visibleTransactions.length === 0 && !loading;
// Net spend of the listed transactions per currency: COMPRA DEVOLUCION,
// excluding Tasa Cero anchors (their cuotas are what count in the budget).
const totalsByCurrency = useMemo(() => {
const totals = new Map<string, number>();
for (const tx of visibleTransactions) {
if (tx.is_installment_anchor) continue;
if (tx.transaction_type !== 'COMPRA' && tx.transaction_type !== 'DEVOLUCION') continue;
const sign = tx.transaction_type === 'DEVOLUCION' ? -1 : 1;
totals.set(tx.currency, (totals.get(tx.currency) ?? 0) + sign * tx.amount);
}
// CRC first, then the rest alphabetically
return [...totals.entries()].sort(([a], [b]) =>
a === 'CRC' ? -1 : b === 'CRC' ? 1 : a.localeCompare(b),
);
}, [visibleTransactions]);
return (
<>
{/* Search + Add */}
<div className="flex items-center gap-3">
<div className="relative flex-1 max-w-sm">
<Card className="overflow-hidden">
<CardContent className="p-0">
{/* Toolbar: source tabs · search · CSV · add */}
<div className="flex flex-wrap items-center gap-2 p-3 border-b border-border">
{toolbarLeft}
<div className="relative flex-1 min-w-44 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
value={search}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-10"
placeholder="Search merchants..."
placeholder="Buscar comercio…"
/>
</div>
<div className="ml-auto flex items-center gap-2">
{toolbarRight}
<Button onClick={() => { setEditing(null); setModalOpen(true); }}>
<Plus className="w-4 h-4" />
{addLabel}
</Button>
</div>
</div>
{/* Summary strip: period · count · total */}
{visibleTransactions.length > 0 && (
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1 px-4 py-2 border-b border-border bg-muted/30 text-sm">
<span className="text-muted-foreground">
{summaryLabel ? `${summaryLabel} · ` : ''}
{visibleTransactions.length} transaccion{visibleTransactions.length === 1 ? '' : 'es'}
</span>
{totalsByCurrency.length > 0 && (
<span
className="flex items-center gap-1.5 text-muted-foreground"
title="Compras menos devoluciones de las transacciones listadas. Excluye compras convertidas a Tasa Cero (cuentan sus cuotas)."
>
Total:
{totalsByCurrency.map(([currency, net], i) => (
<span key={currency} data-sensitive className="font-mono font-semibold text-foreground">
{i > 0 && <span className="text-muted-foreground font-normal mr-1.5">+</span>}
{net < 0 ? '+' : ''}
{formatAmount(net, currency)}
</span>
))}
</span>
)}
</div>
)}
{/* Bulk action bar */}
{selected.size > 0 && (
<div className="hidden md:flex items-center gap-2 rounded-lg border bg-muted/40 px-3 py-2">
<div className="hidden md:flex items-center gap-2 border-b border-border bg-muted/40 px-3 py-2">
<span className="text-sm font-medium">
{selected.size} seleccionada{selected.size === 1 ? '' : 's'}
</span>
@@ -292,8 +348,7 @@ export default function TransactionList({
)}
{/* Mobile list */}
<Card className="md:hidden">
<CardContent className="p-0 divide-y divide-border">
<div className="md:hidden divide-y divide-border">
{empty ? (
<div className="px-5 py-16 text-center text-muted-foreground text-sm">
{emptyIcon || <ArrowLeftRight className="w-8 h-8 mx-auto mb-3 text-muted-foreground/50" />}
@@ -387,13 +442,13 @@ export default function TransactionList({
<ArrowRightFromLine className="w-4 h-4" />
</Button>
)}
<Button variant="ghost" size="icon" title="Edit transaction" aria-label="Edit transaction" onClick={() => handleEdit(tx)}>
<Button variant="ghost" size="icon" title="Editar transacción" aria-label="Edit transaction" onClick={() => handleEdit(tx)}>
<Pencil className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
title="Delete transaction"
title="Eliminar transacción"
aria-label="Delete transaction"
onClick={() => setDeleteId(tx.id)}
className="hover:text-destructive"
@@ -404,12 +459,10 @@ export default function TransactionList({
</div>
))
)}
</CardContent>
</Card>
</div>
{/* Desktop table */}
<Card className="hidden md:block">
<CardContent className="p-0">
<div className="hidden md:block">
<DataTable
columns={columns}
data={visibleTransactions}
@@ -418,6 +471,8 @@ export default function TransactionList({
initialSorting={[{ id: 'date', desc: true }]}
emptyMessage={emptyMessage}
/>
</div>
</CardContent>
</Card>

View File

@@ -0,0 +1,100 @@
import { Wallet } from "lucide-react";
import { Link, useLocation } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { getSyncStatus } from "@/lib/api";
import { isNavActive, navSections } from "@/lib/navigation";
import { NavUser } from "@/components/nav-user";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
useSidebar,
} from "@/components/ui/sidebar";
export function AppSidebar(props: React.ComponentProps<typeof Sidebar>) {
const { pathname } = useLocation();
const { setOpenMobile } = useSidebar();
// Surface stalled ingestion sources as a dot on the Sincronización item.
const syncQ = useQuery({
queryKey: ["sync-status"],
queryFn: () => getSyncStatus().then((r) => r.data),
staleTime: 5 * 60_000,
});
const syncWarnings = syncQ.data?.warnings ?? 0;
return (
<Sidebar collapsible="icon" {...props}>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
size="lg"
render={<Link to="/" aria-label="Ir a Inicio" />}
>
<div className="bg-primary text-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg">
<Wallet className="size-4" strokeWidth={2.5} />
</div>
<span
className="text-base font-bold tracking-tight"
style={{ fontFamily: "var(--font-heading)" }}
>
Wealthy<span className="text-primary">Smart</span>
</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
{navSections.map((section) => (
<SidebarGroup key={section.label}>
<SidebarGroupLabel>{section.label}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{section.items.map(({ to, icon: Icon, label }) => (
<SidebarMenuItem key={to}>
<SidebarMenuButton
isActive={isNavActive(to, pathname)}
tooltip={label}
render={
<Link to={to} onClick={() => setOpenMobile(false)} />
}
>
<Icon />
<span>{label}</span>
</SidebarMenuButton>
{to === "/sync" && syncWarnings > 0 && (
<SidebarMenuBadge>
<span
className="size-2 rounded-full bg-amber-500"
title={`${syncWarnings} fuente(s) atrasada(s)`}
aria-label={`${syncWarnings} fuentes de datos atrasadas`}
/>
</SidebarMenuBadge>
)}
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
))}
</SidebarContent>
<SidebarFooter>
<NavUser />
</SidebarFooter>
<SidebarRail />
</Sidebar>
);
}

View File

@@ -0,0 +1,108 @@
import { ChevronsUpDown, Eye, EyeOff, LogOut, Moon, Sun } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { getMe } from "@/lib/api";
import { useTheme } from "@/contexts/theme-context";
import { usePrivacy } from "@/contexts/privacy-context";
import { useAuth } from "@/AuthContext";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
export function NavUser() {
const { isMobile } = useSidebar();
const { theme, toggleTheme } = useTheme();
const { privacyMode, togglePrivacy } = usePrivacy();
const { logout } = useAuth();
const navigate = useNavigate();
const meQ = useQuery({
queryKey: ["me"],
queryFn: () => getMe().then((r) => r.data),
staleTime: Infinity,
});
const username = meQ.data?.username ?? "Usuario";
const initials = username.slice(0, 2).toUpperCase();
const handleLogout = async () => {
await logout();
navigate("/login", { replace: true });
};
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
render={
<SidebarMenuButton size="lg" className="aria-expanded:bg-muted" />
}
>
<Avatar>
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{username}</span>
<span className="truncate text-xs text-muted-foreground">
WealthySmart
</span>
</div>
<ChevronsUpDown className="ml-auto size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-fit min-w-48"
side={isMobile ? "bottom" : "right"}
align="end"
sideOffset={4}
>
<DropdownMenuGroup>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar>
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{username}</span>
<span className="truncate text-xs text-muted-foreground">
WealthySmart
</span>
</div>
</div>
</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onClick={togglePrivacy}>
{privacyMode ? <EyeOff /> : <Eye />}
{privacyMode ? "Desactivar modo privado" : "Modo privado"}
</DropdownMenuItem>
<DropdownMenuItem onClick={toggleTheme}>
{theme === "dark" ? <Sun /> : <Moon />}
{theme === "dark" ? "Tema claro" : "Tema oscuro"}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => void handleLogout()}>
<LogOut />
Cerrar sesión
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
);
}

View File

@@ -65,7 +65,7 @@ export function getTransactionColumns({
columns.push(
{
accessorKey: 'date',
header: ({ column }) => <DataTableColumnHeader column={column} title="Date" />,
header: ({ column }) => <DataTableColumnHeader column={column} title="Fecha" />,
cell: ({ row }) => (
<span className="font-mono text-muted-foreground text-xs whitespace-nowrap">
{new Date(row.original.date).toLocaleDateString('es-CR', {
@@ -78,7 +78,7 @@ export function getTransactionColumns({
},
{
accessorKey: 'merchant',
header: ({ column }) => <DataTableColumnHeader column={column} title="Merchant" />,
header: ({ column }) => <DataTableColumnHeader column={column} title="Comercio" />,
cell: ({ row }) => {
const tx = row.original;
return (
@@ -129,7 +129,7 @@ export function getTransactionColumns({
columns.push({
accessorFn: (row) => row.category?.name ?? '',
id: 'category',
header: ({ column }) => <DataTableColumnHeader column={column} title="Category" />,
header: ({ column }) => <DataTableColumnHeader column={column} title="Categoría" />,
cell: ({ row }) => {
const category = row.original.category;
return category ? (
@@ -146,7 +146,7 @@ export function getTransactionColumns({
accessorKey: 'amount',
meta: { className: 'text-right' },
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Amount" className="justify-end" />
<DataTableColumnHeader column={column} title="Monto" className="justify-end" />
),
cell: ({ row }) => {
const tx = row.original;
@@ -204,7 +204,7 @@ export function getTransactionColumns({
<Button
variant="ghost"
size="icon"
title="Edit transaction"
title="Editar transacción"
aria-label="Edit transaction"
onClick={() => onEdit(tx)}
>
@@ -213,7 +213,7 @@ export function getTransactionColumns({
<Button
variant="ghost"
size="icon"
title="Delete transaction"
title="Eliminar transacción"
aria-label="Delete transaction"
onClick={() => onDelete(tx.id)}
className="hover:text-destructive"

View File

@@ -0,0 +1,109 @@
"use client"
import * as React from "react"
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
size = "default",
...props
}: AvatarPrimitive.Root.Props & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className
)}
{...props}
/>
)
}
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
"aspect-square size-full rounded-full object-cover",
className
)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: AvatarPrimitive.Fallback.Props) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
}

View File

@@ -0,0 +1,125 @@
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
aria-label="breadcrumb"
data-slot="breadcrumb"
className={cn(className)}
{...props}
/>
)
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"flex flex-wrap items-center gap-1.5 text-sm wrap-break-word text-muted-foreground",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
)
}
function BreadcrumbLink({
className,
render,
...props
}: useRender.ComponentProps<"a">) {
return useRender({
defaultTagName: "a",
props: mergeProps<"a">(
{
className: cn("transition-colors hover:text-foreground", className),
},
props
),
render,
state: {
slot: "breadcrumb-link",
},
})
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? (
<ChevronRightIcon />
)}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn(
"flex size-5 items-center justify-center [&>svg]:size-4",
className
)}
{...props}
>
<MoreHorizontalIcon
/>
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

View File

@@ -0,0 +1,19 @@
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"
function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
return (
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
)
}
function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
return (
<CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@@ -0,0 +1,723 @@
"use client"
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { PanelLeftIcon } from "lucide-react"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
className
)}
{...props}
>
{children}
</div>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
dir,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
dir={dir}
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
data-side={side}
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon-sm"
className={cn(className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("h-8 w-full bg-background shadow-none", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
render,
...props
}: useRender.ComponentProps<"div"> & React.ComponentProps<"div">) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
className
),
},
props
),
render,
state: {
slot: "sidebar-group-label",
sidebar: "group-label",
},
})
}
function SidebarGroupAction({
className,
render,
...props
}: useRender.ComponentProps<"button"> & React.ComponentProps<"button">) {
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
className
),
},
props
),
render,
state: {
slot: "sidebar-group-action",
sidebar: "group-action",
},
})
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-0", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function SidebarMenuButton({
render,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: useRender.ComponentProps<"button"> &
React.ComponentProps<"button"> & {
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const { isMobile, state } = useSidebar()
const comp = useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(sidebarMenuButtonVariants({ variant, size }), className),
},
props
),
render: !tooltip ? render : <TooltipTrigger render={render} />,
state: {
slot: "sidebar-menu-button",
sidebar: "menu-button",
size,
active: isActive,
},
})
if (!tooltip) {
return comp
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
{comp}
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
render,
showOnHover = false,
...props
}: useRender.ComponentProps<"button"> &
React.ComponentProps<"button"> & {
showOnHover?: boolean
}) {
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
className
),
},
props
),
render,
state: {
slot: "sidebar-menu-action",
sidebar: "menu-action",
},
})
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground",
className
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const [width] = React.useState(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
})
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
render,
size = "md",
isActive = false,
className,
...props
}: useRender.ComponentProps<"a"> &
React.ComponentProps<"a"> & {
size?: "sm" | "md"
isActive?: boolean
}) {
return useRender({
defaultTagName: "a",
props: mergeProps<"a">(
{
className: cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
className
),
},
props
),
render,
state: {
slot: "sidebar-menu-sub-button",
sidebar: "menu-sub-button",
size,
active: isActive,
},
})
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}

View File

@@ -0,0 +1,64 @@
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delay = 0,
...props
}: TooltipPrimitive.Provider.Props) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delay={delay}
{...props}
/>
)
}
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
side = "top",
sideOffset = 4,
align = "center",
alignOffset = 0,
children,
...props
}: TooltipPrimitive.Popup.Props &
Pick<
TooltipPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}

View File

@@ -16,11 +16,11 @@ import {
/** All budget data hangs off the ['budget', ...] key family; every mutation
* invalidates the family so projection, month detail and items stay in sync. */
export function useBudget(initialYear: number) {
export function useBudget(initialYear: number, initialMonth?: number) {
const queryClient = useQueryClient();
const [year, setYear] = useState(initialYear);
const [selectedMonth, setSelectedMonth] = useState<number>(
new Date().getMonth() + 1,
initialMonth ?? new Date().getMonth() + 1,
);
const projectionQ = useQuery({

View File

@@ -130,6 +130,8 @@ export async function logout() {
await fetch("/api/auth/logout", { method: "POST", credentials: "same-origin" });
}
export const getMe = () => api.get<{ username: string }>("/auth/me");
// ─── Types ──────────────────────────────────────────────────────────────────
export type Account = Schema<'AccountRead'>;
@@ -240,6 +242,11 @@ export const upsertBalanceOverride = (
export const deleteBalanceOverride = (year: number, month: number) =>
api.delete(`/budget/balance-override/${year}/${month}`);
// --- Transactions ---
export const getRecentTransactions = (limit = 5) =>
api.get<Transaction[]>("/transactions/recent", { params: { limit } });
// --- Installment Plans (Tasa Cero) ---
export type InstallmentPlan = Schema<'InstallmentPlanRead'>;

View File

@@ -18,6 +18,29 @@ export function formatShortDate(dateStr: string): string {
});
}
/** Budget month M = the credit-card cycle ENDING on the 18th of M
* (matches get_cycle_range in the backend). After the 18th we are already
* in next month's cycle — including the Dec 19 → January-next-year wrap. */
export function currentBudgetCycle(today = new Date()): {
year: number;
month: number;
start: Date;
end: Date;
} {
let year = today.getFullYear();
let month = today.getMonth() + 1;
if (today.getDate() > 18) {
month += 1;
if (month === 13) {
month = 1;
year += 1;
}
}
const end = new Date(year, month - 1, 18);
const start = new Date(year, month - 2, 18);
return { year, month, start, end };
}
/** "hace 3 días" / "hace 2 horas" / "hace un momento" */
export function formatRelativeAge(iso: string): string {
const ms = Date.now() - new Date(iso).getTime();

View File

@@ -0,0 +1,71 @@
import {
BarChart3,
Calculator,
Droplets,
Home,
Landmark,
Layers,
PiggyBank,
RefreshCw,
Sparkles,
Telescope,
TrendingUp,
type LucideIcon,
} from "lucide-react";
/** Single source of truth for the sidebar nav and header breadcrumbs. */
export interface NavItem {
to: string;
icon: LucideIcon;
label: string;
}
export interface NavSection {
label: string;
items: NavItem[];
}
export const navSections: NavSection[] = [
{
label: "General",
items: [
{ to: "/", icon: Home, label: "Inicio" },
{ to: "/asistente", icon: Sparkles, label: "Asistente" },
{ to: "/sync", icon: RefreshCw, label: "Sincronización" },
],
},
{
label: "Finanzas",
items: [
{ to: "/budget", icon: Calculator, label: "Presupuesto" },
{ to: "/salarios", icon: Landmark, label: "Salarios" },
{ to: "/financiamientos", icon: Layers, label: "Financiamientos" },
{ to: "/pensions", icon: PiggyBank, label: "Pensiones" },
{ to: "/proyecciones", icon: TrendingUp, label: "Proyecciones" },
{ to: "/planificador", icon: Telescope, label: "Planificador" },
{ to: "/analytics", icon: BarChart3, label: "Analytics" },
],
},
{
label: "Servicios",
items: [
{ to: "/servicios-municipales", icon: Droplets, label: "Municipalidad" },
],
},
];
/** "/" must match exactly — prefix-matching it would mark Inicio active everywhere. */
export function isNavActive(to: string, pathname: string): boolean {
if (to === "/") return pathname === "/";
return pathname === to || pathname.startsWith(`${to}/`);
}
export function titleFor(pathname: string): string {
for (const section of navSections) {
for (const item of section.items) {
if (isNavActive(item.to, pathname)) return item.label;
}
}
return "WealthySmart";
}

View File

@@ -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({ agentId: "wealthysmart" });
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:
@@ -54,7 +83,9 @@ export default function Asistente() {
});
return (
<div className="flex flex-col h-[calc(100vh-105px)]">
// Fills the viewport via Layout's flex chain (SidebarInset > main >
// max-w wrapper are all min-h-0 flex columns) — no vh math.
<div className="flex flex-1 min-h-0 flex-col">
<div className="mb-4">
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2" style={{ fontFamily: "var(--font-heading)" }}>
<Sparkles className="w-5 h-5 text-primary" />

View File

@@ -5,7 +5,10 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import api, { type Transaction } from '@/lib/api';
import { MONTH_NAMES_ES as MONTH_NAMES } from '@/lib/dates';
import {
MONTH_NAMES_ES as MONTH_NAMES,
MONTH_NAMES_ES_SHORT as MONTH_NAMES_SHORT,
} from '@/lib/dates';
import { useBudget } from '@/hooks/useBudget';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
@@ -22,6 +25,17 @@ const MAX_YEAR = 2030;
export default function Budget() {
const currentYear = Math.max(MIN_YEAR, new Date().getFullYear());
const location = useLocation();
const navigate = useNavigate();
// Proyecciones row clicks land here with the clicked period in the state.
const navState = location.state as
| { from?: string; year?: number; month?: number }
| null;
const cameFromProyecciones = navState?.from === 'proyecciones';
const initialYear =
navState?.year && navState.year >= MIN_YEAR && navState.year <= MAX_YEAR
? navState.year
: currentYear;
const {
year,
setYear,
@@ -35,12 +49,8 @@ export default function Budget() {
updateItem,
deleteItem,
refresh,
} = useBudget(currentYear);
} = useBudget(initialYear, navState?.month);
const queryClient = useQueryClient();
const location = useLocation();
const navigate = useNavigate();
const cameFromProyecciones =
(location.state as { from?: string } | null)?.from === 'proyecciones';
const [subTab, setSubTab] = useState<'detail' | 'transactions'>('detail');
const [txSearch, setTxSearch] = useState('');
@@ -79,6 +89,14 @@ export default function Budget() {
});
const transactions = txQuery.data ?? [];
// Make the period explicit: budget month M on the card tab = the billing
// cycle 18/(M-1) → 18/M; cash/transfers use the calendar month.
const cyclePrevMonth = selectedMonth === 1 ? 12 : selectedMonth - 1;
const cycleLabel =
txSource === 'CREDIT_CARD'
? `Ciclo 18 ${MONTH_NAMES_SHORT[cyclePrevMonth].toLowerCase()} 18 ${MONTH_NAMES_SHORT[selectedMonth].toLowerCase()}`
: `Mes de ${MONTH_NAMES[selectedMonth].toLowerCase()}`;
const invalidateTransactionsAndBudget = useCallback(() => {
queryClient.invalidateQueries({ queryKey: ['transactions'] });
queryClient.invalidateQueries({ queryKey: ['installment-plans'] });
@@ -116,6 +134,7 @@ export default function Budget() {
<Button
variant="ghost"
size="sm"
className="mr-4 text-muted-foreground"
onClick={() => navigate('/proyecciones')}
aria-label="Volver a Proyecciones"
>
@@ -174,27 +193,7 @@ export default function Budget() {
)}
</TabsContent>
<TabsContent value="transactions" className="space-y-3 mt-4">
<div className="flex items-center justify-between gap-2">
<Tabs
value={txSource}
onValueChange={(v) => setTxSource(v as typeof txSource)}
>
<TabsList>
<TabsTrigger value="CREDIT_CARD">Tarjeta</TabsTrigger>
<TabsTrigger value="CASH_AND_TRANSFER">Efectivo y Transferencias</TabsTrigger>
</TabsList>
</Tabs>
<Button
variant="outline"
size="sm"
onClick={() => window.open('/api/v1/transactions/export', '_blank')}
title="Descargar todas las transacciones como CSV"
>
<Download className="w-4 h-4 mr-2" aria-hidden="true" />
CSV
</Button>
</div>
<TabsContent value="transactions" className="mt-4">
{txQuery.isError ? (
<ErrorState
message="No se pudieron cargar las transacciones"
@@ -213,6 +212,29 @@ export default function Budget() {
emptyMessage={`Sin transacciones de ${txSource === 'CREDIT_CARD' ? 'tarjeta' : 'efectivo o transferencia'} en ${MONTH_NAMES[selectedMonth]}`}
onToggleDeferred={txSource === 'CREDIT_CARD' ? handleToggleDeferred : undefined}
onConvertTasaCero={txSource === 'CREDIT_CARD' ? setConvertTx : undefined}
summaryLabel={cycleLabel}
toolbarLeft={
<Tabs
value={txSource}
onValueChange={(v) => setTxSource(v as typeof txSource)}
>
<TabsList>
<TabsTrigger value="CREDIT_CARD">Tarjeta</TabsTrigger>
<TabsTrigger value="CASH_AND_TRANSFER">Efectivo y Transferencias</TabsTrigger>
</TabsList>
</Tabs>
}
toolbarRight={
<Button
variant="outline"
size="sm"
onClick={() => window.open('/api/v1/transactions/export', '_blank')}
title="Descargar todas las transacciones como CSV"
>
<Download className="w-4 h-4 mr-2" aria-hidden="true" />
CSV
</Button>
}
/>
)}
</TabsContent>

View File

@@ -0,0 +1,353 @@
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,
);
// One cuota per plan per cycle, so the sum of every active plan's next
// cuota is the load of the upcoming billing batch (next 19th). The last
// cuota absorbs BAC's rounding, so use it when it's the one pending.
const nextCuotaOf = (p: (typeof active)[number]) =>
p.cuotas_billed === p.num_installments - 1
? p.last_installment_amount
: p.installment_amount;
const nextBatchTotal = active.reduce((s, p) => s + nextCuotaOf(p), 0);
const upcoming = [...active].sort(
(a, b) =>
new Date(a.next_cuota_date!).getTime() -
new Date(b.next_cuota_date!).getTime(),
);
const nextDate = upcoming[0]?.next_cuota_date;
const topThree = upcoming.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>
{nextDate
? `Tasa Cero · próximo cobro ${formatShortDate(nextDate)}`
: "Tasa Cero"}
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<StatValue
loading={q.isPending}
value={q.isError ? null : formatAmount(nextBatchTotal, "CRC")}
/>
{topThree.length > 0 && (
<ul className="space-y-0.5 text-xs text-muted-foreground">
{topThree.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(nextCuotaOf(p), 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>
);
}

View File

@@ -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 {

View File

@@ -84,7 +84,17 @@ interface TooltipEntry {
// ─── Constants ────────────────────────────────────────────────────────────────
const CURRENT_AGE = 30;
const BIRTH_DATE = new Date(1988, 7, 29); // 29 de agosto de 1988
function computeCurrentAge(): number {
const now = new Date();
const hadBirthdayThisYear =
now.getMonth() > BIRTH_DATE.getMonth() ||
(now.getMonth() === BIRTH_DATE.getMonth() && now.getDate() >= BIRTH_DATE.getDate());
return now.getFullYear() - BIRTH_DATE.getFullYear() - (hadBirthdayThisYear ? 0 : 1);
}
const CURRENT_AGE = computeCurrentAge();
const FUND_KEYS: FundKey[] = ['FCL', 'ROP', 'VOL'];
@@ -99,7 +109,7 @@ const FUNDS_DEFAULT: Record<FundKey, FundDef> = {
annualRate: 7.5,
isDividend: false,
withdrawalRule: 'Retirable cada 5 años o al cambio de empleo',
defaultTargetAge: 35,
defaultTargetAge: CURRENT_AGE + 5,
},
ROP: {
key: 'ROP',
@@ -255,7 +265,7 @@ const EMPTY_SNAPSHOTS: PensionSnapshot[] = [];
export default function Pensions() {
const [visibleFunds, setVisibleFunds] = useState<Set<FundKey>>(new Set(FUND_KEYS));
const [projections, setProjections] = useState<Record<FundKey, ProjectionState>>({
FCL: { contribution: 150_000, rate: 7.5, targetAge: 35 },
FCL: { contribution: 150_000, rate: 7.5, targetAge: CURRENT_AGE + 5 },
ROP: { contribution: 120_000, rate: 6.0, targetAge: 65 },
VOL: { contribution: 400_000, rate: 8.0, targetAge: 57 },
});

View File

@@ -99,7 +99,9 @@ export default function Proyecciones() {
year={year}
onSelectMonth={(m) => {
setSelectedMonth(m);
navigate('/budget', { state: { from: 'proyecciones' } });
navigate('/budget', {
state: { from: 'proyecciones', year, month: m },
});
}}
onSaveOverride={async (month, value) => {
await saveBalanceOverride(year, month, value);