Add municipal receipt module and convert navbar to sidebar
All checks were successful
Deploy to VPS / deploy (push) Successful in 58s

- New module: Municipalidad de Belén receipt extraction via pdftotext+regex
  - Backend: MunicipalReceipt + WaterMeterReading models, upload/list/detail/water-consumption endpoints
  - Auto-creates budget Transaction on upload (duplicate-safe via reference)
  - Frontend: ServiciosMunicipales page with summary cards, water consumption bar chart, receipt history, PDF upload
- Convert top navbar to left sidebar with section headers (General, Finanzas, Servicios)
  - Desktop: fixed 220px sidebar, mobile: sheet overlay
  - Grouped nav: Dashboard | Presupuesto, Salarios, Pensiones, Analytics | Municipalidad

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-04-02 16:11:51 -06:00
parent 45166f9d20
commit 739a32efd4
8 changed files with 1492 additions and 84 deletions

View File

@@ -5,6 +5,7 @@ import {
BarChart3,
Landmark,
PiggyBank,
Droplets,
LogOut,
Wallet,
Menu,
@@ -12,6 +13,7 @@ import {
Moon,
Eye,
EyeOff,
type LucideIcon,
} from 'lucide-react';
import { useEffect, useState } from 'react';
import { useAuth } from '../AuthContext';
@@ -29,14 +31,74 @@ import {
import { Separator } from '@/components/ui/separator';
import { cn } from '@/lib/utils';
const navItems = [
{ to: '/', icon: LayoutDashboard, label: 'Dashboard' },
{ to: '/budget', icon: Calculator, label: 'Presupuesto' },
{ to: '/salarios', icon: Landmark, label: 'Salarios' },
{ to: '/pensions', icon: PiggyBank, label: 'Pensiones' },
{ to: '/analytics', icon: BarChart3, label: 'Analytics' },
// ─── Navigation Structure ────────────────────────────────────────────────────
interface NavSection {
label: string;
items: { to: string; icon: LucideIcon; label: string }[];
}
const navSections: NavSection[] = [
{
label: 'General',
items: [
{ to: '/', icon: LayoutDashboard, label: 'Dashboard' },
],
},
{
label: 'Finanzas',
items: [
{ to: '/budget', icon: Calculator, label: 'Presupuesto' },
{ to: '/salarios', icon: Landmark, label: 'Salarios' },
{ to: '/pensions', icon: PiggyBank, label: 'Pensiones' },
{ to: '/analytics', icon: BarChart3, label: 'Analytics' },
],
},
{
label: 'Servicios',
items: [
{ to: '/servicios-municipales', icon: Droplets, label: 'Municipalidad' },
],
},
];
// ─── Shared Nav Renderer ─────────────────────────────────────────────────────
function SidebarNav({ onNavigate }: { onNavigate?: () => void }) {
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 }) => (
<NavLink
key={to}
to={to}
end={to === '/'}
onClick={onNavigate}
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
isActive
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
)
}
>
<Icon className="w-4 h-4" />
{label}
</NavLink>
))}
</div>
))}
</nav>
);
}
// ─── Main Layout ─────────────────────────────────────────────────────────────
export default function Layout() {
const { logout } = useAuth();
const { theme, toggleTheme } = useTheme();
@@ -55,10 +117,20 @@ export default function Layout() {
return (
<div className="min-h-screen bg-background text-foreground">
{/* Top bar */}
{/* ── Top bar ───────────────────────────────────────────────────── */}
<header className="border-b border-border backdrop-blur-sm sticky top-0 z-50 bg-background/90">
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-3 flex items-center justify-between">
<div className="px-4 sm:px-6 py-3 flex items-center justify-between">
<div className="flex items-center gap-2.5">
<Button
variant="ghost"
size="icon"
onClick={() => setMobileOpen(true)}
title="Open menu"
aria-label="Open menu"
className="md:hidden"
>
<Menu className="w-5 h-5" />
</Button>
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center">
<Wallet className="w-4 h-4 text-primary-foreground" strokeWidth={2.5} />
</div>
@@ -67,28 +139,6 @@ export default function Layout() {
</span>
</div>
{/* Desktop nav */}
<nav className="hidden md:flex items-center gap-1">
{navItems.map(({ to, icon: Icon, label }) => (
<NavLink
key={to}
to={to}
end={to === '/'}
className={({ isActive }) =>
cn(
'flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors',
isActive
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
)
}
>
<Icon className="w-4 h-4" />
{label}
</NavLink>
))}
</nav>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" onClick={togglePrivacy} title="Toggle privacy mode" aria-label="Toggle privacy mode">
{privacyMode ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
@@ -106,70 +156,69 @@ export default function Layout() {
>
<LogOut className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setMobileOpen(true)}
title="Open menu"
aria-label="Open menu"
className="md:hidden"
>
<Menu className="w-5 h-5" />
</Button>
</div>
</div>
</header>
{/* Mobile nav sheet */}
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetContent side="left" className="p-0">
<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 className="font-heading">
Wealthy<span className="text-primary">Smart</span>
</span>
</SheetTitle>
</SheetHeader>
<Separator />
<nav className="flex flex-col gap-1 p-4">
{navItems.map(({ to, icon: Icon, label }) => (
<SheetClose key={to} render={<span />}>
<NavLink
to={to}
end={to === '/'}
onClick={() => setMobileOpen(false)}
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors',
isActive
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
)
}
>
<Icon className="w-4 h-4" />
{label}
</NavLink>
</SheetClose>
))}
<Separator className="my-2" />
<div className="flex">
{/* ── Desktop sidebar ───────────────────────────────────────── */}
<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-4 py-3 rounded-lg text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted w-full"
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" />
Sign out
Cerrar sesión
</button>
</nav>
</SheetContent>
</Sheet>
</div>
</aside>
<main className="max-w-7xl mx-auto px-4 sm:px-6 py-6">
<Outlet />
</main>
{/* ── Mobile nav sheet ──────────────────────────────────────── */}
<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 className="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); 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 content ──────────────────────────────────────────── */}
<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 />
</div>
</main>
</div>
</div>
);
}