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

@@ -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<Transaction | null>(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({
)}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{tx.merchant}</p>
<div className="flex items-center gap-1">
<p className="text-sm font-medium truncate">{tx.merchant}</p>
{showSourceIcon && (
tx.source === 'CASH'
? <Banknote className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
: tx.source === 'TRANSFER'
? <ArrowLeftRight className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
: null
)}
</div>
<p className="text-xs text-muted-foreground">
{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)}
</span>
<div className="flex items-center gap-0.5 shrink-0">
{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" title="Edit transaction" aria-label="Edit transaction" onClick={() => handleEdit(tx)}>
<Pencil className="w-4 h-4" />
</Button>