mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 13:48:47 +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),
|
session: Session = Depends(get_session),
|
||||||
_user: str = Depends(get_current_user),
|
_user: str = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
|
from app.timeutil import utcnow
|
||||||
|
|
||||||
query = (
|
query = (
|
||||||
select(Transaction)
|
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())
|
.order_by(col(Transaction.date).desc(), col(Transaction.id).desc())
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,6 +20,5 @@
|
|||||||
"hooks": "@/hooks"
|
"hooks": "@/hooks"
|
||||||
},
|
},
|
||||||
"menuColor": "default-translucent",
|
"menuColor": "default-translucent",
|
||||||
"menuAccent": "subtle",
|
"menuAccent": "subtle"
|
||||||
"registries": {}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const queryClient = new QueryClient({
|
|||||||
});
|
});
|
||||||
import Layout from "./components/Layout";
|
import Layout from "./components/Layout";
|
||||||
import LoginPage from "./pages/Login";
|
import LoginPage from "./pages/Login";
|
||||||
|
import Inicio from "./pages/Inicio";
|
||||||
import Asistente from "./pages/Asistente";
|
import Asistente from "./pages/Asistente";
|
||||||
import Analytics from "./pages/Analytics";
|
import Analytics from "./pages/Analytics";
|
||||||
import Budget from "./pages/Budget";
|
import Budget from "./pages/Budget";
|
||||||
@@ -42,7 +43,7 @@ function AppRoutes() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route
|
<Route
|
||||||
path="/login"
|
path="/login"
|
||||||
element={isAuthenticated ? <Navigate to="/asistente" replace /> : <LoginPage />}
|
element={isAuthenticated ? <Navigate to="/" replace /> : <LoginPage />}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
element={
|
element={
|
||||||
@@ -51,7 +52,7 @@ function AppRoutes() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route index element={<Navigate to="/asistente" replace />} />
|
<Route index element={<Inicio />} />
|
||||||
<Route path="/asistente" element={<Asistente />} />
|
<Route path="/asistente" element={<Asistente />} />
|
||||||
<Route path="/budget" element={<Budget />} />
|
<Route path="/budget" element={<Budget />} />
|
||||||
<Route path="/analytics" element={<Analytics />} />
|
<Route path="/analytics" element={<Analytics />} />
|
||||||
|
|||||||
@@ -1,239 +1,100 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { Link, Outlet, useLocation } from "react-router-dom";
|
||||||
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
|
import { Eye, EyeOff, Moon, Sun } from "lucide-react";
|
||||||
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 { getSyncStatus } from "@/lib/api";
|
import { titleFor } from "@/lib/navigation";
|
||||||
import { useTheme } from "@/contexts/theme-context";
|
import { useTheme } from "@/contexts/theme-context";
|
||||||
import { usePrivacy } from "@/contexts/privacy-context";
|
import { usePrivacy } from "@/contexts/privacy-context";
|
||||||
import { useAuth } from "@/AuthContext";
|
import { AppSidebar } from "@/components/app-sidebar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Breadcrumb,
|
||||||
SheetContent,
|
BreadcrumbItem,
|
||||||
SheetHeader,
|
BreadcrumbLink,
|
||||||
SheetTitle,
|
BreadcrumbList,
|
||||||
SheetClose,
|
BreadcrumbPage,
|
||||||
} from "@/components/ui/sheet";
|
BreadcrumbSeparator,
|
||||||
|
} from "@/components/ui/breadcrumb";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { cn } from "@/lib/utils";
|
import {
|
||||||
|
SidebarInset,
|
||||||
interface NavSection {
|
SidebarProvider,
|
||||||
label: string;
|
SidebarTrigger,
|
||||||
items: { to: string; icon: LucideIcon; label: string }[];
|
} from "@/components/ui/sidebar";
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
const { theme, toggleTheme } = useTheme();
|
const { theme, toggleTheme } = useTheme();
|
||||||
const { privacyMode, togglePrivacy } = usePrivacy();
|
const { privacyMode, togglePrivacy } = usePrivacy();
|
||||||
const { logout } = useAuth();
|
const { pathname } = useLocation();
|
||||||
const navigate = useNavigate();
|
const title = titleFor(pathname);
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
|
||||||
|
|
||||||
const handleLogout = async () => {
|
|
||||||
await logout();
|
|
||||||
navigate("/login", { replace: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background text-foreground">
|
<SidebarProvider>
|
||||||
<header className="border-b border-border backdrop-blur-sm sticky top-0 z-50 bg-background/90">
|
<AppSidebar />
|
||||||
<div className="px-4 sm:px-6 py-3 flex items-center justify-between">
|
<SidebarInset>
|
||||||
<div className="flex items-center gap-2.5">
|
<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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => setMobileOpen(true)}
|
onClick={togglePrivacy}
|
||||||
title="Open menu"
|
title="Toggle privacy mode"
|
||||||
aria-label="Open menu"
|
aria-label="Toggle privacy mode"
|
||||||
className="md:hidden"
|
aria-pressed={privacyMode}
|
||||||
>
|
>
|
||||||
<Menu className="w-5 h-5" />
|
{privacyMode ? (
|
||||||
</Button>
|
<EyeOff className="w-4 h-4" />
|
||||||
<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} />
|
<Eye className="w-4 h-4" />
|
||||||
</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" />}
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={handleLogout}
|
onClick={toggleTheme}
|
||||||
title="Sign out"
|
title="Toggle theme"
|
||||||
aria-label="Sign out"
|
aria-label="Toggle theme"
|
||||||
className="hidden md:inline-flex"
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
|
<main className="flex min-h-0 flex-1 flex-col px-4 py-6 sm:px-6 lg:px-8">
|
||||||
<div className="flex">
|
<div className="mx-auto flex min-h-0 w-full max-w-6xl flex-1 flex-col">
|
||||||
<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">
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</SidebarInset>
|
||||||
</div>
|
</SidebarProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,11 @@ export interface TransactionListProps {
|
|||||||
addLabel?: string;
|
addLabel?: string;
|
||||||
onToggleDeferred?: (tx: Transaction) => void;
|
onToggleDeferred?: (tx: Transaction) => void;
|
||||||
onConvertTasaCero?: (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({
|
export default function TransactionList({
|
||||||
@@ -61,9 +66,12 @@ export default function TransactionList({
|
|||||||
emptyMessage = 'No transactions found',
|
emptyMessage = 'No transactions found',
|
||||||
showCategory = true,
|
showCategory = true,
|
||||||
showSourceIcon = false,
|
showSourceIcon = false,
|
||||||
addLabel = 'Add Transaction',
|
addLabel = 'Agregar transacción',
|
||||||
onToggleDeferred,
|
onToggleDeferred,
|
||||||
onConvertTasaCero,
|
onConvertTasaCero,
|
||||||
|
toolbarLeft,
|
||||||
|
toolbarRight,
|
||||||
|
summaryLabel,
|
||||||
}: TransactionListProps) {
|
}: TransactionListProps) {
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<Transaction | null>(null);
|
const [editing, setEditing] = useState<Transaction | null>(null);
|
||||||
@@ -194,28 +202,76 @@ export default function TransactionList({
|
|||||||
: transactions;
|
: transactions;
|
||||||
const empty = visibleTransactions.length === 0 && !loading;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Search + Add */}
|
<Card className="overflow-hidden">
|
||||||
<div className="flex items-center gap-3">
|
<CardContent className="p-0">
|
||||||
<div className="relative flex-1 max-w-sm">
|
|
||||||
|
{/* 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" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
className="pl-10"
|
className="pl-10"
|
||||||
placeholder="Search merchants..."
|
placeholder="Buscar comercio…"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
{toolbarRight}
|
||||||
<Button onClick={() => { setEditing(null); setModalOpen(true); }}>
|
<Button onClick={() => { setEditing(null); setModalOpen(true); }}>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
{addLabel}
|
{addLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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 */}
|
{/* Bulk action bar */}
|
||||||
{selected.size > 0 && (
|
{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">
|
<span className="text-sm font-medium">
|
||||||
{selected.size} seleccionada{selected.size === 1 ? '' : 's'}
|
{selected.size} seleccionada{selected.size === 1 ? '' : 's'}
|
||||||
</span>
|
</span>
|
||||||
@@ -292,8 +348,7 @@ export default function TransactionList({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mobile list */}
|
{/* Mobile list */}
|
||||||
<Card className="md:hidden">
|
<div className="md:hidden divide-y divide-border">
|
||||||
<CardContent className="p-0 divide-y divide-border">
|
|
||||||
{empty ? (
|
{empty ? (
|
||||||
<div className="px-5 py-16 text-center text-muted-foreground text-sm">
|
<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" />}
|
{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" />
|
<ArrowRightFromLine className="w-4 h-4" />
|
||||||
</Button>
|
</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" />
|
<Pencil className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
title="Delete transaction"
|
title="Eliminar transacción"
|
||||||
aria-label="Delete transaction"
|
aria-label="Delete transaction"
|
||||||
onClick={() => setDeleteId(tx.id)}
|
onClick={() => setDeleteId(tx.id)}
|
||||||
className="hover:text-destructive"
|
className="hover:text-destructive"
|
||||||
@@ -404,12 +459,10 @@ export default function TransactionList({
|
|||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Desktop table */}
|
{/* Desktop table */}
|
||||||
<Card className="hidden md:block">
|
<div className="hidden md:block">
|
||||||
<CardContent className="p-0">
|
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={visibleTransactions}
|
data={visibleTransactions}
|
||||||
@@ -418,6 +471,8 @@ export default function TransactionList({
|
|||||||
initialSorting={[{ id: 'date', desc: true }]}
|
initialSorting={[{ id: 'date', desc: true }]}
|
||||||
emptyMessage={emptyMessage}
|
emptyMessage={emptyMessage}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
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(
|
columns.push(
|
||||||
{
|
{
|
||||||
accessorKey: 'date',
|
accessorKey: 'date',
|
||||||
header: ({ column }) => <DataTableColumnHeader column={column} title="Date" />,
|
header: ({ column }) => <DataTableColumnHeader column={column} title="Fecha" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="font-mono text-muted-foreground text-xs whitespace-nowrap">
|
<span className="font-mono text-muted-foreground text-xs whitespace-nowrap">
|
||||||
{new Date(row.original.date).toLocaleDateString('es-CR', {
|
{new Date(row.original.date).toLocaleDateString('es-CR', {
|
||||||
@@ -78,7 +78,7 @@ export function getTransactionColumns({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'merchant',
|
accessorKey: 'merchant',
|
||||||
header: ({ column }) => <DataTableColumnHeader column={column} title="Merchant" />,
|
header: ({ column }) => <DataTableColumnHeader column={column} title="Comercio" />,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const tx = row.original;
|
const tx = row.original;
|
||||||
return (
|
return (
|
||||||
@@ -129,7 +129,7 @@ export function getTransactionColumns({
|
|||||||
columns.push({
|
columns.push({
|
||||||
accessorFn: (row) => row.category?.name ?? '',
|
accessorFn: (row) => row.category?.name ?? '',
|
||||||
id: 'category',
|
id: 'category',
|
||||||
header: ({ column }) => <DataTableColumnHeader column={column} title="Category" />,
|
header: ({ column }) => <DataTableColumnHeader column={column} title="Categoría" />,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const category = row.original.category;
|
const category = row.original.category;
|
||||||
return category ? (
|
return category ? (
|
||||||
@@ -146,7 +146,7 @@ export function getTransactionColumns({
|
|||||||
accessorKey: 'amount',
|
accessorKey: 'amount',
|
||||||
meta: { className: 'text-right' },
|
meta: { className: 'text-right' },
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataTableColumnHeader column={column} title="Amount" className="justify-end" />
|
<DataTableColumnHeader column={column} title="Monto" className="justify-end" />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const tx = row.original;
|
const tx = row.original;
|
||||||
@@ -204,7 +204,7 @@ export function getTransactionColumns({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
title="Edit transaction"
|
title="Editar transacción"
|
||||||
aria-label="Edit transaction"
|
aria-label="Edit transaction"
|
||||||
onClick={() => onEdit(tx)}
|
onClick={() => onEdit(tx)}
|
||||||
>
|
>
|
||||||
@@ -213,7 +213,7 @@ export function getTransactionColumns({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
title="Delete transaction"
|
title="Eliminar transacción"
|
||||||
aria-label="Delete transaction"
|
aria-label="Delete transaction"
|
||||||
onClick={() => onDelete(tx.id)}
|
onClick={() => onDelete(tx.id)}
|
||||||
className="hover:text-destructive"
|
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
|
/** All budget data hangs off the ['budget', ...] key family; every mutation
|
||||||
* invalidates the family so projection, month detail and items stay in sync. */
|
* 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 queryClient = useQueryClient();
|
||||||
const [year, setYear] = useState(initialYear);
|
const [year, setYear] = useState(initialYear);
|
||||||
const [selectedMonth, setSelectedMonth] = useState<number>(
|
const [selectedMonth, setSelectedMonth] = useState<number>(
|
||||||
new Date().getMonth() + 1,
|
initialMonth ?? new Date().getMonth() + 1,
|
||||||
);
|
);
|
||||||
|
|
||||||
const projectionQ = useQuery({
|
const projectionQ = useQuery({
|
||||||
|
|||||||
@@ -130,6 +130,8 @@ export async function logout() {
|
|||||||
await fetch("/api/auth/logout", { method: "POST", credentials: "same-origin" });
|
await fetch("/api/auth/logout", { method: "POST", credentials: "same-origin" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getMe = () => api.get<{ username: string }>("/auth/me");
|
||||||
|
|
||||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type Account = Schema<'AccountRead'>;
|
export type Account = Schema<'AccountRead'>;
|
||||||
@@ -240,6 +242,11 @@ export const upsertBalanceOverride = (
|
|||||||
export const deleteBalanceOverride = (year: number, month: number) =>
|
export const deleteBalanceOverride = (year: number, month: number) =>
|
||||||
api.delete(`/budget/balance-override/${year}/${month}`);
|
api.delete(`/budget/balance-override/${year}/${month}`);
|
||||||
|
|
||||||
|
// --- Transactions ---
|
||||||
|
|
||||||
|
export const getRecentTransactions = (limit = 5) =>
|
||||||
|
api.get<Transaction[]>("/transactions/recent", { params: { limit } });
|
||||||
|
|
||||||
// --- Installment Plans (Tasa Cero) ---
|
// --- Installment Plans (Tasa Cero) ---
|
||||||
|
|
||||||
export type InstallmentPlan = Schema<'InstallmentPlanRead'>;
|
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" */
|
/** "hace 3 días" / "hace 2 horas" / "hace un momento" */
|
||||||
export function formatRelativeAge(iso: string): string {
|
export function formatRelativeAge(iso: string): string {
|
||||||
const ms = Date.now() - new Date(iso).getTime();
|
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 { useCopilotAction } from "@copilotkit/react-core";
|
||||||
import { Sparkles } from "lucide-react";
|
import { Sparkles } from "lucide-react";
|
||||||
import { SpendingSummaryCard, type SpendingSummaryArgs } from "@/components/chat/ChatCards";
|
import { SpendingSummaryCard, type SpendingSummaryArgs } from "@/components/chat/ChatCards";
|
||||||
@@ -16,6 +23,28 @@ const STATIC_SUGGESTIONS = {
|
|||||||
export default function Asistente() {
|
export default function Asistente() {
|
||||||
useConfigureSuggestions(STATIC_SUGGESTIONS);
|
useConfigureSuggestions(STATIC_SUGGESTIONS);
|
||||||
|
|
||||||
|
// A question typed on the Inicio dashboard arrives via router state:
|
||||||
|
// append it as a user message and run the agent once.
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { agent } = useAgent({ 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({
|
useCopilotAction({
|
||||||
name: "render_spending_summary",
|
name: "render_spending_summary",
|
||||||
description:
|
description:
|
||||||
@@ -54,7 +83,9 @@ export default function Asistente() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
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">
|
<div className="mb-4">
|
||||||
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2" style={{ fontFamily: "var(--font-heading)" }}>
|
<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" />
|
<Sparkles className="w-5 h-5 text-primary" />
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
import api, { type Transaction } from '@/lib/api';
|
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 { useBudget } from '@/hooks/useBudget';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
@@ -22,6 +25,17 @@ const MAX_YEAR = 2030;
|
|||||||
|
|
||||||
export default function Budget() {
|
export default function Budget() {
|
||||||
const currentYear = Math.max(MIN_YEAR, new Date().getFullYear());
|
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 {
|
const {
|
||||||
year,
|
year,
|
||||||
setYear,
|
setYear,
|
||||||
@@ -35,12 +49,8 @@ export default function Budget() {
|
|||||||
updateItem,
|
updateItem,
|
||||||
deleteItem,
|
deleteItem,
|
||||||
refresh,
|
refresh,
|
||||||
} = useBudget(currentYear);
|
} = useBudget(initialYear, navState?.month);
|
||||||
const queryClient = useQueryClient();
|
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 [subTab, setSubTab] = useState<'detail' | 'transactions'>('detail');
|
||||||
const [txSearch, setTxSearch] = useState('');
|
const [txSearch, setTxSearch] = useState('');
|
||||||
@@ -79,6 +89,14 @@ export default function Budget() {
|
|||||||
});
|
});
|
||||||
const transactions = txQuery.data ?? [];
|
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(() => {
|
const invalidateTransactionsAndBudget = useCallback(() => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['transactions'] });
|
queryClient.invalidateQueries({ queryKey: ['transactions'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['installment-plans'] });
|
queryClient.invalidateQueries({ queryKey: ['installment-plans'] });
|
||||||
@@ -116,6 +134,7 @@ export default function Budget() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
className="mr-4 text-muted-foreground"
|
||||||
onClick={() => navigate('/proyecciones')}
|
onClick={() => navigate('/proyecciones')}
|
||||||
aria-label="Volver a Proyecciones"
|
aria-label="Volver a Proyecciones"
|
||||||
>
|
>
|
||||||
@@ -174,27 +193,7 @@ export default function Budget() {
|
|||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="transactions" className="space-y-3 mt-4">
|
<TabsContent value="transactions" className="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>
|
|
||||||
{txQuery.isError ? (
|
{txQuery.isError ? (
|
||||||
<ErrorState
|
<ErrorState
|
||||||
message="No se pudieron cargar las transacciones"
|
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]}`}
|
emptyMessage={`Sin transacciones de ${txSource === 'CREDIT_CARD' ? 'tarjeta' : 'efectivo o transferencia'} en ${MONTH_NAMES[selectedMonth]}`}
|
||||||
onToggleDeferred={txSource === 'CREDIT_CARD' ? handleToggleDeferred : undefined}
|
onToggleDeferred={txSource === 'CREDIT_CARD' ? handleToggleDeferred : undefined}
|
||||||
onConvertTasaCero={txSource === 'CREDIT_CARD' ? setConvertTx : 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>
|
</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 {
|
try {
|
||||||
await login(username, password);
|
await login(username, password);
|
||||||
setAuthenticated(true);
|
setAuthenticated(true);
|
||||||
navigate("/asistente", { replace: true });
|
navigate("/", { replace: true });
|
||||||
} catch {
|
} catch {
|
||||||
setError("Invalid credentials");
|
setError("Invalid credentials");
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -84,7 +84,17 @@ interface TooltipEntry {
|
|||||||
|
|
||||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
// ─── 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'];
|
const FUND_KEYS: FundKey[] = ['FCL', 'ROP', 'VOL'];
|
||||||
|
|
||||||
@@ -99,7 +109,7 @@ const FUNDS_DEFAULT: Record<FundKey, FundDef> = {
|
|||||||
annualRate: 7.5,
|
annualRate: 7.5,
|
||||||
isDividend: false,
|
isDividend: false,
|
||||||
withdrawalRule: 'Retirable cada 5 años o al cambio de empleo',
|
withdrawalRule: 'Retirable cada 5 años o al cambio de empleo',
|
||||||
defaultTargetAge: 35,
|
defaultTargetAge: CURRENT_AGE + 5,
|
||||||
},
|
},
|
||||||
ROP: {
|
ROP: {
|
||||||
key: 'ROP',
|
key: 'ROP',
|
||||||
@@ -255,7 +265,7 @@ const EMPTY_SNAPSHOTS: PensionSnapshot[] = [];
|
|||||||
export default function Pensions() {
|
export default function Pensions() {
|
||||||
const [visibleFunds, setVisibleFunds] = useState<Set<FundKey>>(new Set(FUND_KEYS));
|
const [visibleFunds, setVisibleFunds] = useState<Set<FundKey>>(new Set(FUND_KEYS));
|
||||||
const [projections, setProjections] = useState<Record<FundKey, ProjectionState>>({
|
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 },
|
ROP: { contribution: 120_000, rate: 6.0, targetAge: 65 },
|
||||||
VOL: { contribution: 400_000, rate: 8.0, targetAge: 57 },
|
VOL: { contribution: 400_000, rate: 8.0, targetAge: 57 },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -99,7 +99,9 @@ export default function Proyecciones() {
|
|||||||
year={year}
|
year={year}
|
||||||
onSelectMonth={(m) => {
|
onSelectMonth={(m) => {
|
||||||
setSelectedMonth(m);
|
setSelectedMonth(m);
|
||||||
navigate('/budget', { state: { from: 'proyecciones' } });
|
navigate('/budget', {
|
||||||
|
state: { from: 'proyecciones', year, month: m },
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
onSaveOverride={async (month, value) => {
|
onSaveOverride={async (month, value) => {
|
||||||
await saveBalanceOverride(year, month, value);
|
await saveBalanceOverride(year, month, value);
|
||||||
|
|||||||
Reference in New Issue
Block a user