Compare commits

...

4 Commits

Author SHA1 Message Date
Carlos Escalante
792cef5006 Fix analytics case() bug, add privacy mode, add prod DB sync script
All checks were successful
Deploy to VPS / deploy (push) Successful in 28s
Fix SQLAlchemy case() import in monthly-trend endpoint. Add
data-sensitive attributes to Analytics charts and tables for privacy
blur. Add scripts/sync-db.sh for one-click prod-to-local PostgreSQL
sync. Remove SQLite artifacts from gitignore.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 20:10:58 -06:00
Carlos Escalante
78e20f30cb Replace axios with native fetch API wrapper
Drop axios dependency in favor of a lightweight fetch-based client
that preserves the same { data: T } interface, keeping all 25
consumer files unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 20:10:48 -06:00
Carlos Escalante
51c106dc6c Add Proyecciones page with yearly financial projections view
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 20:10:37 -06:00
Carlos Escalante
0fdb5447b7 Add deferred transactions, revamp budget projections and UI
Adds deferred_to_next_cycle flag to transactions for billing cycle
bleed-over handling. Overhauls budget projection engine and refreshes
Budget page with improved monthly detail and transaction columns.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 20:10:23 -06:00
21 changed files with 1155 additions and 401 deletions

2
.gitignore vendored
View File

@@ -2,6 +2,8 @@ node_modules/
dist/ dist/
__pycache__/ __pycache__/
*.pyc *.pyc
*.db
*.db.bak
.env .env
.env.* .env.*
!.env.example !.env.example

View File

