diff --git a/backend/app/api/v1/endpoints/municipal_receipts.py b/backend/app/api/v1/endpoints/municipal_receipts.py new file mode 100644 index 0000000..63e8432 --- /dev/null +++ b/backend/app/api/v1/endpoints/municipal_receipts.py @@ -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 + ], + ) diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 6db6e2e..9b55042 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -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) diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 32a17c1..a26286d 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -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 diff --git a/backend/app/services/municipal_receipt_pdf.py b/backend/app/services/municipal_receipt_pdf.py new file mode 100644 index 0000000..d886476 --- /dev/null +++ b/backend/app/services/municipal_receipt_pdf.py @@ -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 + ], + } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bb0ec3b..27418c8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> {/* Redirect old routes */} } /> } /> diff --git a/frontend/src/api.ts b/frontend/src/api.ts index e153ff4..59f16a7 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -304,3 +304,73 @@ export const getPensionFundSummary = () => export const submitPensionManualEntries = (entries: PensionManualEntry[]) => api.post('/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('/municipal-receipts/upload', form); +}; + +export const getMunicipalReceipts = () => + api.get('/municipal-receipts/'); + +export const getMunicipalReceiptDetail = (id: number) => + api.get(`/municipal-receipts/${id}`); + +export const getWaterConsumption = (months?: number) => + api.get('/municipal-receipts/water-consumption', { + params: months ? { months } : undefined, + }); diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 802f49a..b66db3f 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -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 ( + + ); +} + +// ─── Main Layout ───────────────────────────────────────────────────────────── + export default function Layout() { const { logout } = useAuth(); const { theme, toggleTheme } = useTheme(); @@ -55,10 +117,20 @@ export default function Layout() { return (
- {/* Top bar */} + {/* ── Top bar ───────────────────────────────────────────────────── */}
-
+
+
@@ -67,28 +139,6 @@ export default function Layout() {
- {/* Desktop nav */} - -
-
- {/* Mobile nav sheet */} - - - - -
- -
- - WealthySmart - -
-
- - -
-
+
+ -
- -
+ {/* ── Mobile nav sheet ──────────────────────────────────────── */} + + + + +
+ +
+ + WealthySmart + +
+
+ +
+
+ setMobileOpen(false)} /> +
+
+ + }> + + +
+
+
+
+ + {/* ── Main content ──────────────────────────────────────────── */} +
+
+ +
+
+ ); } diff --git a/frontend/src/pages/ServiciosMunicipales.tsx b/frontend/src/pages/ServiciosMunicipales.tsx new file mode 100644 index 0000000..2253663 --- /dev/null +++ b/frontend/src/pages/ServiciosMunicipales.tsx @@ -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 = { + '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>(); + + 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 ( +
+

{label}

+ {payload.map((entry) => ( +
+ + + Medidor {entry.name} + + {entry.value} m³ +
+ ))} + +
+ Total + {total} m³ +
+
+ ); +} + +// ─── Main Component ────────────────────────────────────────────────────────── + +export default function ServiciosMunicipales() { + const [receipts, setReceipts] = useState([]); + const [waterReadings, setWaterReadings] = useState([]); + const [loading, setLoading] = useState(true); + + // Upload state + const [uploadedFile, setUploadedFile] = useState(null); + const [isDragging, setIsDragging] = useState(false); + const [isUploading, setIsUploading] = useState(false); + const [uploadResult, setUploadResult] = useState(null); + const fileInputRef = useRef(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 ( +
+ {/* ── Page Header ─────────────────────────────────────────────────── */} +
+
+ +
+
+

Servicios Municipales

+

+ Municipalidad de Belén — recibos y consumo de agua +

+
+
+ + {/* ── Summary Cards ───────────────────────────────────────────────── */} +
+ + +
+ + + Último recibo + +
+

+ {latestReceipt ? formatCRC(latestReceipt.total) : '—'} +

+ {latestReceipt && ( +

+ {periodLabel(latestReceipt.period)} +

+ )} +
+
+ + + +
+ + + Promedio mensual + +
+

