mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-05-19 11:08:47 +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:
@@ -186,6 +186,9 @@ export interface MonthlyProjection {
|
||||
uncovered_actual: number;
|
||||
gran_total_egresos: number;
|
||||
net_balance: number;
|
||||
carryover_balance: number;
|
||||
cumulative_balance: number;
|
||||
balance_overridden: boolean;
|
||||
}
|
||||
|
||||
export interface YearlyProjection {
|
||||
@@ -225,6 +228,10 @@ export const getYearlyProjection = (year: number) =>
|
||||
api.get<YearlyProjection>(`/budget/projection/${year}`);
|
||||
export const getMonthlyDetail = (year: number, month: number) =>
|
||||
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 ---
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
createRecurringItem,
|
||||
updateRecurringItem as apiUpdateItem,
|
||||
deleteRecurringItem as apiDeleteItem,
|
||||
upsertBalanceOverride,
|
||||
deleteBalanceOverride,
|
||||
} from '@/api';
|
||||
|
||||
export function useBudget(initialYear: number) {
|
||||
@@ -71,6 +73,16 @@ export function useBudget(initialYear: number) {
|
||||
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 {
|
||||
year,
|
||||
setYear,
|
||||
@@ -84,6 +96,8 @@ export function useBudget(initialYear: number) {
|
||||
addItem,
|
||||
updateItem,
|
||||
deleteItem,
|
||||
saveBalanceOverride,
|
||||
clearBalanceOverride,
|
||||
refresh: () => Promise.all([fetchProjection(), fetchMonthDetail(), fetchRecurringItems()]),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,8 +18,11 @@ const MONTH_NAMES = [
|
||||
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre',
|
||||
];
|
||||
|
||||
const MIN_YEAR = 2026;
|
||||
const MAX_YEAR = 2030;
|
||||
|
||||
export default function Budget() {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const currentYear = Math.max(MIN_YEAR, new Date().getFullYear());
|
||||
const {
|
||||
year,
|
||||
setYear,
|
||||
@@ -33,6 +36,8 @@ export default function Budget() {
|
||||
addItem,
|
||||
updateItem,
|
||||
deleteItem,
|
||||
saveBalanceOverride,
|
||||
clearBalanceOverride,
|
||||
refresh,
|
||||
} = useBudget(currentYear);
|
||||
|
||||
@@ -81,11 +86,11 @@ export default function Budget() {
|
||||
<h1 className="text-2xl font-bold tracking-tight">Presupuesto</h1>
|
||||
</div>
|
||||
<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" />
|
||||
</Button>
|
||||
<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" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -194,10 +199,17 @@ export default function Budget() {
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user