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:
Carlos Escalante
2026-04-03 20:10:23 -06:00
parent 37e04273b9
commit 0fdb5447b7
11 changed files with 845 additions and 276 deletions

View File

@@ -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<Transaction, unknown>[] {
const columns: ColumnDef<Transaction, unknown>[] = [
{
@@ -55,6 +59,17 @@ export function getTransactionColumns({
)}
</div>
<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>
);
},
@@ -109,6 +124,18 @@ export function getTransactionColumns({
const tx = row.original;
return (
<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
variant="ghost"
size="icon"