mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-05-19 08:28:48 +02:00
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>
This commit is contained in:
@@ -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"]],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 ---
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user