mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 12:28:48 +02:00
Compare commits
10 Commits
f2bcce702a
...
10d9bd209f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10d9bd209f | ||
|
|
b1c387c415 | ||
|
|
7ba70ae524 | ||
|
|
d9039c76b9 | ||
|
|
d117fb79ea | ||
|
|
d2610b2eef | ||
|
|
3d71234a61 | ||
|
|
72a5840752 | ||
|
|
f832138b41 | ||
|
|
5296230416 |
@@ -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)
|
||||
)
|
||||
|
||||
@@ -20,6 +20,5 @@
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default-translucent",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
"menuAccent": "subtle"
|
||||
}
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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">
|
||||
</header>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
<Button onClick={() => { setEditing(null); setModalOpen(true); }}>
|
||||
<Plus className="w-4 h-4" />
|
||||
{addLabel}
|
||||
</Button>
|
||||
<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,7 +471,9 @@ export default function TransactionList({
|
||||
initialSorting={[{ id: 'date', desc: true }]}
|
||||
emptyMessage={emptyMessage}
|
||||
/>
|
||||
</CardContent>
|
||||
</div>
|
||||
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Modals */}
|
||||
|
||||
100
frontend/src/components/app-sidebar.tsx
Normal file
100
frontend/src/components/app-sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
108
frontend/src/components/nav-user.tsx
Normal file
108
frontend/src/components/nav-user.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
109
frontend/src/components/ui/avatar.tsx
Normal file
109
frontend/src/components/ui/avatar.tsx
Normal 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,
|
||||
}
|
||||
125
frontend/src/components/ui/breadcrumb.tsx
Normal file
125
frontend/src/components/ui/breadcrumb.tsx
Normal 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,
|
||||
}
|
||||
19
frontend/src/components/ui/collapsible.tsx
Normal file
19
frontend/src/components/ui/collapsible.tsx
Normal 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 }
|
||||
723
frontend/src/components/ui/sidebar.tsx
Normal file
723
frontend/src/components/ui/sidebar.tsx
Normal 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,
|
||||
}
|
||||
64
frontend/src/components/ui/tooltip.tsx
Normal file
64
frontend/src/components/ui/tooltip.tsx
Normal 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 }
|
||||
19
frontend/src/hooks/use-mobile.ts
Normal file
19
frontend/src/hooks/use-mobile.ts
Normal 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
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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'>;
|
||||
|
||||
@@ -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();
|
||||
|
||||
71
frontend/src/lib/navigation.ts
Normal file
71
frontend/src/lib/navigation.ts
Normal 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";
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
353
frontend/src/pages/Inicio.tsx
Normal file
353
frontend/src/pages/Inicio.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user