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

@@ -9,6 +9,7 @@ import Budget from './pages/Budget';
import Analytics from './pages/Analytics';
import Salarios from './pages/Salarios';
import Pensions from './pages/Pensions';
import ServiciosMunicipales from './pages/ServiciosMunicipales';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated } = useAuth();
@@ -36,6 +37,7 @@ function AppRoutes() {
<Route path="/analytics" element={<Analytics />} />
<Route path="/salarios" element={<Salarios />} />
<Route path="/pensions" element={<Pensions />} />
<Route path="/servicios-municipales" element={<ServiciosMunicipales />} />
{/* Redirect old routes */}
<Route path="/transactions" element={<Navigate to="/budget" replace />} />
<Route path="/transfers" element={<Navigate to="/budget" replace />} />

View File

@@ -304,3 +304,73 @@ export const getPensionFundSummary = () =>
export const submitPensionManualEntries = (entries: PensionManualEntry[]) =>
api.post<PensionUploadResult>('/pensions/manual', { entries });
// --- Municipal Receipts ---
export interface MunicipalCharge {
detail: string;
amount: number;
}
export interface WaterMeterReading {
id: number;
meter_id: string;
period: string;
reading_previous: number;
reading_current: number;
consumption_m3: number;
agua_potable: number;
serv_ambientales: number;
alcant_sanitario: number;
iva: number;
is_historical: boolean;
receipt_id: number | null;
created_at: string;
}
export interface MunicipalReceipt {
id: number;
receipt_date: string;
due_date: string;
period: string;
account: string;
finca: string;
holder_name: string;
holder_cedula: string;
holder_address: string;
subtotal: number;
interests: number;
iva: number;
total: number;
raw_charges: MunicipalCharge[];
source_filename: string;
created_at: string;
}
export interface MunicipalReceiptDetail extends MunicipalReceipt {
water_readings: WaterMeterReading[];
}
export interface MunicipalReceiptUploadResult {
imported: number;
updated: number;
errors: string[];
receipt: MunicipalReceipt | null;
}
export const uploadMunicipalReceipt = (file: File) => {
const form = new FormData();
form.append('file', file);
return api.post<MunicipalReceiptUploadResult>('/municipal-receipts/upload', form);
};
export const getMunicipalReceipts = () =>
api.get<MunicipalReceipt[]>('/municipal-receipts/');
export const getMunicipalReceiptDetail = (id: number) =>
api.get<MunicipalReceiptDetail>(`/municipal-receipts/${id}`);
export const getWaterConsumption = (months?: number) =>
api.get<WaterMeterReading[]>('/municipal-receipts/water-consumption', {
params: months ? { months } : undefined,
});

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>
);
}

View File

