mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-05-19 09:28:47 +02:00
Add budget module and push notifications for transactions
All checks were successful
Deploy to VPS / deploy (push) Successful in 34s
All checks were successful
Deploy to VPS / deploy (push) Successful in 34s
Budget: recurring items CRUD, yearly/monthly projections with no-double-count logic, and full UI (overview, monthly detail, recurring items manager). Push notifications: Web Push via VAPID keys, triggered on transaction creation from n8n. Includes service worker handlers, frontend subscription flow, and a test button on the Dashboard (temporary). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2
.github/workflows/deploy.yml
vendored
2
.github/workflows/deploy.yml
vendored
@@ -21,6 +21,8 @@ jobs:
|
|||||||
ADMIN_USERNAME=${{ secrets.ADMIN_USERNAME }}
|
ADMIN_USERNAME=${{ secrets.ADMIN_USERNAME }}
|
||||||
ADMIN_PASSWORD=${{ secrets.ADMIN_PASSWORD }}
|
ADMIN_PASSWORD=${{ secrets.ADMIN_PASSWORD }}
|
||||||
LETSENCRYPT_EMAIL=${{ secrets.LETSENCRYPT_EMAIL }}
|
LETSENCRYPT_EMAIL=${{ secrets.LETSENCRYPT_EMAIL }}
|
||||||
|
VAPID_PRIVATE_KEY=${{ secrets.VAPID_PRIVATE_KEY }}
|
||||||
|
VAPID_PUBLIC_KEY=${{ secrets.VAPID_PUBLIC_KEY }}
|
||||||
ENVEOF
|
ENVEOF
|
||||||
sed -i 's/^[[:space:]]*//' .env.prod
|
sed -i 's/^[[:space:]]*//' .env.prod
|
||||||
|
|
||||||
|
|||||||
210
backend/app/api/v1/endpoints/budget.py
Normal file
210
backend/app/api/v1/endpoints/budget.py
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
||||||
|
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 (
|
||||||
|
RecurringItem,
|
||||||
|
RecurringItemCreate,
|
||||||
|
RecurringItemRead,
|
||||||
|
RecurringItemType,
|
||||||
|
RecurringItemUpdate,
|
||||||
|
)
|
||||||
|
from app.services.budget_projection import compute_monthly_projection
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/budget", tags=["budget"])
|
||||||
|
|
||||||
|
|
||||||
|
# --- Recurring Item CRUD ---
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/recurring", response_model=list[RecurringItemRead])
|
||||||
|
def list_recurring_items(
|
||||||
|
item_type: Optional[RecurringItemType] = None,
|
||||||
|
is_active: Optional[bool] = None,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_user: str = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
query = select(RecurringItem)
|
||||||
|
if item_type:
|
||||||
|
query = query.where(RecurringItem.item_type == item_type)
|
||||||
|
if is_active is not None:
|
||||||
|
query = query.where(RecurringItem.is_active == is_active)
|
||||||
|
query = query.order_by(RecurringItem.item_type, RecurringItem.name)
|
||||||
|
return session.exec(query).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/recurring", response_model=RecurringItemRead, status_code=201)
|
||||||
|
def create_recurring_item(
|
||||||
|
data: RecurringItemCreate,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_user: str = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
item = RecurringItem.model_validate(data)
|
||||||
|
session.add(item)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(item)
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/recurring/{item_id}", response_model=RecurringItemRead)
|
||||||
|
def update_recurring_item(
|
||||||
|
item_id: int,
|
||||||
|
data: RecurringItemUpdate,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_user: str = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
item = session.get(RecurringItem, item_id)
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status_code=404, detail="Recurring item not found")
|
||||||
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
for key, value in update_data.items():
|
||||||
|
setattr(item, key, value)
|
||||||
|
session.add(item)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(item)
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/recurring/{item_id}", status_code=204)
|
||||||
|
def delete_recurring_item(
|
||||||
|
item_id: int,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_user: str = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
item = session.get(RecurringItem, item_id)
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status_code=404, detail="Recurring item not found")
|
||||||
|
session.delete(item)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Projection Endpoints ---
|
||||||
|
|
||||||
|
|
||||||
|
class MonthlyProjectionResponse(BaseModel):
|
||||||
|
month: int
|
||||||
|
year: int
|
||||||
|
projected_income: float
|
||||||
|
projected_fixed_expenses: float
|
||||||
|
projected_savings: float
|
||||||
|
actual_credit_card: float
|
||||||
|
actual_cash: float
|
||||||
|
actual_transfers: float
|
||||||
|
uncovered_actual: float
|
||||||
|
gran_total_egresos: float
|
||||||
|
net_balance: float
|
||||||
|
|
||||||
|
|
||||||
|
class YearlyProjectionResponse(BaseModel):
|
||||||
|
year: int
|
||||||
|
months: list[MonthlyProjectionResponse]
|
||||||
|
annual_income: float
|
||||||
|
annual_expenses: float
|
||||||
|
annual_savings: float
|
||||||
|
annual_net: float
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/projection/{year}", response_model=YearlyProjectionResponse)
|
||||||
|
def get_yearly_projection(
|
||||||
|
year: int,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_user: str = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
months = []
|
||||||
|
annual_income = 0.0
|
||||||
|
annual_expenses = 0.0
|
||||||
|
annual_savings = 0.0
|
||||||
|
annual_net = 0.0
|
||||||
|
|
||||||
|
for m in range(1, 13):
|
||||||
|
data = compute_monthly_projection(session, year, m)
|
||||||
|
monthly = MonthlyProjectionResponse(
|
||||||
|
month=data["month"],
|
||||||
|
year=data["year"],
|
||||||
|
projected_income=data["projected_income"],
|
||||||
|
projected_fixed_expenses=data["projected_fixed_expenses"],
|
||||||
|
projected_savings=data["projected_savings"],
|
||||||
|
actual_credit_card=data["actual_credit_card"],
|
||||||
|
actual_cash=data["actual_cash"],
|
||||||
|
actual_transfers=data["actual_transfers"],
|
||||||
|
uncovered_actual=data["uncovered_actual"],
|
||||||
|
gran_total_egresos=data["gran_total_egresos"],
|
||||||
|
net_balance=data["net_balance"],
|
||||||
|
)
|
||||||
|
months.append(monthly)
|
||||||
|
annual_income += data["projected_income"]
|
||||||
|
annual_expenses += data["gran_total_egresos"]
|
||||||
|
annual_savings += data["projected_savings"]
|
||||||
|
annual_net += data["net_balance"]
|
||||||
|
|
||||||
|
return YearlyProjectionResponse(
|
||||||
|
year=year,
|
||||||
|
months=months,
|
||||||
|
annual_income=annual_income,
|
||||||
|
annual_expenses=annual_expenses,
|
||||||
|
annual_savings=annual_savings,
|
||||||
|
annual_net=annual_net,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RecurringItemDetail(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
amount: float
|
||||||
|
projected_amount: float | None = None
|
||||||
|
used_actual: bool = False
|
||||||
|
item_type: str
|
||||||
|
frequency: str
|
||||||
|
category_name: str | None = None
|
||||||
|
category_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ActualsBySource(BaseModel):
|
||||||
|
source: str
|
||||||
|
total_compra: float
|
||||||
|
total_devolucion: float
|
||||||
|
net: float
|
||||||
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
class MonthlyDetailResponse(BaseModel):
|
||||||
|
year: int
|
||||||
|
month: int
|
||||||
|
income_items: list[RecurringItemDetail]
|
||||||
|
expense_items: list[RecurringItemDetail]
|
||||||
|
savings_items: list[RecurringItemDetail]
|
||||||
|
actuals_by_source: list[ActualsBySource]
|
||||||
|
total_projected_income: float
|
||||||
|
total_projected_expenses: float
|
||||||
|
total_projected_savings: float
|
||||||
|
uncovered_actual: float
|
||||||
|
gran_total_egresos: float
|
||||||
|
net_balance: float
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/month/{year}/{month}", response_model=MonthlyDetailResponse)
|
||||||
|
def get_monthly_detail(
|
||||||
|
year: int,
|
||||||
|
month: int = Path(ge=1, le=12),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_user: str = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
data = compute_monthly_projection(session, year, month)
|
||||||
|
return MonthlyDetailResponse(
|
||||||
|
year=data["year"],
|
||||||
|
month=data["month"],
|
||||||
|
income_items=[RecurringItemDetail(**i) for i in data["income_items"]],
|
||||||
|
expense_items=[RecurringItemDetail(**i) for i in data["expense_items"]],
|
||||||
|
savings_items=[RecurringItemDetail(**i) for i in data["savings_items"]],
|
||||||
|
actuals_by_source=[ActualsBySource(**a) for a in data["actuals_by_source"]],
|
||||||
|
total_projected_income=data["projected_income"],
|
||||||
|
total_projected_expenses=data["projected_fixed_expenses"],
|
||||||
|
total_projected_savings=data["projected_savings"],
|
||||||
|
uncovered_actual=data["uncovered_actual"],
|
||||||
|
gran_total_egresos=data["gran_total_egresos"],
|
||||||
|
net_balance=data["net_balance"],
|
||||||
|
)
|
||||||
93
backend/app/api/v1/endpoints/notifications.py
Normal file
93
backend/app/api/v1/endpoints/notifications.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pywebpush import WebPushException, webpush
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.auth import get_current_user
|
||||||
|
from app.config import settings
|
||||||
|
from app.db import get_session
|
||||||
|
from app.models.models import PushSubscription, PushSubscriptionCreate
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/notifications", tags=["notifications"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/vapid-public-key")
|
||||||
|
def get_vapid_public_key(_user: str = Depends(get_current_user)):
|
||||||
|
if not settings.VAPID_PUBLIC_KEY:
|
||||||
|
raise HTTPException(status_code=503, detail="Push notifications not configured")
|
||||||
|
return {"publicKey": settings.VAPID_PUBLIC_KEY}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/subscribe", status_code=201)
|
||||||
|
def subscribe(
|
||||||
|
data: PushSubscriptionCreate,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_user: str = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
existing = session.exec(
|
||||||
|
select(PushSubscription).where(PushSubscription.endpoint == data.endpoint)
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
existing.p256dh = data.keys["p256dh"]
|
||||||
|
existing.auth = data.keys["auth"]
|
||||||
|
session.add(existing)
|
||||||
|
session.commit()
|
||||||
|
return {"status": "updated"}
|
||||||
|
|
||||||
|
sub = PushSubscription(
|
||||||
|
endpoint=data.endpoint,
|
||||||
|
p256dh=data.keys["p256dh"],
|
||||||
|
auth=data.keys["auth"],
|
||||||
|
)
|
||||||
|
session.add(sub)
|
||||||
|
session.commit()
|
||||||
|
return {"status": "subscribed"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/unsubscribe")
|
||||||
|
def unsubscribe(
|
||||||
|
data: PushSubscriptionCreate,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_user: str = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
existing = session.exec(
|
||||||
|
select(PushSubscription).where(PushSubscription.endpoint == data.endpoint)
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
session.delete(existing)
|
||||||
|
session.commit()
|
||||||
|
return {"status": "unsubscribed"}
|
||||||
|
|
||||||
|
|
||||||
|
def send_push_to_all(session: Session, title: str, body: str, url: str = "/"):
|
||||||
|
"""Send a push notification to all registered subscriptions."""
|
||||||
|
if not settings.VAPID_PRIVATE_KEY or not settings.VAPID_PUBLIC_KEY:
|
||||||
|
logger.debug("VAPID keys not configured, skipping push notification")
|
||||||
|
return
|
||||||
|
|
||||||
|
subscriptions = session.exec(select(PushSubscription)).all()
|
||||||
|
payload = json.dumps({"title": title, "body": body, "url": url})
|
||||||
|
|
||||||
|
for sub in subscriptions:
|
||||||
|
subscription_info = {
|
||||||
|
"endpoint": sub.endpoint,
|
||||||
|
"keys": {"p256dh": sub.p256dh, "auth": sub.auth},
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
webpush(
|
||||||
|
subscription_info=subscription_info,
|
||||||
|
data=payload,
|
||||||
|
vapid_private_key=settings.VAPID_PRIVATE_KEY,
|
||||||
|
vapid_claims={"sub": settings.VAPID_CLAIM_EMAIL},
|
||||||
|
)
|
||||||
|
except WebPushException as e:
|
||||||
|
logger.warning("Push failed for %s: %s", sub.endpoint[:50], e)
|
||||||
|
if e.response and e.response.status_code in (404, 410):
|
||||||
|
session.delete(sub)
|
||||||
|
session.commit()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Unexpected push error for %s", sub.endpoint[:50])
|
||||||
@@ -7,8 +7,10 @@ from sqlmodel import Session, col, func, select
|
|||||||
|
|
||||||
from app.auth import get_current_user
|
from app.auth import get_current_user
|
||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
|
from app.api.v1.endpoints.notifications import send_push_to_all
|
||||||
from app.models.models import (
|
from app.models.models import (
|
||||||
Category,
|
Category,
|
||||||
|
Currency,
|
||||||
Transaction,
|
Transaction,
|
||||||
TransactionCreate,
|
TransactionCreate,
|
||||||
TransactionRead,
|
TransactionRead,
|
||||||
@@ -55,6 +57,8 @@ def list_transactions(
|
|||||||
category_id: Optional[int] = None,
|
category_id: Optional[int] = None,
|
||||||
cycle_year: Optional[int] = None,
|
cycle_year: Optional[int] = None,
|
||||||
cycle_month: Optional[int] = None,
|
cycle_month: Optional[int] = None,
|
||||||
|
start_date: Optional[str] = None,
|
||||||
|
end_date: Optional[str] = None,
|
||||||
limit: int = Query(default=50, le=500),
|
limit: int = Query(default=50, le=500),
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
@@ -70,6 +74,11 @@ def list_transactions(
|
|||||||
if cycle_year and cycle_month:
|
if cycle_year and cycle_month:
|
||||||
start, end = get_cycle_range(cycle_year, cycle_month)
|
start, end = get_cycle_range(cycle_year, cycle_month)
|
||||||
query = query.where(Transaction.date >= start, Transaction.date < end)
|
query = query.where(Transaction.date >= start, Transaction.date < end)
|
||||||
|
elif start_date and end_date:
|
||||||
|
query = query.where(
|
||||||
|
Transaction.date >= datetime.fromisoformat(start_date),
|
||||||
|
Transaction.date < datetime.fromisoformat(end_date),
|
||||||
|
)
|
||||||
query = query.order_by(col(Transaction.date).desc()).offset(offset).limit(limit)
|
query = query.order_by(col(Transaction.date).desc()).offset(offset).limit(limit)
|
||||||
return session.exec(query).all()
|
return session.exec(query).all()
|
||||||
|
|
||||||
@@ -170,6 +179,17 @@ def create_transaction(
|
|||||||
session.add(tx)
|
session.add(tx)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(tx)
|
session.refresh(tx)
|
||||||
|
|
||||||
|
# Send push notification
|
||||||
|
symbol = "₡" if tx.currency == Currency.CRC else tx.currency.value
|
||||||
|
amount_str = f"{symbol}{tx.amount:,.0f}" if tx.currency == Currency.CRC else f"{symbol}{tx.amount:,.2f}"
|
||||||
|
send_push_to_all(
|
||||||
|
session,
|
||||||
|
title=f"💳 {tx.merchant}",
|
||||||
|
body=f"{amount_str} — {tx.bank.value} {tx.transaction_type.value.lower()}",
|
||||||
|
url=f"/budget",
|
||||||
|
)
|
||||||
|
|
||||||
return tx
|
return tx
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ from app.api.v1.endpoints import (
|
|||||||
accounts,
|
accounts,
|
||||||
analytics,
|
analytics,
|
||||||
auth,
|
auth,
|
||||||
|
budget,
|
||||||
categories,
|
categories,
|
||||||
exchange_rate,
|
exchange_rate,
|
||||||
import_transactions,
|
import_transactions,
|
||||||
|
notifications,
|
||||||
settings,
|
settings,
|
||||||
tokens,
|
tokens,
|
||||||
transactions,
|
transactions,
|
||||||
@@ -22,3 +24,5 @@ api_router.include_router(exchange_rate.router)
|
|||||||
api_router.include_router(tokens.router)
|
api_router.include_router(tokens.router)
|
||||||
api_router.include_router(analytics.router)
|
api_router.include_router(analytics.router)
|
||||||
api_router.include_router(settings.router)
|
api_router.include_router(settings.router)
|
||||||
|
api_router.include_router(budget.router)
|
||||||
|
api_router.include_router(notifications.router)
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ class Settings(BaseSettings):
|
|||||||
ADMIN_PASSWORD: str = "admin"
|
ADMIN_PASSWORD: str = "admin"
|
||||||
BCCR_API_EMAIL: str = ""
|
BCCR_API_EMAIL: str = ""
|
||||||
BCCR_API_TOKEN: str = ""
|
BCCR_API_TOKEN: str = ""
|
||||||
|
VAPID_PRIVATE_KEY: str = ""
|
||||||
|
VAPID_PUBLIC_KEY: str = ""
|
||||||
|
VAPID_CLAIM_EMAIL: str = "mailto:admin@wealth.cescalante.dev"
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
|
|||||||
@@ -2,11 +2,24 @@ import enum
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from sqlalchemy import Column
|
from sqlalchemy import JSON, Column
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
|
||||||
from sqlmodel import Field, Relationship, SQLModel
|
from sqlmodel import Field, Relationship, SQLModel
|
||||||
|
|
||||||
|
|
||||||
|
class RecurringItemType(str, enum.Enum):
|
||||||
|
INCOME = "INCOME"
|
||||||
|
EXPENSE = "EXPENSE"
|
||||||
|
SAVINGS = "SAVINGS"
|
||||||
|
|
||||||
|
|
||||||
|
class RecurringFrequency(str, enum.Enum):
|
||||||
|
WEEKLY = "WEEKLY"
|
||||||
|
MONTHLY = "MONTHLY"
|
||||||
|
QUARTERLY = "QUARTERLY"
|
||||||
|
BIANNUAL = "BIANNUAL"
|
||||||
|
YEARLY = "YEARLY"
|
||||||
|
|
||||||
|
|
||||||
class TransactionType(str, enum.Enum):
|
class TransactionType(str, enum.Enum):
|
||||||
COMPRA = "COMPRA"
|
COMPRA = "COMPRA"
|
||||||
DEVOLUCION = "DEVOLUCION"
|
DEVOLUCION = "DEVOLUCION"
|
||||||
@@ -207,7 +220,7 @@ class UserSettings(SQLModel, table=True):
|
|||||||
key: str = Field(index=True, unique=True, default="default")
|
key: str = Field(index=True, unique=True, default="default")
|
||||||
data: dict = Field(
|
data: dict = Field(
|
||||||
default_factory=dict,
|
default_factory=dict,
|
||||||
sa_column=Column(JSONB, nullable=False, server_default="{}"),
|
sa_column=Column(JSON, nullable=False, server_default="{}"),
|
||||||
)
|
)
|
||||||
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
@@ -220,3 +233,69 @@ class UserSettingsRead(SQLModel):
|
|||||||
|
|
||||||
class UserSettingsUpdate(SQLModel):
|
class UserSettingsUpdate(SQLModel):
|
||||||
data: dict
|
data: dict
|
||||||
|
|
||||||
|
|
||||||
|
# --- Recurring Item ---
|
||||||
|
|
||||||
|
|
||||||
|
class RecurringItemBase(SQLModel):
|
||||||
|
name: str
|
||||||
|
amount: float
|
||||||
|
currency: Currency = Currency.CRC
|
||||||
|
item_type: RecurringItemType
|
||||||
|
frequency: RecurringFrequency = RecurringFrequency.MONTHLY
|
||||||
|
day_of_month: Optional[int] = None
|
||||||
|
month_of_year: Optional[int] = None
|
||||||
|
override_amounts: Optional[dict] = Field(
|
||||||
|
default=None,
|
||||||
|
sa_column=Column(JSON, nullable=True),
|
||||||
|
)
|
||||||
|
category_id: Optional[int] = Field(default=None, foreign_key="category.id")
|
||||||
|
is_active: bool = True
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class RecurringItem(RecurringItemBase, table=True):
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
category: Optional[Category] = Relationship()
|
||||||
|
|
||||||
|
|
||||||
|
class RecurringItemCreate(RecurringItemBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class RecurringItemRead(RecurringItemBase):
|
||||||
|
id: int
|
||||||
|
created_at: datetime
|
||||||
|
category: Optional[CategoryRead] = None
|
||||||
|
|
||||||
|
|
||||||
|
class RecurringItemUpdate(SQLModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
amount: Optional[float] = None
|
||||||
|
currency: Optional[Currency] = None
|
||||||
|
item_type: Optional[RecurringItemType] = None
|
||||||
|
frequency: Optional[RecurringFrequency] = None
|
||||||
|
day_of_month: Optional[int] = None
|
||||||
|
month_of_year: Optional[int] = None
|
||||||
|
override_amounts: Optional[dict] = None
|
||||||
|
category_id: Optional[int] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
# --- Push Subscription ---
|
||||||
|
|
||||||
|
|
||||||
|
class PushSubscription(SQLModel, table=True):
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
endpoint: str = Field(unique=True)
|
||||||
|
p256dh: str
|
||||||
|
auth: str
|
||||||
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class PushSubscriptionCreate(SQLModel):
|
||||||
|
endpoint: str
|
||||||
|
keys: dict # {"p256dh": "...", "auth": "..."}
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
from app.db import engine
|
from app.db import engine
|
||||||
from app.models.models import Account, AccountType, Bank, Category, Currency
|
from app.models.models import (
|
||||||
|
Account,
|
||||||
|
AccountType,
|
||||||
|
Bank,
|
||||||
|
Category,
|
||||||
|
Currency,
|
||||||
|
RecurringFrequency,
|
||||||
|
RecurringItem,
|
||||||
|
RecurringItemType,
|
||||||
|
)
|
||||||
|
|
||||||
DEFAULT_CATEGORIES = [
|
DEFAULT_CATEGORIES = [
|
||||||
("Groceries", "shopping-cart", "automercado,auto mercado,fresh market,macrobiotica,pricesmart,price smart,grassfedcr,pequeno mundo"),
|
("Groceries", "shopping-cart", "automercado,auto mercado,fresh market,macrobiotica,pricesmart,price smart,grassfedcr,pequeno mundo"),
|
||||||
@@ -45,6 +54,143 @@ DEFAULT_ACCOUNTS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_RECURRING_ITEMS = [
|
||||||
|
# Incomes
|
||||||
|
{
|
||||||
|
"name": "Alquiler Apt 1",
|
||||||
|
"amount": 320000,
|
||||||
|
"item_type": RecurringItemType.INCOME,
|
||||||
|
"frequency": RecurringFrequency.MONTHLY,
|
||||||
|
"day_of_month": 1,
|
||||||
|
"notes": "Tenant rent - start of month",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Alquiler Apt 2",
|
||||||
|
"amount": 360000,
|
||||||
|
"item_type": RecurringItemType.INCOME,
|
||||||
|
"frequency": RecurringFrequency.MONTHLY,
|
||||||
|
"day_of_month": 15,
|
||||||
|
"notes": "Tenant rent - mid month",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Salario Quincenal 1",
|
||||||
|
"amount": 1400000,
|
||||||
|
"item_type": RecurringItemType.INCOME,
|
||||||
|
"frequency": RecurringFrequency.MONTHLY,
|
||||||
|
"day_of_month": 15,
|
||||||
|
"notes": "Net salary - mid month",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Salario Quincenal 2",
|
||||||
|
"amount": 1400000,
|
||||||
|
"item_type": RecurringItemType.INCOME,
|
||||||
|
"frequency": RecurringFrequency.MONTHLY,
|
||||||
|
"day_of_month": 30,
|
||||||
|
"notes": "Net salary - end of month",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Aguinaldo",
|
||||||
|
"amount": 3000000,
|
||||||
|
"item_type": RecurringItemType.INCOME,
|
||||||
|
"frequency": RecurringFrequency.YEARLY,
|
||||||
|
"month_of_year": 12,
|
||||||
|
"notes": "Yearly bonus",
|
||||||
|
},
|
||||||
|
# Fixed expenses
|
||||||
|
{
|
||||||
|
"name": "Hipoteca",
|
||||||
|
"amount": 450000,
|
||||||
|
"item_type": RecurringItemType.EXPENSE,
|
||||||
|
"frequency": RecurringFrequency.MONTHLY,
|
||||||
|
"notes": "Mortgage payment estimate",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Comida y Gasolina",
|
||||||
|
"amount": 300000,
|
||||||
|
"item_type": RecurringItemType.EXPENSE,
|
||||||
|
"frequency": RecurringFrequency.MONTHLY,
|
||||||
|
"notes": "Food & Gas estimate",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CNFL",
|
||||||
|
"amount": 50000,
|
||||||
|
"item_type": RecurringItemType.EXPENSE,
|
||||||
|
"frequency": RecurringFrequency.MONTHLY,
|
||||||
|
"notes": "Electricity",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Internet",
|
||||||
|
"amount": 50000,
|
||||||
|
"item_type": RecurringItemType.EXPENSE,
|
||||||
|
"frequency": RecurringFrequency.MONTHLY,
|
||||||
|
"notes": "Internet service",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Municipalidad",
|
||||||
|
"amount": 30000,
|
||||||
|
"item_type": RecurringItemType.EXPENSE,
|
||||||
|
"frequency": RecurringFrequency.MONTHLY,
|
||||||
|
"override_amounts": {"3": 150000, "6": 150000, "9": 150000, "12": 150000},
|
||||||
|
"notes": "Local gov fees; 150k in property tax quarters",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Tennis y Limpieza",
|
||||||
|
"amount": 150000,
|
||||||
|
"item_type": RecurringItemType.EXPENSE,
|
||||||
|
"frequency": RecurringFrequency.MONTHLY,
|
||||||
|
"notes": "Tennis lessons + house cleaning",
|
||||||
|
},
|
||||||
|
# Cash transfers
|
||||||
|
{
|
||||||
|
"name": "Empleada Doméstica",
|
||||||
|
"amount": 20000,
|
||||||
|
"item_type": RecurringItemType.EXPENSE,
|
||||||
|
"frequency": RecurringFrequency.WEEKLY,
|
||||||
|
"day_of_month": 0,
|
||||||
|
"notes": "Weekly maid payment (~80k/month)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Clases de Tennis",
|
||||||
|
"amount": 50000,
|
||||||
|
"item_type": RecurringItemType.EXPENSE,
|
||||||
|
"frequency": RecurringFrequency.MONTHLY,
|
||||||
|
"notes": "Monthly tennis lessons cash transfer",
|
||||||
|
},
|
||||||
|
# Sporadic
|
||||||
|
{
|
||||||
|
"name": "CCE (Country Club)",
|
||||||
|
"amount": 720000,
|
||||||
|
"item_type": RecurringItemType.EXPENSE,
|
||||||
|
"frequency": RecurringFrequency.YEARLY,
|
||||||
|
"month_of_year": 2,
|
||||||
|
"notes": "Yearly country club fee",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Seguro Vehicular",
|
||||||
|
"amount": 150000,
|
||||||
|
"item_type": RecurringItemType.EXPENSE,
|
||||||
|
"frequency": RecurringFrequency.BIANNUAL,
|
||||||
|
"month_of_year": 1,
|
||||||
|
"notes": "Car insurance every 6 months (Jan, Jul)",
|
||||||
|
},
|
||||||
|
# Savings
|
||||||
|
{
|
||||||
|
"name": "Ahorro MEMP",
|
||||||
|
"amount": 200000,
|
||||||
|
"item_type": RecurringItemType.SAVINGS,
|
||||||
|
"frequency": RecurringFrequency.MONTHLY,
|
||||||
|
"notes": "Monthly savings to MEMP account",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Ahorro MPAT",
|
||||||
|
"amount": 200000,
|
||||||
|
"item_type": RecurringItemType.SAVINGS,
|
||||||
|
"frequency": RecurringFrequency.MONTHLY,
|
||||||
|
"notes": "Monthly savings to MPAT account",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def seed_db():
|
def seed_db():
|
||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
existing = session.exec(select(Category)).first()
|
existing = session.exec(select(Category)).first()
|
||||||
@@ -58,3 +204,9 @@ def seed_db():
|
|||||||
for bank, currency, label, account_type in DEFAULT_ACCOUNTS:
|
for bank, currency, label, account_type in DEFAULT_ACCOUNTS:
|
||||||
session.add(Account(bank=bank, currency=currency, label=label, account_type=account_type))
|
session.add(Account(bank=bank, currency=currency, label=label, account_type=account_type))
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
existing_recurring = session.exec(select(RecurringItem)).first()
|
||||||
|
if not existing_recurring:
|
||||||
|
for item_data in DEFAULT_RECURRING_ITEMS:
|
||||||
|
session.add(RecurringItem(**item_data))
|
||||||
|
session.commit()
|
||||||
|
|||||||
254
backend/app/services/budget_projection.py
Normal file
254
backend/app/services/budget_projection.py
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
import calendar
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlmodel import Session, func, select
|
||||||
|
|
||||||
|
from app.models.models import (
|
||||||
|
RecurringFrequency,
|
||||||
|
RecurringItem,
|
||||||
|
RecurringItemType,
|
||||||
|
Transaction,
|
||||||
|
TransactionSource,
|
||||||
|
TransactionType,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_effective_amount(item: RecurringItem, month: int, year: int) -> float | None:
|
||||||
|
"""Return the effective amount for a recurring item in a given month, or None if inactive."""
|
||||||
|
freq = item.frequency
|
||||||
|
|
||||||
|
if freq == RecurringFrequency.MONTHLY:
|
||||||
|
if item.override_amounts and str(month) in item.override_amounts:
|
||||||
|
return float(item.override_amounts[str(month)])
|
||||||
|
return item.amount
|
||||||
|
|
||||||
|
if freq == RecurringFrequency.WEEKLY:
|
||||||
|
# Count occurrences of the weekday in this month
|
||||||
|
# day_of_month stores day-of-week: 0=Monday
|
||||||
|
weekday = item.day_of_month if item.day_of_month is not None else 0
|
||||||
|
cal = calendar.monthcalendar(year, month)
|
||||||
|
count = sum(1 for week in cal if week[weekday] != 0)
|
||||||
|
return item.amount * count
|
||||||
|
|
||||||
|
if freq == RecurringFrequency.QUARTERLY:
|
||||||
|
# Active in months 3, 6, 9, 12 by default
|
||||||
|
if month % 3 == 0:
|
||||||
|
if item.override_amounts and str(month) in item.override_amounts:
|
||||||
|
return float(item.override_amounts[str(month)])
|
||||||
|
return item.amount
|
||||||
|
return None
|
||||||
|
|
||||||
|
if freq == RecurringFrequency.BIANNUAL:
|
||||||
|
# Active in month_of_year and 6 months later
|
||||||
|
base = item.month_of_year or 1
|
||||||
|
second = base + 6 if base <= 6 else base - 6
|
||||||
|
if month in (base, second):
|
||||||
|
return item.amount
|
||||||
|
return None
|
||||||
|
|
||||||
|
if freq == RecurringFrequency.YEARLY:
|
||||||
|
if month == (item.month_of_year or 12):
|
||||||
|
return item.amount
|
||||||
|
return None
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_month_range(year: int, month: int) -> tuple[datetime, datetime]:
|
||||||
|
"""Return (start, end) for a calendar month."""
|
||||||
|
start = datetime(year, month, 1)
|
||||||
|
if month == 12:
|
||||||
|
end = datetime(year + 1, 1, 1)
|
||||||
|
else:
|
||||||
|
end = datetime(year, month + 1, 1)
|
||||||
|
return start, end
|
||||||
|
|
||||||
|
|
||||||
|
def compute_actuals_by_source(
|
||||||
|
session: Session, year: int, month: int
|
||||||
|
) -> dict[str, dict]:
|
||||||
|
"""Query actual transaction totals for a calendar month, grouped by source."""
|
||||||
|
start, end = get_month_range(year, month)
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
for source in TransactionSource:
|
||||||
|
compra = session.exec(
|
||||||
|
select(func.coalesce(func.sum(Transaction.amount), 0)).where(
|
||||||
|
Transaction.date >= start,
|
||||||
|
Transaction.date < end,
|
||||||
|
Transaction.source == source,
|
||||||
|
Transaction.transaction_type == TransactionType.COMPRA,
|
||||||
|
)
|
||||||
|
).one()
|
||||||
|
devolucion = session.exec(
|
||||||
|
select(func.coalesce(func.sum(Transaction.amount), 0)).where(
|
||||||
|
Transaction.date >= start,
|
||||||
|
Transaction.date < end,
|
||||||
|
Transaction.source == source,
|
||||||
|
Transaction.transaction_type == TransactionType.DEVOLUCION,
|
||||||
|
)
|
||||||
|
).one()
|
||||||
|
count = session.exec(
|
||||||
|
select(func.count()).where(
|
||||||
|
Transaction.date >= start,
|
||||||
|
Transaction.date < end,
|
||||||
|
Transaction.source == source,
|
||||||
|
)
|
||||||
|
).one()
|
||||||
|
|
||||||
|
compra_val = float(compra)
|
||||||
|
devolucion_val = float(devolucion)
|
||||||
|
results[source.value] = {
|
||||||
|
"source": source.value,
|
||||||
|
"total_compra": compra_val,
|
||||||
|
"total_devolucion": devolucion_val,
|
||||||
|
"net": compra_val - devolucion_val,
|
||||||
|
"count": count,
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def compute_actuals_by_category(
|
||||||
|
session: Session, year: int, month: int
|
||||||
|
) -> dict[int, float]:
|
||||||
|
"""Return {category_id: net_amount} for actual transactions in a calendar month."""
|
||||||
|
start, end = get_month_range(year, month)
|
||||||
|
|
||||||
|
rows = session.exec(
|
||||||
|
select(
|
||||||
|
Transaction.category_id,
|
||||||
|
Transaction.transaction_type,
|
||||||
|
func.sum(Transaction.amount),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
Transaction.date >= start,
|
||||||
|
Transaction.date < end,
|
||||||
|
Transaction.category_id.is_not(None), # type: ignore[union-attr]
|
||||||
|
)
|
||||||
|
.group_by(Transaction.category_id, Transaction.transaction_type)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
totals: dict[int, float] = {}
|
||||||
|
for cat_id, tx_type, amount in rows:
|
||||||
|
val = float(amount)
|
||||||
|
if tx_type == TransactionType.DEVOLUCION:
|
||||||
|
val = -val
|
||||||
|
totals[cat_id] = totals.get(cat_id, 0) + val
|
||||||
|
return totals
|
||||||
|
|
||||||
|
|
||||||
|
def compute_monthly_projection(
|
||||||
|
session: Session, year: int, month: int
|
||||||
|
) -> dict:
|
||||||
|
"""Compute full monthly projection with no-double-count logic."""
|
||||||
|
items = session.exec(
|
||||||
|
select(RecurringItem).where(RecurringItem.is_active == True) # noqa: E712
|
||||||
|
).all()
|
||||||
|
|
||||||
|
actuals_by_source = compute_actuals_by_source(session, year, month)
|
||||||
|
actuals_by_category = compute_actuals_by_category(session, year, month)
|
||||||
|
|
||||||
|
income_items = []
|
||||||
|
expense_items = []
|
||||||
|
savings_items = []
|
||||||
|
|
||||||
|
total_income = 0.0
|
||||||
|
total_fixed_expenses = 0.0
|
||||||
|
total_savings = 0.0
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
effective = get_effective_amount(item, month, year)
|
||||||
|
if effective is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
detail = {
|
||||||
|
"id": item.id,
|
||||||
|
"name": item.name,
|
||||||
|
"amount": effective,
|
||||||
|
"item_type": item.item_type.value,
|
||||||
|
"frequency": item.frequency.value,
|
||||||
|
"category_name": item.category.name if item.category else None,
|
||||||
|
"category_id": item.category_id,
|
||||||
|
"used_actual": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.item_type == RecurringItemType.INCOME:
|
||||||
|
income_items.append(detail)
|
||||||
|
total_income += effective
|
||||||
|
|
||||||
|
elif item.item_type == RecurringItemType.EXPENSE:
|
||||||
|
# No-double-count: if category has actuals, use actual instead
|
||||||
|
if item.category_id and item.category_id in actuals_by_category:
|
||||||
|
actual_amount = actuals_by_category[item.category_id]
|
||||||
|
detail["amount"] = actual_amount
|
||||||
|
detail["projected_amount"] = effective
|
||||||
|
detail["used_actual"] = True
|
||||||
|
total_fixed_expenses += actual_amount
|
||||||
|
else:
|
||||||
|
total_fixed_expenses += effective
|
||||||
|
expense_items.append(detail)
|
||||||
|
|
||||||
|
elif item.item_type == RecurringItemType.SAVINGS:
|
||||||
|
savings_items.append(detail)
|
||||||
|
total_savings += effective
|
||||||
|
|
||||||
|
# Sum actuals from sources for categories NOT covered by recurring items
|
||||||
|
covered_category_ids = {
|
||||||
|
item.category_id
|
||||||
|
for item in items
|
||||||
|
if item.item_type == RecurringItemType.EXPENSE
|
||||||
|
and item.category_id is not None
|
||||||
|
and get_effective_amount(item, month, year) is not None
|
||||||
|
}
|
||||||
|
|
||||||
|
uncovered_actual = 0.0
|
||||||
|
for cat_id, amount in actuals_by_category.items():
|
||||||
|
if cat_id not in covered_category_ids:
|
||||||
|
uncovered_actual += amount
|
||||||
|
|
||||||
|
# Also add transactions with no category
|
||||||
|
start, end = get_month_range(year, month)
|
||||||
|
uncategorized = session.exec(
|
||||||
|
select(
|
||||||
|
Transaction.transaction_type,
|
||||||
|
func.sum(Transaction.amount),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
Transaction.date >= start,
|
||||||
|
Transaction.date < end,
|
||||||
|
Transaction.category_id.is_(None), # type: ignore[union-attr]
|
||||||
|
)
|
||||||
|
.group_by(Transaction.transaction_type)
|
||||||
|
).all()
|
||||||
|
for tx_type, amount in uncategorized:
|
||||||
|
val = float(amount)
|
||||||
|
if tx_type == TransactionType.DEVOLUCION:
|
||||||
|
val = -val
|
||||||
|
uncovered_actual += val
|
||||||
|
|
||||||
|
actual_credit_card = actuals_by_source.get("CREDIT_CARD", {}).get("net", 0)
|
||||||
|
actual_cash = actuals_by_source.get("CASH", {}).get("net", 0)
|
||||||
|
actual_transfers = actuals_by_source.get("TRANSFER", {}).get("net", 0)
|
||||||
|
|
||||||
|
gran_total = total_fixed_expenses + uncovered_actual
|
||||||
|
# Savings are NOT deducted — they are already deducted from gross salary
|
||||||
|
# (the income amounts are net, post-savings)
|
||||||
|
net_balance = total_income - gran_total
|
||||||
|
|
||||||
|
return {
|
||||||
|
"year": year,
|
||||||
|
"month": month,
|
||||||
|
"projected_income": total_income,
|
||||||
|
"projected_fixed_expenses": total_fixed_expenses,
|
||||||
|
"projected_savings": total_savings,
|
||||||
|
"actual_credit_card": actual_credit_card,
|
||||||
|
"actual_cash": actual_cash,
|
||||||
|
"actual_transfers": actual_transfers,
|
||||||
|
"uncovered_actual": uncovered_actual,
|
||||||
|
"gran_total_egresos": gran_total,
|
||||||
|
"net_balance": net_balance,
|
||||||
|
"income_items": income_items,
|
||||||
|
"expense_items": expense_items,
|
||||||
|
"savings_items": savings_items,
|
||||||
|
"actuals_by_source": list(actuals_by_source.values()),
|
||||||
|
}
|
||||||
@@ -8,3 +8,5 @@ python-multipart
|
|||||||
python-dotenv
|
python-dotenv
|
||||||
alembic
|
alembic
|
||||||
httpx
|
httpx
|
||||||
|
pywebpush
|
||||||
|
py-vapid
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ services:
|
|||||||
SECRET_KEY: ${SECRET_KEY}
|
SECRET_KEY: ${SECRET_KEY}
|
||||||
ADMIN_USERNAME: ${ADMIN_USERNAME}
|
ADMIN_USERNAME: ${ADMIN_USERNAME}
|
||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||||
|
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
|
||||||
|
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
|
||||||
expose:
|
expose:
|
||||||
- "8000"
|
- "8000"
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ services:
|
|||||||
container_name: wealthysmart-backend-dev
|
container_name: wealthysmart-backend-dev
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql://wealthy_user:wealthy_pass@db:5432/wealthysmart
|
DATABASE_URL: postgresql://wealthy_user:wealthy_pass@db:5432/wealthysmart
|
||||||
|
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
|
||||||
|
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
|
||||||
ports:
|
ports:
|
||||||
- "8001:8000"
|
- "8001:8000"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ self.addEventListener('fetch', (event) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only handle http(s) requests — skip chrome-extension:// etc.
|
||||||
|
if (!url.protocol.startsWith('http')) return;
|
||||||
|
|
||||||
// Cache-first for static assets
|
// Cache-first for static assets
|
||||||
if (url.pathname.startsWith('/assets/')) {
|
if (url.pathname.startsWith('/assets/')) {
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
@@ -50,3 +53,46 @@ self.addEventListener('fetch', (event) => {
|
|||||||
// Default: network with cache fallback
|
// Default: network with cache fallback
|
||||||
event.respondWith(fetch(request).catch(() => caches.match(request)));
|
event.respondWith(fetch(request).catch(() => caches.match(request)));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Push Notifications ---
|
||||||
|
|
||||||
|
self.addEventListener('push', (event) => {
|
||||||
|
if (!event.data) return;
|
||||||
|
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = event.data.json();
|
||||||
|
} catch {
|
||||||
|
// Fallback for plain-text pushes (e.g. browser test pushes)
|
||||||
|
data = { title: 'WealthySmart', body: event.data.text() };
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
body: data.body,
|
||||||
|
icon: '/icons/icon-192.png',
|
||||||
|
badge: '/icons/icon-192.png',
|
||||||
|
data: { url: data.url || '/' },
|
||||||
|
vibrate: [200, 100, 200],
|
||||||
|
tag: 'transaction',
|
||||||
|
renotify: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
event.waitUntil(self.registration.showNotification(data.title, options));
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('notificationclick', (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
|
||||||
|
const url = event.notification.data?.url || '/';
|
||||||
|
event.waitUntil(
|
||||||
|
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => {
|
||||||
|
for (const client of windowClients) {
|
||||||
|
if (client.url.includes(self.location.origin) && 'focus' in client) {
|
||||||
|
client.navigate(url);
|
||||||
|
return client.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return clients.openWindow(url);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ import { ThemeProvider } from './ThemeContext';
|
|||||||
import Layout from './components/Layout';
|
import Layout from './components/Layout';
|
||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
import Dashboard from './pages/Dashboard';
|
import Dashboard from './pages/Dashboard';
|
||||||
import Transactions from './pages/Transactions';
|
import Budget from './pages/Budget';
|
||||||
import Transfers from './pages/Transfers';
|
|
||||||
import Analytics from './pages/Analytics';
|
import Analytics from './pages/Analytics';
|
||||||
|
|
||||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
@@ -30,9 +29,11 @@ function AppRoutes() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
<Route path="/transactions" element={<Transactions />} />
|
<Route path="/budget" element={<Budget />} />
|
||||||
<Route path="/analytics" element={<Analytics />} />
|
<Route path="/analytics" element={<Analytics />} />
|
||||||
<Route path="/transfers" element={<Transfers />} />
|
{/* Redirect old routes */}
|
||||||
|
<Route path="/transactions" element={<Navigate to="/budget" replace />} />
|
||||||
|
<Route path="/transfers" element={<Navigate to="/budget" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -103,3 +103,125 @@ export interface Transaction {
|
|||||||
category: Category | null;
|
category: Category | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Budget / Recurring Items ---
|
||||||
|
|
||||||
|
export type RecurringItemType = 'INCOME' | 'EXPENSE' | 'SAVINGS';
|
||||||
|
export type RecurringFrequency = 'WEEKLY' | 'MONTHLY' | 'QUARTERLY' | 'BIANNUAL' | 'YEARLY';
|
||||||
|
|
||||||
|
export interface RecurringItem {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
item_type: RecurringItemType;
|
||||||
|
frequency: RecurringFrequency;
|
||||||
|
day_of_month: number | null;
|
||||||
|
month_of_year: number | null;
|
||||||
|
override_amounts: Record<string, number> | null;
|
||||||
|
category_id: number | null;
|
||||||
|
is_active: boolean;
|
||||||
|
notes: string | null;
|
||||||
|
created_at: string;
|
||||||
|
category: Category | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecurringItemCreate {
|
||||||
|
name: string;
|
||||||
|
amount: number;
|
||||||
|
currency?: string;
|
||||||
|
item_type: RecurringItemType;
|
||||||
|
frequency?: RecurringFrequency;
|
||||||
|
day_of_month?: number | null;
|
||||||
|
month_of_year?: number | null;
|
||||||
|
override_amounts?: Record<string, number> | null;
|
||||||
|
category_id?: number | null;
|
||||||
|
is_active?: boolean;
|
||||||
|
notes?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecurringItemUpdate {
|
||||||
|
name?: string;
|
||||||
|
amount?: number;
|
||||||
|
currency?: string;
|
||||||
|
item_type?: RecurringItemType;
|
||||||
|
frequency?: RecurringFrequency;
|
||||||
|
day_of_month?: number | null;
|
||||||
|
month_of_year?: number | null;
|
||||||
|
override_amounts?: Record<string, number> | null;
|
||||||
|
category_id?: number | null;
|
||||||
|
is_active?: boolean;
|
||||||
|
notes?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecurringItemDetail {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
amount: number;
|
||||||
|
projected_amount: number | null;
|
||||||
|
used_actual: boolean;
|
||||||
|
item_type: string;
|
||||||
|
frequency: string;
|
||||||
|
category_name: string | null;
|
||||||
|
category_id: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActualsBySource {
|
||||||
|
source: string;
|
||||||
|
total_compra: number;
|
||||||
|
total_devolucion: number;
|
||||||
|
net: number;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonthlyProjection {
|
||||||
|
month: number;
|
||||||
|
year: number;
|
||||||
|
projected_income: number;
|
||||||
|
projected_fixed_expenses: number;
|
||||||
|
projected_savings: number;
|
||||||
|
actual_credit_card: number;
|
||||||
|
actual_cash: number;
|
||||||
|
actual_transfers: number;
|
||||||
|
uncovered_actual: number;
|
||||||
|
gran_total_egresos: number;
|
||||||
|
net_balance: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface YearlyProjection {
|
||||||
|
year: number;
|
||||||
|
months: MonthlyProjection[];
|
||||||
|
annual_income: number;
|
||||||
|
annual_expenses: number;
|
||||||
|
annual_savings: number;
|
||||||
|
annual_net: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonthlyDetail {
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
income_items: RecurringItemDetail[];
|
||||||
|
expense_items: RecurringItemDetail[];
|
||||||
|
savings_items: RecurringItemDetail[];
|
||||||
|
actuals_by_source: ActualsBySource[];
|
||||||
|
total_projected_income: number;
|
||||||
|
total_projected_expenses: number;
|
||||||
|
total_projected_savings: number;
|
||||||
|
uncovered_actual: number;
|
||||||
|
gran_total_egresos: number;
|
||||||
|
net_balance: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Budget API functions
|
||||||
|
export const getRecurringItems = (params?: { item_type?: string; is_active?: boolean }) =>
|
||||||
|
api.get<RecurringItem[]>('/budget/recurring', { params });
|
||||||
|
export const createRecurringItem = (data: RecurringItemCreate) =>
|
||||||
|
api.post<RecurringItem>('/budget/recurring', data);
|
||||||
|
export const updateRecurringItem = (id: number, data: RecurringItemUpdate) =>
|
||||||
|
api.patch<RecurringItem>(`/budget/recurring/${id}`, data);
|
||||||
|
export const deleteRecurringItem = (id: number) =>
|
||||||
|
api.delete(`/budget/recurring/${id}`);
|
||||||
|
export const getYearlyProjection = (year: number) =>
|
||||||
|
api.get<YearlyProjection>(`/budget/projection/${year}`);
|
||||||
|
export const getMonthlyDetail = (year: number, month: number) =>
|
||||||
|
api.get<MonthlyDetail>(`/budget/month/${year}/${month}`);
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
CreditCard,
|
Calculator,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
ArrowLeftRight,
|
|
||||||
LogOut,
|
LogOut,
|
||||||
Wallet,
|
Wallet,
|
||||||
Menu,
|
Menu,
|
||||||
Sun,
|
Sun,
|
||||||
Moon,
|
Moon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useAuth } from '../AuthContext';
|
import { useAuth } from '../AuthContext';
|
||||||
import { useTheme } from '../ThemeContext';
|
import { useTheme } from '../ThemeContext';
|
||||||
|
import { subscribeToPush } from '../pushNotifications';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
@@ -26,9 +26,8 @@ import { cn } from '@/lib/utils';
|
|||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ to: '/', icon: LayoutDashboard, label: 'Dashboard' },
|
{ to: '/', icon: LayoutDashboard, label: 'Dashboard' },
|
||||||
{ to: '/transactions', icon: CreditCard, label: 'Transactions' },
|
{ to: '/budget', icon: Calculator, label: 'Presupuesto' },
|
||||||
{ to: '/analytics', icon: BarChart3, label: 'Analytics' },
|
{ to: '/analytics', icon: BarChart3, label: 'Analytics' },
|
||||||
{ to: '/transfers', icon: ArrowLeftRight, label: 'Cash & Transfers' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
@@ -37,6 +36,10 @@ export default function Layout() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
subscribeToPush();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
logout();
|
logout();
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
|
|||||||
235
frontend/src/components/budget/MonthlyDetail.tsx
Normal file
235
frontend/src/components/budget/MonthlyDetail.tsx
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
import { type MonthlyDetail as MonthlyDetailType } from '@/api';
|
||||||
|
import { formatAmount } from '@/lib/format';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import {
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown,
|
||||||
|
PiggyBank,
|
||||||
|
CreditCard,
|
||||||
|
Banknote,
|
||||||
|
ArrowLeftRight,
|
||||||
|
Info,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
const MONTH_NAMES = [
|
||||||
|
'', 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
|
||||||
|
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre',
|
||||||
|
];
|
||||||
|
|
||||||
|
const SOURCE_LABELS: Record<string, { label: string; icon: typeof CreditCard }> = {
|
||||||
|
CREDIT_CARD: { label: 'Tarjeta de Crédito', icon: CreditCard },
|
||||||
|
CASH: { label: 'Efectivo', icon: Banknote },
|
||||||
|
TRANSFER: { label: 'Transferencias', icon: ArrowLeftRight },
|
||||||
|
};
|
||||||
|
|
||||||
|
interface MonthlyDetailProps {
|
||||||
|
detail: MonthlyDetailType;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MonthlyDetail({ detail, loading }: MonthlyDetailProps) {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<Card key={i} className="animate-pulse">
|
||||||
|
<CardContent className="h-48" />
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold">
|
||||||
|
Detalle: {MONTH_NAMES[detail.month]} {detail.year}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
{/* Income Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<TrendingUp className="w-4 h-4 text-primary" />
|
||||||
|
Ingresos
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{detail.income_items.map((item) => (
|
||||||
|
<div key={item.id} className="flex items-center justify-between text-sm">
|
||||||
|
<span className="truncate mr-2">{item.name}</span>
|
||||||
|
<span className="font-mono text-primary whitespace-nowrap">
|
||||||
|
{formatAmount(item.amount, 'CRC')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Separator />
|
||||||
|
<div className="flex items-center justify-between font-semibold text-sm">
|
||||||
|
<span>Total</span>
|
||||||
|
<span className="font-mono text-primary">
|
||||||
|
{formatAmount(detail.total_projected_income, 'CRC')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Expenses Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<TrendingDown className="w-4 h-4 text-destructive" />
|
||||||
|
Egresos Fijos
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{detail.expense_items.map((item) => (
|
||||||
|
<div key={item.id} className="flex items-center justify-between text-sm">
|
||||||
|
<div className="flex items-center gap-1 truncate mr-2">
|
||||||
|
<span className="truncate">{item.name}</span>
|
||||||
|
{item.used_actual && (
|
||||||
|
<Badge variant="secondary" className="text-[10px] px-1 py-0 shrink-0">
|
||||||
|
real
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-right whitespace-nowrap">
|
||||||
|
<span className="font-mono">{formatAmount(item.amount, 'CRC')}</span>
|
||||||
|
{item.used_actual && item.projected_amount != null && (
|
||||||
|
<span className="block text-[10px] text-muted-foreground font-mono line-through">
|
||||||
|
{formatAmount(item.projected_amount, 'CRC')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{detail.expense_items.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">Sin egresos fijos</p>
|
||||||
|
)}
|
||||||
|
<Separator />
|
||||||
|
<div className="flex items-center justify-between font-semibold text-sm">
|
||||||
|
<span>Total Fijos</span>
|
||||||
|
<span className="font-mono">
|
||||||
|
{formatAmount(detail.total_projected_expenses, 'CRC')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Actuals Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<CreditCard className="w-4 h-4" />
|
||||||
|
Transacciones Reales
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{detail.actuals_by_source.map((src) => {
|
||||||
|
const meta = SOURCE_LABELS[src.source];
|
||||||
|
if (!meta || src.count === 0) return null;
|
||||||
|
const Icon = meta.icon;
|
||||||
|
return (
|
||||||
|
<div key={src.source} className="flex items-center justify-between text-sm">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Icon className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
<span>{meta.label}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">({src.count})</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono whitespace-nowrap">
|
||||||
|
{formatAmount(src.net, 'CRC')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{detail.uncovered_actual > 0 && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Info className="w-3 h-3 text-muted-foreground" />
|
||||||
|
<span className="text-muted-foreground">No cubierto por fijos</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono">{formatAmount(detail.uncovered_actual, 'CRC')}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Savings + Summary */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
{/* Savings */}
|
||||||
|
{detail.savings_items.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<PiggyBank className="w-4 h-4" />
|
||||||
|
Ahorro
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{detail.savings_items.map((item) => (
|
||||||
|
<div key={item.id} className="flex items-center justify-between text-sm">
|
||||||
|
<span>{item.name}</span>
|
||||||
|
<span className="font-mono">{formatAmount(item.amount, 'CRC')}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Separator />
|
||||||
|
<div className="flex items-center justify-between font-semibold text-sm">
|
||||||
|
<span>Total Ahorro</span>
|
||||||
|
<span className="font-mono">
|
||||||
|
{formatAmount(detail.total_projected_savings, 'CRC')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
<Card className={cn(
|
||||||
|
'border-2',
|
||||||
|
detail.net_balance >= 0 ? 'border-primary/30' : 'border-destructive/30',
|
||||||
|
)}>
|
||||||
|
<CardContent className="pt-6 space-y-3">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span>Total Ingresos</span>
|
||||||
|
<span className="font-mono font-medium text-primary">
|
||||||
|
+{formatAmount(detail.total_projected_income, 'CRC')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span>Gran Total Egresos</span>
|
||||||
|
<span className="font-mono font-medium">
|
||||||
|
-{formatAmount(detail.gran_total_egresos, 'CRC')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span>Ahorro</span>
|
||||||
|
<span className="font-mono font-medium">
|
||||||
|
-{formatAmount(detail.total_projected_savings, 'CRC')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-semibold">Balance Neto</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'font-mono font-bold text-lg',
|
||||||
|
detail.net_balance >= 0 ? 'text-primary' : 'text-destructive',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{detail.net_balance >= 0 ? '+' : ''}
|
||||||
|
{formatAmount(detail.net_balance, 'CRC')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
310
frontend/src/components/budget/RecurringItemDialog.tsx
Normal file
310
frontend/src/components/budget/RecurringItemDialog.tsx
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
type RecurringItem,
|
||||||
|
type RecurringItemCreate,
|
||||||
|
type RecurringItemUpdate,
|
||||||
|
type RecurringItemType,
|
||||||
|
type RecurringFrequency,
|
||||||
|
} from '@/api';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Plus, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
|
const TYPE_OPTIONS: { value: RecurringItemType; label: string }[] = [
|
||||||
|
{ value: 'INCOME', label: 'Ingreso' },
|
||||||
|
{ value: 'EXPENSE', label: 'Egreso' },
|
||||||
|
{ value: 'SAVINGS', label: 'Ahorro' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const FREQ_OPTIONS: { value: RecurringFrequency; label: string }[] = [
|
||||||
|
{ value: 'WEEKLY', label: 'Semanal' },
|
||||||
|
{ value: 'MONTHLY', label: 'Mensual' },
|
||||||
|
{ value: 'QUARTERLY', label: 'Trimestral' },
|
||||||
|
{ value: 'BIANNUAL', label: 'Semestral' },
|
||||||
|
{ value: 'YEARLY', label: 'Anual' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MONTH_LABELS = [
|
||||||
|
'', 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
|
||||||
|
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre',
|
||||||
|
];
|
||||||
|
|
||||||
|
interface RecurringItemDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
item?: RecurringItem | null;
|
||||||
|
onSave: (data: RecurringItemCreate | RecurringItemUpdate) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RecurringItemDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
item,
|
||||||
|
onSave,
|
||||||
|
}: RecurringItemDialogProps) {
|
||||||
|
const isEdit = !!item;
|
||||||
|
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [amount, setAmount] = useState('');
|
||||||
|
const [itemType, setItemType] = useState<RecurringItemType>('EXPENSE');
|
||||||
|
const [frequency, setFrequency] = useState<RecurringFrequency>('MONTHLY');
|
||||||
|
const [dayOfMonth, setDayOfMonth] = useState('');
|
||||||
|
const [monthOfYear, setMonthOfYear] = useState('');
|
||||||
|
const [overrides, setOverrides] = useState<{ month: string; amount: string }[]>([]);
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
if (item) {
|
||||||
|
setName(item.name);
|
||||||
|
setAmount(String(item.amount));
|
||||||
|
setItemType(item.item_type);
|
||||||
|
setFrequency(item.frequency);
|
||||||
|
setDayOfMonth(item.day_of_month != null ? String(item.day_of_month) : '');
|
||||||
|
setMonthOfYear(item.month_of_year != null ? String(item.month_of_year) : '');
|
||||||
|
setOverrides(
|
||||||
|
item.override_amounts
|
||||||
|
? Object.entries(item.override_amounts).map(([m, a]) => ({
|
||||||
|
month: m,
|
||||||
|
amount: String(a),
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
setNotes(item.notes || '');
|
||||||
|
} else {
|
||||||
|
setName('');
|
||||||
|
setAmount('');
|
||||||
|
setItemType('EXPENSE');
|
||||||
|
setFrequency('MONTHLY');
|
||||||
|
setDayOfMonth('');
|
||||||
|
setMonthOfYear('');
|
||||||
|
setOverrides([]);
|
||||||
|
setNotes('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [open, item]);
|
||||||
|
|
||||||
|
const showDayOfMonth = frequency === 'MONTHLY' || frequency === 'WEEKLY';
|
||||||
|
const showMonthOfYear = frequency === 'YEARLY' || frequency === 'BIANNUAL';
|
||||||
|
const showOverrides = frequency === 'MONTHLY';
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const overrideAmounts =
|
||||||
|
overrides.length > 0
|
||||||
|
? Object.fromEntries(
|
||||||
|
overrides
|
||||||
|
.filter((o) => o.month && o.amount)
|
||||||
|
.map((o) => [o.month, parseFloat(o.amount)]),
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
name,
|
||||||
|
amount: parseFloat(amount),
|
||||||
|
item_type: itemType,
|
||||||
|
frequency,
|
||||||
|
day_of_month: dayOfMonth ? parseInt(dayOfMonth) : null,
|
||||||
|
month_of_year: monthOfYear ? parseInt(monthOfYear) : null,
|
||||||
|
override_amounts: overrideAmounts,
|
||||||
|
notes: notes || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
await onSave(data);
|
||||||
|
onOpenChange(false);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{isEdit ? 'Editar' : 'Nuevo'} Item Recurrente</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="ri-name">Nombre</Label>
|
||||||
|
<Input id="ri-name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="ri-amount">Monto (CRC)</Label>
|
||||||
|
<Input
|
||||||
|
id="ri-amount"
|
||||||
|
type="number"
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Tipo</Label>
|
||||||
|
<Select value={itemType} onValueChange={(v) => v && setItemType(v as RecurringItemType)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{TYPE_OPTIONS.map((o) => (
|
||||||
|
<SelectItem key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Frecuencia</Label>
|
||||||
|
<Select value={frequency} onValueChange={(v) => v && setFrequency(v as RecurringFrequency)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{FREQ_OPTIONS.map((o) => (
|
||||||
|
<SelectItem key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showDayOfMonth && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="ri-day">
|
||||||
|
{frequency === 'WEEKLY' ? 'Día de semana (0=Lun)' : 'Día del mes'}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="ri-day"
|
||||||
|
type="number"
|
||||||
|
value={dayOfMonth}
|
||||||
|
onChange={(e) => setDayOfMonth(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showMonthOfYear && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Mes</Label>
|
||||||
|
<Select value={monthOfYear} onValueChange={(v) => v && setMonthOfYear(v)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Seleccionar" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{Array.from({ length: 12 }, (_, i) => i + 1).map((m) => (
|
||||||
|
<SelectItem key={m} value={String(m)}>
|
||||||
|
{MONTH_LABELS[m]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showOverrides && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs text-muted-foreground">
|
||||||
|
Montos por mes (sobreescrituras)
|
||||||
|
</Label>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setOverrides([...overrides, { month: '', amount: '' }])}
|
||||||
|
>
|
||||||
|
<Plus className="w-3 h-3 mr-1" />
|
||||||
|
Agregar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{overrides.map((o, idx) => (
|
||||||
|
<div key={idx} className="flex items-center gap-2">
|
||||||
|
<Select
|
||||||
|
value={o.month}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
if (!v) return;
|
||||||
|
const next = [...overrides];
|
||||||
|
next[idx].month = v;
|
||||||
|
setOverrides(next);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-28">
|
||||||
|
<SelectValue placeholder="Mes" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{Array.from({ length: 12 }, (_, i) => i + 1).map((m) => (
|
||||||
|
<SelectItem key={m} value={String(m)}>
|
||||||
|
{MONTH_LABELS[m]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="Monto"
|
||||||
|
value={o.amount}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = [...overrides];
|
||||||
|
next[idx].amount = e.target.value;
|
||||||
|
setOverrides(next);
|
||||||
|
}}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setOverrides(overrides.filter((_, i) => i !== idx))}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="ri-notes">Notas</Label>
|
||||||
|
<Textarea
|
||||||
|
id="ri-notes"
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={saving || !name || !amount}>
|
||||||
|
{saving ? 'Guardando...' : isEdit ? 'Guardar' : 'Crear'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
184
frontend/src/components/budget/RecurringItemsManager.tsx
Normal file
184
frontend/src/components/budget/RecurringItemsManager.tsx
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { type ColumnDef } from '@tanstack/react-table';
|
||||||
|
import {
|
||||||
|
type RecurringItem,
|
||||||
|
type RecurringItemCreate,
|
||||||
|
type RecurringItemUpdate,
|
||||||
|
} from '@/api';
|
||||||
|
import { formatAmount } from '@/lib/format';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { DataTable } from '@/components/ui/data-table';
|
||||||
|
import { DataTableColumnHeader } from '@/components/ui/data-table-column-header';
|
||||||
|
import { Pencil, Plus, Trash2 } from 'lucide-react';
|
||||||
|
import RecurringItemDialog from './RecurringItemDialog';
|
||||||
|
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||||
|
|
||||||
|
const TYPE_LABELS: Record<string, { label: string; variant: 'default' | 'secondary' | 'outline' }> = {
|
||||||
|
INCOME: { label: 'Ingreso', variant: 'default' },
|
||||||
|
EXPENSE: { label: 'Egreso', variant: 'secondary' },
|
||||||
|
SAVINGS: { label: 'Ahorro', variant: 'outline' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const FREQ_LABELS: Record<string, string> = {
|
||||||
|
WEEKLY: 'Semanal',
|
||||||
|
MONTHLY: 'Mensual',
|
||||||
|
QUARTERLY: 'Trimestral',
|
||||||
|
BIANNUAL: 'Semestral',
|
||||||
|
YEARLY: 'Anual',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface RecurringItemsManagerProps {
|
||||||
|
items: RecurringItem[];
|
||||||
|
onAdd: (data: RecurringItemCreate) => Promise<void>;
|
||||||
|
onUpdate: (id: number, data: RecurringItemUpdate) => Promise<void>;
|
||||||
|
onDelete: (id: number) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RecurringItemsManager({
|
||||||
|
items,
|
||||||
|
onAdd,
|
||||||
|
onUpdate,
|
||||||
|
onDelete,
|
||||||
|
}: RecurringItemsManagerProps) {
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editItem, setEditItem] = useState<RecurringItem | null>(null);
|
||||||
|
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const handleEdit = (item: RecurringItem) => {
|
||||||
|
setEditItem(item);
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
setEditItem(null);
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async (data: RecurringItemCreate | RecurringItemUpdate) => {
|
||||||
|
if (editItem) {
|
||||||
|
await onUpdate(editItem.id, data as RecurringItemUpdate);
|
||||||
|
} else {
|
||||||
|
await onAdd(data as RecurringItemCreate);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (deleteId != null) {
|
||||||
|
await onDelete(deleteId);
|
||||||
|
setDeleteId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = useMemo<ColumnDef<RecurringItem, unknown>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: 'name',
|
||||||
|
header: ({ column }) => <DataTableColumnHeader column={column} title="Nombre" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">{row.original.name}</span>
|
||||||
|
{!row.original.is_active && (
|
||||||
|
<Badge variant="outline" className="ml-2 text-[10px]">inactivo</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'item_type',
|
||||||
|
header: ({ column }) => <DataTableColumnHeader column={column} title="Tipo" />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const meta = TYPE_LABELS[row.original.item_type];
|
||||||
|
return <Badge variant={meta?.variant ?? 'secondary'}>{meta?.label ?? row.original.item_type}</Badge>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'frequency',
|
||||||
|
header: ({ column }) => <DataTableColumnHeader column={column} title="Frecuencia" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-sm">{FREQ_LABELS[row.original.frequency] ?? row.original.frequency}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'amount',
|
||||||
|
meta: { className: 'text-right' },
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title="Monto" className="justify-end" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-sm">
|
||||||
|
{formatAmount(row.original.amount, row.original.currency)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
meta: { className: 'text-right' },
|
||||||
|
size: 80,
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
title="Editar"
|
||||||
|
aria-label="Editar"
|
||||||
|
onClick={() => handleEdit(row.original)}
|
||||||
|
>
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
title="Eliminar"
|
||||||
|
aria-label="Eliminar"
|
||||||
|
onClick={() => setDeleteId(row.original.id)}
|
||||||
|
className="hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-semibold">Items Recurrentes</h3>
|
||||||
|
<Button size="sm" onClick={handleAdd}>
|
||||||
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
|
Nuevo
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={items}
|
||||||
|
pagination
|
||||||
|
pageSize={20}
|
||||||
|
initialSorting={[{ id: 'item_type', desc: false }]}
|
||||||
|
emptyMessage="No hay items recurrentes."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RecurringItemDialog
|
||||||
|
open={dialogOpen}
|
||||||
|
onOpenChange={setDialogOpen}
|
||||||
|
item={editItem}
|
||||||
|
onSave={handleSave}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{deleteId != null && (
|
||||||
|
<ConfirmDialog
|
||||||
|
title="Eliminar item"
|
||||||
|
message="Esta acción no se puede deshacer."
|
||||||
|
confirmLabel="Eliminar"
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
onCancel={() => setDeleteId(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
frontend/src/components/budget/YearlyOverview.tsx
Normal file
97
frontend/src/components/budget/YearlyOverview.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { type MonthlyProjection } from '@/api';
|
||||||
|
import { formatAmount } from '@/lib/format';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
|
||||||
|
const MONTH_NAMES = [
|
||||||
|
'', 'Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun',
|
||||||
|
'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic',
|
||||||
|
];
|
||||||
|
|
||||||
|
interface YearlyOverviewProps {
|
||||||
|
months: MonthlyProjection[];
|
||||||
|
selectedMonth: number;
|
||||||
|
onSelectMonth: (month: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function YearlyOverview({
|
||||||
|
months,
|
||||||
|
selectedMonth,
|
||||||
|
onSelectMonth,
|
||||||
|
}: YearlyOverviewProps) {
|
||||||
|
const currentMonth = new Date().getMonth() + 1;
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Mes</TableHead>
|
||||||
|
<TableHead className="text-right">Ingresos</TableHead>
|
||||||
|
<TableHead className="text-right">Egresos Fijos</TableHead>
|
||||||
|
<TableHead className="text-right">Otros Gastos</TableHead>
|
||||||
|
<TableHead className="text-right">Gran Total</TableHead>
|
||||||
|
<TableHead className="text-right">Ahorro</TableHead>
|
||||||
|
<TableHead className="text-right">Balance</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{months.map((m) => {
|
||||||
|
const isSelected = m.month === selectedMonth;
|
||||||
|
const isCurrent = m.month === currentMonth && m.year === currentYear;
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={m.month}
|
||||||
|
className={cn(
|
||||||
|
'cursor-pointer transition-colors',
|
||||||
|
isSelected && 'bg-accent',
|
||||||
|
isCurrent && !isSelected && 'bg-accent/40',
|
||||||
|
)}
|
||||||
|
onClick={() => onSelectMonth(m.month)}
|
||||||
|
>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{MONTH_NAMES[m.month]}
|
||||||
|
{isCurrent && (
|
||||||
|
<span className="ml-1.5 inline-block w-1.5 h-1.5 rounded-full bg-primary" />
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono text-sm text-primary">
|
||||||
|
{formatAmount(m.projected_income, 'CRC')}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono text-sm">
|
||||||
|
{formatAmount(m.projected_fixed_expenses, 'CRC')}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono text-sm text-muted-foreground">
|
||||||
|
{formatAmount(m.uncovered_actual, 'CRC')}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono text-sm font-medium">
|
||||||
|
{formatAmount(m.gran_total_egresos, 'CRC')}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono text-sm text-muted-foreground">
|
||||||
|
{formatAmount(m.projected_savings, 'CRC')}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell
|
||||||
|
className={cn(
|
||||||
|
'text-right font-mono text-sm font-semibold',
|
||||||
|
m.net_balance >= 0 ? 'text-primary' : 'text-destructive',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{m.net_balance >= 0 ? '+' : ''}
|
||||||
|
{formatAmount(m.net_balance, 'CRC')}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
frontend/src/hooks/useBudget.ts
Normal file
89
frontend/src/hooks/useBudget.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
type YearlyProjection,
|
||||||
|
type MonthlyDetail,
|
||||||
|
type RecurringItem,
|
||||||
|
type RecurringItemCreate,
|
||||||
|
type RecurringItemUpdate,
|
||||||
|
getYearlyProjection,
|
||||||
|
getMonthlyDetail,
|
||||||
|
getRecurringItems,
|
||||||
|
createRecurringItem,
|
||||||
|
updateRecurringItem as apiUpdateItem,
|
||||||
|
deleteRecurringItem as apiDeleteItem,
|
||||||
|
} from '@/api';
|
||||||
|
|
||||||
|
export function useBudget(initialYear: number) {
|
||||||
|
const [year, setYear] = useState(initialYear);
|
||||||
|
const [selectedMonth, setSelectedMonth] = useState<number>(new Date().getMonth() + 1);
|
||||||
|
const [projection, setProjection] = useState<YearlyProjection | null>(null);
|
||||||
|
const [monthDetail, setMonthDetail] = useState<MonthlyDetail | null>(null);
|
||||||
|
const [recurringItems, setRecurringItems] = useState<RecurringItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [monthLoading, setMonthLoading] = useState(false);
|
||||||
|
|
||||||
|
const fetchProjection = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { data } = await getYearlyProjection(year);
|
||||||
|
setProjection(data);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [year]);
|
||||||
|
|
||||||
|
const fetchMonthDetail = useCallback(async () => {
|
||||||
|
setMonthLoading(true);
|
||||||
|
try {
|
||||||
|
const { data } = await getMonthlyDetail(year, selectedMonth);
|
||||||
|
setMonthDetail(data);
|
||||||
|
} finally {
|
||||||
|
setMonthLoading(false);
|
||||||
|
}
|
||||||
|
}, [year, selectedMonth]);
|
||||||
|
|
||||||
|
const fetchRecurringItems = useCallback(async () => {
|
||||||
|
const { data } = await getRecurringItems();
|
||||||
|
setRecurringItems(data);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchProjection();
|
||||||
|
fetchRecurringItems();
|
||||||
|
}, [fetchProjection, fetchRecurringItems]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMonthDetail();
|
||||||
|
}, [fetchMonthDetail]);
|
||||||
|
|
||||||
|
const addItem = async (data: RecurringItemCreate) => {
|
||||||
|
await createRecurringItem(data);
|
||||||
|
await Promise.all([fetchProjection(), fetchMonthDetail(), fetchRecurringItems()]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateItem = async (id: number, data: RecurringItemUpdate) => {
|
||||||
|
await apiUpdateItem(id, data);
|
||||||
|
await Promise.all([fetchProjection(), fetchMonthDetail(), fetchRecurringItems()]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteItem = async (id: number) => {
|
||||||
|
await apiDeleteItem(id);
|
||||||
|
await Promise.all([fetchProjection(), fetchMonthDetail(), fetchRecurringItems()]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
year,
|
||||||
|
setYear,
|
||||||
|
selectedMonth,
|
||||||
|
setSelectedMonth,
|
||||||
|
projection,
|
||||||
|
monthDetail,
|
||||||
|
recurringItems,
|
||||||
|
loading,
|
||||||
|
monthLoading,
|
||||||
|
addItem,
|
||||||
|
updateItem,
|
||||||
|
deleteItem,
|
||||||
|
refresh: () => Promise.all([fetchProjection(), fetchMonthDetail(), fetchRecurringItems()]),
|
||||||
|
};
|
||||||
|
}
|
||||||
205
frontend/src/pages/Budget.tsx
Normal file
205
frontend/src/pages/Budget.tsx
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { ChevronLeft, ChevronRight, Calculator, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import api, { type Transaction } from '@/api';
|
||||||
|
import { useBudget } from '@/hooks/useBudget';
|
||||||
|
import { formatAmount } from '@/lib/format';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import YearlyOverview from '@/components/budget/YearlyOverview';
|
||||||
|
import MonthlyDetail from '@/components/budget/MonthlyDetail';
|
||||||
|
import RecurringItemsManager from '@/components/budget/RecurringItemsManager';
|
||||||
|
import TransactionList from '@/components/TransactionList';
|
||||||
|
|
||||||
|
const MONTH_NAMES = [
|
||||||
|
'', 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
|
||||||
|
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre',
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Budget() {
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const {
|
||||||
|
year,
|
||||||
|
setYear,
|
||||||
|
selectedMonth,
|
||||||
|
setSelectedMonth,
|
||||||
|
projection,
|
||||||
|
monthDetail,
|
||||||
|
recurringItems,
|
||||||
|
loading,
|
||||||
|
monthLoading,
|
||||||
|
addItem,
|
||||||
|
updateItem,
|
||||||
|
deleteItem,
|
||||||
|
refresh,
|
||||||
|
} = useBudget(currentYear);
|
||||||
|
|
||||||
|
// Transaction list state for the selected month
|
||||||
|
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
||||||
|
const [txLoading, setTxLoading] = useState(false);
|
||||||
|
const [txSearch, setTxSearch] = useState('');
|
||||||
|
const [txSource, setTxSource] = useState<'CREDIT_CARD' | 'CASH' | 'TRANSFER'>('CREDIT_CARD');
|
||||||
|
|
||||||
|
const fetchTransactions = useCallback(async () => {
|
||||||
|
setTxLoading(true);
|
||||||
|
try {
|
||||||
|
// Use calendar month date range
|
||||||
|
const startDate = `${year}-${String(selectedMonth).padStart(2, '0')}-01`;
|
||||||
|
const endMonth = selectedMonth === 12 ? 1 : selectedMonth + 1;
|
||||||
|
const endYear = selectedMonth === 12 ? year + 1 : year;
|
||||||
|
const endDate = `${endYear}-${String(endMonth).padStart(2, '0')}-01`;
|
||||||
|
|
||||||
|
const { data } = await api.get<Transaction[]>('/transactions/', {
|
||||||
|
params: {
|
||||||
|
source: txSource,
|
||||||
|
search: txSearch || undefined,
|
||||||
|
limit: 200,
|
||||||
|
start_date: startDate,
|
||||||
|
end_date: endDate,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setTransactions(data);
|
||||||
|
} finally {
|
||||||
|
setTxLoading(false);
|
||||||
|
}
|
||||||
|
}, [year, selectedMonth, txSource, txSearch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTransactions();
|
||||||
|
}, [fetchTransactions]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Calculator className="w-6 h-6 text-primary" />
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Presupuesto</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button variant="outline" size="icon" onClick={() => setYear(year - 1)}>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<span className="w-16 text-center font-semibold tabular-nums">{year}</span>
|
||||||
|
<Button variant="outline" size="icon" onClick={() => setYear(year + 1)}>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue="overview">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="overview">Resumen</TabsTrigger>
|
||||||
|
<TabsTrigger value="items">Items Recurrentes</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="overview" className="space-y-6 mt-4">
|
||||||
|
{/* Annual Summary */}
|
||||||
|
{projection && (
|
||||||
|
<div className="grid gap-3 grid-cols-2 md:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-4 pb-3 px-4">
|
||||||
|
<p className="text-xs text-muted-foreground">Ingresos Anuales</p>
|
||||||
|
<p className="text-lg font-bold font-mono text-primary">
|
||||||
|
{formatAmount(projection.annual_income, 'CRC')}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-4 pb-3 px-4">
|
||||||
|
<p className="text-xs text-muted-foreground">Egresos Anuales</p>
|
||||||
|
<p className="text-lg font-bold font-mono">
|
||||||
|
{formatAmount(projection.annual_expenses, 'CRC')}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-4 pb-3 px-4">
|
||||||
|
<p className="text-xs text-muted-foreground">Ahorro Anual</p>
|
||||||
|
<p className="text-lg font-bold font-mono">
|
||||||
|
{formatAmount(projection.annual_savings, 'CRC')}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-4 pb-3 px-4">
|
||||||
|
<p className="text-xs text-muted-foreground">Balance Neto Anual</p>
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
'text-lg font-bold font-mono',
|
||||||
|
projection.annual_net >= 0 ? 'text-primary' : 'text-destructive',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{projection.annual_net >= 0 ? '+' : ''}
|
||||||
|
{formatAmount(projection.annual_net, 'CRC')}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Yearly Overview Table */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : projection ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<YearlyOverview
|
||||||
|
months={projection.months}
|
||||||
|
selectedMonth={selectedMonth}
|
||||||
|
onSelectMonth={setSelectedMonth}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Monthly Detail */}
|
||||||
|
{monthDetail && <MonthlyDetail detail={monthDetail} loading={monthLoading} />}
|
||||||
|
|
||||||
|
{/* Transactions for selected month */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-lg font-semibold">
|
||||||
|
Transacciones — {MONTH_NAMES[selectedMonth]} {year}
|
||||||
|
</h3>
|
||||||
|
<Tabs
|
||||||
|
value={txSource}
|
||||||
|
onValueChange={(v) => setTxSource(v as typeof txSource)}
|
||||||
|
>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="CREDIT_CARD">Tarjeta</TabsTrigger>
|
||||||
|
<TabsTrigger value="CASH">Efectivo</TabsTrigger>
|
||||||
|
<TabsTrigger value="TRANSFER">Transferencias</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
<TransactionList
|
||||||
|
transactions={transactions}
|
||||||
|
loading={txLoading}
|
||||||
|
source={txSource}
|
||||||
|
search={txSearch}
|
||||||
|
onSearchChange={setTxSearch}
|
||||||
|
onRefresh={() => {
|
||||||
|
fetchTransactions();
|
||||||
|
refresh();
|
||||||
|
}}
|
||||||
|
showCategory={txSource === 'CREDIT_CARD'}
|
||||||
|
emptyMessage={`Sin transacciones de ${txSource === 'CREDIT_CARD' ? 'tarjeta' : txSource === 'CASH' ? 'efectivo' : 'transferencia'} en ${MONTH_NAMES[selectedMonth]}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="items" className="mt-4">
|
||||||
|
<RecurringItemsManager
|
||||||
|
items={recurringItems}
|
||||||
|
onAdd={addItem}
|
||||||
|
onUpdate={updateItem}
|
||||||
|
onDelete={deleteItem}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Pencil,
|
Pencil,
|
||||||
Check,
|
Check,
|
||||||
X,
|
X,
|
||||||
|
BellRing,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import api, { type Account, type Transaction } from '../api';
|
import api, { type Account, type Transaction } from '../api';
|
||||||
@@ -124,6 +125,7 @@ export default function Dashboard() {
|
|||||||
const [editValue, setEditValue] = useState('');
|
const [editValue, setEditValue] = useState('');
|
||||||
const [exchangeRate, setExchangeRate] = useState<{ buy_rate: number; sell_rate: number } | null>(null);
|
const [exchangeRate, setExchangeRate] = useState<{ buy_rate: number; sell_rate: number } | null>(null);
|
||||||
const [configSection, setConfigSection] = useState<string | null>(null);
|
const [configSection, setConfigSection] = useState<string | null>(null);
|
||||||
|
const [testingPush, setTestingPush] = useState(false);
|
||||||
|
|
||||||
const { settings, patchSection } = useSettings();
|
const { settings, patchSection } = useSettings();
|
||||||
|
|
||||||
@@ -316,6 +318,48 @@ export default function Dashboard() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Test push notification */}
|
||||||
|
<Card className="border-dashed border-yellow-500/50">
|
||||||
|
<CardContent className="p-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Test Push Notification</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Creates a mock transaction to trigger a push notification</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={testingPush}
|
||||||
|
onClick={async () => {
|
||||||
|
setTestingPush(true);
|
||||||
|
try {
|
||||||
|
const merchants = ['Walmart', 'AutoMercado', 'Uber Eats', 'Amazon', 'PriceSmart'];
|
||||||
|
const amounts = [4500, 12350, 8900, 25000, 67800];
|
||||||
|
const i = Math.floor(Math.random() * merchants.length);
|
||||||
|
await api.post('/transactions/', {
|
||||||
|
merchant: merchants[i],
|
||||||
|
amount: amounts[i],
|
||||||
|
currency: 'CRC',
|
||||||
|
date: new Date().toISOString(),
|
||||||
|
bank: 'BAC',
|
||||||
|
source: 'CREDIT_CARD',
|
||||||
|
transaction_type: 'COMPRA',
|
||||||
|
reference: `test-push-${Date.now()}`,
|
||||||
|
notes: '[TEST] Push notification test — safe to delete',
|
||||||
|
});
|
||||||
|
fetchData();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Test push failed:', e);
|
||||||
|
} finally {
|
||||||
|
setTestingPush(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BellRing className="w-4 h-4 mr-2" />
|
||||||
|
{testingPush ? 'Sending...' : 'Send test'}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Section config dialog */}
|
{/* Section config dialog */}
|
||||||
{configSection && settings.dashboard.sections[configSection] && (
|
{configSection && settings.dashboard.sections[configSection] && (
|
||||||
<SectionConfigDialog
|
<SectionConfigDialog
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Wallet, ArrowRight, AlertCircle } from 'lucide-react';
|
|||||||
|
|
||||||
import { login } from '../api';
|
import { login } from '../api';
|
||||||
import { useAuth } from '../AuthContext';
|
import { useAuth } from '../AuthContext';
|
||||||
|
import { subscribeToPush } from '../pushNotifications';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
@@ -24,6 +25,7 @@ export default function Login() {
|
|||||||
try {
|
try {
|
||||||
await login(username, password);
|
await login(username, password);
|
||||||
setAuthenticated(true);
|
setAuthenticated(true);
|
||||||
|
subscribeToPush();
|
||||||
navigate('/');
|
navigate('/');
|
||||||
} catch {
|
} catch {
|
||||||
setError('Invalid credentials');
|
setError('Invalid credentials');
|
||||||
|
|||||||
51
frontend/src/pushNotifications.ts
Normal file
51
frontend/src/pushNotifications.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import api from './api';
|
||||||
|
|
||||||
|
function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||||
|
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||||
|
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const rawData = window.atob(base64);
|
||||||
|
const outputArray = new Uint8Array(rawData.length);
|
||||||
|
for (let i = 0; i < rawData.length; ++i) {
|
||||||
|
outputArray[i] = rawData.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return outputArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function subscribeToPush(): Promise<void> {
|
||||||
|
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const permission = await Notification.requestPermission();
|
||||||
|
if (permission !== 'granted') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<{ publicKey: string }>('/notifications/vapid-public-key');
|
||||||
|
const registration = await navigator.serviceWorker.ready;
|
||||||
|
|
||||||
|
const existing = await registration.pushManager.getSubscription();
|
||||||
|
if (existing) {
|
||||||
|
await sendSubscriptionToServer(existing);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = await registration.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: urlBase64ToUint8Array(data.publicKey),
|
||||||
|
});
|
||||||
|
|
||||||
|
await sendSubscriptionToServer(subscription);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Push subscription failed:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendSubscriptionToServer(subscription: PushSubscription): Promise<void> {
|
||||||
|
const json = subscription.toJSON();
|
||||||
|
await api.post('/notifications/subscribe', {
|
||||||
|
endpoint: json.endpoint,
|
||||||
|
keys: json.keys,
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user