+ {receipts.length > 0 ? formatCRC(avgMonthly) : '—'} +

+

+ {receipts.length} {receipts.length === 1 ? 'recibo' : 'recibos'} +

+
+
+ + + +
+ + + Consumo actual + +
+

+ {currentConsumption.total > 0 ? `${currentConsumption.total} m³` : '—'} +

+ {chartData.length > 0 && ( +

+ {periodLabel(chartData[chartData.length - 1].period)} +

+ )} +
+
+ + + +
+ {consumptionDelta <= 0 ? ( + + ) : ( + + )} + + Variación + +
+

+ {currentConsumption.prev > 0 + ? `${consumptionDelta > 0 ? '+' : ''}${consumptionDelta} m³` + : '—'} +

+

+ vs mes anterior +

+
+
+
+ + {/* ── Water Consumption Chart ─────────────────────────────────────── */} + {chartData.length > 0 && ( +
+

+ + Consumo de Agua (m³) +

+ + + + + + + + } /> + ( + + Medidor {value} + + )} + /> + {meterIds.map((id) => ( + + ))} + + + + +
+ )} + + {/* ── Receipt History ──────────────────────────────────────────────── */} +
+

+ + Historial de Recibos +

+ {loading && receipts.length === 0 ? ( + + + + Cargando... + + + ) : receipts.length === 0 ? ( + + + +

No hay recibos aún. Sube un PDF para comenzar.

+
+
+ ) : ( + + + + {receipts.map((receipt) => ( + + +
+
+ + {periodLabel(receipt.period)} + + + Vence{' '} + {new Date(receipt.due_date).toLocaleDateString('es-CR', { + day: 'numeric', + month: 'short', + })} + +
+ + {formatCRC(receipt.total)} + +
+
+ +
+ {/* Charges breakdown */} +
+ + + + + + + + + {receipt.raw_charges.map((charge, i) => ( + + + + + ))} + + + {receipt.interests > 0 && ( + + + + + )} + {receipt.iva > 0 && ( + + + + + )} + + + + + +
+ Detalle + + Monto +
{charge.detail} + {formatCRC(charge.amount)} +
Intereses + {formatCRC(receipt.interests)} +
IVA + {formatCRC(receipt.iva)} +
Total + {formatCRC(receipt.total)} +
+
+ + {/* Meta info */} +
+ Cuenta: {receipt.account} + Finca: {receipt.finca} + + Fecha:{' '} + {new Date(receipt.receipt_date).toLocaleDateString('es-CR')} + +
+
+
+
+ ))} +
+
+
+ )} +
+ + {/* ── PDF Upload ──────────────────────────────────────────────────── */} +
+

+ + Subir Recibo +

+ + + {/* Drop zone */} +
{ 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(' ')} + > + +
+

+ {isDragging + ? 'Suelta el archivo aquí' + : 'Arrastra el PDF aquí o toca para seleccionar'} +

+

+ Solo archivos PDF · Recibo Municipal de Belén +

+
+
+ + handleFile(e.target.files)} + /> + + {/* Selected file */} + {uploadedFile && ( +
+
+ +
+

{uploadedFile.name}

+

{formatFileSize(uploadedFile.size)}

+
+
+ +
+ )} + + {/* Submit */} + + + {/* Upload result */} + {uploadResult && ( +
0 && !uploadResult.receipt + ? 'border-destructive/50 bg-destructive/5' + : 'border-emerald-500/50 bg-emerald-500/5', + ].join(' ')} + > +
+ {uploadResult.receipt ? ( + + ) : ( + + )} + + {uploadResult.imported > 0 && 'Recibo importado'} + {uploadResult.updated > 0 && 'Recibo actualizado'} + {!uploadResult.receipt && 'Error al procesar'} + +
+ {uploadResult.receipt && ( +
+ + {periodLabel(uploadResult.receipt.period)} + + + {formatCRC(uploadResult.receipt.total)} + +
+ )} + {uploadResult.errors.map((err, i) => ( +

{err}

+ ))} +
+ )} +
+
+
+
+ ); +}