From 0fdb5447b78333e7c2ef88e9c1e4b980f14f9a58 Mon Sep 17 00:00:00 2001 From: Carlos Escalante Date: Fri, 3 Apr 2026 20:10:23 -0600 Subject: [PATCH] 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) --- backend/app/api/v1/endpoints/budget.py | 7 + backend/app/api/v1/endpoints/transactions.py | 34 +- backend/app/db.py | 15 + backend/app/main.py | 3 +- backend/app/models/models.py | 2 + backend/app/services/budget_projection.py | 421 ++++++++++++++---- frontend/src/components/TransactionList.tsx | 33 +- .../src/components/budget/MonthlyDetail.tsx | 368 ++++++++++++--- .../transactions/transaction-columns.tsx | 29 +- frontend/src/index.css | 20 +- frontend/src/pages/Budget.tsx | 189 ++++---- 11 files changed, 845 insertions(+), 276 deletions(-) diff --git a/backend/app/api/v1/endpoints/budget.py b/backend/app/api/v1/endpoints/budget.py index b4b62fe..3712de7 100644 --- a/backend/app/api/v1/endpoints/budget.py +++ b/backend/app/api/v1/endpoints/budget.py @@ -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"]], ) diff --git a/backend/app/api/v1/endpoints/transactions.py b/backend/app/api/v1/endpoints/transactions.py index e35d76f..82a32a8 100644 --- a/backend/app/api/v1/endpoints/transactions.py +++ b/backend/app/api/v1/endpoints/transactions.py @@ -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), diff --git a/backend/app/db.py b/backend/app/db.py index b743046..7d0c02b 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py index 4d1ea1e..5a8ec95 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 diff --git a/backend/app/models/models.py b/backend/app/models/models.py index a26286d..02165be 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -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 --- diff --git a/backend/app/services/budget_projection.py b/backend/app/services/budget_projection.py index 5ccbc31..5a7470f 100644 --- a/backend/app/services/budget_projection.py +++ b/backend/app/services/budget_projection.py @@ -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, } diff --git a/frontend/src/components/TransactionList.tsx b/frontend/src/components/TransactionList.tsx index 94be7d4..8fd1317 100644 --- a/frontend/src/components/TransactionList.tsx +++ b/frontend/src/components/TransactionList.tsx @@ -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(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({ )}
-

{tx.merchant}

+
+

{tx.merchant}

+ {showSourceIcon && ( + tx.source === 'CASH' + ? + : tx.source === 'TRANSFER' + ? + : null + )} +

{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)}

