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

@@ -0,0 +1,285 @@
from datetime import datetime
from dateutil.relativedelta import relativedelta
from typing import Optional
from fastapi import APIRouter, Depends, Query, UploadFile
from pydantic import BaseModel
from sqlmodel import Session, select
from app.auth import get_current_user
from app.db import get_session
from app.models.models import (
Category,
Currency,
MunicipalReceipt,
MunicipalReceiptRead,
Transaction,
TransactionSource,
TransactionType,
WaterMeterReading,
WaterMeterReadingRead,
)
from app.services.municipal_receipt_pdf import extract_municipal_receipt
router = APIRouter(prefix="/municipal-receipts", tags=["municipal-receipts"])
# --- Response models ---
class MunicipalReceiptDetailRead(MunicipalReceiptRead):
water_readings: list[WaterMeterReadingRead] = []
class MunicipalReceiptUploadResult(BaseModel):
imported: int
updated: int
errors: list[str]
receipt: Optional[MunicipalReceiptRead] = None
# --- Helpers ---
def _auto_categorize(merchant: str, session: Session) -> Optional[int]:
categories = session.exec(select(Category)).all()
merchant_lower = merchant.lower()
for cat in categories:
if cat.auto_match_patterns:
patterns = [p.strip().lower() for p in cat.auto_match_patterns.split(",")]
if any(p in merchant_lower for p in patterns if p):
return cat.id
return None
def _upsert_receipt(
session: Session, data: dict, filename: str
) -> tuple[MunicipalReceipt, bool]:
"""Insert or update a municipal receipt. Returns (row, is_new)."""
r = data["receipt"]
totals = data["totals"]
receipt_date_str = r["date"]
# The receipt is issued in month N but covers month N-1
receipt_dt = datetime.strptime(receipt_date_str, "%Y-%m-%d").date()
billing_month = receipt_dt - relativedelta(months=1)
period = billing_month.strftime("%Y-%m")
existing = session.exec(
select(MunicipalReceipt).where(
MunicipalReceipt.account == r["account"],
MunicipalReceipt.period == period,
)
).first()
charges = [
{"detail": c["detail"], "amount": c.get("amount", 0)}
for c in data.get("charges", [])
]
fields = dict(
receipt_date=datetime.strptime(receipt_date_str, "%Y-%m-%d").date(),
due_date=datetime.strptime(r["due_date"], "%Y-%m-%d").date(),
period=period,
account=r["account"],
finca=r.get("finca", ""),
holder_name=r.get("account_holder", {}).get("name", ""),
holder_cedula=r.get("account_holder", {}).get("cedula", ""),
holder_address=r.get("account_holder", {}).get("address", ""),
subtotal=totals.get("subtotal", 0),
interests=totals.get("interests", 0),
iva=totals.get("iva", 0),
total=totals.get("total", 0),
raw_charges=charges,
source_filename=filename,
)
if existing:
for k, v in fields.items():
setattr(existing, k, v)
session.add(existing)
# Delete old water readings for this receipt
old_readings = session.exec(
select(WaterMeterReading).where(
WaterMeterReading.receipt_id == existing.id
)
).all()
for rd in old_readings:
session.delete(rd)
session.flush()
return existing, False
row = MunicipalReceipt(**fields)
session.add(row)
session.flush()
return row, True
def _insert_water_readings(
session: Session, receipt: MunicipalReceipt, data: dict
) -> None:
"""Insert water meter readings (current + historical) for a receipt."""
# Current period readings
for wm in data.get("water_meters", []):
reading = WaterMeterReading(
receipt_id=receipt.id,
meter_id=str(wm["meter_id"]),
period=wm["period"],
reading_previous=wm.get("reading_previous", 0),
reading_current=wm.get("reading_current", 0),
consumption_m3=wm.get("consumption_m3", 0),
agua_potable=wm.get("agua_potable", 0),
serv_ambientales=wm.get("serv_ambientales", 0),
alcant_sanitario=wm.get("alcant_sanitario", 0),
iva=wm.get("iva", 0),
is_historical=False,
)
session.add(reading)
# Historical consumption entries
for hc in data.get("historical_consumption", []):
period = hc["period"]
meter_id = str(hc["meter_id"])
# Upsert: check if this historical entry already exists
existing = session.exec(
select(WaterMeterReading).where(
WaterMeterReading.meter_id == meter_id,
WaterMeterReading.period == period,
WaterMeterReading.is_historical == True, # noqa: E712
)
).first()
if existing:
existing.consumption_m3 = hc.get("consumption_m3", 0)
session.add(existing)
else:
session.add(
WaterMeterReading(
receipt_id=receipt.id,
meter_id=meter_id,
period=period,
consumption_m3=hc.get("consumption_m3", 0),
is_historical=True,
)
)
def _ensure_transaction(
session: Session, receipt: MunicipalReceipt
) -> None:
"""Create a budget Transaction for this receipt if one doesn't exist."""
reference = f"municipal-{receipt.account}-{receipt.period}"
existing = session.exec(
select(Transaction).where(Transaction.reference == reference)
).first()
if existing:
# Update amount in case receipt was re-uploaded with corrections
existing.amount = receipt.total
session.add(existing)
return
category_id = _auto_categorize("municipalidad", session)
tx = Transaction(
amount=receipt.total,
currency=Currency.CRC,
merchant="Municipalidad de Belén",
date=datetime.combine(receipt.receipt_date, datetime.min.time()),
transaction_type=TransactionType.COMPRA,
source=TransactionSource.TRANSFER,
reference=reference,
category_id=category_id,
notes=f"Recibo municipal {receipt.period}",
)
session.add(tx)
# --- Endpoints ---
@router.post("/upload", response_model=MunicipalReceiptUploadResult)
async def upload_municipal_receipt(
file: UploadFile,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
filename = file.filename or "unknown.pdf"
errors: list[str] = []
try:
pdf_bytes = await file.read()
data = extract_municipal_receipt(pdf_bytes, filename)
except ValueError as e:
return MunicipalReceiptUploadResult(imported=0, updated=0, errors=[str(e)])
except Exception as e:
return MunicipalReceiptUploadResult(
imported=0, updated=0, errors=[f"{filename}: {e}"]
)
receipt, is_new = _upsert_receipt(session, data, filename)
_insert_water_readings(session, receipt, data)
_ensure_transaction(session, receipt)
session.commit()
session.refresh(receipt)
return MunicipalReceiptUploadResult(
imported=1 if is_new else 0,
updated=0 if is_new else 1,
errors=errors,
receipt=MunicipalReceiptRead.model_validate(receipt),
)
@router.get("/", response_model=list[MunicipalReceiptRead])
def list_receipts(
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
rows = session.exec(
select(MunicipalReceipt).order_by(
MunicipalReceipt.receipt_date.desc() # type: ignore[union-attr]
)
).all()
return rows
@router.get("/water-consumption", response_model=list[WaterMeterReadingRead])
def get_water_consumption(
months: int = Query(default=24, ge=1, le=120),
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
rows = session.exec(
select(WaterMeterReading)
.where(WaterMeterReading.is_historical == False) # noqa: E712
.order_by(
WaterMeterReading.period.asc(), # type: ignore[union-attr]
WaterMeterReading.meter_id.asc(), # type: ignore[union-attr]
)
.limit(months * 3) # up to 3 meters per month
).all()
return rows
@router.get("/{receipt_id}", response_model=MunicipalReceiptDetailRead)
def get_receipt_detail(
receipt_id: int,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
receipt = session.get(MunicipalReceipt, receipt_id)
if not receipt:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Receipt not found")
readings = session.exec(
select(WaterMeterReading).where(
WaterMeterReading.receipt_id == receipt_id
)
).all()
return MunicipalReceiptDetailRead(
**MunicipalReceiptRead.model_validate(receipt).model_dump(),
water_readings=[
WaterMeterReadingRead.model_validate(r) for r in readings
],
)

View File

@@ -8,6 +8,7 @@ from app.api.v1.endpoints import (
categories,
exchange_rate,
import_transactions,
municipal_receipts,
notifications,
pensions,
salarios,
@@ -30,3 +31,4 @@ api_router.include_router(budget.router)
api_router.include_router(notifications.router)
api_router.include_router(salarios.router)
api_router.include_router(pensions.router)
api_router.include_router(municipal_receipts.router)

View File

@@ -358,3 +358,75 @@ class BalanceOverrideRead(SQLModel):
month: int
override_balance: float
updated_at: datetime
# --- Municipal Receipt ---
class MunicipalReceiptBase(SQLModel):
receipt_date: date
due_date: date
period: str # "YYYY-MM"
account: str
finca: str
holder_name: str
holder_cedula: str
holder_address: str
subtotal: float
interests: float
iva: float
total: float
raw_charges: list[dict] = Field(
default_factory=list,
sa_column=Column(JSON, nullable=False, server_default="[]"),
)
source_filename: str
class MunicipalReceipt(MunicipalReceiptBase, table=True):
__table_args__ = (UniqueConstraint("account", "period"),)
id: Optional[int] = Field(default=None, primary_key=True)
created_at: datetime = Field(default_factory=datetime.utcnow)
water_readings: list["WaterMeterReading"] = Relationship(
back_populates="receipt",
)
class MunicipalReceiptCreate(MunicipalReceiptBase):
pass
class MunicipalReceiptRead(MunicipalReceiptBase):
id: int
created_at: datetime
# --- Water Meter Reading ---
class WaterMeterReadingBase(SQLModel):
meter_id: str
period: str # "YYYY-MM"
reading_previous: float = 0
reading_current: float = 0
consumption_m3: float
agua_potable: float = 0
serv_ambientales: float = 0
alcant_sanitario: float = 0
iva: float = 0
is_historical: bool = False
receipt_id: Optional[int] = Field(default=None, foreign_key="municipalreceipt.id")
class WaterMeterReading(WaterMeterReadingBase, table=True):
__table_args__ = (UniqueConstraint("meter_id", "period", "is_historical"),)
id: Optional[int] = Field(default=None, primary_key=True)
created_at: datetime = Field(default_factory=datetime.utcnow)
receipt: Optional[MunicipalReceipt] = Relationship(
back_populates="water_readings",
)
class WaterMeterReadingRead(WaterMeterReadingBase):
id: int
created_at: datetime

View File

@@ -0,0 +1,291 @@
"""
Extract structured data from Municipalidad de Belén receipts using pdftotext + regex.
"""
import re
import subprocess
import tempfile
from dataclasses import dataclass, field
def _parse_amount(s: str) -> float:
"""Parse a Costa Rican formatted number: '1,875.00' → 1875.00"""
return float(s.replace(",", ""))
def _parse_date(s: str) -> str:
"""Convert dd/mm/yyyy → YYYY-MM-DD"""
d, m, y = s.strip().split("/")
return f"{y}-{m.zfill(2)}-{d.zfill(2)}"
def _parse_period(s: str) -> str:
"""Convert mm/yyyy → YYYY-MM"""
m, y = s.strip().split("/")
return f"{y}-{m.zfill(2)}"
@dataclass
class Charge:
detail: str
interests: float
iva: float
amount: float
@dataclass
class WaterMeter:
period: str
meter_id: str
reading_previous: int
reading_current: int
consumption_m3: int
agua_potable: float
serv_ambientales: float
alcant_sanitario: float
iva: float
@dataclass
class HistoricalConsumption:
meter_id: str
period: str
consumption_m3: int
@dataclass
class MunicipalReceiptData:
receipt_date: str # YYYY-MM-DD
due_date: str # YYYY-MM-DD
holder_name: str
holder_cedula: str
holder_address: str
account: str
finca: str
charges: list[Charge] = field(default_factory=list)
subtotal: float = 0.0
interests: float = 0.0
iva: float = 0.0
total: float = 0.0
water_meters: list[WaterMeter] = field(default_factory=list)
historical_consumption: list[HistoricalConsumption] = field(default_factory=list)
def _pdf_to_text(pdf_bytes: bytes) -> str:
"""Convert PDF bytes to text using pdftotext -layout."""
with tempfile.NamedTemporaryFile(suffix=".pdf") as tmp:
tmp.write(pdf_bytes)
tmp.flush()
result = subprocess.run(
["pdftotext", "-layout", tmp.name, "-"],
capture_output=True,
text=True,
timeout=10,
)
if result.returncode != 0:
raise ValueError(f"pdftotext failed: {result.stderr}")
return result.stdout
# Regex patterns
RE_FECHA = re.compile(r"Fecha:\s*(\d{2}/\d{2}/\d{4})")
RE_VENCIMIENTO = re.compile(r"Fecha de vencimiento:\s*(\d{2}/\d{2}/\d{4})")
RE_NOMBRE = re.compile(r"Nombre:\s*(.+)")
RE_CEDULA = re.compile(r"Cédula:\s*(\d+)")
RE_DIRECCION = re.compile(r"Dirección:\s*(.+)")
# Charge line: DETAIL_TEXT account finca interests iva periodo_actual periodo_anterior
RE_CHARGE = re.compile(
r"^([A-ZÁÉÍÓÚÑ][A-ZÁÉÍÓÚÑ\s.]+?)\s+"
r"(\d{4})\s+"
r"(\d{6}---\d{3})\s+"
r"([\d,]+\.\d{2})\s+"
r"([\d,]+\.\d{2})\s+"
r"([\d,]+\.\d{2})\s+"
r"([\d,]+\.\d{2})\s*$"
)
RE_SUBTOTAL = re.compile(r"Sub-Total:\s+([\d,]+\.\d{2})")
RE_INTERESES = re.compile(r"Intereses:\s+([\d,]+\.\d{2})")
RE_IVA = re.compile(r"IVA\s+([\d,]+\.\d{2})")
RE_TOTAL = re.compile(r"Total:\s+([\d,]+\.\d{2})")
# Water meter line: period meter_id lec_ant lec_act consumo agua_potable serv_amb alcant iva
RE_WATER_METER = re.compile(
r"(\d{2}/\d{4})\s+"
r"(\d{4})\s+"
r"(\d{5})\s+"
r"(\d{5})\s+"
r"(\d+)\s+"
r"([\d,]+\.\d{2})\s+"
r"([\d,]+\.\d{2})\s+"
r"([\d,]+\.\d{2})\s+"
r"([\d,]+\.\d{2})"
)
# Historical consumption: meter_id period consumption
RE_HISTORICAL = re.compile(
r"(\d{4})\s+(\d{2}/\d{4})\s+(\d{5})"
)
def extract_municipal_receipt(
pdf_bytes: bytes, filename: str
) -> dict:
"""Extract structured data from a municipal receipt PDF.
Returns a dict matching the target JSON schema.
"""
text = _pdf_to_text(pdf_bytes)
if "RECIBO MUNICIPAL" not in text:
raise ValueError(f"{filename}: Not a municipal receipt")
data = MunicipalReceiptData(
receipt_date="",
due_date="",
holder_name="",
holder_cedula="",
holder_address="",
account="",
finca="",
)
# --- Header fields ---
m = RE_FECHA.search(text)
if m:
data.receipt_date = _parse_date(m.group(1))
m = RE_VENCIMIENTO.search(text)
if m:
data.due_date = _parse_date(m.group(1))
m = RE_NOMBRE.search(text)
if m:
data.holder_name = m.group(1).strip()
m = RE_CEDULA.search(text)
if m:
data.holder_cedula = m.group(1).strip()
m = RE_DIRECCION.search(text)
if m:
data.holder_address = m.group(1).strip().rstrip(".")
# --- Charges ---
for line in text.splitlines():
m = RE_CHARGE.match(line.strip())
if m:
detail = m.group(1).strip()
data.account = m.group(2)
data.finca = m.group(3)
interests = _parse_amount(m.group(4))
iva = _parse_amount(m.group(5))
amount = _parse_amount(m.group(6))
data.charges.append(Charge(detail=detail, interests=interests, iva=iva, amount=amount))
# --- Totals ---
m = RE_SUBTOTAL.search(text)
if m:
data.subtotal = _parse_amount(m.group(1))
m = RE_INTERESES.search(text)
if m:
data.interests = _parse_amount(m.group(1))
m = RE_IVA.search(text)
if m:
data.iva = _parse_amount(m.group(1))
m = RE_TOTAL.search(text)
if m:
data.total = _parse_amount(m.group(1))
# --- Water meters ---
for m in RE_WATER_METER.finditer(text):
data.water_meters.append(
WaterMeter(
period=_parse_period(m.group(1)),
meter_id=m.group(2),
reading_previous=int(m.group(3)),
reading_current=int(m.group(4)),
consumption_m3=int(m.group(5)),
agua_potable=_parse_amount(m.group(6)),
serv_ambientales=_parse_amount(m.group(7)),
alcant_sanitario=_parse_amount(m.group(8)),
iva=_parse_amount(m.group(9)),
)
)
# --- Historical consumption ---
# Only parse lines AFTER "DETALLE DE CONSUMO MESES ANTERIORES"
hist_section = text.split("DETALLE DE CONSUMO MESES ANTERIORES")
if len(hist_section) > 1:
for m in RE_HISTORICAL.finditer(hist_section[1]):
data.historical_consumption.append(
HistoricalConsumption(
meter_id=m.group(1),
period=_parse_period(m.group(2)),
consumption_m3=int(m.group(3)),
)
)
# --- Validation ---
if not data.receipt_date:
raise ValueError(f"{filename}: Could not parse receipt date")
if not data.charges:
raise ValueError(f"{filename}: No charges found")
# --- Build output dict ---
return {
"receipt": {
"type": "RECIBO MUNICIPAL",
"issuer": {
"name": "MUNICIPALIDAD DE BELÉN",
"phone": "(506) 2587-0000",
"fax": "(506) 2293-3667",
"website": "www.belen.go.cr",
},
"date": data.receipt_date,
"due_date": data.due_date,
"account_holder": {
"name": data.holder_name,
"cedula": data.holder_cedula,
"address": data.holder_address,
},
"account": data.account,
"finca": data.finca,
},
"charges": [
{"detail": c.detail, "interests": c.interests, "iva": c.iva, "amount": c.amount}
for c in data.charges
],
"totals": {
"subtotal": data.subtotal,
"interests": data.interests,
"iva": data.iva,
"total": data.total,
},
"water_meters": [
{
"period": wm.period,
"meter_id": wm.meter_id,
"reading_previous": wm.reading_previous,
"reading_current": wm.reading_current,
"consumption_m3": wm.consumption_m3,
"agua_potable": wm.agua_potable,
"serv_ambientales": wm.serv_ambientales,
"alcant_sanitario": wm.alcant_sanitario,
"iva": wm.iva,
}
for wm in data.water_meters
],
"historical_consumption": [
{
"meter_id": hc.meter_id,
"period": hc.period,
"consumption_m3": hc.consumption_m3,
}
for hc in data.historical_consumption
],
}

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