@@ -0,0 +1,637 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
import {
Droplets,
Upload,
X,
FileText,
Loader2,
CheckCircle2,
AlertTriangle,
Receipt,
TrendingDown,
TrendingUp,
CalendarDays,
} from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import {
uploadMunicipalReceipt,
getMunicipalReceipts,
getWaterConsumption,
type MunicipalReceipt,
type MunicipalReceiptUploadResult,
type WaterMeterReading,
} from '@/api';
// ─── Constants ───────────────────────────────────────────────────────────────
const METER_COLORS: Record<string, string> = {
'7335': '#3b82f6',
'7345': '#10b981',
'9345': '#f59e0b',
};
const MONTH_NAMES_ES = [
'Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun',
'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic',
];
const DEFAULT_METER_COLOR = '#8b5cf6';
// ─── Utilities ───────────────────────────────────────────────────────────────
const formatCRC = (amount: number): string =>
`${amount.toLocaleString('es-CR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
function periodLabel(period: string): string {
const [yearStr, monthStr] = period.split('-');
const monthIdx = parseInt(monthStr, 10) - 1;
return `${MONTH_NAMES_ES[monthIdx]} ${yearStr.slice(2)}`;
}
function meterColor(meterId: string): string {
return METER_COLORS[meterId] ?? DEFAULT_METER_COLOR;
}
// ─── Chart Data ──────────────────────────────────────────────────────────────
interface ChartPoint {
period: string;
label: string;
[meterId: string]: number | string;
}
function buildChartData(readings: WaterMeterReading[]): ChartPoint[] {
const byPeriod = new Map<string, Record<string, number>>();
for (const r of readings) {
if (!byPeriod.has(r.period)) byPeriod.set(r.period, {});
byPeriod.get(r.period)![r.meter_id] = r.consumption_m3;
}
const sorted = Array.from(byPeriod.keys()).sort();
return sorted.map((period) => ({
period,
label: periodLabel(period),
...byPeriod.get(period)!,
}));
}
function getMeterIds(readings: WaterMeterReading[]): string[] {
return [...new Set(readings.map((r) => r.meter_id))].sort();
}
// ─── Chart Tooltip ───────────────────────────────────────────────────────────
interface TooltipEntry {
name: string;
value: number;
color: string;
}
function ChartTooltipContent({
active,
payload,
label,
}: {
active?: boolean;
payload?: TooltipEntry[];
label?: string;
}) {
if (!active || !payload?.length) return null;
const total = payload.reduce((sum, e) => sum + e.value, 0);
return (
<div className="bg-popover border border-border rounded-lg p-3 shadow-lg text-sm min-w-[180px]">
<p className="font-semibold mb-2 text-foreground">{label}</p>
{payload.map((entry) => (
<div key={entry.name} className="flex items-center justify-between gap-4 py-0.5">
<span className="flex items-center gap-1.5">
<span
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
style={{ background: entry.color }}
/>
<span className="text-muted-foreground">Medidor {entry.name}</span>
</span>
<span className="font-mono font-medium text-foreground">{entry.value} m³</span>
</div>
))}
<Separator className="my-1.5" />
<div className="flex justify-between text-xs font-medium">
<span className="text-muted-foreground">Total</span>
<span className="font-mono">{total} m³</span>
</div>
</div>
);
}
// ─── Main Component ──────────────────────────────────────────────────────────
export default function ServiciosMunicipales() {
const [receipts, setReceipts] = useState<MunicipalReceipt[]>([]);
const [waterReadings, setWaterReadings] = useState<WaterMeterReading[]>([]);
const [loading, setLoading] = useState(true);
// Upload state
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [uploadResult, setUploadResult] = useState<MunicipalReceiptUploadResult | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const loadData = useCallback(async () => {
setLoading(true);
try {
const [receiptsRes, waterRes] = await Promise.all([
getMunicipalReceipts(),
getWaterConsumption(24),
]);
setReceipts(receiptsRes.data);
setWaterReadings(waterRes.data);
} catch {
// API not available yet
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadData();
}, [loadData]);
// Derived data
const chartData = useMemo(() => buildChartData(waterReadings), [waterReadings]);
const meterIds = useMemo(() => getMeterIds(waterReadings), [waterReadings]);
const latestReceipt = receipts[0] ?? null;
const avgMonthly = useMemo(() => {
if (receipts.length === 0) return 0;
const sum = receipts.reduce((s, r) => s + r.total, 0);
return sum / receipts.length;
}, [receipts]);
const currentConsumption = useMemo(() => {
if (chartData.length === 0) return { total: 0, prev: 0 };
const latest = chartData[chartData.length - 1];
const prev = chartData.length >= 2 ? chartData[chartData.length - 2] : null;
const sumValues = (point: ChartPoint) =>
meterIds.reduce((s, id) => s + ((point[id] as number) || 0), 0);
return {
total: sumValues(latest),
prev: prev ? sumValues(prev) : 0,
};
}, [chartData, meterIds]);
const consumptionDelta = currentConsumption.prev > 0
? currentConsumption.total - currentConsumption.prev
: 0;
// Upload handlers
const handleFile = useCallback((files: FileList | null) => {
if (!files) return;
const pdf = Array.from(files).find((f) => f.type === 'application/pdf');
if (pdf) {
setUploadedFile(pdf);
setUploadResult(null);
}
}, []);
const handleUpload = async () => {
if (!uploadedFile) return;
setIsUploading(true);
setUploadResult(null);
try {
const { data } = await uploadMunicipalReceipt(uploadedFile);
setUploadResult(data);
setUploadedFile(null);
await loadData();
} catch (err) {
setUploadResult({
imported: 0,
updated: 0,
errors: [err instanceof Error ? err.message : 'Error al subir archivo'],
receipt: null,
});
} finally {
setIsUploading(false);
}
};
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
return (
<div className="space-y-8">
{/* ── Page Header ─────────────────────────────────────────────────── */}
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
<Droplets className="w-5 h-5 text-primary" />
</div>
<div>
<h1 className="text-2xl font-bold font-heading">Servicios Municipales</h1>
<p className="text-sm text-muted-foreground">
Municipalidad de Belén recibos y consumo de agua
</p>
</div>
</div>
{/* ── Summary Cards ───────────────────────────────────────────────── */}
<section className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-1.5 mb-2">
<Receipt className="w-3.5 h-3.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">
Último recibo
</span>
</div>
<p data-sensitive className="text-lg font-bold font-mono">
{latestReceipt ? formatCRC(latestReceipt.total) : '—'}
</p>
{latestReceipt && (
<p className="text-xs text-muted-foreground mt-0.5">
{periodLabel(latestReceipt.period)}
</p>
)}
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-1.5 mb-2">
<CalendarDays className="w-3.5 h-3.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">
Promedio mensual
</span>
</div>
<p data-sensitive className="text-lg font-bold font-mono">
{receipts.length > 0 ? formatCRC(avgMonthly) : '—'}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{receipts.length} {receipts.length === 1 ? 'recibo' : 'recibos'}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-1.5 mb-2">
<Droplets className="w-3.5 h-3.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">
Consumo actual
</span>
</div>
<p className="text-lg font-bold font-mono">
{currentConsumption.total > 0 ? `${currentConsumption.total}` : '—'}
</p>
{chartData.length > 0 && (
<p className="text-xs text-muted-foreground mt-0.5">
{periodLabel(chartData[chartData.length - 1].period)}
</p>
)}
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-1.5 mb-2">
{consumptionDelta <= 0 ? (
<TrendingDown className="w-3.5 h-3.5 text-emerald-500" />
) : (
<TrendingUp className="w-3.5 h-3.5 text-amber-500" />
)}
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">
Variación
</span>
</div>
<p
className={`text-lg font-bold font-mono ${
consumptionDelta <= 0 ? 'text-emerald-500' : 'text-amber-500'
}`}
>
{currentConsumption.prev > 0
? `${consumptionDelta > 0 ? '+' : ''}${consumptionDelta}`
: '—'}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
vs mes anterior
</p>
</CardContent>
</Card>
</section>
{/* ── Water Consumption Chart ─────────────────────────────────────── */}
{chartData.length > 0 && (
<section className="space-y-3">
<h2 className="text-base font-semibold font-heading flex items-center gap-2 text-muted-foreground uppercase tracking-wider">
<Droplets className="w-4 h-4" />
Consumo de Agua (m³)
</h2>
<Card>
<CardContent className="p-4">
<ResponsiveContainer width="100%" height={280}>
<BarChart data={chartData} margin={{ top: 4, right: 8, left: 8, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey="label"
tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }}
axisLine={{ stroke: 'var(--border)' }}
tickLine={false}
/>
<YAxis
tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }}
axisLine={false}
tickLine={false}
width={32}
unit=" m³"
/>
<Tooltip content={<ChartTooltipContent />} />
<Legend
formatter={(value: string) => (
<span style={{ fontSize: 12, color: 'var(--foreground)' }}>
Medidor {value}
</span>
)}
/>
{meterIds.map((id) => (
<Bar
key={id}
dataKey={id}
name={id}
fill={meterColor(id)}
radius={[3, 3, 0, 0]}
maxBarSize={32}
/>
))}
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</section>
)}
{/* ── Receipt History ──────────────────────────────────────────────── */}
<section className="space-y-3">
<h2 className="text-base font-semibold font-heading flex items-center gap-2 text-muted-foreground uppercase tracking-wider">
<Receipt className="w-4 h-4" />
Historial de Recibos
</h2>
{loading && receipts.length === 0 ? (
<Card>
<CardContent className="p-8 flex items-center justify-center text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin mr-2" />
Cargando...
</CardContent>
</Card>
) : receipts.length === 0 ? (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
<Receipt className="w-8 h-8 mx-auto mb-2 opacity-40" />
<p className="text-sm">No hay recibos aún. Sube un PDF para comenzar.</p>
</CardContent>
</Card>
) : (
<Card>
<CardContent className="p-0">
<Accordion>
{receipts.map((receipt) => (
<AccordionItem key={receipt.id} value={String(receipt.id)}>
<AccordionTrigger className="px-4 py-3 hover:no-underline">
<div className="flex items-center justify-between w-full pr-2">
<div className="flex items-center gap-3">
<Badge variant="outline" className="font-mono text-xs">
{periodLabel(receipt.period)}
</Badge>
<span className="text-sm text-muted-foreground hidden sm:inline">
Vence{' '}
{new Date(receipt.due_date).toLocaleDateString('es-CR', {
day: 'numeric',
month: 'short',
})}
</span>
</div>
<span data-sensitive className="font-mono font-bold text-sm">
{formatCRC(receipt.total)}
</span>
</div>
</AccordionTrigger>
<AccordionContent className="px-4 pb-4">
<div className="space-y-3">
{/* Charges breakdown */}
<div className="rounded-lg border border-border overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50">
<th className="text-left px-3 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Detalle
</th>
<th className="text-right px-3 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Monto
</th>
</tr>
</thead>
<tbody>
{receipt.raw_charges.map((charge, i) => (
<tr key={i} className="border-t border-border">
<td className="px-3 py-2 text-foreground">{charge.detail}</td>
<td data-sensitive className="px-3 py-2 text-right font-mono">
{formatCRC(charge.amount)}
</td>
</tr>
))}
</tbody>
<tfoot>
{receipt.interests > 0 && (
<tr className="border-t border-border bg-muted/30">
<td className="px-3 py-1.5 text-xs text-muted-foreground">Intereses</td>
<td data-sensitive className="px-3 py-1.5 text-right font-mono text-xs">
{formatCRC(receipt.interests)}
</td>
</tr>
)}
{receipt.iva > 0 && (
<tr className="border-t border-border bg-muted/30">
<td className="px-3 py-1.5 text-xs text-muted-foreground">IVA</td>
<td data-sensitive className="px-3 py-1.5 text-right font-mono text-xs">
{formatCRC(receipt.iva)}
</td>
</tr>
)}
<tr className="border-t-2 border-border font-bold">
<td className="px-3 py-2">Total</td>
<td data-sensitive className="px-3 py-2 text-right font-mono">
{formatCRC(receipt.total)}
</td>
</tr>
</tfoot>
</table>
</div>
{/* Meta info */}
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span>Cuenta: {receipt.account}</span>
<span>Finca: {receipt.finca}</span>
<span>
Fecha:{' '}
{new Date(receipt.receipt_date).toLocaleDateString('es-CR')}
</span>
</div>
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</CardContent>
</Card>
)}
</section>
{/* ── PDF Upload ──────────────────────────────────────────────────── */}
<section className="space-y-3">
<h2 className="text-base font-semibold font-heading flex items-center gap-2 text-muted-foreground uppercase tracking-wider">
<FileText className="w-4 h-4" />
Subir Recibo
</h2>
<Card>
<CardContent className="p-6 space-y-4">
{/* Drop zone */}
<div
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
onDragLeave={(e) => { e.preventDefault(); setIsDragging(false); }}
onDrop={(e) => { e.preventDefault(); setIsDragging(false); handleFile(e.dataTransfer.files); }}
onClick={() => fileInputRef.current?.click()}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && fileInputRef.current?.click()}
aria-label="Seleccionar archivo PDF"
className={[
'border-2 border-dashed rounded-lg p-8',
'flex flex-col items-center justify-center gap-3',
'cursor-pointer transition-colors select-none',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
isDragging
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50 hover:bg-muted/30',
].join(' ')}
>
<Upload
className={`w-8 h-8 ${isDragging ? 'text-primary' : 'text-muted-foreground'}`}
/>
<div className="text-center">
<p className="text-sm font-medium">
{isDragging
? 'Suelta el archivo aquí'
: 'Arrastra el PDF aquí o toca para seleccionar'}
</p>
<p className="text-xs text-muted-foreground mt-1">
Solo archivos PDF · Recibo Municipal de Belén
</p>
</div>
</div>
<input
ref={fileInputRef}
type="file"
accept="application/pdf"
className="hidden"
onChange={(e) => handleFile(e.target.files)}
/>
{/* Selected file */}
{uploadedFile && (
<div className="flex items-center justify-between gap-3 p-2.5 rounded-lg bg-muted/50 border border-border">
<div className="flex items-center gap-2.5 min-w-0">
<FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<div className="min-w-0">
<p className="text-sm font-medium truncate">{uploadedFile.name}</p>
<p className="text-xs text-muted-foreground">{formatFileSize(uploadedFile.size)}</p>
</div>
</div>
<button
onClick={() => setUploadedFile(null)}
className="flex-shrink-0 p-1 rounded hover:bg-destructive/10 hover:text-destructive transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={`Eliminar ${uploadedFile.name}`}
>
<X className="w-3.5 h-3.5" />
</button>
</div>
)}
{/* Submit */}
<Button
onClick={handleUpload}
disabled={!uploadedFile || isUploading}
className="w-full"
>
{isUploading ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Upload className="w-4 h-4 mr-2" />
)}
{isUploading ? 'Extrayendo datos...' : 'Subir Recibo'}
</Button>
{/* Upload result */}
{uploadResult && (
<div
className={[
'rounded-lg border p-4 space-y-2',
uploadResult.errors.length > 0 && !uploadResult.receipt
? 'border-destructive/50 bg-destructive/5'
: 'border-emerald-500/50 bg-emerald-500/5',
].join(' ')}
>
<div className="flex items-center gap-2">
{uploadResult.receipt ? (
<CheckCircle2 className="w-4 h-4 text-emerald-500" />
) : (
<AlertTriangle className="w-4 h-4 text-amber-500" />
)}
<span className="text-sm font-medium">
{uploadResult.imported > 0 && 'Recibo importado'}
{uploadResult.updated > 0 && 'Recibo actualizado'}
{!uploadResult.receipt && 'Error al procesar'}
</span>
</div>
{uploadResult.receipt && (
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">
{periodLabel(uploadResult.receipt.period)}
</span>
<span data-sensitive className="font-mono font-medium">
{formatCRC(uploadResult.receipt.total)}
</span>
</div>
)}
{uploadResult.errors.map((err, i) => (
<p key={i} className="text-xs text-destructive">{err}</p>
))}
</div>
)}
</CardContent>
</Card>
</section>
</div>
);
}