mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-05-19 09:28:47 +02:00
Add municipal receipt module and convert navbar to sidebar
All checks were successful
Deploy to VPS / deploy (push) Successful in 58s
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:
285
backend/app/api/v1/endpoints/municipal_receipts.py
Normal file
285
backend/app/api/v1/endpoints/municipal_receipts.py
Normal 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
|
||||||
|
],
|
||||||
|
)
|
||||||
@@ -8,6 +8,7 @@ from app.api.v1.endpoints import (
|
|||||||
categories,
|
categories,
|
||||||
exchange_rate,
|
exchange_rate,
|
||||||
import_transactions,
|
import_transactions,
|
||||||
|
municipal_receipts,
|
||||||
notifications,
|
notifications,
|
||||||
pensions,
|
pensions,
|
||||||
salarios,
|
salarios,
|
||||||
@@ -30,3 +31,4 @@ api_router.include_router(budget.router)
|
|||||||
api_router.include_router(notifications.router)
|
api_router.include_router(notifications.router)
|
||||||
api_router.include_router(salarios.router)
|
api_router.include_router(salarios.router)
|
||||||
api_router.include_router(pensions.router)
|
api_router.include_router(pensions.router)
|
||||||
|
api_router.include_router(municipal_receipts.router)
|
||||||
|
|||||||
@@ -358,3 +358,75 @@ class BalanceOverrideRead(SQLModel):
|
|||||||
month: int
|
month: int
|
||||||
override_balance: float
|
override_balance: float
|
||||||
updated_at: datetime
|
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
|
||||||
|
|||||||
291
backend/app/services/municipal_receipt_pdf.py
Normal file
291
backend/app/services/municipal_receipt_pdf.py
Normal 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
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import Budget from './pages/Budget';
|
|||||||
import Analytics from './pages/Analytics';
|
import Analytics from './pages/Analytics';
|
||||||
import Salarios from './pages/Salarios';
|
import Salarios from './pages/Salarios';
|
||||||
import Pensions from './pages/Pensions';
|
import Pensions from './pages/Pensions';
|
||||||
|
import ServiciosMunicipales from './pages/ServiciosMunicipales';
|
||||||
|
|
||||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
const { isAuthenticated } = useAuth();
|
const { isAuthenticated } = useAuth();
|
||||||
@@ -36,6 +37,7 @@ function AppRoutes() {
|
|||||||
<Route path="/analytics" element={<Analytics />} />
|
<Route path="/analytics" element={<Analytics />} />
|
||||||
<Route path="/salarios" element={<Salarios />} />
|
<Route path="/salarios" element={<Salarios />} />
|
||||||
<Route path="/pensions" element={<Pensions />} />
|
<Route path="/pensions" element={<Pensions />} />
|
||||||
|
<Route path="/servicios-municipales" element={<ServiciosMunicipales />} />
|
||||||
{/* Redirect old routes */}
|
{/* Redirect old routes */}
|
||||||
<Route path="/transactions" element={<Navigate to="/budget" replace />} />
|
<Route path="/transactions" element={<Navigate to="/budget" replace />} />
|
||||||
<Route path="/transfers" element={<Navigate to="/budget" replace />} />
|
<Route path="/transfers" element={<Navigate to="/budget" replace />} />
|
||||||
|
|||||||
@@ -304,3 +304,73 @@ export const getPensionFundSummary = () =>
|
|||||||
|
|
||||||
export const submitPensionManualEntries = (entries: PensionManualEntry[]) =>
|
export const submitPensionManualEntries = (entries: PensionManualEntry[]) =>
|
||||||
api.post<PensionUploadResult>('/pensions/manual', { entries });
|
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,
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
BarChart3,
|
BarChart3,
|
||||||
Landmark,
|
Landmark,
|
||||||
PiggyBank,
|
PiggyBank,
|
||||||
|
Droplets,
|
||||||
LogOut,
|
LogOut,
|
||||||
Wallet,
|
Wallet,
|
||||||
Menu,
|
Menu,
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
Moon,
|
Moon,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
|
type LucideIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useAuth } from '../AuthContext';
|
import { useAuth } from '../AuthContext';
|
||||||
@@ -29,14 +31,74 @@ import {
|
|||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const navItems = [
|
// ─── Navigation Structure ────────────────────────────────────────────────────
|
||||||
{ to: '/', icon: LayoutDashboard, label: 'Dashboard' },
|
|
||||||
{ to: '/budget', icon: Calculator, label: 'Presupuesto' },
|
interface NavSection {
|
||||||
{ to: '/salarios', icon: Landmark, label: 'Salarios' },
|
label: string;
|
||||||
{ to: '/pensions', icon: PiggyBank, label: 'Pensiones' },
|
items: { to: string; icon: LucideIcon; label: string }[];
|
||||||
{ to: '/analytics', icon: BarChart3, label: 'Analytics' },
|
}
|
||||||
|
|
||||||
|
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() {
|
export default function Layout() {
|
||||||
const { logout } = useAuth();
|
const { logout } = useAuth();
|
||||||
const { theme, toggleTheme } = useTheme();
|
const { theme, toggleTheme } = useTheme();
|
||||||
@@ -55,10 +117,20 @@ export default function Layout() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background text-foreground">
|
<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">
|
<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">
|
<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">
|
<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} />
|
<Wallet className="w-4 h-4 text-primary-foreground" strokeWidth={2.5} />
|
||||||
</div>
|
</div>
|
||||||
@@ -67,28 +139,6 @@ export default function Layout() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<div className="flex items-center gap-1">
|
||||||
<Button variant="ghost" size="icon" onClick={togglePrivacy} title="Toggle privacy mode" aria-label="Toggle privacy mode">
|
<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" />}
|
{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" />
|
<LogOut className="w-4 h-4" />
|
||||||
</Button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Mobile nav sheet */}
|
<div className="flex">
|
||||||
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
{/* ── Desktop sidebar ───────────────────────────────────────── */}
|
||||||
<SheetContent side="left" className="p-0">
|
<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">
|
||||||
<SheetHeader className="p-4">
|
<div className="flex-1">
|
||||||
<SheetTitle className="flex items-center gap-2.5">
|
<SidebarNav />
|
||||||
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center">
|
</div>
|
||||||
<Wallet className="w-4 h-4 text-primary-foreground" strokeWidth={2.5} />
|
<div className="px-3 pb-4">
|
||||||
</div>
|
<Separator className="mb-2" />
|
||||||
<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" />
|
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
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" />
|
<LogOut className="w-4 h-4" />
|
||||||
Sign out
|
Cerrar sesión
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</div>
|
||||||
</SheetContent>
|
</aside>
|
||||||
</Sheet>
|
|
||||||
|
|
||||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 py-6">
|
{/* ── Mobile nav sheet ──────────────────────────────────────── */}
|
||||||
<Outlet />
|
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
||||||
</main>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
637
frontend/src/pages/ServiciosMunicipales.tsx
Normal file
637
frontend/src/pages/ServiciosMunicipales.tsx
Normal 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} m³` : '—'}
|
||||||
|
</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} m³`
|
||||||
|
: '—'}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user