+ {onToggleDeferred && ( + + )} diff --git a/frontend/src/components/budget/MonthlyDetail.tsx b/frontend/src/components/budget/MonthlyDetail.tsx index 573c65a..ce386f9 100644 --- a/frontend/src/components/budget/MonthlyDetail.tsx +++ b/frontend/src/components/budget/MonthlyDetail.tsx @@ -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 = { - CREDIT_CARD: { label: 'Tarjeta de Crédito', icon: CreditCard }, +const PALETTES: Record = { + 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 = { CASH: { label: 'Efectivo', icon: Banknote }, TRANSFER: { label: 'Transferencias', icon: ArrowLeftRight }, }; @@ -28,9 +54,12 @@ const SOURCE_LABELS: Record 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('chatgpt'); + if (loading) { return (
@@ -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 { + if (paletteMode === 'chatgpt') { + const sorted = [...data].sort((a, b) => b.value - a.value); + const map = new Map(); + sorted.forEach((item, i) => { + map.set(item.name, colors[Math.min(i, colors.length - 1)]); + }); + return map; + } + // Gemini: positional + const map = new Map(); + 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((acc, item) => { + acc[item.name] = { label: item.name, color: incomeColorMap.get(item.name)! }; + return acc; + }, {}); + + const expenseConfig = expenseData.reduce((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((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 (
-

- Detalle: {MONTH_NAMES[detail.month]} {detail.year} -

+ {/* Palette Toggle */} +
+ Paleta: + + +
-
- {/* Income Card */} + {/* Pie Charts */} +
+ {/* Income Pie */} - + Ingresos - - {detail.income_items.map((item) => ( -
- {item.name} - - {formatAmount(item.amount, 'CRC')} - + + {incomeData.length > 0 ? ( +
+ + + + {incomeData.map((item, i) => ( + + ))} + + ( + {name}: {formatAmount(Number(value), 'CRC')} + )} + /> + } + /> + + +
+ {incomeData.map((item, i) => ( +
+
+ {item.name} +
+ ))} +
+ +
+ Total + + {formatAmount(detail.total_projected_income, 'CRC')} + +
- ))} - -
- Total - - {formatAmount(detail.total_projected_income, 'CRC')} - -
+ ) : ( +

Sin ingresos

+ )} - {/* Expenses Card */} + {/* Expenses Pie */} - + Egresos Fijos - - {detail.expense_items.map((item) => ( -
-
- {item.name} - {item.used_actual && ( - - real - - )} + + {expenseData.length > 0 ? ( +
+ + + + {expenseData.map((item, i) => ( + + ))} + + ( + {name}: {formatAmount(Number(value), 'CRC')} + )} + /> + } + /> + + +
+ {expenseData.map((item, i) => ( +
+
+ {item.name} +
+ ))}
-
- {formatAmount(item.amount, 'CRC')} - {item.used_actual && item.projected_amount != null && ( - - {formatAmount(item.projected_amount, 'CRC')} - - )} + +
+ Total Fijos + + {formatAmount(detail.total_projected_expenses, 'CRC')} +
- ))} - {detail.expense_items.length === 0 && ( -

Sin egresos fijos

+ ) : ( +

Sin egresos fijos

)} - -
- Total Fijos + + +
+ + {/* Credit Card by Category */} + {ccData.length > 0 && ( + + + + + Tarjeta de Crédito + + + +
+ + + + {ccData.map((_, i) => ( + + ))} + + ( + {name}: {formatAmount(Number(value), 'CRC')} + )} + /> + } + /> + + +
+ {ccData.map((item, i) => ( +
+
+ {item.name} +
+ ))} +
+
+ +
+ Total Tarjeta - {formatAmount(detail.total_projected_expenses, 'CRC')} + {formatAmount(ccTotal, 'CRC')}
+ )} - {/* Actuals Card */} + {/* Actuals + Savings + Summary */} +
+ {/* Cash & Transfer Actuals Card */} - - Transacciones Reales + + Efectivo o Transferencias - {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 (
-
+
+ {formatAmount(src.net, 'CRC')}
); })} + {cashTransferActuals.length === 0 && ( +

Sin transacciones

+ )} {detail.uncovered_actual > 0 && ( <> @@ -159,10 +400,7 @@ export default function MonthlyDetail({ detail, loading }: MonthlyDetailProps) { )}
-
- {/* Savings + Summary */} -
{/* Savings */} {detail.savings_items.length > 0 && ( diff --git a/frontend/src/components/transactions/transaction-columns.tsx b/frontend/src/components/transactions/transaction-columns.tsx index 0bf7884..66901f5 100644 --- a/frontend/src/components/transactions/transaction-columns.tsx +++ b/frontend/src/components/transactions/transaction-columns.tsx @@ -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[] { const columns: ColumnDef[] = [ { @@ -55,6 +59,17 @@ export function getTransactionColumns({ )}
{tx.merchant} + {showSourceIcon && tx.source === 'CASH' && ( + + )} + {showSourceIcon && tx.source === 'TRANSFER' && ( + + )} + {tx.deferred_to_next_cycle && ( + + Diferida + + )}
); }, @@ -109,6 +124,18 @@ export function getTransactionColumns({ const tx = row.original; return (
+ {onToggleDeferred && ( + + )} + + {MONTH_NAMES[selectedMonth]} {year} + + +
+
- {monthDetail && } + {monthDetail && ( + + )} @@ -126,14 +168,13 @@ export default function Budget() { > Tarjeta - Efectivo - Transferencias + Efectivo y Transferencias { @@ -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} /> - - - {projection && ( -
- - -

Ingresos Anuales

-

- {formatAmount(projection.annual_income, 'CRC')} -

-
-
- - -

Egresos Anuales

-

- {formatAmount(projection.annual_expenses, 'CRC')} -

-
-
- - -

Ahorro Anual

-

- {formatAmount(projection.annual_savings, 'CRC')} -

-
-
- - -

Balance Neto Anual

-

= 0 ? 'text-primary' : 'text-destructive', - )} - > - {projection.annual_net >= 0 ? '+' : ''} - {formatAmount(projection.annual_net, 'CRC')} -

-
-
-
- )} - - {loading ? ( -
- -
- ) : projection ? ( - - - { - setSelectedMonth(m); - setSubTab('detail'); - }} - onSaveOverride={async (month, value) => { - await saveBalanceOverride(year, month, value); - }} - onClearOverride={async (month) => { - await clearBalanceOverride(year, month); - }} - /> - - - ) : null} -