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/
__pycache__/
*.pyc
*.db
*.db.bak
.env
.env.*
!.env.example

View File

@@ -3,12 +3,13 @@ from typing import Optional
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy import case
from sqlmodel import Session, func, select
from app.auth import get_current_user
from app.db import get_session
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"])
@@ -104,7 +105,7 @@ def monthly_trend(
func.count(),
func.coalesce(
func.sum(
func.case(
case(
(Transaction.currency == "CRC", Transaction.amount),
else_=0,
)
@@ -113,7 +114,7 @@ def monthly_trend(
),
func.coalesce(
func.sum(
func.case(
case(
(Transaction.currency == "USD", Transaction.amount),
else_=0,
)

View File

@@ -194,6 +194,11 @@ class ActualsBySource(BaseModel):
count: int
class CCCategorySpending(BaseModel):
category_name: str
amount: float
class MonthlyDetailResponse(BaseModel):
year: int
month: int
@@ -207,6 +212,7 @@ class MonthlyDetailResponse(BaseModel):
uncovered_actual: float
gran_total_egresos: float
net_balance: float
cc_by_category: list[CCCategorySpending]
@router.get("/month/{year}/{month}", response_model=MonthlyDetailResponse)
@@ -230,6 +236,7 @@ def get_monthly_detail(
uncovered_actual=data["uncovered_actual"],
gran_total_egresos=data["gran_total_egresos"],
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,
)
from app.services.budget_projection import get_cycle_range, get_previous_cycle
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):
year: int
month: int
@@ -54,6 +46,7 @@ def auto_categorize(merchant: str, session: Session) -> Optional[int]:
@router.get("/", response_model=list[TransactionRead])
def list_transactions(
source: Optional[TransactionSource] = None,
exclude_source: Optional[TransactionSource] = None,
search: Optional[str] = None,
category_id: Optional[int] = None,
cycle_year: Optional[int] = None,
@@ -68,13 +61,32 @@ def list_transactions(
query = select(Transaction)
if source:
query = query.where(Transaction.source == source)
if exclude_source:
query = query.where(Transaction.source != exclude_source)
if category_id:
query = query.where(Transaction.category_id == category_id)
if search:
query = query.where(col(Transaction.merchant).ilike(f"%{search}%"))
if cycle_year and 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:
query = query.where(
Transaction.date >= datetime.fromisoformat(start_date),

View File

@@ -1,3 +1,4 @@
from sqlalchemy import text
from sqlmodel import SQLModel, Session, create_engine
from app.config import settings
@@ -9,6 +10,20 @@ def init_db():
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():
with Session(engine) as 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.config import settings
from app.db import init_db
from app.db import init_db, run_migrations
from app.seed import seed_db
@asynccontextmanager
async def lifespan(app: FastAPI):
init_db()
run_migrations()
seed_db()
yield

View File

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

View File

@@ -71,81 +71,308 @@ def get_month_range(year: int, month: int) -> tuple[datetime, datetime]:
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(
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)
"""Query actual transaction totals grouped by source.
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 = {}
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,
Transaction.transaction_type != TransactionType.DEPOSITO,
)
).one()
if source == TransactionSource.CREDIT_CARD:
start, end = cc_start, cc_end
# Normal transactions in this cycle (not deferred)
compra_normal = 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,
Transaction.deferred_to_next_cycle == False, # noqa: E712
)
).one()
# Deferred from previous cycle
compra_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.COMPRA,
Transaction.deferred_to_next_cycle == True, # noqa: E712
)
).one()
compra = float(compra_normal) + float(compra_deferred)
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,
}
dev_normal = 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,
Transaction.deferred_to_next_cycle == False, # noqa: E712
)
).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
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)
"""Return {category_id: net_amount} for actual transactions.
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]
Transaction.transaction_type != TransactionType.DEPOSITO,
)
.group_by(Transaction.category_id, Transaction.transaction_type)
).all()
Credit card uses billing cycle (18th-18th) with deferred logic.
Cash/Transfer use calendar month (1st-1st).
"""
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)
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
def _merge_rows(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
# 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
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(
session: Session, year: int, month: int
) -> dict:
@@ -215,30 +442,71 @@ def compute_monthly_projection(
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]
Transaction.transaction_type != TransactionType.DEPOSITO,
)
.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
# Also add transactions with no category (hybrid ranges + deferred)
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)
def _sum_uncategorized(rows: list) -> float:
total = 0.0
for tx_type, amount in rows:
val = float(amount)
if tx_type == TransactionType.DEVOLUCION:
val = -val
total += val
return total
# CC uncategorized: this cycle (not deferred)
uncovered_actual += _sum_uncategorized(
session.exec(
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_cash = actuals_by_source.get("CASH", {}).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
# Savings are NOT deducted — they are already deducted from gross salary
@@ -261,6 +529,7 @@ def compute_monthly_projection(
"expense_items": expense_items,
"savings_items": savings_items,
"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/noto-sans": "^5.2.10",
"@tanstack/react-table": "^8.21.3",
"axios": "^1.13.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.562.0",

View File

@@ -20,9 +20,6 @@ importers:
'@tanstack/react-table':
specifier: ^8.21.3
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:
specifier: ^0.7.1
version: 0.7.1
@@ -928,12 +925,6 @@ packages:
resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==}
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:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
@@ -1020,10 +1011,6 @@ packages:
color-name@1.1.4:
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:
resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==}
engines: {node: '>=16'}
@@ -1164,10 +1151,6 @@ packages:
resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==}
engines: {node: '>=12'}
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
depd@2.0.0:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'}
@@ -1234,10 +1217,6 @@ packages:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
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:
resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==}
engines: {node: '>=18'}
@@ -1330,19 +1309,6 @@ packages:
resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
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:
resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==}
engines: {node: '>=12.20.0'}
@@ -1421,10 +1387,6 @@ packages:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'}
has-tostringtag@1.0.2:
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
engines: {node: '>= 0.4'}
hasown@2.0.2:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
@@ -1723,18 +1685,10 @@ packages:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'}
mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
mime-db@1.54.0:
resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
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:
resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
engines: {node: '>=18'}
@@ -1913,9 +1867,6 @@ packages:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'}
proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
qs@6.15.0:
resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==}
engines: {node: '>=0.6'}
@@ -3094,16 +3045,6 @@ snapshots:
dependencies:
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: {}
baseline-browser-mapping@2.10.10: {}
@@ -3188,10 +3129,6 @@ snapshots:
color-name@1.1.4: {}
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
commander@11.1.0: {}
commander@14.0.3: {}
@@ -3291,8 +3228,6 @@ snapshots:
define-lazy-prop@3.0.0: {}
delayed-stream@1.0.0: {}
depd@2.0.0: {}
detect-libc@2.1.2: {}
@@ -3348,13 +3283,6 @@ snapshots:
dependencies:
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:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.4
@@ -3511,16 +3439,6 @@ snapshots:
transitivePeerDependencies:
- 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:
dependencies:
fetch-blob: 3.2.0
@@ -3587,10 +3505,6 @@ snapshots:
has-symbols@1.1.0: {}
has-tostringtag@1.0.2:
dependencies:
has-symbols: 1.1.0
hasown@2.0.2:
dependencies:
function-bind: 1.1.2
@@ -3804,14 +3718,8 @@ snapshots:
braces: 3.0.3
picomatch: 2.3.1
mime-db@1.52.0: {}
mime-db@1.54.0: {}
mime-types@2.1.35:
dependencies:
mime-db: 1.52.0
mime-types@3.0.2:
dependencies:
mime-db: 1.54.0
@@ -3989,8 +3897,6 @@ snapshots:
forwarded: 0.2.0
ipaddr.js: 1.9.1
proxy-from-env@1.1.0: {}
qs@6.15.0:
dependencies:
side-channel: 1.1.0

View File

@@ -9,6 +9,7 @@ import Budget from './pages/Budget';
import Analytics from './pages/Analytics';
import Salarios from './pages/Salarios';
import Pensions from './pages/Pensions';
import Proyecciones from './pages/Proyecciones';
import ServiciosMunicipales from './pages/ServiciosMunicipales';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
@@ -35,6 +36,7 @@ function AppRoutes() {
<Route path="/" element={<Dashboard />} />
<Route path="/budget" element={<Budget />} />
<Route path="/analytics" element={<Analytics />} />
<Route path="/proyecciones" element={<Proyecciones />} />
<Route path="/salarios" element={<Salarios />} />
<Route path="/pensions" element={<Pensions />} />
<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({
baseURL: import.meta.env.VITE_API_URL || '/api/v1',
});
class ApiError extends Error {
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) => {
const token = localStorage.getItem('token');
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
interface RequestConfig {
params?: Record<string, string | number | boolean | undefined>;
}
api.interceptors.response.use(
(res) => res,
(err) => {
if (err.response?.status === 401) {
localStorage.removeItem('token');
window.location.href = '/login';
async function request<T>(method: string, url: string, body?: unknown, config?: RequestConfig): Promise<{ data: T }> {
let fullUrl = `${BASE_URL}${url}`;
if (config?.params) {
const search = new URLSearchParams();
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;
@@ -101,6 +154,7 @@ export interface Transaction {
notes: string | null;
category_id: number | null;
category: Category | null;
deferred_to_next_cycle: boolean;
created_at: string;
}
@@ -213,6 +267,7 @@ export interface MonthlyDetail {
uncovered_actual: number;
gran_total_egresos: number;
net_balance: number;
cc_by_category: { category_name: string; amount: number }[];
}
// Budget API functions

View File

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

View File

@@ -7,6 +7,8 @@ import {
TrendingUp,
TrendingDown,
ArrowLeftRight,
ArrowRightFromLine,
Banknote,
} from 'lucide-react';
import api, { type Transaction } from '../api';
@@ -30,7 +32,9 @@ export interface TransactionListProps {
emptyIcon?: React.ReactNode;
emptyMessage?: string;
showCategory?: boolean;
showSourceIcon?: boolean;
addLabel?: string;
onToggleDeferred?: (tx: Transaction) => void;
}
export default function TransactionList({
@@ -43,7 +47,9 @@ export default function TransactionList({
emptyIcon,
emptyMessage = 'No transactions found',
showCategory = true,
showSourceIcon = false,
addLabel = 'Add Transaction',
onToggleDeferred,
}: TransactionListProps) {
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<Transaction | null>(null);
@@ -68,8 +74,8 @@ export default function TransactionList({
};
const columns = useMemo(
() => getTransactionColumns({ showCategory, onEdit: handleEdit, onDelete: (id) => setDeleteId(id) }),
[showCategory],
() => getTransactionColumns({ showCategory, showSourceIcon, onEdit: handleEdit, onDelete: (id) => setDeleteId(id), onToggleDeferred }),
[showCategory, showSourceIcon, onToggleDeferred],
);
const empty = transactions.length === 0 && !loading;
@@ -119,7 +125,16 @@ export default function TransactionList({
)}
</div>
<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">
{new Date(tx.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
{showCategory && tx.category && (
@@ -138,6 +153,18 @@ export default function TransactionList({
{formatAmount(tx.amount, tx.currency)}
</span>
<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)}>
<Pencil className="w-4 h-4" />
</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 { 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 { Button } from '@/components/ui/button';
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from '@/components/ui/chart';
import {
TrendingUp,
TrendingDown,
@@ -14,13 +23,30 @@ import {
Info,
} from 'lucide-react';
const MONTH_NAMES = [
'', 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre',
];
type PaletteMode = 'chatgpt' | 'gemini';
const SOURCE_LABELS: Record<string, { label: string; icon: typeof CreditCard }> = {
CREDIT_CARD: { label: 'Tarjeta de Crédito', icon: CreditCard },
const PALETTES: Record<PaletteMode, { income: string[]; expense: string[]; cc: string[] }> = {
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 },
TRANSFER: { label: 'Transferencias', icon: ArrowLeftRight },
};
@@ -28,9 +54,12 @@ const SOURCE_LABELS: Record<string, { label: string; icon: typeof CreditCard }>
interface MonthlyDetailProps {
detail: MonthlyDetailType;
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) {
return (
<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 (
<div className="space-y-4">
<h3 className="text-lg font-semibold">
Detalle: {MONTH_NAMES[detail.month]} {detail.year}
</h3>
{/* Palette Toggle */}
<div className="flex items-center justify-end gap-1">
<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">
{/* Income Card */}
{/* Pie Charts */}
<div className="grid gap-4 md:grid-cols-2">
{/* Income Pie */}
<Card>
<CardHeader className="pb-2">
<CardHeader className="pb-0">
<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 data-sensitive className="font-mono text-primary whitespace-nowrap">
{formatAmount(item.amount, 'CRC')}
</span>
<CardContent>
{incomeData.length > 0 ? (
<div className="flex flex-col items-center">
<ChartContainer config={incomeConfig} className="h-[200px] w-full">
<PieChart>
<Pie
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>
))}
<Separator />
<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>
) : (
<p className="text-sm text-muted-foreground py-8 text-center">Sin ingresos</p>
)}
</CardContent>
</Card>
{/* Expenses Card */}
{/* Expenses Pie */}
<Card>
<CardHeader className="pb-2">
<CardHeader className="pb-0">
<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>
)}
<CardContent>
{expenseData.length > 0 ? (
<div className="flex flex-col items-center">
<ChartContainer config={expenseConfig} className="h-[200px] w-full">
<PieChart>
<Pie
data={expenseData}
dataKey="value"
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 data-sensitive 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>
)}
<Separator className="mt-3" />
<div className="flex items-center justify-between w-full mt-2 font-semibold text-sm">
<span>Total Fijos</span>
<span data-sensitive className="font-mono">
{formatAmount(detail.total_projected_expenses, 'CRC')}
</span>
</div>
</div>
))}
{detail.expense_items.length === 0 && (
<p className="text-sm text-muted-foreground">Sin egresos fijos</p>
) : (
<p className="text-sm text-muted-foreground py-8 text-center">Sin egresos fijos</p>
)}
<Separator />
<div className="flex items-center justify-between font-semibold text-sm">
<span>Total Fijos</span>
</CardContent>
</Card>
</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">
{formatAmount(detail.total_projected_expenses, 'CRC')}
{formatAmount(ccTotal, 'CRC')}
</span>
</div>
</CardContent>
</Card>
)}
{/* Actuals Card */}
{/* Actuals + Savings + Summary */}
<div className="grid gap-4 md:grid-cols-3">
{/* Cash & Transfer 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
<Banknote className="w-4 h-4" />
Efectivo o Transferencias
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{detail.actuals_by_source.map((src) => {
{cashTransferActuals.map((src) => {
const meta = SOURCE_LABELS[src.source];
if (!meta || src.count === 0) return null;
if (!meta) return null;
const Icon = meta.icon;
const isClickable = onNavigateToTransactions != null;
return (
<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" />
<span>{meta.label}</span>
<span className="text-xs text-muted-foreground">({src.count})</span>
</div>
</button>
<span data-sensitive className="font-mono whitespace-nowrap">
{formatAmount(src.net, 'CRC')}
</span>
</div>
);
})}
{cashTransferActuals.length === 0 && (
<p className="text-sm text-muted-foreground">Sin transacciones</p>
)}
{detail.uncovered_actual > 0 && (
<>
<Separator />
@@ -159,10 +400,7 @@ export default function MonthlyDetail({ detail, loading }: MonthlyDetailProps) {
)}
</CardContent>
</Card>
</div>
{/* Savings + Summary */}
<div className="grid gap-4 md:grid-cols-2">
{/* Savings */}
{detail.savings_items.length > 0 && (
<Card>

View File

@@ -1,5 +1,5 @@
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 { formatAmount } from '@/lib/format';
@@ -10,14 +10,18 @@ import { DataTableColumnHeader } from '@/components/ui/data-table-column-header'
interface TransactionColumnOptions {
showCategory: boolean;
showSourceIcon?: boolean;
onEdit: (tx: Transaction) => void;
onDelete: (txId: number) => void;
onToggleDeferred?: (tx: Transaction) => void;
}
export function getTransactionColumns({
showCategory,
showSourceIcon,
onEdit,
onDelete,
onToggleDeferred,
}: TransactionColumnOptions): ColumnDef<Transaction, unknown>[] {
const columns: ColumnDef<Transaction, unknown>[] = [
{
@@ -55,6 +59,17 @@ export function getTransactionColumns({
)}
</div>
<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>
);
},
@@ -109,6 +124,18 @@ export function getTransactionColumns({
const tx = row.original;
return (
<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
variant="ghost"
size="icon"

View File

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

View File

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

View File

@@ -1,14 +1,10 @@
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 { 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';
@@ -28,51 +24,69 @@ export default function Budget() {
setYear,
selectedMonth,
setSelectedMonth,
projection,
monthDetail,
recurringItems,
loading,
monthLoading,
addItem,
updateItem,
deleteItem,
saveBalanceOverride,
clearBalanceOverride,
refresh,
} = useBudget(currentYear);
const [subTab, setSubTab] = useState<'detail' | 'transactions' | 'projections'>('detail');
const [subTab, setSubTab] = useState<'detail' | 'transactions'>('detail');
// 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 [txSource, setTxSource] = useState<'CREDIT_CARD' | 'CASH_AND_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 params: Record<string, unknown> = {
search: txSearch || undefined,
limit: 200,
};
const { data } = await api.get<Transaction[]>('/transactions/', {
params: {
source: txSource,
search: txSearch || undefined,
limit: 200,
start_date: startDate,
end_date: endDate,
},
});
if (txSource === 'CREDIT_CARD') {
params.source = 'CREDIT_CARD';
// Credit card: billing cycle that ends around the 18th of selectedMonth
const prevMonth = selectedMonth === 1 ? 12 : selectedMonth - 1;
const prevYear = selectedMonth === 1 ? year - 1 : year;
params.cycle_year = prevYear;
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);
} finally {
setTxLoading(false);
}
}, [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(() => {
fetchTransactions();
}, [fetchTransactions]);
@@ -107,16 +121,44 @@ export default function Budget() {
value={subTab}
onValueChange={(v) => setSubTab(v as typeof subTab)}
>
<TabsList variant="line">
<TabsTrigger value="detail">
Detalle: {MONTH_NAMES[selectedMonth]} {year}
</TabsTrigger>
<TabsTrigger value="transactions">Transacciones</TabsTrigger>
<TabsTrigger value="projections">Proyecciones</TabsTrigger>
</TabsList>
<div className="flex items-center justify-between">
<TabsList variant="line">
<TabsTrigger value="detail">Detalle</TabsTrigger>
<TabsTrigger value="transactions">Transacciones</TabsTrigger>
</TabsList>
<div className="flex items-center gap-1">
<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">
{monthDetail && <MonthlyDetail detail={monthDetail} loading={monthLoading} />}
{monthDetail && (
<MonthlyDetail
detail={monthDetail}
loading={monthLoading}
onNavigateToTransactions={handleNavigateToTransactions}
/>
)}
</TabsContent>
<TabsContent value="transactions" className="space-y-3 mt-4">
@@ -126,14 +168,13 @@ export default function Budget() {
>
<TabsList>
<TabsTrigger value="CREDIT_CARD">Tarjeta</TabsTrigger>
<TabsTrigger value="CASH">Efectivo</TabsTrigger>
<TabsTrigger value="TRANSFER">Transferencias</TabsTrigger>
<TabsTrigger value="CASH_AND_TRANSFER">Efectivo y Transferencias</TabsTrigger>
</TabsList>
</Tabs>
<TransactionList
transactions={transactions}
loading={txLoading}
source={txSource}
source={txSource === 'CREDIT_CARD' ? 'CREDIT_CARD' : 'CASH'}
search={txSearch}
onSearchChange={setTxSearch}
onRefresh={() => {
@@ -141,81 +182,11 @@ export default function Budget() {
refresh();
}}
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 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>
</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."