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,9 +1,18 @@
import { useState } from 'react';
import { PieChart, Pie, Cell } from 'recharts';
import { type MonthlyDetail as MonthlyDetailType } from '@/api';
import { formatAmount } from '@/lib/format';
import { cn } from '@/lib/utils';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { Button } from '@/components/ui/button';
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from '@/components/ui/chart';
import {
TrendingUp,
TrendingDown,
@@ -14,13 +23,30 @@ import {
Info,
} from 'lucide-react';
const MONTH_NAMES = [
'', 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre',
];
type PaletteMode = 'chatgpt' | 'gemini';
const SOURCE_LABELS: Record<string, { label: string; icon: typeof CreditCard }> = {
CREDIT_CARD: { label: 'Tarjeta de Crédito', icon: CreditCard },
const PALETTES: Record<PaletteMode, { income: string[]; expense: string[]; cc: string[] }> = {
chatgpt: {
// Pure green scale, darkest → lightest (assigned by rank)
income: ['#14532D', '#16A34A', '#4ADE80', '#BBF7D0'],
// Pure amber scale, darkest → lightest (assigned by rank)
expense: ['#92400E', '#B45309', '#D97706', '#F59E0B', '#FCD34D'],
// Warm-to-cool alternating for CC categories
cc: ['#B45309', '#2563EB', '#DC2626', '#16A34A', '#7C3AED',
'#D97706', '#0F766E', '#DB2777', '#EA580C', '#4F46E5'],
},
gemini: {
// Qualitative greens: dark green, mint, pale green, forest
income: ['#2D6A4F', '#52B788', '#B7E4C7', '#1B4332'],
// Terracotta, slate blue, sage, sand — diverse hues
expense: ['#E07A5F', '#3D405B', '#81B29A', '#F2CC8F', '#D56B4E', '#2E344A', '#6A9E85', '#E5B87A'],
// Pastel/muted diverse for CC categories
cc: ['#6366F1', '#EC4899', '#14B8A6', '#F97316', '#8B5CF6',
'#06B6D4', '#EF4444', '#10B981', '#F59E0B', '#3B82F6'],
},
};
const SOURCE_LABELS: Record<string, { label: string; icon: typeof Banknote }> = {
CASH: { label: 'Efectivo', icon: Banknote },
TRANSFER: { label: 'Transferencias', icon: ArrowLeftRight },
};
@@ -28,9 +54,12 @@ const SOURCE_LABELS: Record<string, { label: string; icon: typeof CreditCard }>
interface MonthlyDetailProps {
detail: MonthlyDetailType;
loading?: boolean;
onNavigateToTransactions?: () => void;
}
export default function MonthlyDetail({ detail, loading }: MonthlyDetailProps) {
export default function MonthlyDetail({ detail, loading, onNavigateToTransactions }: MonthlyDetailProps) {
const [paletteMode, setPaletteMode] = useState<PaletteMode>('chatgpt');
if (loading) {
return (
<div className="grid gap-4 md:grid-cols-3">
@@ -43,108 +72,320 @@ export default function MonthlyDetail({ detail, loading }: MonthlyDetailProps) {
);
}
const { income: incomeColors, expense: expenseColors, cc: ccColors } = PALETTES[paletteMode];
const incomeData = detail.income_items.map((item) => ({ name: item.name, value: item.amount }));
const expenseData = detail.expense_items.map((item) => ({ name: item.name, value: item.amount }));
// For ChatGPT mode: assign colors by rank (largest = darkest)
// For Gemini mode: assign colors by position (qualitative)
function buildColorMap(data: { name: string; value: number }[], colors: string[]): Map<string, string> {
if (paletteMode === 'chatgpt') {
const sorted = [...data].sort((a, b) => b.value - a.value);
const map = new Map<string, string>();
sorted.forEach((item, i) => {
map.set(item.name, colors[Math.min(i, colors.length - 1)]);
});
return map;
}
// Gemini: positional
const map = new Map<string, string>();
data.forEach((item, i) => {
map.set(item.name, colors[i % colors.length]);
});
return map;
}
const incomeColorMap = buildColorMap(incomeData, incomeColors);
const expenseColorMap = buildColorMap(expenseData, expenseColors);
const incomeConfig = incomeData.reduce<ChartConfig>((acc, item) => {
acc[item.name] = { label: item.name, color: incomeColorMap.get(item.name)! };
return acc;
}, {});
const expenseConfig = expenseData.reduce<ChartConfig>((acc, item) => {
acc[item.name] = { label: item.name, color: expenseColorMap.get(item.name)! };
return acc;
}, {});
// CC spending by category
const ccData = (detail.cc_by_category ?? []).map((item) => ({
name: item.category_name,
value: item.amount,
}));
const ccConfig = ccData.reduce<ChartConfig>((acc, item, i) => {
acc[item.name] = { label: item.name, color: ccColors[i % ccColors.length] };
return acc;
}, {});
const ccTotal = ccData.reduce((sum, item) => sum + item.value, 0);
// Filter actuals to only cash and transfer (no credit card)
const cashTransferActuals = detail.actuals_by_source.filter(
(src) => src.source !== 'CREDIT_CARD' && src.count > 0
);
return (
<div className="space-y-4">
<h3 className="text-lg font-semibold">
Detalle: {MONTH_NAMES[detail.month]} {detail.year}
</h3>
{/* Palette Toggle */}
<div className="flex items-center justify-end gap-1">
<span className="text-xs text-muted-foreground mr-1">Paleta:</span>
<Button
variant={paletteMode === 'chatgpt' ? 'default' : 'outline'}
size="sm"
className="h-6 text-xs px-2"
onClick={() => setPaletteMode('chatgpt')}
>
ChatGPT
</Button>
<Button
variant={paletteMode === 'gemini' ? 'default' : 'outline'}
size="sm"
className="h-6 text-xs px-2"
onClick={() => setPaletteMode('gemini')}
>
Gemini
</Button>
</div>
<div className="grid gap-4 md:grid-cols-3">
{/* Income Card */}
{/* Pie Charts */}
<div className="grid gap-4 md:grid-cols-2">
{/* Income Pie */}
<Card>
<CardHeader className="pb-2">
<CardHeader className="pb-0">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<TrendingUp className="w-4 h-4 text-primary" />
Ingresos
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{detail.income_items.map((item) => (
<div key={item.id} className="flex items-center justify-between text-sm">
<span className="truncate mr-2">{item.name}</span>
<span data-sensitive className="font-mono text-primary whitespace-nowrap">
{formatAmount(item.amount, 'CRC')}
</span>
<CardContent>
{incomeData.length > 0 ? (
<div className="flex flex-col items-center">
<ChartContainer config={incomeConfig} className="h-[200px] w-full">
<PieChart>
<Pie
data={incomeData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={80}
paddingAngle={2}
strokeWidth={2}
stroke="var(--card)"
>
{incomeData.map((item, i) => (
<Cell key={i} fill={incomeColorMap.get(item.name)!} />
))}
</Pie>
<ChartTooltip
content={
<ChartTooltipContent
nameKey="name"
formatter={(value, name) => (
<span>{name}: {formatAmount(Number(value), 'CRC')}</span>
)}
/>
}
/>
</PieChart>
</ChartContainer>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 mt-1 w-full">
{incomeData.map((item, i) => (
<div key={item.name} className="flex items-center gap-1.5 text-xs">
<div
className="w-2 h-2 rounded-full shrink-0"
style={{ background: incomeColorMap.get(item.name) }}
/>
<span className="truncate text-muted-foreground">{item.name}</span>
</div>
))}
</div>
<Separator className="mt-3" />
<div className="flex items-center justify-between w-full mt-2 font-semibold text-sm">
<span>Total</span>
<span data-sensitive className="font-mono text-primary">
{formatAmount(detail.total_projected_income, 'CRC')}
</span>
</div>
</div>
))}
<Separator />
<div className="flex items-center justify-between font-semibold text-sm">
<span>Total</span>
<span data-sensitive className="font-mono text-primary">
{formatAmount(detail.total_projected_income, 'CRC')}
</span>
</div>
) : (
<p className="text-sm text-muted-foreground py-8 text-center">Sin ingresos</p>
)}
</CardContent>
</Card>
{/* Expenses Card */}
{/* Expenses Pie */}
<Card>
<CardHeader className="pb-2">
<CardHeader className="pb-0">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<TrendingDown className="w-4 h-4 text-destructive" />
Egresos Fijos
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{detail.expense_items.map((item) => (
<div key={item.id} className="flex items-center justify-between text-sm">
<div className="flex items-center gap-1 truncate mr-2">
<span className="truncate">{item.name}</span>
{item.used_actual && (
<Badge variant="secondary" className="text-[10px] px-1 py-0 shrink-0">
real
</Badge>
)}
<CardContent>
{expenseData.length > 0 ? (
<div className="flex flex-col items-center">
<ChartContainer config={expenseConfig} className="h-[200px] w-full">
<PieChart>
<Pie
data={expenseData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={80}
paddingAngle={2}
strokeWidth={2}
stroke="var(--card)"
>
{expenseData.map((item, i) => (
<Cell key={i} fill={expenseColorMap.get(item.name)!} />
))}
</Pie>
<ChartTooltip
content={
<ChartTooltipContent
formatter={(value, name) => (
<span>{name}: {formatAmount(Number(value), 'CRC')}</span>
)}
/>
}
/>
</PieChart>
</ChartContainer>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 mt-1 w-full">
{expenseData.map((item, i) => (
<div key={item.name} className="flex items-center gap-1.5 text-xs">
<div
className="w-2 h-2 rounded-full shrink-0"
style={{ background: expenseColorMap.get(item.name) }}
/>
<span className="truncate text-muted-foreground">{item.name}</span>
</div>
))}
</div>
<div data-sensitive className="text-right whitespace-nowrap">
<span className="font-mono">{formatAmount(item.amount, 'CRC')}</span>
{item.used_actual && item.projected_amount != null && (
<span className="block text-[10px] text-muted-foreground font-mono line-through">
{formatAmount(item.projected_amount, 'CRC')}
</span>
)}
<Separator className="mt-3" />
<div className="flex items-center justify-between w-full mt-2 font-semibold text-sm">
<span>Total Fijos</span>
<span data-sensitive className="font-mono">
{formatAmount(detail.total_projected_expenses, 'CRC')}
</span>
</div>
</div>
))}
{detail.expense_items.length === 0 && (
<p className="text-sm text-muted-foreground">Sin egresos fijos</p>
) : (
<p className="text-sm text-muted-foreground py-8 text-center">Sin egresos fijos</p>
)}
<Separator />
<div className="flex items-center justify-between font-semibold text-sm">
<span>Total Fijos</span>
</CardContent>
</Card>
</div>
{/* Credit Card by Category */}
{ccData.length > 0 && (
<Card>
<CardHeader className="pb-0">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<CreditCard className="w-4 h-4" />
Tarjeta de Crédito
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col md:flex-row items-center gap-4">
<ChartContainer config={ccConfig} className="h-[200px] w-full md:w-1/2">
<PieChart>
<Pie
data={ccData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={80}
paddingAngle={2}
strokeWidth={2}
stroke="var(--card)"
>
{ccData.map((_, i) => (
<Cell key={i} fill={ccColors[i % ccColors.length]} />
))}
</Pie>
<ChartTooltip
content={
<ChartTooltipContent
nameKey="name"
formatter={(value, name) => (
<span>{name}: {formatAmount(Number(value), 'CRC')}</span>
)}
/>
}
/>
</PieChart>
</ChartContainer>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 w-full md:w-1/2">
{ccData.map((item, i) => (
<div key={item.name} className="flex items-center gap-1.5 text-xs">
<div
className="w-2 h-2 rounded-full shrink-0"
style={{ background: ccColors[i % ccColors.length] }}
/>
<span className="truncate text-muted-foreground">{item.name}</span>
</div>
))}
</div>
</div>
<Separator className="mt-3" />
<div className="flex items-center justify-between w-full mt-2 font-semibold text-sm">
<span>Total Tarjeta</span>
<span data-sensitive className="font-mono">
{formatAmount(detail.total_projected_expenses, 'CRC')}
{formatAmount(ccTotal, 'CRC')}
</span>
</div>
</CardContent>
</Card>
)}
{/* Actuals Card */}
{/* Actuals + Savings + Summary */}
<div className="grid gap-4 md:grid-cols-3">
{/* Cash & Transfer Actuals Card */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<CreditCard className="w-4 h-4" />
Transacciones Reales
<Banknote className="w-4 h-4" />
Efectivo o Transferencias
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{detail.actuals_by_source.map((src) => {
{cashTransferActuals.map((src) => {
const meta = SOURCE_LABELS[src.source];
if (!meta || src.count === 0) return null;
if (!meta) return null;
const Icon = meta.icon;
const isClickable = onNavigateToTransactions != null;
return (
<div key={src.source} className="flex items-center justify-between text-sm">
<div className="flex items-center gap-1.5">
<button
type="button"
className={cn(
'flex items-center gap-1.5',
isClickable && 'cursor-pointer hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded',
)}
onClick={isClickable ? onNavigateToTransactions : undefined}
disabled={!isClickable}
>
<Icon className="w-3.5 h-3.5 text-muted-foreground" />
<span>{meta.label}</span>
<span className="text-xs text-muted-foreground">({src.count})</span>
</div>
</button>
<span data-sensitive className="font-mono whitespace-nowrap">
{formatAmount(src.net, 'CRC')}
</span>
</div>
);
})}
{cashTransferActuals.length === 0 && (
<p className="text-sm text-muted-foreground">Sin transacciones</p>
)}
{detail.uncovered_actual > 0 && (
<>
<Separator />
@@ -159,10 +400,7 @@ export default function MonthlyDetail({ detail, loading }: MonthlyDetailProps) {
)}
</CardContent>
</Card>
</div>
{/* Savings + Summary */}
<div className="grid gap-4 md:grid-cols-2">
{/* Savings */}
{detail.savings_items.length > 0 && (
<Card>