mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-05-19 08:48:48 +02:00
Add cumulative balance tracking with editable overrides
All checks were successful
Deploy to VPS / deploy (push) Successful in 21s
All checks were successful
Deploy to VPS / deploy (push) Successful in 21s
- New BalanceOverride table for manual balance adjustments per month
- Cumulative balance computation with cross-year carryover
- Three new columns: Acum. Anterior, Neto Mes, Balance Acum.
- Inline editing on Balance Acum. cell (pencil icon for overrides)
- Year navigation clamped to 2026–2030, fresh start at March 2026
- PUT/DELETE /budget/balance-override/{year}/{month} endpoints
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
||||||
@@ -7,13 +8,23 @@ from sqlmodel import Session, select
|
|||||||
from app.auth import get_current_user
|
from app.auth import get_current_user
|
||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
from app.models.models import (
|
from app.models.models import (
|
||||||
|
BalanceOverride,
|
||||||
|
BalanceOverrideCreate,
|
||||||
|
BalanceOverrideRead,
|
||||||
RecurringItem,
|
RecurringItem,
|
||||||
RecurringItemCreate,
|
RecurringItemCreate,
|
||||||
RecurringItemRead,
|
RecurringItemRead,
|
||||||
RecurringItemType,
|
RecurringItemType,
|
||||||
RecurringItemUpdate,
|
RecurringItemUpdate,
|
||||||
)
|
)
|
||||||
from app.services.budget_projection import compute_monthly_projection
|
from app.services.budget_projection import (
|
||||||
|
FRESH_START_MONTH,
|
||||||
|
FRESH_START_YEAR,
|
||||||
|
MAX_YEAR,
|
||||||
|
MIN_YEAR,
|
||||||
|
compute_monthly_projection,
|
||||||
|
compute_yearly_projection_with_cumulative,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/budget", tags=["budget"])
|
router = APIRouter(prefix="/budget", tags=["budget"])
|
||||||
|
|
||||||
@@ -97,6 +108,9 @@ class MonthlyProjectionResponse(BaseModel):
|
|||||||
uncovered_actual: float
|
uncovered_actual: float
|
||||||
gran_total_egresos: float
|
gran_total_egresos: float
|
||||||
net_balance: float
|
net_balance: float
|
||||||
|
carryover_balance: float = 0.0
|
||||||
|
cumulative_balance: float = 0.0
|
||||||
|
balance_overridden: bool = False
|
||||||
|
|
||||||
|
|
||||||
class YearlyProjectionResponse(BaseModel):
|
class YearlyProjectionResponse(BaseModel):
|
||||||
@@ -114,14 +128,20 @@ def get_yearly_projection(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
_user: str = Depends(get_current_user),
|
_user: str = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
|
if year < MIN_YEAR or year > MAX_YEAR:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Year must be between {MIN_YEAR} and {MAX_YEAR}",
|
||||||
|
)
|
||||||
|
|
||||||
|
months_data = compute_yearly_projection_with_cumulative(session, year)
|
||||||
months = []
|
months = []
|
||||||
annual_income = 0.0
|
annual_income = 0.0
|
||||||
annual_expenses = 0.0
|
annual_expenses = 0.0
|
||||||
annual_savings = 0.0
|
annual_savings = 0.0
|
||||||
annual_net = 0.0
|
annual_net = 0.0
|
||||||
|
|
||||||
for m in range(1, 13):
|
for data in months_data:
|
||||||
data = compute_monthly_projection(session, year, m)
|
|
||||||
monthly = MonthlyProjectionResponse(
|
monthly = MonthlyProjectionResponse(
|
||||||
month=data["month"],
|
month=data["month"],
|
||||||
year=data["year"],
|
year=data["year"],
|
||||||
@@ -134,6 +154,9 @@ def get_yearly_projection(
|
|||||||
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"],
|
||||||
|
carryover_balance=data["carryover_balance"],
|
||||||
|
cumulative_balance=data["cumulative_balance"],
|
||||||
|
balance_overridden=data["balance_overridden"],
|
||||||
)
|
)
|
||||||
months.append(monthly)
|
months.append(monthly)
|
||||||
annual_income += data["projected_income"]
|
annual_income += data["projected_income"]
|
||||||
@@ -208,3 +231,63 @@ def get_monthly_detail(
|
|||||||
gran_total_egresos=data["gran_total_egresos"],
|
gran_total_egresos=data["gran_total_egresos"],
|
||||||
net_balance=data["net_balance"],
|
net_balance=data["net_balance"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Balance Override CRUD ---
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/balance-override/{year}/{month}",
|
||||||
|
response_model=BalanceOverrideRead,
|
||||||
|
)
|
||||||
|
def upsert_balance_override(
|
||||||
|
year: int,
|
||||||
|
month: int = Path(ge=1, le=12),
|
||||||
|
data: BalanceOverrideCreate = ...,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_user: str = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
if year < MIN_YEAR or year > MAX_YEAR:
|
||||||
|
raise HTTPException(400, f"Year must be between {MIN_YEAR} and {MAX_YEAR}")
|
||||||
|
if year == FRESH_START_YEAR and month < FRESH_START_MONTH:
|
||||||
|
raise HTTPException(400, f"Cannot override before {FRESH_START_YEAR}-{FRESH_START_MONTH:02d}")
|
||||||
|
|
||||||
|
existing = session.exec(
|
||||||
|
select(BalanceOverride).where(
|
||||||
|
BalanceOverride.year == year, BalanceOverride.month == month
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
existing.override_balance = data.override_balance
|
||||||
|
existing.updated_at = datetime.utcnow()
|
||||||
|
session.add(existing)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(existing)
|
||||||
|
return existing
|
||||||
|
|
||||||
|
override = BalanceOverride(
|
||||||
|
year=year, month=month, override_balance=data.override_balance
|
||||||
|
)
|
||||||
|
session.add(override)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(override)
|
||||||
|
return override
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/balance-override/{year}/{month}", status_code=204)
|
||||||
|
def delete_balance_override(
|
||||||
|
year: int,
|
||||||
|
month: int = Path(ge=1, le=12),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_user: str = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
existing = session.exec(
|
||||||
|
select(BalanceOverride).where(
|
||||||
|
BalanceOverride.year == year, BalanceOverride.month == month
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if not existing:
|
||||||
|
raise HTTPException(404, "No override found for this month")
|
||||||
|
session.delete(existing)
|
||||||
|
session.commit()
|
||||||
|
|||||||
@@ -333,3 +333,28 @@ class PensionSnapshot(PensionSnapshotBase, table=True):
|
|||||||
class PensionSnapshotRead(PensionSnapshotBase):
|
class PensionSnapshotRead(PensionSnapshotBase):
|
||||||
id: int
|
id: int
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
# --- Balance Override ---
|
||||||
|
|
||||||
|
|
||||||
|
class BalanceOverride(SQLModel, table=True):
|
||||||
|
__table_args__ = (UniqueConstraint("year", "month"),)
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
year: int
|
||||||
|
month: int
|
||||||
|
override_balance: float
|
||||||
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class BalanceOverrideCreate(SQLModel):
|
||||||
|
override_balance: float
|
||||||
|
|
||||||
|
|
||||||
|
class BalanceOverrideRead(SQLModel):
|
||||||
|
id: int
|
||||||
|
year: int
|
||||||
|
month: int
|
||||||
|
override_balance: float
|
||||||
|
updated_at: datetime
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from datetime import datetime
|
|||||||
from sqlmodel import Session, func, select
|
from sqlmodel import Session, func, select
|
||||||
|
|
||||||
from app.models.models import (
|
from app.models.models import (
|
||||||
|
BalanceOverride,
|
||||||
RecurringFrequency,
|
RecurringFrequency,
|
||||||
RecurringItem,
|
RecurringItem,
|
||||||
RecurringItemType,
|
RecurringItemType,
|
||||||
@@ -12,6 +13,12 @@ from app.models.models import (
|
|||||||
TransactionType,
|
TransactionType,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
MIN_YEAR = 2026
|
||||||
|
MAX_YEAR = 2030
|
||||||
|
# Fresh start: months before this are zeroed out
|
||||||
|
FRESH_START_YEAR = 2026
|
||||||
|
FRESH_START_MONTH = 3
|
||||||
|
|
||||||
|
|
||||||
def get_effective_amount(item: RecurringItem, month: int, year: int) -> float | None:
|
def get_effective_amount(item: RecurringItem, month: int, year: int) -> float | None:
|
||||||
"""Return the effective amount for a recurring item in a given month, or None if inactive."""
|
"""Return the effective amount for a recurring item in a given month, or None if inactive."""
|
||||||
@@ -255,3 +262,80 @@ def compute_monthly_projection(
|
|||||||
"savings_items": savings_items,
|
"savings_items": savings_items,
|
||||||
"actuals_by_source": list(actuals_by_source.values()),
|
"actuals_by_source": list(actuals_by_source.values()),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_december_cumulative(session: Session, year: int) -> float:
|
||||||
|
"""Get the cumulative balance for December of a given year."""
|
||||||
|
# Check for an override first
|
||||||
|
override = session.exec(
|
||||||
|
select(BalanceOverride).where(
|
||||||
|
BalanceOverride.year == year, BalanceOverride.month == 12
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if override:
|
||||||
|
return override.override_balance
|
||||||
|
|
||||||
|
# Compute the full year to get December's cumulative
|
||||||
|
overrides = session.exec(
|
||||||
|
select(BalanceOverride).where(BalanceOverride.year == year)
|
||||||
|
).all()
|
||||||
|
override_map = {o.month: o.override_balance for o in overrides}
|
||||||
|
|
||||||
|
cumulative = 0.0
|
||||||
|
if year > FRESH_START_YEAR:
|
||||||
|
cumulative = _get_december_cumulative(session, year - 1)
|
||||||
|
|
||||||
|
for m in range(1, 13):
|
||||||
|
if year == FRESH_START_YEAR and m < FRESH_START_MONTH:
|
||||||
|
continue
|
||||||
|
data = compute_monthly_projection(session, year, m)
|
||||||
|
cumulative += data["net_balance"]
|
||||||
|
if m in override_map:
|
||||||
|
cumulative = override_map[m]
|
||||||
|
|
||||||
|
return cumulative
|
||||||
|
|
||||||
|
|
||||||
|
def compute_yearly_projection_with_cumulative(
|
||||||
|
session: Session, year: int
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Compute all 12 months with cumulative balance tracking."""
|
||||||
|
overrides = session.exec(
|
||||||
|
select(BalanceOverride).where(BalanceOverride.year == year)
|
||||||
|
).all()
|
||||||
|
override_map = {o.month: o.override_balance for o in overrides}
|
||||||
|
|
||||||
|
# Determine January carryover
|
||||||
|
if year <= FRESH_START_YEAR:
|
||||||
|
carryover = 0.0
|
||||||
|
else:
|
||||||
|
carryover = _get_december_cumulative(session, year - 1)
|
||||||
|
|
||||||
|
months = []
|
||||||
|
for m in range(1, 13):
|
||||||
|
data = compute_monthly_projection(session, year, m)
|
||||||
|
|
||||||
|
is_before_fresh_start = (
|
||||||
|
year == FRESH_START_YEAR and m < FRESH_START_MONTH
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_before_fresh_start:
|
||||||
|
data["carryover_balance"] = 0.0
|
||||||
|
data["cumulative_balance"] = 0.0
|
||||||
|
data["balance_overridden"] = False
|
||||||
|
else:
|
||||||
|
data["carryover_balance"] = carryover
|
||||||
|
cumulative = carryover + data["net_balance"]
|
||||||
|
|
||||||
|
if m in override_map:
|
||||||
|
cumulative = override_map[m]
|
||||||
|
data["balance_overridden"] = True
|
||||||
|
else:
|
||||||
|
data["balance_overridden"] = False
|
||||||
|
|
||||||
|
data["cumulative_balance"] = cumulative
|
||||||
|
carryover = cumulative
|
||||||
|
|
||||||
|
months.append(data)
|
||||||
|
|
||||||
|
return months
|
||||||
|
|||||||
@@ -186,6 +186,9 @@ export interface MonthlyProjection {
|
|||||||
uncovered_actual: number;
|
uncovered_actual: number;
|
||||||
gran_total_egresos: number;
|
gran_total_egresos: number;
|
||||||
net_balance: number;
|
net_balance: number;
|
||||||
|
carryover_balance: number;
|
||||||
|
cumulative_balance: number;
|
||||||
|
balance_overridden: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface YearlyProjection {
|
export interface YearlyProjection {
|
||||||
@@ -225,6 +228,10 @@ export const getYearlyProjection = (year: number) =>
|
|||||||
api.get<YearlyProjection>(`/budget/projection/${year}`);
|
api.get<YearlyProjection>(`/budget/projection/${year}`);
|
||||||
export const getMonthlyDetail = (year: number, month: number) =>
|
export const getMonthlyDetail = (year: number, month: number) =>
|
||||||
api.get<MonthlyDetail>(`/budget/month/${year}/${month}`);
|
api.get<MonthlyDetail>(`/budget/month/${year}/${month}`);
|
||||||
|
export const upsertBalanceOverride = (year: number, month: number, override_balance: number) =>
|
||||||
|
api.put(`/budget/balance-override/${year}/${month}`, { override_balance });
|
||||||
|
export const deleteBalanceOverride = (year: number, month: number) =>
|
||||||
|
api.delete(`/budget/balance-override/${year}/${month}`);
|
||||||
|
|
||||||
// --- Salarios ---
|
// --- Salarios ---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { Pencil } from 'lucide-react';
|
||||||
|
|
||||||
import { type MonthlyProjection } from '@/api';
|
import { type MonthlyProjection } from '@/api';
|
||||||
import { formatAmount } from '@/lib/format';
|
import { formatAmount } from '@/lib/format';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -15,19 +19,65 @@ const MONTH_NAMES = [
|
|||||||
'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic',
|
'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const FRESH_START_YEAR = 2026;
|
||||||
|
const FRESH_START_MONTH = 3;
|
||||||
|
|
||||||
interface YearlyOverviewProps {
|
interface YearlyOverviewProps {
|
||||||
months: MonthlyProjection[];
|
months: MonthlyProjection[];
|
||||||
selectedMonth: number;
|
selectedMonth: number;
|
||||||
|
year: number;
|
||||||
onSelectMonth: (month: number) => void;
|
onSelectMonth: (month: number) => void;
|
||||||
|
onSaveOverride: (month: number, value: number) => Promise<void>;
|
||||||
|
onClearOverride: (month: number) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function YearlyOverview({
|
export default function YearlyOverview({
|
||||||
months,
|
months,
|
||||||
selectedMonth,
|
selectedMonth,
|
||||||
|
year,
|
||||||
onSelectMonth,
|
onSelectMonth,
|
||||||
|
onSaveOverride,
|
||||||
|
onClearOverride,
|
||||||
}: YearlyOverviewProps) {
|
}: YearlyOverviewProps) {
|
||||||
const currentMonth = new Date().getMonth() + 1;
|
const currentMonth = new Date().getMonth() + 1;
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
|
const [editingMonth, setEditingMonth] = useState<number | null>(null);
|
||||||
|
const [editValue, setEditValue] = useState('');
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editingMonth !== null && inputRef.current) {
|
||||||
|
inputRef.current.focus();
|
||||||
|
inputRef.current.select();
|
||||||
|
}
|
||||||
|
}, [editingMonth]);
|
||||||
|
|
||||||
|
const handleStartEdit = (m: MonthlyProjection) => {
|
||||||
|
setEditingMonth(m.month);
|
||||||
|
setEditValue(String(Math.round(m.cumulative_balance)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (editingMonth === null) return;
|
||||||
|
const trimmed = editValue.trim();
|
||||||
|
if (trimmed === '') {
|
||||||
|
await onClearOverride(editingMonth);
|
||||||
|
} else {
|
||||||
|
const num = parseFloat(trimmed);
|
||||||
|
if (!isNaN(num)) {
|
||||||
|
await onSaveOverride(editingMonth, num);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setEditingMonth(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleSave();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setEditingMonth(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
@@ -40,13 +90,19 @@ export default function YearlyOverview({
|
|||||||
<TableHead className="text-right">Otros Gastos</TableHead>
|
<TableHead className="text-right">Otros Gastos</TableHead>
|
||||||
<TableHead className="text-right">Gran Total</TableHead>
|
<TableHead className="text-right">Gran Total</TableHead>
|
||||||
<TableHead className="text-right">Ahorro</TableHead>
|
<TableHead className="text-right">Ahorro</TableHead>
|
||||||
<TableHead className="text-right">Balance</TableHead>
|
<TableHead className="text-right">Acum. Anterior</TableHead>
|
||||||
|
<TableHead className="text-right">Neto Mes</TableHead>
|
||||||
|
<TableHead className="text-right">Balance Acum.</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{months.map((m) => {
|
{months.map((m) => {
|
||||||
const isSelected = m.month === selectedMonth;
|
const isSelected = m.month === selectedMonth;
|
||||||
const isCurrent = m.month === currentMonth && m.year === currentYear;
|
const isCurrent = m.month === currentMonth && m.year === currentYear;
|
||||||
|
const isBeforeFreshStart =
|
||||||
|
year === FRESH_START_YEAR && m.month < FRESH_START_MONTH;
|
||||||
|
const isEditing = editingMonth === m.month;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={m.month}
|
key={m.month}
|
||||||
@@ -54,6 +110,7 @@ export default function YearlyOverview({
|
|||||||
'cursor-pointer transition-colors',
|
'cursor-pointer transition-colors',
|
||||||
isSelected && 'bg-accent',
|
isSelected && 'bg-accent',
|
||||||
isCurrent && !isSelected && 'bg-accent/40',
|
isCurrent && !isSelected && 'bg-accent/40',
|
||||||
|
isBeforeFreshStart && 'opacity-40',
|
||||||
)}
|
)}
|
||||||
onClick={() => onSelectMonth(m.month)}
|
onClick={() => onSelectMonth(m.month)}
|
||||||
>
|
>
|
||||||
@@ -78,6 +135,22 @@ export default function YearlyOverview({
|
|||||||
<TableCell className="text-right font-mono text-sm text-muted-foreground">
|
<TableCell className="text-right font-mono text-sm text-muted-foreground">
|
||||||
{formatAmount(m.projected_savings, 'CRC')}
|
{formatAmount(m.projected_savings, 'CRC')}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell
|
||||||
|
className={cn(
|
||||||
|
'text-right font-mono text-sm',
|
||||||
|
m.carryover_balance >= 0
|
||||||
|
? 'text-muted-foreground'
|
||||||
|
: 'text-destructive',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isBeforeFreshStart
|
||||||
|
? '—'
|
||||||
|
: <>
|
||||||
|
{m.carryover_balance >= 0 ? '+' : ''}
|
||||||
|
{formatAmount(m.carryover_balance, 'CRC')}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
</TableCell>
|
||||||
<TableCell
|
<TableCell
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-right font-mono text-sm font-semibold',
|
'text-right font-mono text-sm font-semibold',
|
||||||
@@ -87,6 +160,44 @@ export default function YearlyOverview({
|
|||||||
{m.net_balance >= 0 ? '+' : ''}
|
{m.net_balance >= 0 ? '+' : ''}
|
||||||
{formatAmount(m.net_balance, 'CRC')}
|
{formatAmount(m.net_balance, 'CRC')}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell
|
||||||
|
className="text-right font-mono text-sm font-semibold p-0 pr-2"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (isBeforeFreshStart) return;
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!isEditing) handleStartEdit(m);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isBeforeFreshStart ? (
|
||||||
|
<span className="px-2">—</span>
|
||||||
|
) : isEditing ? (
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
type="number"
|
||||||
|
value={editValue}
|
||||||
|
onChange={(e) => setEditValue(e.target.value)}
|
||||||
|
onBlur={handleSave}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className="h-7 w-36 text-right font-mono text-sm ml-auto"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1 px-2 py-0.5 rounded cursor-pointer hover:bg-muted/50',
|
||||||
|
m.cumulative_balance >= 0
|
||||||
|
? 'text-primary'
|
||||||
|
: 'text-destructive',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{m.balance_overridden && (
|
||||||
|
<Pencil className="w-3 h-3 text-amber-500 shrink-0" />
|
||||||
|
)}
|
||||||
|
{m.cumulative_balance >= 0 ? '+' : ''}
|
||||||
|
{formatAmount(m.cumulative_balance, 'CRC')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
createRecurringItem,
|
createRecurringItem,
|
||||||
updateRecurringItem as apiUpdateItem,
|
updateRecurringItem as apiUpdateItem,
|
||||||
deleteRecurringItem as apiDeleteItem,
|
deleteRecurringItem as apiDeleteItem,
|
||||||
|
upsertBalanceOverride,
|
||||||
|
deleteBalanceOverride,
|
||||||
} from '@/api';
|
} from '@/api';
|
||||||
|
|
||||||
export function useBudget(initialYear: number) {
|
export function useBudget(initialYear: number) {
|
||||||
@@ -71,6 +73,16 @@ export function useBudget(initialYear: number) {
|
|||||||
await Promise.all([fetchProjection(), fetchMonthDetail(), fetchRecurringItems()]);
|
await Promise.all([fetchProjection(), fetchMonthDetail(), fetchRecurringItems()]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const saveBalanceOverride = async (overrideYear: number, month: number, value: number) => {
|
||||||
|
await upsertBalanceOverride(overrideYear, month, value);
|
||||||
|
await fetchProjection();
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearBalanceOverride = async (overrideYear: number, month: number) => {
|
||||||
|
await deleteBalanceOverride(overrideYear, month);
|
||||||
|
await fetchProjection();
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
year,
|
year,
|
||||||
setYear,
|
setYear,
|
||||||
@@ -84,6 +96,8 @@ export function useBudget(initialYear: number) {
|
|||||||
addItem,
|
addItem,
|
||||||
updateItem,
|
updateItem,
|
||||||
deleteItem,
|
deleteItem,
|
||||||
|
saveBalanceOverride,
|
||||||
|
clearBalanceOverride,
|
||||||
refresh: () => Promise.all([fetchProjection(), fetchMonthDetail(), fetchRecurringItems()]),
|
refresh: () => Promise.all([fetchProjection(), fetchMonthDetail(), fetchRecurringItems()]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,11 @@ const MONTH_NAMES = [
|
|||||||
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre',
|
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const MIN_YEAR = 2026;
|
||||||
|
const MAX_YEAR = 2030;
|
||||||
|
|
||||||
export default function Budget() {
|
export default function Budget() {
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = Math.max(MIN_YEAR, new Date().getFullYear());
|
||||||
const {
|
const {
|
||||||
year,
|
year,
|
||||||
setYear,
|
setYear,
|
||||||
@@ -33,6 +36,8 @@ export default function Budget() {
|
|||||||
addItem,
|
addItem,
|
||||||
updateItem,
|
updateItem,
|
||||||
deleteItem,
|
deleteItem,
|
||||||
|
saveBalanceOverride,
|
||||||
|
clearBalanceOverride,
|
||||||
refresh,
|
refresh,
|
||||||
} = useBudget(currentYear);
|
} = useBudget(currentYear);
|
||||||
|
|
||||||
@@ -81,11 +86,11 @@ export default function Budget() {
|
|||||||
<h1 className="text-2xl font-bold tracking-tight">Presupuesto</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Presupuesto</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button variant="outline" size="icon" onClick={() => setYear(year - 1)}>
|
<Button variant="outline" size="icon" disabled={year <= MIN_YEAR} onClick={() => setYear(year - 1)}>
|
||||||
<ChevronLeft className="w-4 h-4" />
|
<ChevronLeft className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<span className="w-16 text-center font-semibold tabular-nums">{year}</span>
|
<span className="w-16 text-center font-semibold tabular-nums">{year}</span>
|
||||||
<Button variant="outline" size="icon" onClick={() => setYear(year + 1)}>
|
<Button variant="outline" size="icon" disabled={year >= MAX_YEAR} onClick={() => setYear(year + 1)}>
|
||||||
<ChevronRight className="w-4 h-4" />
|
<ChevronRight className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -194,10 +199,17 @@ export default function Budget() {
|
|||||||
<YearlyOverview
|
<YearlyOverview
|
||||||
months={projection.months}
|
months={projection.months}
|
||||||
selectedMonth={selectedMonth}
|
selectedMonth={selectedMonth}
|
||||||
|
year={year}
|
||||||
onSelectMonth={(m) => {
|
onSelectMonth={(m) => {
|
||||||
setSelectedMonth(m);
|
setSelectedMonth(m);
|
||||||
setSubTab('detail');
|
setSubTab('detail');
|
||||||
}}
|
}}
|
||||||
|
onSaveOverride={async (month, value) => {
|
||||||
|
await saveBalanceOverride(year, month, value);
|
||||||
|
}}
|
||||||
|
onClearOverride={async (month) => {
|
||||||
|
await clearBalanceOverride(year, month);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
Reference in New Issue
Block a user