Add cumulative balance tracking with editable overrides
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:
Carlos Escalante
2026-03-30 12:03:43 -06:00
parent 99d0c4ebd7
commit b68129a171
7 changed files with 343 additions and 7 deletions

View File

@@ -1,6 +1,10 @@
import { useState, useRef, useEffect } from 'react';
import { Pencil } from 'lucide-react';
import { type MonthlyProjection } from '@/api';
import { formatAmount } from '@/lib/format';
import { cn } from '@/lib/utils';
import { Input } from '@/components/ui/input';
import {
Table,
TableBody,
@@ -15,19 +19,65 @@ const MONTH_NAMES = [
'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic',
];
const FRESH_START_YEAR = 2026;
const FRESH_START_MONTH = 3;
interface YearlyOverviewProps {
months: MonthlyProjection[];
selectedMonth: number;
year: number;
onSelectMonth: (month: number) => void;
onSaveOverride: (month: number, value: number) => Promise<void>;
onClearOverride: (month: number) => Promise<void>;
}
export default function YearlyOverview({
months,
selectedMonth,
year,
onSelectMonth,
onSaveOverride,
onClearOverride,
}: YearlyOverviewProps) {
const currentMonth = new Date().getMonth() + 1;
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 (
<div className="overflow-x-auto">
@@ -40,13 +90,19 @@ export default function YearlyOverview({
<TableHead className="text-right">Otros Gastos</TableHead>
<TableHead className="text-right">Gran Total</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>
</TableHeader>
<TableBody>
{months.map((m) => {
const isSelected = m.month === selectedMonth;
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 (
<TableRow
key={m.month}
@@ -54,6 +110,7 @@ export default function YearlyOverview({
'cursor-pointer transition-colors',
isSelected && 'bg-accent',
isCurrent && !isSelected && 'bg-accent/40',
isBeforeFreshStart && 'opacity-40',
)}
onClick={() => onSelectMonth(m.month)}
>
@@ -78,6 +135,22 @@ export default function YearlyOverview({
<TableCell className="text-right font-mono text-sm text-muted-foreground">
{formatAmount(m.projected_savings, 'CRC')}
</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
className={cn(
'text-right font-mono text-sm font-semibold',
@@ -87,6 +160,44 @@ export default function YearlyOverview({
{m.net_balance >= 0 ? '+' : ''}
{formatAmount(m.net_balance, 'CRC')}
</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>
);
})}