@@ -3,12 +3,13 @@ from typing import Optional
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import case
from sqlmodel import Session, func, select from sqlmodel import Session, 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.models.models import Category, Transaction from app.models.models import Category, Transaction
from app.api.v1.endpoints.transactions import get_cycle_range from app.services.budget_projection import get_cycle_range
router = APIRouter(prefix="/analytics", tags=["analytics"]) router = APIRouter(prefix="/analytics", tags=["analytics"])
@@ -104,7 +105,7 @@ def monthly_trend(
func.count(), func.count(),
func.coalesce( func.coalesce(
func.sum( func.sum(
func.case( case(
(Transaction.currency == "CRC", Transaction.amount), (Transaction.currency == "CRC", Transaction.amount),
else_=0, else_=0,
) )
@@ -113,7 +114,7 @@ def monthly_trend(
), ),
func.coalesce( func.coalesce(
func.sum( func.sum(
func.case( case(
(Transaction.currency == "USD", Transaction.amount), (Transaction.currency == "USD", Transaction.amount),
else_=0, else_=0,
) )

View File

@@ -194,6 +194,11 @@ class ActualsBySource(BaseModel):
count: int count: int
class CCCategorySpending(BaseModel):
category_name: str
amount: float
class MonthlyDetailResponse(BaseModel): class MonthlyDetailResponse(BaseModel):
year: int year: int
month: int month: int
@@ -207,6 +212,7 @@ class MonthlyDetailResponse(BaseModel):
uncovered_actual: float uncovered_actual: float
gran_total_egresos: float gran_total_egresos: float
net_balance: float net_balance: float
cc_by_category: list[CCCategorySpending]
@router.get("/month/{year}/{month}", response_model=MonthlyDetailResponse) @router.get("/month/{year}/{month}", response_model=MonthlyDetailResponse)
@@ -230,6 +236,7 @@ def get_monthly_detail(
uncovered_actual=data["uncovered_actual"], uncovered_actual=data["uncovered_actual"],
gran_total_egresos=data["gran_total_egresos"], gran_total_egresos=data["gran_total_egresos"],
net_balance=data["net_balance"], net_balance=data["net_balance"],
cc_by_category=[CCCategorySpending(**c) for c in data["cc_by_category"]],
) )

View File

@@ -19,19 +19,11 @@ from app.models.models import (
TransactionUpdate, TransactionUpdate,
) )
from app.services.budget_projection import get_cycle_range, get_previous_cycle
router = APIRouter(prefix="/transactions", tags=["transactions"]) router = APIRouter(prefix="/transactions", tags=["transactions"])
def get_cycle_range(year: int, month: int) -> tuple[datetime, datetime]:
"""Return (start, end) for billing cycle: month/18 to month+1/18."""
start = datetime(year, month, 18)
if month == 12:
end = datetime(year + 1, 1, 18)
else:
end = datetime(year, month + 1, 18)
return start, end
class BillingCycle(BaseModel): class BillingCycle(BaseModel):
year: int year: int
month: int month: int
@@ -54,6 +46,7 @@ def auto_categorize(merchant: str, session: Session) -> Optional[int]:
@router.get("/", response_model=list[TransactionRead]) @router.get("/", response_model=list[TransactionRead])
def list_transactions( def list_transactions(
source: Optional[TransactionSource] = None, source: Optional[TransactionSource] = None,
exclude_source: Optional[TransactionSource] = None,
search: Optional[str] = None, search: Optional[str] = None,
category_id: Optional[int] = None, category_id: Optional[int] = None,
cycle_year: Optional[int] = None, cycle_year: Optional[int] = None,
@@ -68,13 +61,32 @@ def list_transactions(
query = select(Transaction) query = select(Transaction)
if source: if source:
query = query.where(Transaction.source == source) query = query.where(Transaction.source == source)
if exclude_source:
query = query.where(Transaction.source != exclude_source)
if category_id: if category_id:
query = query.where(Transaction.category_id == category_id) query = query.where(Transaction.category_id == category_id)
if search: if search:
query = query.where(col(Transaction.merchant).ilike(f"%{search}%")) query = query.where(col(Transaction.merchant).ilike(f"%{search}%"))
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) prev_y, prev_m = get_previous_cycle(cycle_year, cycle_month)
prev_start, prev_end = get_cycle_range(prev_y, prev_m)
# Normal transactions in this cycle (not deferred) + deferred from previous cycle
from sqlalchemy import or_, and_
query = query.where(
or_(
and_(
Transaction.date >= start,
Transaction.date < end,
Transaction.deferred_to_next_cycle == False, # noqa: E712
),
and_(
Transaction.date >= prev_start,
Transaction.date < prev_end,
Transaction.deferred_to_next_cycle == True, # noqa: E712
),
)
)
elif start_date and end_date: elif start_date and end_date:
query = query.where( query = query.where(
Transaction.date >= datetime.fromisoformat(start_date), Transaction.date >= datetime.fromisoformat(start_date),

View File

@@ -1,3 +1,4 @@
from sqlalchemy import text
from sqlmodel import SQLModel, Session, create_engine from sqlmodel import SQLModel, Session, create_engine
from app.config import settings from app.config import settings
@@ -9,6 +10,20 @@ def init_db():
SQLModel.metadata.create_all(engine) SQLModel.metadata.create_all(engine)
def run_migrations():
"""Run idempotent schema migrations for columns added after initial create."""
with engine.connect() as conn:
try:
conn.execute(
text(
"ALTER TABLE transaction ADD COLUMN deferred_to_next_cycle BOOLEAN NOT NULL DEFAULT 0"
)
)
conn.commit()
except Exception:
conn.rollback()
def get_session(): def get_session():
with Session(engine) as session: with Session(engine) as session:
yield session yield session

View File

@@ -5,13 +5,14 @@ from fastapi.middleware.cors import CORSMiddleware
from app.api.v1.router import api_router from app.api.v1.router import api_router
from app.config import settings from app.config import settings
from app.db import init_db from app.db import init_db, run_migrations
from app.seed import seed_db from app.seed import seed_db
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
init_db() init_db()
run_migrations()
seed_db() seed_db()
yield yield

View File

@@ -140,6 +140,7 @@ class TransactionBase(SQLModel):
bank: Bank = Bank.BAC bank: Bank = Bank.BAC
notes: Optional[str] = None notes: Optional[str] = None
category_id: Optional[int] = Field(default=None, foreign_key="category.id") category_id: Optional[int] = Field(default=None, foreign_key="category.id")
deferred_to_next_cycle: bool = Field(default=False)
class Transaction(TransactionBase, table=True): class Transaction(TransactionBase, table=True):
@@ -168,6 +169,7 @@ class TransactionUpdate(SQLModel):
source: Optional[TransactionSource] = None source: Optional[TransactionSource] = None
notes: Optional[str] = None notes: Optional[str] = None
category_id: Optional[int] = None category_id: Optional[int] = None
deferred_to_next_cycle: Optional[bool] = None
# --- Exchange Rate --- # --- Exchange Rate ---

View File

@@ -71,81 +71,308 @@ def get_month_range(year: int, month: int) -> tuple[datetime, datetime]:
return start, end return start, end
def get_cycle_range(year: int, month: int) -> tuple[datetime, datetime]:
"""Return (start, end) for billing cycle: month/18 to month+1/18."""
start = datetime(year, month, 18)
if month == 12:
end = datetime(year + 1, 1, 18)
else:
end = datetime(year, month + 1, 18)
return start, end
def get_previous_cycle(year: int, month: int) -> tuple[int, int]:
"""Return (year, month) for the billing cycle preceding the given one."""
if month == 1:
return year - 1, 12
return year, month - 1
def compute_actuals_by_source( def compute_actuals_by_source(
session: Session, year: int, month: int session: Session, year: int, month: int
) -> dict[str, dict]: ) -> dict[str, dict]:
"""Query actual transaction totals for a calendar month, grouped by source.""" """Query actual transaction totals grouped by source.
start, end = get_month_range(year, month)
Credit card uses billing cycle (18th-18th) with deferred logic.
Cash/Transfer use calendar month (1st-1st).
"""
# CC billing cycle for budget month M is the cycle that *ends* around the 18th of M
# i.e. cycle (M-1): from (M-1)/18 to M/18, paid with month M salary
cc_cycle_y, cc_cycle_m = get_previous_cycle(year, month)
cc_start, cc_end = get_cycle_range(cc_cycle_y, cc_cycle_m)
prev_cc_y, prev_cc_m = get_previous_cycle(cc_cycle_y, cc_cycle_m)
prev_start, prev_end = get_cycle_range(prev_cc_y, prev_cc_m)
cal_start, cal_end = get_month_range(year, month)
results = {} results = {}
for source in TransactionSource: for source in TransactionSource:
compra = session.exec( if source == TransactionSource.CREDIT_CARD:
select(func.coalesce(func.sum(Transaction.amount), 0)).where( start, end = cc_start, cc_end
Transaction.date >= start, # Normal transactions in this cycle (not deferred)
Transaction.date < end, compra_normal = session.exec(
Transaction.source == source, select(func.coalesce(func.sum(Transaction.amount), 0)).where(
Transaction.transaction_type == TransactionType.COMPRA, Transaction.date >= start,
) Transaction.date < end,
).one() Transaction.source == source,
devolucion = session.exec( Transaction.transaction_type == TransactionType.COMPRA,
select(func.coalesce(func.sum(Transaction.amount), 0)).where( Transaction.deferred_to_next_cycle == False, # noqa: E712
Transaction.date >= start, )
Transaction.date < end, ).one()
Transaction.source == source, # Deferred from previous cycle
Transaction.transaction_type == TransactionType.DEVOLUCION, compra_deferred = session.exec(
) select(func.coalesce(func.sum(Transaction.amount), 0)).where(
).one() Transaction.date >= prev_start,
count = session.exec( Transaction.date < prev_end,
select(func.count()).where( Transaction.source == source,
Transaction.date >= start, Transaction.transaction_type == TransactionType.COMPRA,
Transaction.date < end, Transaction.deferred_to_next_cycle == True, # noqa: E712
Transaction.source == source, )
Transaction.transaction_type != TransactionType.DEPOSITO, ).one()
) compra = float(compra_normal) + float(compra_deferred)
).one()
compra_val = float(compra) dev_normal = session.exec(
devolucion_val = float(devolucion) select(func.coalesce(func.sum(Transaction.amount), 0)).where(
results[source.value] = { Transaction.date >= start,
"source": source.value, Transaction.date < end,
"total_compra": compra_val, Transaction.source == source,
"total_devolucion": devolucion_val, Transaction.transaction_type == TransactionType.DEVOLUCION,
"net": compra_val - devolucion_val, Transaction.deferred_to_next_cycle == False, # noqa: E712
"count": count, )
} ).one()
dev_deferred = session.exec(
select(func.coalesce(func.sum(Transaction.amount), 0)).where(
Transaction.date >= prev_start,
Transaction.date < prev_end,
Transaction.source == source,
Transaction.transaction_type == TransactionType.DEVOLUCION,
Transaction.deferred_to_next_cycle == True, # noqa: E712
)
).one()
devolucion = float(dev_normal) + float(dev_deferred)
count_normal = session.exec(
select(func.count()).where(
Transaction.date >= start,
Transaction.date < end,
Transaction.source == source,
Transaction.transaction_type != TransactionType.DEPOSITO,
Transaction.deferred_to_next_cycle == False, # noqa: E712
)
).one()
count_deferred = session.exec(
select(func.count()).where(
Transaction.date >= prev_start,
Transaction.date < prev_end,
Transaction.source == source,
Transaction.transaction_type != TransactionType.DEPOSITO,
Transaction.deferred_to_next_cycle == True, # noqa: E712
)
).one()
count = count_normal + count_deferred
results[source.value] = {
"source": source.value,
"total_compra": compra,
"total_devolucion": devolucion,
"net": compra - devolucion,
"count": count,
}
else:
# Cash / Transfer: calendar month, no deferred logic
compra = session.exec(
select(func.coalesce(func.sum(Transaction.amount), 0)).where(
Transaction.date >= cal_start,
Transaction.date < cal_end,
Transaction.source == source,
Transaction.transaction_type == TransactionType.COMPRA,
)
).one()
devolucion = session.exec(
select(func.coalesce(func.sum(Transaction.amount), 0)).where(
Transaction.date >= cal_start,
Transaction.date < cal_end,
Transaction.source == source,
Transaction.transaction_type == TransactionType.DEVOLUCION,
)
).one()
count = session.exec(
select(func.count()).where(
Transaction.date >= cal_start,
Transaction.date < cal_end,
Transaction.source == source,
Transaction.transaction_type != TransactionType.DEPOSITO,
)
).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 return results
def compute_actuals_by_category( def compute_actuals_by_category(
session: Session, year: int, month: int session: Session, year: int, month: int
) -> dict[int, float]: ) -> dict[int, float]:
"""Return {category_id: net_amount} for actual transactions in a calendar month.""" """Return {category_id: net_amount} for actual transactions.
start, end = get_month_range(year, month)
rows = session.exec( Credit card uses billing cycle (18th-18th) with deferred logic.
select( Cash/Transfer use calendar month (1st-1st).
Transaction.category_id, """
Transaction.transaction_type, cc_cycle_y, cc_cycle_m = get_previous_cycle(year, month)
func.sum(Transaction.amount), cc_start, cc_end = get_cycle_range(cc_cycle_y, cc_cycle_m)
) prev_cc_y, prev_cc_m = get_previous_cycle(cc_cycle_y, cc_cycle_m)
.where( prev_start, prev_end = get_cycle_range(prev_cc_y, prev_cc_m)
Transaction.date >= start, cal_start, cal_end = get_month_range(year, month)
Transaction.date < end,
Transaction.category_id.is_not(None), # type: ignore[union-attr]
Transaction.transaction_type != TransactionType.DEPOSITO,
)
.group_by(Transaction.category_id, Transaction.transaction_type)
).all()
totals: dict[int, float] = {} totals: dict[int, float] = {}
for cat_id, tx_type, amount in rows:
val = float(amount) def _merge_rows(rows: list) -> None:
if tx_type == TransactionType.DEVOLUCION: for cat_id, tx_type, amount in rows:
val = -val val = float(amount)
totals[cat_id] = totals.get(cat_id, 0) + val if tx_type == TransactionType.DEVOLUCION:
val = -val
totals[cat_id] = totals.get(cat_id, 0) + val
# 1) CC normal in this cycle (not deferred)
_merge_rows(
session.exec(
select(
Transaction.category_id,
Transaction.transaction_type,
func.sum(Transaction.amount),
)
.where(
Transaction.date >= cc_start,
Transaction.date < cc_end,
Transaction.source == TransactionSource.CREDIT_CARD,
Transaction.category_id.is_not(None), # type: ignore[union-attr]
Transaction.transaction_type != TransactionType.DEPOSITO,
Transaction.deferred_to_next_cycle == False, # noqa: E712
)
.group_by(Transaction.category_id, Transaction.transaction_type)
).all()
)
# 2) CC deferred from previous cycle
_merge_rows(
session.exec(
select(
Transaction.category_id,
Transaction.transaction_type,
func.sum(Transaction.amount),
)
.where(
Transaction.date >= prev_start,
Transaction.date < prev_end,
Transaction.source == TransactionSource.CREDIT_CARD,
Transaction.category_id.is_not(None), # type: ignore[union-attr]
Transaction.transaction_type != TransactionType.DEPOSITO,
Transaction.deferred_to_next_cycle == True, # noqa: E712
)
.group_by(Transaction.category_id, Transaction.transaction_type)
).all()
)
# 3) Non-CC: calendar month
_merge_rows(
session.exec(
select(
Transaction.category_id,
Transaction.transaction_type,
func.sum(Transaction.amount),
)
.where(
Transaction.date >= cal_start,
Transaction.date < cal_end,
Transaction.source != TransactionSource.CREDIT_CARD,
Transaction.category_id.is_not(None), # type: ignore[union-attr]
Transaction.transaction_type != TransactionType.DEPOSITO,
)
.group_by(Transaction.category_id, Transaction.transaction_type)
).all()
)
return totals return totals
def compute_cc_by_category(
session: Session, year: int, month: int
) -> list[dict]:
"""Return credit card spending by category for the billing cycle."""
cc_cycle_y, cc_cycle_m = get_previous_cycle(year, month)
cc_start, cc_end = get_cycle_range(cc_cycle_y, cc_cycle_m)
prev_cc_y, prev_cc_m = get_previous_cycle(cc_cycle_y, cc_cycle_m)
prev_start, prev_end = get_cycle_range(prev_cc_y, prev_cc_m)
totals: dict[int | None, float] = {}
def _merge(rows: list) -> None:
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
# CC normal in this cycle
_merge(
session.exec(
select(
Transaction.category_id,
Transaction.transaction_type,
func.sum(Transaction.amount),
)
.where(
Transaction.date >= cc_start,
Transaction.date < cc_end,
Transaction.source == TransactionSource.CREDIT_CARD,
Transaction.transaction_type != TransactionType.DEPOSITO,
Transaction.deferred_to_next_cycle == False, # noqa: E712
)
.group_by(Transaction.category_id, Transaction.transaction_type)
).all()
)
# CC deferred from previous cycle
_merge(
session.exec(
select(
Transaction.category_id,
Transaction.transaction_type,
func.sum(Transaction.amount),
)
.where(
Transaction.date >= prev_start,
Transaction.date < prev_end,
Transaction.source == TransactionSource.CREDIT_CARD,
Transaction.transaction_type != TransactionType.DEPOSITO,
Transaction.deferred_to_next_cycle == True, # noqa: E712
)
.group_by(Transaction.category_id, Transaction.transaction_type)
).all()
)
# Resolve category names
from app.models.models import Category
result = []
for cat_id, amount in totals.items():
if amount <= 0:
continue
if cat_id is not None:
cat = session.get(Category, cat_id)
name = cat.name if cat else "Sin categoría"
else:
name = "Sin categoría"
result.append({"category_name": name, "amount": round(amount, 2)})
return sorted(result, key=lambda x: x["amount"], reverse=True)
def compute_monthly_projection( def compute_monthly_projection(
session: Session, year: int, month: int session: Session, year: int, month: int
) -> dict: ) -> dict:
@@ -215,30 +442,71 @@ def compute_monthly_projection(
if cat_id not in covered_category_ids: if cat_id not in covered_category_ids:
uncovered_actual += amount uncovered_actual += amount
# Also add transactions with no category # Also add transactions with no category (hybrid ranges + deferred)
start, end = get_month_range(year, month) cc_cycle_y, cc_cycle_m = get_previous_cycle(year, month)
uncategorized = session.exec( cc_start, cc_end = get_cycle_range(cc_cycle_y, cc_cycle_m)
select( prev_cc_y, prev_cc_m = get_previous_cycle(cc_cycle_y, cc_cycle_m)
Transaction.transaction_type, prev_start, prev_end = get_cycle_range(prev_cc_y, prev_cc_m)
func.sum(Transaction.amount), cal_start, cal_end = get_month_range(year, month)
)
.where( def _sum_uncategorized(rows: list) -> float:
Transaction.date >= start, total = 0.0
Transaction.date < end, for tx_type, amount in rows:
Transaction.category_id.is_(None), # type: ignore[union-attr] val = float(amount)
Transaction.transaction_type != TransactionType.DEPOSITO, if tx_type == TransactionType.DEVOLUCION:
) val = -val
.group_by(Transaction.transaction_type) total += val
).all() return total
for tx_type, amount in uncategorized:
val = float(amount) # CC uncategorized: this cycle (not deferred)
if tx_type == TransactionType.DEVOLUCION: uncovered_actual += _sum_uncategorized(
val = -val session.exec(
uncovered_actual += val select(Transaction.transaction_type, func.sum(Transaction.amount))
.where(
Transaction.date >= cc_start,
Transaction.date < cc_end,
Transaction.source == TransactionSource.CREDIT_CARD,
Transaction.category_id.is_(None), # type: ignore[union-attr]
Transaction.transaction_type != TransactionType.DEPOSITO,
Transaction.deferred_to_next_cycle == False, # noqa: E712
)
.group_by(Transaction.transaction_type)
).all()
)
# CC uncategorized: deferred from previous cycle
uncovered_actual += _sum_uncategorized(
session.exec(
select(Transaction.transaction_type, func.sum(Transaction.amount))
.where(
Transaction.date >= prev_start,
Transaction.date < prev_end,
Transaction.source == TransactionSource.CREDIT_CARD,
Transaction.category_id.is_(None), # type: ignore[union-attr]
Transaction.transaction_type != TransactionType.DEPOSITO,
Transaction.deferred_to_next_cycle == True, # noqa: E712
)
.group_by(Transaction.transaction_type)
).all()
)
# Non-CC uncategorized: calendar month
uncovered_actual += _sum_uncategorized(
session.exec(
select(Transaction.transaction_type, func.sum(Transaction.amount))
.where(
Transaction.date >= cal_start,
Transaction.date < cal_end,
Transaction.source != TransactionSource.CREDIT_CARD,
Transaction.category_id.is_(None), # type: ignore[union-attr]
Transaction.transaction_type != TransactionType.DEPOSITO,
)
.group_by(Transaction.transaction_type)
).all()
)
actual_credit_card = actuals_by_source.get("CREDIT_CARD", {}).get("net", 0) actual_credit_card = actuals_by_source.get("CREDIT_CARD", {}).get("net", 0)
actual_cash = actuals_by_source.get("CASH", {}).get("net", 0) actual_cash = actuals_by_source.get("CASH", {}).get("net", 0)
actual_transfers = actuals_by_source.get("TRANSFER", {}).get("net", 0) actual_transfers = actuals_by_source.get("TRANSFER", {}).get("net", 0)
cc_by_category = compute_cc_by_category(session, year, month)
gran_total = total_fixed_expenses + uncovered_actual gran_total = total_fixed_expenses + uncovered_actual
# Savings are NOT deducted — they are already deducted from gross salary # Savings are NOT deducted — they are already deducted from gross salary
@@ -261,6 +529,7 @@ def compute_monthly_projection(
"expense_items": expense_items, "expense_items": expense_items,
"savings_items": savings_items, "savings_items": savings_items,
"actuals_by_source": list(actuals_by_source.values()), "actuals_by_source": list(actuals_by_source.values()),
"cc_by_category": cc_by_category,
} }

View File

@@ -13,7 +13,6 @@
"@fontsource-variable/ibm-plex-sans": "^5.2.8", "@fontsource-variable/ibm-plex-sans": "^5.2.8",
"@fontsource-variable/noto-sans": "^5.2.10", "@fontsource-variable/noto-sans": "^5.2.10",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"axios": "^1.13.6",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",

View File

@@ -20,9 +20,6 @@ importers:
'@tanstack/react-table': '@tanstack/react-table':
specifier: ^8.21.3 specifier: ^8.21.3
version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
axios:
specifier: ^1.13.6
version: 1.13.6
class-variance-authority: class-variance-authority:
specifier: ^0.7.1 specifier: ^0.7.1
version: 0.7.1 version: 0.7.1
@@ -928,12 +925,6 @@ packages:
resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==}
engines: {node: '>=4'} engines: {node: '>=4'}
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
axios@1.13.6:
resolution: {integrity: sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==}
balanced-match@4.0.4: balanced-match@4.0.4:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22} engines: {node: 18 || 20 || >=22}
@@ -1020,10 +1011,6 @@ packages:
color-name@1.1.4: color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
commander@11.1.0: commander@11.1.0:
resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==}
engines: {node: '>=16'} engines: {node: '>=16'}
@@ -1164,10 +1151,6 @@ packages:
resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==}
engines: {node: '>=12'} engines: {node: '>=12'}
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
depd@2.0.0: depd@2.0.0:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -1234,10 +1217,6 @@ packages:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
es-set-tostringtag@2.1.0:
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
engines: {node: '>= 0.4'}
esbuild@0.27.4: esbuild@0.27.4:
resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -1330,19 +1309,6 @@ packages:
resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
engines: {node: '>= 18.0.0'} engines: {node: '>= 18.0.0'}
follow-redirects@1.15.11:
resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
peerDependenciesMeta:
debug:
optional: true
form-data@4.0.5:
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
engines: {node: '>= 6'}
formdata-polyfill@4.0.10: formdata-polyfill@4.0.10:
resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==}
engines: {node: '>=12.20.0'} engines: {node: '>=12.20.0'}
@@ -1421,10 +1387,6 @@ packages:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
has-tostringtag@1.0.2:
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
engines: {node: '>= 0.4'}
hasown@2.0.2: hasown@2.0.2:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -1723,18 +1685,10 @@ packages:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'} engines: {node: '>=8.6'}
mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
mime-db@1.54.0: mime-db@1.54.0:
resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
mime-types@3.0.2: mime-types@3.0.2:
resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -1913,9 +1867,6 @@ packages:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
qs@6.15.0: qs@6.15.0:
resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==}
engines: {node: '>=0.6'} engines: {node: '>=0.6'}
@@ -3094,16 +3045,6 @@ snapshots:
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
asynckit@0.4.0: {}
axios@1.13.6:
dependencies:
follow-redirects: 1.15.11
form-data: 4.0.5
proxy-from-env: 1.1.0
transitivePeerDependencies:
- debug
balanced-match@4.0.4: {} balanced-match@4.0.4: {}
baseline-browser-mapping@2.10.10: {} baseline-browser-mapping@2.10.10: {}
@@ -3188,10 +3129,6 @@ snapshots:
color-name@1.1.4: {} color-name@1.1.4: {}
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
commander@11.1.0: {} commander@11.1.0: {}
commander@14.0.3: {} commander@14.0.3: {}
@@ -3291,8 +3228,6 @@ snapshots:
define-lazy-prop@3.0.0: {} define-lazy-prop@3.0.0: {}
delayed-stream@1.0.0: {}
depd@2.0.0: {} depd@2.0.0: {}
detect-libc@2.1.2: {} detect-libc@2.1.2: {}
@@ -3348,13 +3283,6 @@ snapshots:
dependencies: dependencies:
es-errors: 1.3.0 es-errors: 1.3.0
es-set-tostringtag@2.1.0:
dependencies:
es-errors: 1.3.0
get-intrinsic: 1.3.0
has-tostringtag: 1.0.2
hasown: 2.0.2
esbuild@0.27.4: esbuild@0.27.4:
optionalDependencies: optionalDependencies:
'@esbuild/aix-ppc64': 0.27.4 '@esbuild/aix-ppc64': 0.27.4
@@ -3511,16 +3439,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
follow-redirects@1.15.11: {}
form-data@4.0.5:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
hasown: 2.0.2
mime-types: 2.1.35
formdata-polyfill@4.0.10: formdata-polyfill@4.0.10:
dependencies: dependencies:
fetch-blob: 3.2.0 fetch-blob: 3.2.0
@@ -3587,10 +3505,6 @@ snapshots:
has-symbols@1.1.0: {} has-symbols@1.1.0: {}
has-tostringtag@1.0.2:
dependencies:
has-symbols: 1.1.0
hasown@2.0.2: hasown@2.0.2:
dependencies: dependencies:
function-bind: 1.1.2 function-bind: 1.1.2
@@ -3804,14 +3718,8 @@ snapshots:
braces: 3.0.3 braces: 3.0.3
picomatch: 2.3.1 picomatch: 2.3.1
mime-db@1.52.0: {}
mime-db@1.54.0: {} mime-db@1.54.0: {}
mime-types@2.1.35:
dependencies:
mime-db: 1.52.0
mime-types@3.0.2: mime-types@3.0.2:
dependencies: dependencies:
mime-db: 1.54.0 mime-db: 1.54.0
@@ -3989,8 +3897,6 @@ snapshots:
forwarded: 0.2.0 forwarded: 0.2.0
ipaddr.js: 1.9.1 ipaddr.js: 1.9.1
proxy-from-env@1.1.0: {}
qs@6.15.0: qs@6.15.0:
dependencies: dependencies:
side-channel: 1.1.0 side-channel: 1.1.0

View File

@@ -9,6 +9,7 @@ import Budget from './pages/Budget';
import Analytics from './pages/Analytics'; import Analytics from './pages/Analytics';
import Salarios from './pages/Salarios'; import Salarios from './pages/Salarios';
import Pensions from './pages/Pensions'; import Pensions from './pages/Pensions';
import Proyecciones from './pages/Proyecciones';
import ServiciosMunicipales from './pages/ServiciosMunicipales'; import ServiciosMunicipales from './pages/ServiciosMunicipales';
function ProtectedRoute({ children }: { children: React.ReactNode }) { function ProtectedRoute({ children }: { children: React.ReactNode }) {
@@ -35,6 +36,7 @@ function AppRoutes() {
<Route path="/" element={<Dashboard />} /> <Route path="/" element={<Dashboard />} />
<Route path="/budget" element={<Budget />} /> <Route path="/budget" element={<Budget />} />
<Route path="/analytics" element={<Analytics />} /> <Route path="/analytics" element={<Analytics />} />
<Route path="/proyecciones" element={<Proyecciones />} />
<Route path="/salarios" element={<Salarios />} /> <Route path="/salarios" element={<Salarios />} />
<Route path="/pensions" element={<Pensions />} /> <Route path="/pensions" element={<Pensions />} />
<Route path="/servicios-municipales" element={<ServiciosMunicipales />} /> <Route path="/servicios-municipales" element={<ServiciosMunicipales />} />

View File

@@ -1,25 +1,78 @@
import axios from 'axios'; const BASE_URL = import.meta.env.VITE_API_URL || '/api/v1';
const api = axios.create({ class ApiError extends Error {
baseURL: import.meta.env.VITE_API_URL || '/api/v1', response: { status: number; data: unknown };
}); constructor(status: number, data: unknown) {
super(`Request failed with status ${status}`);
this.response = { status, data };
}
}
api.interceptors.request.use((config) => { interface RequestConfig {
const token = localStorage.getItem('token'); params?: Record<string, string | number | boolean | undefined>;
if (token) config.headers.Authorization = `Bearer ${token}`; }
return config;
});
api.interceptors.response.use( async function request<T>(method: string, url: string, body?: unknown, config?: RequestConfig): Promise<{ data: T }> {
(res) => res, let fullUrl = `${BASE_URL}${url}`;
(err) => {
if (err.response?.status === 401) { if (config?.params) {
localStorage.removeItem('token'); const search = new URLSearchParams();
window.location.href = '/login'; for (const [k, v] of Object.entries(config.params)) {
if (v !== undefined) search.set(k, String(v));
} }
return Promise.reject(err); const qs = search.toString();
if (qs) fullUrl += `?${qs}`;
}
const headers: Record<string, string> = {};
const token = localStorage.getItem('token');
if (token) headers['Authorization'] = `Bearer ${token}`;
let fetchBody: BodyInit | undefined;
if (body instanceof FormData || body instanceof URLSearchParams) {
fetchBody = body;
} else if (body !== undefined) {
headers['Content-Type'] = 'application/json';
fetchBody = JSON.stringify(body);
}
const res = await fetch(fullUrl, { method, headers, body: fetchBody });
if (res.status === 401) {
localStorage.removeItem('token');
window.location.href = '/login';
throw new ApiError(401, null);
}
if (!res.ok) {
let data: unknown = null;
try { data = await res.json(); } catch {}
throw new ApiError(res.status, data);
}
if (res.status === 204) return { data: null as T };
const data = await res.json();
return { data };
}
const api = {
get<T = any>(url: string, config?: RequestConfig) {
return request<T>('GET', url, undefined, config);
}, },
); post<T = any>(url: string, body?: unknown, config?: RequestConfig) {
return request<T>('POST', url, body, config);
},
patch<T = any>(url: string, body?: unknown, config?: RequestConfig) {
return request<T>('PATCH', url, body, config);
},
put<T = any>(url: string, body?: unknown, config?: RequestConfig) {
return request<T>('PUT', url, body, config);
},
delete<T = any>(url: string, config?: RequestConfig) {
return request<T>('DELETE', url, undefined, config);
},
};
export default api; export default api;
@@ -101,6 +154,7 @@ export interface Transaction {
notes: string | null; notes: string | null;
category_id: number | null; category_id: number | null;
category: Category | null; category: Category | null;
deferred_to_next_cycle: boolean;
created_at: string; created_at: string;
} }
@@ -213,6 +267,7 @@ export interface MonthlyDetail {
uncovered_actual: number; uncovered_actual: number;
gran_total_egresos: number; gran_total_egresos: number;
net_balance: number; net_balance: number;
cc_by_category: { category_name: string; amount: number }[];
} }
// Budget API functions // Budget API functions

View File

@@ -7,6 +7,7 @@ import {
PiggyBank, PiggyBank,
Droplets, Droplets,
LogOut, LogOut,
TrendingUp,
Wallet, Wallet,
Menu, Menu,
Sun, Sun,
@@ -51,6 +52,7 @@ const navSections: NavSection[] = [
{ to: '/budget', icon: Calculator, label: 'Presupuesto' }, { to: '/budget', icon: Calculator, label: 'Presupuesto' },
{ to: '/salarios', icon: Landmark, label: 'Salarios' }, { to: '/salarios', icon: Landmark, label: 'Salarios' },
{ to: '/pensions', icon: PiggyBank, label: 'Pensiones' }, { to: '/pensions', icon: PiggyBank, label: 'Pensiones' },
{ to: '/proyecciones', icon: TrendingUp, label: 'Proyecciones' },
{ to: '/analytics', icon: BarChart3, label: 'Analytics' }, { to: '/analytics', icon: BarChart3, label: 'Analytics' },
], ],
}, },

View File

@@ -7,6 +7,8 @@ import {
TrendingUp, TrendingUp,
TrendingDown, TrendingDown,
ArrowLeftRight, ArrowLeftRight,
ArrowRightFromLine,
Banknote,
} from 'lucide-react'; } from 'lucide-react';
import api, { type Transaction } from '../api'; import api, { type Transaction } from '../api';
@@ -30,7 +32,9 @@ export interface TransactionListProps {
emptyIcon?: React.ReactNode; emptyIcon?: React.ReactNode;
emptyMessage?: string; emptyMessage?: string;
showCategory?: boolean; showCategory?: boolean;
showSourceIcon?: boolean;
addLabel?: string; addLabel?: string;
onToggleDeferred?: (tx: Transaction) => void;
} }
export default function TransactionList({ export default function TransactionList({
@@ -43,7 +47,9 @@ export default function TransactionList({
emptyIcon, emptyIcon,
emptyMessage = 'No transactions found', emptyMessage = 'No transactions found',
showCategory = true, showCategory = true,
showSourceIcon = false,
addLabel = 'Add Transaction', addLabel = 'Add Transaction',
onToggleDeferred,
}: TransactionListProps) { }: TransactionListProps) {
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<Transaction | null>(null); const [editing, setEditing] = useState<Transaction | null>(null);
@@ -68,8 +74,8 @@ export default function TransactionList({
}; };
const columns = useMemo( const columns = useMemo(
() => getTransactionColumns({ showCategory, onEdit: handleEdit, onDelete: (id) => setDeleteId(id) }), () => getTransactionColumns({ showCategory, showSourceIcon, onEdit: handleEdit, onDelete: (id) => setDeleteId(id), onToggleDeferred }),
[showCategory], [showCategory, showSourceIcon, onToggleDeferred],
); );
const empty = transactions.length === 0 && !loading; const empty = transactions.length === 0 && !loading;
@@ -119,7 +125,16 @@ export default function TransactionList({
)} )}
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{tx.merchant}</p> <div className="flex items-center gap-1">
<p className="text-sm font-medium truncate">{tx.merchant}</p>
{showSourceIcon && (
tx.source === 'CASH'
? <Banknote className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
: tx.source === 'TRANSFER'
? <ArrowLeftRight className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
: null
)}
</div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{new Date(tx.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} {new Date(tx.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
{showCategory && tx.category && ( {showCategory && tx.category && (
@@ -138,6 +153,18 @@ export default function TransactionList({
{formatAmount(tx.amount, tx.currency)} {formatAmount(tx.amount, tx.currency)}
</span> </span>
<div className="flex items-center gap-0.5 shrink-0"> <div className="flex items-center gap-0.5 shrink-0">
{onToggleDeferred && (
<Button
variant="ghost"
size="icon"
title={tx.deferred_to_next_cycle ? 'Quitar diferida' : 'Diferir al siguiente ciclo'}
aria-label={tx.deferred_to_next_cycle ? 'Remove deferred' : 'Defer to next cycle'}
onClick={() => onToggleDeferred(tx)}
className={cn(tx.deferred_to_next_cycle && 'text-amber-600')}
>
<ArrowRightFromLine className="w-4 h-4" />
</Button>
)}
<Button variant="ghost" size="icon" title="Edit transaction" aria-label="Edit transaction" onClick={() => handleEdit(tx)}> <Button variant="ghost" size="icon" title="Edit transaction" aria-label="Edit transaction" onClick={() => handleEdit(tx)}>
<Pencil className="w-4 h-4" /> <Pencil className="w-4 h-4" />
</Button> </Button>

View File

@@ -1,9 +1,18 @@
import { useState } from 'react';
import { PieChart, Pie, Cell } from 'recharts';
import { type MonthlyDetail as MonthlyDetailType } from '@/api'; import { type MonthlyDetail as MonthlyDetailType } from '@/api';
import { formatAmount } from '@/lib/format'; import { formatAmount } from '@/lib/format';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { Button } from '@/components/ui/button';
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from '@/components/ui/chart';
import { import {
TrendingUp, TrendingUp,
TrendingDown, TrendingDown,
@@ -14,13 +23,30 @@ import {
Info, Info,
} from 'lucide-react'; } from 'lucide-react';
const MONTH_NAMES = [ type PaletteMode = 'chatgpt' | 'gemini';
'', 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre',
];
const SOURCE_LABELS: Record<string, { label: string; icon: typeof CreditCard }> = { const PALETTES: Record<PaletteMode, { income: string[]; expense: string[]; cc: string[] }> = {
CREDIT_CARD: { label: 'Tarjeta de Crédito', icon: CreditCard }, chatgpt: {
// Pure green scale, darkest → lightest (assigned by rank)
income: ['#14532D', '#16A34A', '#4ADE80', '#BBF7D0'],
// Pure amber scale, darkest → lightest (assigned by rank)
expense: ['#92400E', '#B45309', '#D97706', '#F59E0B', '#FCD34D'],
// Warm-to-cool alternating for CC categories
cc: ['#B45309', '#2563EB', '#DC2626', '#16A34A', '#7C3AED',
'#D97706', '#0F766E', '#DB2777', '#EA580C', '#4F46E5'],
},
gemini: {
// Qualitative greens: dark green, mint, pale green, forest
income: ['#2D6A4F', '#52B788', '#B7E4C7', '#1B4332'],
// Terracotta, slate blue, sage, sand — diverse hues
expense: ['#E07A5F', '#3D405B', '#81B29A', '#F2CC8F', '#D56B4E', '#2E344A', '#6A9E85', '#E5B87A'],
// Pastel/muted diverse for CC categories
cc: ['#6366F1', '#EC4899', '#14B8A6', '#F97316', '#8B5CF6',
'#06B6D4', '#EF4444', '#10B981', '#F59E0B', '#3B82F6'],
},
};
const SOURCE_LABELS: Record<string, { label: string; icon: typeof Banknote }> = {
CASH: { label: 'Efectivo', icon: Banknote }, CASH: { label: 'Efectivo', icon: Banknote },
TRANSFER: { label: 'Transferencias', icon: ArrowLeftRight }, TRANSFER: { label: 'Transferencias', icon: ArrowLeftRight },
}; };
@@ -28,9 +54,12 @@ const SOURCE_LABELS: Record<string, { label: string; icon: typeof CreditCard }>
interface MonthlyDetailProps { interface MonthlyDetailProps {
detail: MonthlyDetailType; detail: MonthlyDetailType;
loading?: boolean; loading?: boolean;
onNavigateToTransactions?: () => void;
} }
export default function MonthlyDetail({ detail, loading }: MonthlyDetailProps) { export default function MonthlyDetail({ detail, loading, onNavigateToTransactions }: MonthlyDetailProps) {
const [paletteMode, setPaletteMode] = useState<PaletteMode>('chatgpt');
if (loading) { if (loading) {
return ( return (
<div className="grid gap-4 md:grid-cols-3"> <div className="grid gap-4 md:grid-cols-3">
@@ -43,108 +72,320 @@ export default function MonthlyDetail({ detail, loading }: MonthlyDetailProps) {
); );
} }
const { income: incomeColors, expense: expenseColors, cc: ccColors } = PALETTES[paletteMode];
const incomeData = detail.income_items.map((item) => ({ name: item.name, value: item.amount }));
const expenseData = detail.expense_items.map((item) => ({ name: item.name, value: item.amount }));
// For ChatGPT mode: assign colors by rank (largest = darkest)
// For Gemini mode: assign colors by position (qualitative)
function buildColorMap(data: { name: string; value: number }[], colors: string[]): Map<string, string> {
if (paletteMode === 'chatgpt') {
const sorted = [...data].sort((a, b) => b.value - a.value);
const map = new Map<string, string>();
sorted.forEach((item, i) => {
map.set(item.name, colors[Math.min(i, colors.length - 1)]);
});
return map;
}
// Gemini: positional
const map = new Map<string, string>();
data.forEach((item, i) => {
map.set(item.name, colors[i % colors.length]);
});
return map;
}
const incomeColorMap = buildColorMap(incomeData, incomeColors);
const expenseColorMap = buildColorMap(expenseData, expenseColors);
const incomeConfig = incomeData.reduce<ChartConfig>((acc, item) => {
acc[item.name] = { label: item.name, color: incomeColorMap.get(item.name)! };
return acc;
}, {});
const expenseConfig = expenseData.reduce<ChartConfig>((acc, item) => {
acc[item.name] = { label: item.name, color: expenseColorMap.get(item.name)! };
return acc;
}, {});
// CC spending by category
const ccData = (detail.cc_by_category ?? []).map((item) => ({
name: item.category_name,
value: item.amount,
}));
const ccConfig = ccData.reduce<ChartConfig>((acc, item, i) => {
acc[item.name] = { label: item.name, color: ccColors[i % ccColors.length] };
return acc;
}, {});
const ccTotal = ccData.reduce((sum, item) => sum + item.value, 0);
// Filter actuals to only cash and transfer (no credit card)
const cashTransferActuals = detail.actuals_by_source.filter(
(src) => src.source !== 'CREDIT_CARD' && src.count > 0
);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-lg font-semibold"> {/* Palette Toggle */}
Detalle: {MONTH_NAMES[detail.month]} {detail.year} <div className="flex items-center justify-end gap-1">
</h3> <span className="text-xs text-muted-foreground mr-1">Paleta:</span>
<Button
variant={paletteMode === 'chatgpt' ? 'default' : 'outline'}
size="sm"
className="h-6 text-xs px-2"
onClick={() => setPaletteMode('chatgpt')}
>
ChatGPT
</Button>
<Button
variant={paletteMode === 'gemini' ? 'default' : 'outline'}
size="sm"
className="h-6 text-xs px-2"
onClick={() => setPaletteMode('gemini')}
>
Gemini
</Button>
</div>
<div className="grid gap-4 md:grid-cols-3"> {/* Pie Charts */}
{/* Income Card */} <div className="grid gap-4 md:grid-cols-2">
{/* Income Pie */}
<Card> <Card>
<CardHeader className="pb-2"> <CardHeader className="pb-0">
<CardTitle className="text-sm font-medium flex items-center gap-2"> <CardTitle className="text-sm font-medium flex items-center gap-2">
<TrendingUp className="w-4 h-4 text-primary" /> <TrendingUp className="w-4 h-4 text-primary" />
Ingresos Ingresos
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-2"> <CardContent>
{detail.income_items.map((item) => ( {incomeData.length > 0 ? (
<div key={item.id} className="flex items-center justify-between text-sm"> <div className="flex flex-col items-center">
<span className="truncate mr-2">{item.name}</span> <ChartContainer config={incomeConfig} className="h-[200px] w-full">
<span data-sensitive className="font-mono text-primary whitespace-nowrap"> <PieChart>
{formatAmount(item.amount, 'CRC')} <Pie
</span> data={incomeData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={80}
paddingAngle={2}
strokeWidth={2}
stroke="var(--card)"
>
{incomeData.map((item, i) => (
<Cell key={i} fill={incomeColorMap.get(item.name)!} />
))}
</Pie>
<ChartTooltip
content={
<ChartTooltipContent
nameKey="name"
formatter={(value, name) => (
<span>{name}: {formatAmount(Number(value), 'CRC')}</span>
)}
/>
}
/>
</PieChart>
</ChartContainer>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 mt-1 w-full">
{incomeData.map((item, i) => (
<div key={item.name} className="flex items-center gap-1.5 text-xs">
<div
className="w-2 h-2 rounded-full shrink-0"
style={{ background: incomeColorMap.get(item.name) }}
/>
<span className="truncate text-muted-foreground">{item.name}</span>
</div>
))}
</div>
<Separator className="mt-3" />
<div className="flex items-center justify-between w-full mt-2 font-semibold text-sm">
<span>Total</span>
<span data-sensitive className="font-mono text-primary">
{formatAmount(detail.total_projected_income, 'CRC')}
</span>
</div>
</div> </div>
))} ) : (
<Separator /> <p className="text-sm text-muted-foreground py-8 text-center">Sin ingresos</p>
<div className="flex items-center justify-between font-semibold text-sm"> )}
<span>Total</span>
<span data-sensitive className="font-mono text-primary">
{formatAmount(detail.total_projected_income, 'CRC')}
</span>
</div>
</CardContent> </CardContent>
</Card> </Card>
{/* Expenses Card */} {/* Expenses Pie */}
<Card> <Card>
<CardHeader className="pb-2"> <CardHeader className="pb-0">
<CardTitle className="text-sm font-medium flex items-center gap-2"> <CardTitle className="text-sm font-medium flex items-center gap-2">
<TrendingDown className="w-4 h-4 text-destructive" /> <TrendingDown className="w-4 h-4 text-destructive" />
Egresos Fijos Egresos Fijos
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-2"> <CardContent>
{detail.expense_items.map((item) => ( {expenseData.length > 0 ? (
<div key={item.id} className="flex items-center justify-between text-sm"> <div className="flex flex-col items-center">
<div className="flex items-center gap-1 truncate mr-2"> <ChartContainer config={expenseConfig} className="h-[200px] w-full">
<span className="truncate">{item.name}</span> <PieChart>
{item.used_actual && ( <Pie
<Badge variant="secondary" className="text-[10px] px-1 py-0 shrink-0"> data={expenseData}
real dataKey="value"
</Badge> nameKey="name"
)} cx="50%"
cy="50%"
innerRadius={50}
outerRadius={80}
paddingAngle={2}
strokeWidth={2}
stroke="var(--card)"
>
{expenseData.map((item, i) => (
<Cell key={i} fill={expenseColorMap.get(item.name)!} />
))}
</Pie>
<ChartTooltip
content={
<ChartTooltipContent
formatter={(value, name) => (
<span>{name}: {formatAmount(Number(value), 'CRC')}</span>
)}
/>
}
/>
</PieChart>
</ChartContainer>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 mt-1 w-full">
{expenseData.map((item, i) => (
<div key={item.name} className="flex items-center gap-1.5 text-xs">
<div
className="w-2 h-2 rounded-full shrink-0"
style={{ background: expenseColorMap.get(item.name) }}
/>
<span className="truncate text-muted-foreground">{item.name}</span>
</div>
))}
</div> </div>
<div data-sensitive className="text-right whitespace-nowrap"> <Separator className="mt-3" />
<span className="font-mono">{formatAmount(item.amount, 'CRC')}</span> <div className="flex items-center justify-between w-full mt-2 font-semibold text-sm">
{item.used_actual && item.projected_amount != null && ( <span>Total Fijos</span>
<span className="block text-[10px] text-muted-foreground font-mono line-through"> <span data-sensitive className="font-mono">
{formatAmount(item.projected_amount, 'CRC')} {formatAmount(detail.total_projected_expenses, 'CRC')}
</span> </span>
)}
</div> </div>
</div> </div>
))} ) : (
{detail.expense_items.length === 0 && ( <p className="text-sm text-muted-foreground py-8 text-center">Sin egresos fijos</p>
<p className="text-sm text-muted-foreground">Sin egresos fijos</p>
)} )}
<Separator /> </CardContent>
<div className="flex items-center justify-between font-semibold text-sm"> </Card>
<span>Total Fijos</span> </div>
{/* Credit Card by Category */}
{ccData.length > 0 && (
<Card>
<CardHeader className="pb-0">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<CreditCard className="w-4 h-4" />
Tarjeta de Crédito
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col md:flex-row items-center gap-4">
<ChartContainer config={ccConfig} className="h-[200px] w-full md:w-1/2">
<PieChart>
<Pie
data={ccData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={80}
paddingAngle={2}
strokeWidth={2}
stroke="var(--card)"
>
{ccData.map((_, i) => (
<Cell key={i} fill={ccColors[i % ccColors.length]} />
))}
</Pie>
<ChartTooltip
content={
<ChartTooltipContent
nameKey="name"
formatter={(value, name) => (
<span>{name}: {formatAmount(Number(value), 'CRC')}</span>
)}
/>
}
/>
</PieChart>
</ChartContainer>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 w-full md:w-1/2">
{ccData.map((item, i) => (
<div key={item.name} className="flex items-center gap-1.5 text-xs">
<div
className="w-2 h-2 rounded-full shrink-0"
style={{ background: ccColors[i % ccColors.length] }}
/>
<span className="truncate text-muted-foreground">{item.name}</span>
</div>
))}
</div>
</div>
<Separator className="mt-3" />
<div className="flex items-center justify-between w-full mt-2 font-semibold text-sm">
<span>Total Tarjeta</span>
<span data-sensitive className="font-mono"> <span data-sensitive className="font-mono">
{formatAmount(detail.total_projected_expenses, 'CRC')} {formatAmount(ccTotal, 'CRC')}
</span> </span>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
)}
{/* Actuals Card */} {/* Actuals + Savings + Summary */}
<div className="grid gap-4 md:grid-cols-3">
{/* Cash & Transfer Actuals Card */}
<Card> <Card>
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2"> <CardTitle className="text-sm font-medium flex items-center gap-2">
<CreditCard className="w-4 h-4" /> <Banknote className="w-4 h-4" />
Transacciones Reales Efectivo o Transferencias
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-2"> <CardContent className="space-y-2">
{detail.actuals_by_source.map((src) => { {cashTransferActuals.map((src) => {
const meta = SOURCE_LABELS[src.source]; const meta = SOURCE_LABELS[src.source];
if (!meta || src.count === 0) return null; if (!meta) return null;
const Icon = meta.icon; const Icon = meta.icon;
const isClickable = onNavigateToTransactions != null;
return ( return (
<div key={src.source} className="flex items-center justify-between text-sm"> <div key={src.source} className="flex items-center justify-between text-sm">
<div className="flex items-center gap-1.5"> <button
type="button"
className={cn(
'flex items-center gap-1.5',
isClickable && 'cursor-pointer hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded',
)}
onClick={isClickable ? onNavigateToTransactions : undefined}
disabled={!isClickable}
>
<Icon className="w-3.5 h-3.5 text-muted-foreground" /> <Icon className="w-3.5 h-3.5 text-muted-foreground" />
<span>{meta.label}</span> <span>{meta.label}</span>
<span className="text-xs text-muted-foreground">({src.count})</span> <span className="text-xs text-muted-foreground">({src.count})</span>
</div> </button>
<span data-sensitive className="font-mono whitespace-nowrap"> <span data-sensitive className="font-mono whitespace-nowrap">
{formatAmount(src.net, 'CRC')} {formatAmount(src.net, 'CRC')}
</span> </span>
</div> </div>
); );
})} })}
{cashTransferActuals.length === 0 && (
<p className="text-sm text-muted-foreground">Sin transacciones</p>
)}
{detail.uncovered_actual > 0 && ( {detail.uncovered_actual > 0 && (
<> <>
<Separator /> <Separator />
@@ -159,10 +400,7 @@ export default function MonthlyDetail({ detail, loading }: MonthlyDetailProps) {
)} )}
</CardContent> </CardContent>
</Card> </Card>
</div>
{/* Savings + Summary */}
<div className="grid gap-4 md:grid-cols-2">
{/* Savings */} {/* Savings */}
{detail.savings_items.length > 0 && ( {detail.savings_items.length > 0 && (
<Card> <Card>

View File

@@ -1,5 +1,5 @@
import { type ColumnDef } from '@tanstack/react-table'; import { type ColumnDef } from '@tanstack/react-table';
import { Pencil, Trash2, TrendingDown, TrendingUp } from 'lucide-react'; import { ArrowLeftRight, ArrowRightFromLine, Banknote, Pencil, Trash2, TrendingDown, TrendingUp } from 'lucide-react';
import { type Transaction } from '@/api'; import { type Transaction } from '@/api';
import { formatAmount } from '@/lib/format'; import { formatAmount } from '@/lib/format';
@@ -10,14 +10,18 @@ import { DataTableColumnHeader } from '@/components/ui/data-table-column-header'
interface TransactionColumnOptions { interface TransactionColumnOptions {
showCategory: boolean; showCategory: boolean;
showSourceIcon?: boolean;
onEdit: (tx: Transaction) => void; onEdit: (tx: Transaction) => void;
onDelete: (txId: number) => void; onDelete: (txId: number) => void;
onToggleDeferred?: (tx: Transaction) => void;
} }
export function getTransactionColumns({ export function getTransactionColumns({
showCategory, showCategory,
showSourceIcon,
onEdit, onEdit,
onDelete, onDelete,
onToggleDeferred,
}: TransactionColumnOptions): ColumnDef<Transaction, unknown>[] { }: TransactionColumnOptions): ColumnDef<Transaction, unknown>[] {
const columns: ColumnDef<Transaction, unknown>[] = [ const columns: ColumnDef<Transaction, unknown>[] = [
{ {
@@ -55,6 +59,17 @@ export function getTransactionColumns({
)} )}
</div> </div>
<span className="truncate">{tx.merchant}</span> <span className="truncate">{tx.merchant}</span>
{showSourceIcon && tx.source === 'CASH' && (
<Banknote className="w-3.5 h-3.5 text-muted-foreground shrink-0 ml-1" />
)}
{showSourceIcon && tx.source === 'TRANSFER' && (
<ArrowLeftRight className="w-3.5 h-3.5 text-muted-foreground shrink-0 ml-1" />
)}
{tx.deferred_to_next_cycle && (
<Badge variant="outline" className="ml-1.5 text-[10px] px-1 py-0 shrink-0 text-amber-600 border-amber-300">
Diferida
</Badge>
)}
</div> </div>
); );
}, },
@@ -109,6 +124,18 @@ export function getTransactionColumns({
const tx = row.original; const tx = row.original;
return ( return (
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
{onToggleDeferred && (
<Button
variant="ghost"
size="icon"
title={tx.deferred_to_next_cycle ? 'Quitar diferida' : 'Diferir al siguiente ciclo'}
aria-label={tx.deferred_to_next_cycle ? 'Remove deferred' : 'Defer to next cycle'}
onClick={() => onToggleDeferred(tx)}
className={cn(tx.deferred_to_next_cycle && 'text-amber-600')}
>
<ArrowRightFromLine className="w-4 h-4" />
</Button>
)}
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"

View File

@@ -25,11 +25,11 @@
--border: oklch(0.92 0.004 286.32); --border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32); --input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067); --ring: oklch(0.705 0.015 286.067);
--chart-1: oklch(0.905 0.182 98.111); --chart-1: oklch(0.55 0.16 145);
--chart-2: oklch(0.795 0.184 86.047); --chart-2: oklch(0.62 0.19 25);
--chart-3: oklch(0.681 0.162 75.834); --chart-3: oklch(0.58 0.14 250);
--chart-4: oklch(0.554 0.135 66.442); --chart-4: oklch(0.68 0.15 80);
--chart-5: oklch(0.476 0.114 61.907); --chart-5: oklch(0.52 0.13 320);
--radius: 0.625rem; --radius: 0.625rem;
--sidebar: oklch(0.985 0 0); --sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823); --sidebar-foreground: oklch(0.141 0.005 285.823);
@@ -60,11 +60,11 @@
--border: oklch(1 0 0 / 10%); --border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%); --input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938); --ring: oklch(0.552 0.016 285.938);
--chart-1: oklch(0.905 0.182 98.111); --chart-1: oklch(0.60 0.16 145);
--chart-2: oklch(0.795 0.184 86.047); --chart-2: oklch(0.67 0.19 25);
--chart-3: oklch(0.681 0.162 75.834); --chart-3: oklch(0.63 0.14 250);
--chart-4: oklch(0.554 0.135 66.442); --chart-4: oklch(0.73 0.15 80);
--chart-5: oklch(0.476 0.114 61.907); --chart-5: oklch(0.57 0.13 320);
--sidebar: oklch(0.21 0.006 285.885); --sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0); --sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.704 0.14 182.503); --sidebar-primary: oklch(0.704 0.14 182.503);

View File

@@ -46,9 +46,8 @@ interface DailySpending {
} }
const COLORS = [ const COLORS = [
'var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)', 'var(--chart-5)', '#B45309', '#16A34A', '#2563EB', '#DC2626', '#7C3AED',
'oklch(0.7 0.15 30)', 'oklch(0.65 0.2 300)', 'oklch(0.6 0.15 150)', '#D97706', '#0F766E', '#DB2777', '#EA580C', '#4F46E5',
'oklch(0.75 0.12 60)', 'oklch(0.55 0.18 250)',
]; ];
function formatCRC(value: number) { function formatCRC(value: number) {
@@ -132,7 +131,7 @@ export default function Analytics() {
</div> </div>
) : ( ) : (
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<ChartContainer config={pieChartConfig} className="h-[260px] w-full"> <ChartContainer data-sensitive config={pieChartConfig} className="h-[260px] w-full">
<PieChart> <PieChart>
<Pie <Pie
data={byCategory} data={byCategory}
@@ -168,7 +167,7 @@ export default function Analytics() {
style={{ background: COLORS[i % COLORS.length] }} style={{ background: COLORS[i % COLORS.length] }}
/> />
<span className="text-muted-foreground truncate">{cat.category_name}</span> <span className="text-muted-foreground truncate">{cat.category_name}</span>
<span className="text-muted-foreground/60 ml-auto">{cat.percentage}%</span> <span data-sensitive className="text-muted-foreground/60 ml-auto">{cat.percentage}%</span>
</div> </div>
))} ))}
</div> </div>
@@ -190,7 +189,7 @@ export default function Analytics() {
No data No data
</div> </div>
) : ( ) : (
<ChartContainer config={trendChartConfig} className="h-[300px] w-full"> <ChartContainer data-sensitive config={trendChartConfig} className="h-[300px] w-full">
<BarChart data={trend}> <BarChart data={trend}>
<XAxis <XAxis
dataKey="label" dataKey="label"
@@ -229,7 +228,7 @@ export default function Analytics() {
No data for this period No data for this period
</div> </div>
) : ( ) : (
<ChartContainer config={dailyChartConfig} className="h-[240px] w-full"> <ChartContainer data-sensitive config={dailyChartConfig} className="h-[240px] w-full">
<LineChart data={daily}> <LineChart data={daily}>
<XAxis <XAxis
dataKey="date" dataKey="date"
@@ -287,11 +286,11 @@ export default function Analytics() {
style={{ background: COLORS[i % COLORS.length] }} style={{ background: COLORS[i % COLORS.length] }}
/> />
<span className="text-sm flex-1">{cat.category_name}</span> <span className="text-sm flex-1">{cat.category_name}</span>
<span className="text-xs text-muted-foreground">{cat.count} txns</span> <span data-sensitive className="text-xs text-muted-foreground">{cat.count} txns</span>
<span className="text-sm font-mono font-medium w-32 text-right"> <span data-sensitive className="text-sm font-mono font-medium w-32 text-right">
{formatCRC(cat.total)} {formatCRC(cat.total)}
</span> </span>
<div className="w-24 bg-muted rounded-full h-1.5"> <div data-sensitive className="w-24 bg-muted rounded-full h-1.5">
<div <div
className="h-1.5 rounded-full" className="h-1.5 rounded-full"
style={{ style={{

View File

@@ -1,14 +1,10 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { ChevronLeft, ChevronRight, Calculator, Loader2 } from 'lucide-react'; import { ChevronLeft, ChevronRight, Calculator } from 'lucide-react';
import api, { type Transaction } from '@/api'; import api, { type Transaction } from '@/api';
import { useBudget } from '@/hooks/useBudget'; import { useBudget } from '@/hooks/useBudget';
import { formatAmount } from '@/lib/format';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import YearlyOverview from '@/components/budget/YearlyOverview';
import MonthlyDetail from '@/components/budget/MonthlyDetail'; import MonthlyDetail from '@/components/budget/MonthlyDetail';
import RecurringItemsManager from '@/components/budget/RecurringItemsManager'; import RecurringItemsManager from '@/components/budget/RecurringItemsManager';
import TransactionList from '@/components/TransactionList'; import TransactionList from '@/components/TransactionList';
@@ -28,51 +24,69 @@ export default function Budget() {
setYear, setYear,
selectedMonth, selectedMonth,
setSelectedMonth, setSelectedMonth,
projection,
monthDetail, monthDetail,
recurringItems, recurringItems,
loading,
monthLoading, monthLoading,
addItem, addItem,
updateItem, updateItem,
deleteItem, deleteItem,
saveBalanceOverride,
clearBalanceOverride,
refresh, refresh,
} = useBudget(currentYear); } = useBudget(currentYear);
const [subTab, setSubTab] = useState<'detail' | 'transactions' | 'projections'>('detail'); const [subTab, setSubTab] = useState<'detail' | 'transactions'>('detail');
// Transaction list state for the selected month // Transaction list state for the selected month
const [transactions, setTransactions] = useState<Transaction[]>([]); const [transactions, setTransactions] = useState<Transaction[]>([]);
const [txLoading, setTxLoading] = useState(false); const [txLoading, setTxLoading] = useState(false);
const [txSearch, setTxSearch] = useState(''); const [txSearch, setTxSearch] = useState('');
const [txSource, setTxSource] = useState<'CREDIT_CARD' | 'CASH' | 'TRANSFER'>('CREDIT_CARD'); const [txSource, setTxSource] = useState<'CREDIT_CARD' | 'CASH_AND_TRANSFER'>('CREDIT_CARD');
const fetchTransactions = useCallback(async () => { const fetchTransactions = useCallback(async () => {
setTxLoading(true); setTxLoading(true);
try { try {
// Use calendar month date range const params: Record<string, unknown> = {
const startDate = `${year}-${String(selectedMonth).padStart(2, '0')}-01`; search: txSearch || undefined,
const endMonth = selectedMonth === 12 ? 1 : selectedMonth + 1; limit: 200,
const endYear = selectedMonth === 12 ? year + 1 : year; };
const endDate = `${endYear}-${String(endMonth).padStart(2, '0')}-01`;
const { data } = await api.get<Transaction[]>('/transactions/', { if (txSource === 'CREDIT_CARD') {
params: { params.source = 'CREDIT_CARD';
source: txSource, // Credit card: billing cycle that ends around the 18th of selectedMonth
search: txSearch || undefined, const prevMonth = selectedMonth === 1 ? 12 : selectedMonth - 1;
limit: 200, const prevYear = selectedMonth === 1 ? year - 1 : year;
start_date: startDate, params.cycle_year = prevYear;
end_date: endDate, params.cycle_month = prevMonth;
}, } else {
}); // Cash + Transfer merged: calendar month, exclude credit card
params.exclude_source = 'CREDIT_CARD';
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`;
params.start_date = startDate;
params.end_date = endDate;
}
const { data } = await api.get<Transaction[]>('/transactions/', { params });
setTransactions(data); setTransactions(data);
} finally { } finally {
setTxLoading(false); setTxLoading(false);
} }
}, [year, selectedMonth, txSource, txSearch]); }, [year, selectedMonth, txSource, txSearch]);
const handleToggleDeferred = useCallback(async (tx: Transaction) => {
await api.patch(`/transactions/${tx.id}`, {
deferred_to_next_cycle: !tx.deferred_to_next_cycle,
});
fetchTransactions();
refresh();
}, [fetchTransactions, refresh]);
const handleNavigateToTransactions = useCallback(() => {
setTxSource('CASH_AND_TRANSFER');
setSubTab('transactions');
}, []);
useEffect(() => { useEffect(() => {
fetchTransactions(); fetchTransactions();
}, [fetchTransactions]); }, [fetchTransactions]);
@@ -107,16 +121,44 @@ export default function Budget() {
value={subTab} value={subTab}
onValueChange={(v) => setSubTab(v as typeof subTab)} onValueChange={(v) => setSubTab(v as typeof subTab)}
> >
<TabsList variant="line"> <div className="flex items-center justify-between">
<TabsTrigger value="detail"> <TabsList variant="line">
Detalle: {MONTH_NAMES[selectedMonth]} {year} <TabsTrigger value="detail">Detalle</TabsTrigger>
</TabsTrigger> <TabsTrigger value="transactions">Transacciones</TabsTrigger>
<TabsTrigger value="transactions">Transacciones</TabsTrigger> </TabsList>
<TabsTrigger value="projections">Proyecciones</TabsTrigger> <div className="flex items-center gap-1">
</TabsList> <Button
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={selectedMonth <= 1}
onClick={() => setSelectedMonth(selectedMonth - 1)}
>
<ChevronLeft className="w-4 h-4" />
</Button>
<span className="text-sm font-medium w-28 text-center">
{MONTH_NAMES[selectedMonth]} {year}
</span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={selectedMonth >= 12}
onClick={() => setSelectedMonth(selectedMonth + 1)}
>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
<TabsContent value="detail" className="space-y-6 mt-4"> <TabsContent value="detail" className="space-y-6 mt-4">
{monthDetail && <MonthlyDetail detail={monthDetail} loading={monthLoading} />} {monthDetail && (
<MonthlyDetail
detail={monthDetail}
loading={monthLoading}
onNavigateToTransactions={handleNavigateToTransactions}
/>
)}
</TabsContent> </TabsContent>
<TabsContent value="transactions" className="space-y-3 mt-4"> <TabsContent value="transactions" className="space-y-3 mt-4">
@@ -126,14 +168,13 @@ export default function Budget() {
> >
<TabsList> <TabsList>
<TabsTrigger value="CREDIT_CARD">Tarjeta</TabsTrigger> <TabsTrigger value="CREDIT_CARD">Tarjeta</TabsTrigger>
<TabsTrigger value="CASH">Efectivo</TabsTrigger> <TabsTrigger value="CASH_AND_TRANSFER">Efectivo y Transferencias</TabsTrigger>
<TabsTrigger value="TRANSFER">Transferencias</TabsTrigger>
</TabsList> </TabsList>
</Tabs> </Tabs>
<TransactionList <TransactionList
transactions={transactions} transactions={transactions}
loading={txLoading} loading={txLoading}
source={txSource} source={txSource === 'CREDIT_CARD' ? 'CREDIT_CARD' : 'CASH'}
search={txSearch} search={txSearch}
onSearchChange={setTxSearch} onSearchChange={setTxSearch}
onRefresh={() => { onRefresh={() => {
@@ -141,81 +182,11 @@ export default function Budget() {
refresh(); refresh();
}} }}
showCategory={txSource === 'CREDIT_CARD'} showCategory={txSource === 'CREDIT_CARD'}
emptyMessage={`Sin transacciones de ${txSource === 'CREDIT_CARD' ? 'tarjeta' : txSource === 'CASH' ? 'efectivo' : 'transferencia'} en ${MONTH_NAMES[selectedMonth]}`} showSourceIcon={txSource === 'CASH_AND_TRANSFER'}
emptyMessage={`Sin transacciones de ${txSource === 'CREDIT_CARD' ? 'tarjeta' : 'efectivo o transferencia'} en ${MONTH_NAMES[selectedMonth]}`}
onToggleDeferred={txSource === 'CREDIT_CARD' ? handleToggleDeferred : undefined}
/> />
</TabsContent> </TabsContent>
<TabsContent value="projections" className="space-y-6 mt-4">
{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 data-sensitive 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 data-sensitive 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 data-sensitive 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
data-sensitive
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>
)}
{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}
year={year}
onSelectMonth={(m) => {
setSelectedMonth(m);
setSubTab('detail');
}}
onSaveOverride={async (month, value) => {
await saveBalanceOverride(year, month, value);
}}
onClearOverride={async (month) => {
await clearBalanceOverride(year, month);
}}
/>
</CardContent>
</Card>
) : null}
</TabsContent>
</Tabs> </Tabs>
</TabsContent> </TabsContent>

View File

@@ -0,0 +1,120 @@
import { ChevronLeft, ChevronRight, Loader2, TrendingUp } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
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 YearlyOverview from '@/components/budget/YearlyOverview';
const MIN_YEAR = 2026;
const MAX_YEAR = 2030;
export default function Proyecciones() {
const currentYear = Math.max(MIN_YEAR, new Date().getFullYear());
const {
year,
setYear,
setSelectedMonth,
projection,
loading,
saveBalanceOverride,
clearBalanceOverride,
} = useBudget(currentYear);
const navigate = useNavigate();
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<TrendingUp className="w-6 h-6 text-primary" />
<h1 className="text-2xl font-bold tracking-tight">Proyecciones</h1>
</div>
<div className="flex items-center gap-1">
<Button variant="outline" size="icon" disabled={year <= MIN_YEAR} 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" disabled={year >= MAX_YEAR} onClick={() => setYear(year + 1)}>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
{/* Annual summary cards */}
{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 data-sensitive 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 data-sensitive 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 data-sensitive 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
data-sensitive
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 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={0}
year={year}
onSelectMonth={(m) => {
setSelectedMonth(m);
navigate('/budget');
}}
onSaveOverride={async (month, value) => {
await saveBalanceOverride(year, month, value);
}}
onClearOverride={async (month) => {
await clearBalanceOverride(year, month);
}}
/>
</CardContent>
</Card>
) : null}
</div>
);
}

99
scripts/sync-db.sh Executable file
View File

@@ -0,0 +1,99 @@
#!/usr/bin/env bash
set -euo pipefail
# ── Configuration ────────────────────────────────────────────────
PROD_SSH_ALIAS="production"
PROD_CONTAINER="wealthysmart-db-prod"
PROD_DB="wealthysmart"
PROD_USER="wealthy_user"
LOCAL_CONTAINER="wealthysmart-db-dev"
LOCAL_DB="wealthysmart"
LOCAL_USER="wealthy_user"
LOCAL_PASS="wealthy_pass"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
DUMP_FILE="$(mktemp -t wealthysmart-dump-XXXXXX)"
# ── Cleanup on exit ─────────────────────────────────────────────
cleanup() { rm -f "$DUMP_FILE"; }
trap cleanup EXIT
# ── Confirmation ─────────────────────────────────────────────────
echo "=== WealthySmart Database Sync ==="
echo ""
echo "This will DESTROY your local dev database and replace it"
echo "with a copy of production data."
echo ""
read -r -p "Continue? [y/N] " confirm
if [[ "$confirm" != [yY] ]]; then
echo "Aborted."
exit 0
fi
# ── 1. Dump production ──────────────────────────────────────────
echo ""
echo "[1/5] Dumping production database..."
ssh "$PROD_SSH_ALIAS" \
"docker exec $PROD_CONTAINER pg_dump \
--format=custom \
--no-owner \
--no-acl \
-U $PROD_USER \
$PROD_DB" > "$DUMP_FILE"
if [[ ! -s "$DUMP_FILE" ]]; then
echo "ERROR: Dump file is empty. SSH or pg_dump may have failed."
exit 1
fi
DUMP_SIZE=$(du -h "$DUMP_FILE" | cut -f1)
echo " Done. Dump size: $DUMP_SIZE"
# ── 2. Ensure local DB container is running ─────────────────────
echo "[2/5] Ensuring local dev database is running..."
cd "$PROJECT_ROOT"
if ! docker inspect --format='{{.State.Running}}' "$LOCAL_CONTAINER" 2>/dev/null | grep -q "true"; then
echo " Starting db service..."
docker compose up -d db
fi
for i in $(seq 1 30); do
if docker inspect --format='{{.State.Health.Status}}' "$LOCAL_CONTAINER" 2>/dev/null | grep -q "healthy"; then
break
fi
if [[ $i -eq 30 ]]; then
echo "ERROR: Local DB container did not become healthy within 30s."
exit 1
fi
sleep 1
done
echo " Local DB is running and healthy."
# ── 3. Drop and recreate local database ─────────────────────────
echo "[3/5] Dropping and recreating local dev database..."
docker exec "$LOCAL_CONTAINER" bash -c \
"PGPASSWORD='$LOCAL_PASS' dropdb -U $LOCAL_USER --if-exists $LOCAL_DB && \
PGPASSWORD='$LOCAL_PASS' createdb -U $LOCAL_USER $LOCAL_DB"
echo " Done."
# ── 4. Restore ──────────────────────────────────────────────────
echo "[4/5] Restoring dump into local dev database..."
docker exec -i "$LOCAL_CONTAINER" pg_restore \
--no-owner \
--no-acl \
--dbname="$LOCAL_DB" \
-U "$LOCAL_USER" < "$DUMP_FILE"
# ── 5. Run pending migrations ───────────────────────────────────
echo "[5/5] Running pending migrations..."
docker exec "$LOCAL_CONTAINER" psql -U "$LOCAL_USER" -d "$LOCAL_DB" -c \
"ALTER TABLE transaction ADD COLUMN IF NOT EXISTS deferred_to_next_cycle BOOLEAN NOT NULL DEFAULT false;" \
2>/dev/null || true
echo " Done."
echo ""
echo "=== Sync complete! ==="
echo "Local dev database now mirrors production."