Add budget module and push notifications for transactions
All checks were successful
Deploy to VPS / deploy (push) Successful in 34s

Budget: recurring items CRUD, yearly/monthly projections with no-double-count
logic, and full UI (overview, monthly detail, recurring items manager).

Push notifications: Web Push via VAPID keys, triggered on transaction creation
from n8n. Includes service worker handlers, frontend subscription flow, and
a test button on the Dashboard (temporary).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-03-26 22:28:14 -06:00
parent 2cd0d3b2e1
commit 8d76059ae8
25 changed files with 2225 additions and 13 deletions

View File

@@ -0,0 +1,235 @@
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 {
TrendingUp,
TrendingDown,
PiggyBank,
CreditCard,
Banknote,
ArrowLeftRight,
Info,
} from 'lucide-react';
const MONTH_NAMES = [
'', 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre',
];
const SOURCE_LABELS: Record<string, { label: string; icon: typeof CreditCard }> = {
CREDIT_CARD: { label: 'Tarjeta de Crédito', icon: CreditCard },
CASH: { label: 'Efectivo', icon: Banknote },
TRANSFER: { label: 'Transferencias', icon: ArrowLeftRight },
};
interface MonthlyDetailProps {
detail: MonthlyDetailType;
loading?: boolean;
}
export default function MonthlyDetail({ detail, loading }: MonthlyDetailProps) {
if (loading) {
return (
<div className="grid gap-4 md:grid-cols-3">
{[1, 2, 3].map((i) => (
<Card key={i} className="animate-pulse">
<CardContent className="h-48" />
</Card>
))}
</div>
);
}
return (
<div className="space-y-4">
<h3 className="text-lg font-semibold">
Detalle: {MONTH_NAMES[detail.month]} {detail.year}
</h3>
<div className="grid gap-4 md:grid-cols-3">
{/* Income Card */}
<Card>
<CardHeader className="pb-2">
<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 className="font-mono text-primary whitespace-nowrap">
{formatAmount(item.amount, 'CRC')}
</span>
</div>
))}
<Separator />
<div className="flex items-center justify-between font-semibold text-sm">
<span>Total</span>
<span className="font-mono text-primary">
{formatAmount(detail.total_projected_income, 'CRC')}
</span>
</div>
</CardContent>
</Card>
{/* Expenses Card */}
<Card>
<CardHeader className="pb-2">
<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>
)}
</div>
<div 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>
)}
</div>
</div>
))}
{detail.expense_items.length === 0 && (
<p className="text-sm text-muted-foreground">Sin egresos fijos</p>
)}
<Separator />
<div className="flex items-center justify-between font-semibold text-sm">
<span>Total Fijos</span>
<span className="font-mono">
{formatAmount(detail.total_projected_expenses, 'CRC')}
</span>
</div>
</CardContent>
</Card>
{/* 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
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{detail.actuals_by_source.map((src) => {
const meta = SOURCE_LABELS[src.source];
if (!meta || src.count === 0) return null;
const Icon = meta.icon;
return (
<div key={src.source} className="flex items-center justify-between text-sm">
<div className="flex items-center gap-1.5">
<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>
<span className="font-mono whitespace-nowrap">
{formatAmount(src.net, 'CRC')}
</span>
</div>
);
})}
{detail.uncovered_actual > 0 && (
<>
<Separator />
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-1">
<Info className="w-3 h-3 text-muted-foreground" />
<span className="text-muted-foreground">No cubierto por fijos</span>
</div>
<span className="font-mono">{formatAmount(detail.uncovered_actual, 'CRC')}</span>
</div>
</>
)}
</CardContent>
</Card>
</div>
{/* Savings + Summary */}
<div className="grid gap-4 md:grid-cols-2">
{/* Savings */}
{detail.savings_items.length > 0 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<PiggyBank className="w-4 h-4" />
Ahorro
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{detail.savings_items.map((item) => (
<div key={item.id} className="flex items-center justify-between text-sm">
<span>{item.name}</span>
<span className="font-mono">{formatAmount(item.amount, 'CRC')}</span>
</div>
))}
<Separator />
<div className="flex items-center justify-between font-semibold text-sm">
<span>Total Ahorro</span>
<span className="font-mono">
{formatAmount(detail.total_projected_savings, 'CRC')}
</span>
</div>
</CardContent>
</Card>
)}
{/* Summary */}
<Card className={cn(
'border-2',
detail.net_balance >= 0 ? 'border-primary/30' : 'border-destructive/30',
)}>
<CardContent className="pt-6 space-y-3">
<div className="flex items-center justify-between text-sm">
<span>Total Ingresos</span>
<span className="font-mono font-medium text-primary">
+{formatAmount(detail.total_projected_income, 'CRC')}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span>Gran Total Egresos</span>
<span className="font-mono font-medium">
-{formatAmount(detail.gran_total_egresos, 'CRC')}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span>Ahorro</span>
<span className="font-mono font-medium">
-{formatAmount(detail.total_projected_savings, 'CRC')}
</span>
</div>
<Separator />
<div className="flex items-center justify-between">
<span className="font-semibold">Balance Neto</span>
<span
className={cn(
'font-mono font-bold text-lg',
detail.net_balance >= 0 ? 'text-primary' : 'text-destructive',
)}
>
{detail.net_balance >= 0 ? '+' : ''}
{formatAmount(detail.net_balance, 'CRC')}
</span>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,310 @@
import { useState, useEffect } from 'react';
import {
type RecurringItem,
type RecurringItemCreate,
type RecurringItemUpdate,
type RecurringItemType,
type RecurringFrequency,
} from '@/api';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { Plus, Trash2 } from 'lucide-react';
const TYPE_OPTIONS: { value: RecurringItemType; label: string }[] = [
{ value: 'INCOME', label: 'Ingreso' },
{ value: 'EXPENSE', label: 'Egreso' },
{ value: 'SAVINGS', label: 'Ahorro' },
];
const FREQ_OPTIONS: { value: RecurringFrequency; label: string }[] = [
{ value: 'WEEKLY', label: 'Semanal' },
{ value: 'MONTHLY', label: 'Mensual' },
{ value: 'QUARTERLY', label: 'Trimestral' },
{ value: 'BIANNUAL', label: 'Semestral' },
{ value: 'YEARLY', label: 'Anual' },
];
const MONTH_LABELS = [
'', 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre',
];
interface RecurringItemDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
item?: RecurringItem | null;
onSave: (data: RecurringItemCreate | RecurringItemUpdate) => Promise<void>;
}
export default function RecurringItemDialog({
open,
onOpenChange,
item,
onSave,
}: RecurringItemDialogProps) {
const isEdit = !!item;
const [name, setName] = useState('');
const [amount, setAmount] = useState('');
const [itemType, setItemType] = useState<RecurringItemType>('EXPENSE');
const [frequency, setFrequency] = useState<RecurringFrequency>('MONTHLY');
const [dayOfMonth, setDayOfMonth] = useState('');
const [monthOfYear, setMonthOfYear] = useState('');
const [overrides, setOverrides] = useState<{ month: string; amount: string }[]>([]);
const [notes, setNotes] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
if (open) {
if (item) {
setName(item.name);
setAmount(String(item.amount));
setItemType(item.item_type);
setFrequency(item.frequency);
setDayOfMonth(item.day_of_month != null ? String(item.day_of_month) : '');
setMonthOfYear(item.month_of_year != null ? String(item.month_of_year) : '');
setOverrides(
item.override_amounts
? Object.entries(item.override_amounts).map(([m, a]) => ({
month: m,
amount: String(a),
}))
: [],
);
setNotes(item.notes || '');
} else {
setName('');
setAmount('');
setItemType('EXPENSE');
setFrequency('MONTHLY');
setDayOfMonth('');
setMonthOfYear('');
setOverrides([]);
setNotes('');
}
}
}, [open, item]);
const showDayOfMonth = frequency === 'MONTHLY' || frequency === 'WEEKLY';
const showMonthOfYear = frequency === 'YEARLY' || frequency === 'BIANNUAL';
const showOverrides = frequency === 'MONTHLY';
const handleSubmit = async () => {
setSaving(true);
try {
const overrideAmounts =
overrides.length > 0
? Object.fromEntries(
overrides
.filter((o) => o.month && o.amount)
.map((o) => [o.month, parseFloat(o.amount)]),
)
: null;
const data = {
name,
amount: parseFloat(amount),
item_type: itemType,
frequency,
day_of_month: dayOfMonth ? parseInt(dayOfMonth) : null,
month_of_year: monthOfYear ? parseInt(monthOfYear) : null,
override_amounts: overrideAmounts,
notes: notes || null,
};
await onSave(data);
onOpenChange(false);
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{isEdit ? 'Editar' : 'Nuevo'} Item Recurrente</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1.5">
<Label htmlFor="ri-name">Nombre</Label>
<Input id="ri-name" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="ri-amount">Monto (CRC)</Label>
<Input
id="ri-amount"
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label>Tipo</Label>
<Select value={itemType} onValueChange={(v) => v && setItemType(v as RecurringItemType)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{TYPE_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>Frecuencia</Label>
<Select value={frequency} onValueChange={(v) => v && setFrequency(v as RecurringFrequency)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FREQ_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{showDayOfMonth && (
<div className="space-y-1.5">
<Label htmlFor="ri-day">
{frequency === 'WEEKLY' ? 'Día de semana (0=Lun)' : 'Día del mes'}
</Label>
<Input
id="ri-day"
type="number"
value={dayOfMonth}
onChange={(e) => setDayOfMonth(e.target.value)}
/>
</div>
)}
{showMonthOfYear && (
<div className="space-y-1.5">
<Label>Mes</Label>
<Select value={monthOfYear} onValueChange={(v) => v && setMonthOfYear(v)}>
<SelectTrigger>
<SelectValue placeholder="Seleccionar" />
</SelectTrigger>
<SelectContent>
{Array.from({ length: 12 }, (_, i) => i + 1).map((m) => (
<SelectItem key={m} value={String(m)}>
{MONTH_LABELS[m]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
{showOverrides && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">
Montos por mes (sobreescrituras)
</Label>
<Button
variant="ghost"
size="sm"
onClick={() => setOverrides([...overrides, { month: '', amount: '' }])}
>
<Plus className="w-3 h-3 mr-1" />
Agregar
</Button>
</div>
{overrides.map((o, idx) => (
<div key={idx} className="flex items-center gap-2">
<Select
value={o.month}
onValueChange={(v) => {
if (!v) return;
const next = [...overrides];
next[idx].month = v;
setOverrides(next);
}}
>
<SelectTrigger className="w-28">
<SelectValue placeholder="Mes" />
</SelectTrigger>
<SelectContent>
{Array.from({ length: 12 }, (_, i) => i + 1).map((m) => (
<SelectItem key={m} value={String(m)}>
{MONTH_LABELS[m]}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
type="number"
placeholder="Monto"
value={o.amount}
onChange={(e) => {
const next = [...overrides];
next[idx].amount = e.target.value;
setOverrides(next);
}}
className="flex-1"
/>
<Button
variant="ghost"
size="icon"
onClick={() => setOverrides(overrides.filter((_, i) => i !== idx))}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
))}
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="ri-notes">Notas</Label>
<Textarea
id="ri-notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancelar
</Button>
<Button onClick={handleSubmit} disabled={saving || !name || !amount}>
{saving ? 'Guardando...' : isEdit ? 'Guardar' : 'Crear'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,184 @@
import { useState, useMemo } from 'react';
import { type ColumnDef } from '@tanstack/react-table';
import {
type RecurringItem,
type RecurringItemCreate,
type RecurringItemUpdate,
} from '@/api';
import { formatAmount } from '@/lib/format';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { DataTable } from '@/components/ui/data-table';
import { DataTableColumnHeader } from '@/components/ui/data-table-column-header';
import { Pencil, Plus, Trash2 } from 'lucide-react';
import RecurringItemDialog from './RecurringItemDialog';
import ConfirmDialog from '@/components/ConfirmDialog';
const TYPE_LABELS: Record<string, { label: string; variant: 'default' | 'secondary' | 'outline' }> = {
INCOME: { label: 'Ingreso', variant: 'default' },
EXPENSE: { label: 'Egreso', variant: 'secondary' },
SAVINGS: { label: 'Ahorro', variant: 'outline' },
};
const FREQ_LABELS: Record<string, string> = {
WEEKLY: 'Semanal',
MONTHLY: 'Mensual',
QUARTERLY: 'Trimestral',
BIANNUAL: 'Semestral',
YEARLY: 'Anual',
};
interface RecurringItemsManagerProps {
items: RecurringItem[];
onAdd: (data: RecurringItemCreate) => Promise<void>;
onUpdate: (id: number, data: RecurringItemUpdate) => Promise<void>;
onDelete: (id: number) => Promise<void>;
}
export default function RecurringItemsManager({
items,
onAdd,
onUpdate,
onDelete,
}: RecurringItemsManagerProps) {
const [dialogOpen, setDialogOpen] = useState(false);
const [editItem, setEditItem] = useState<RecurringItem | null>(null);
const [deleteId, setDeleteId] = useState<number | null>(null);
const handleEdit = (item: RecurringItem) => {
setEditItem(item);
setDialogOpen(true);
};
const handleAdd = () => {
setEditItem(null);
setDialogOpen(true);
};
const handleSave = async (data: RecurringItemCreate | RecurringItemUpdate) => {
if (editItem) {
await onUpdate(editItem.id, data as RecurringItemUpdate);
} else {
await onAdd(data as RecurringItemCreate);
}
};
const handleDelete = async () => {
if (deleteId != null) {
await onDelete(deleteId);
setDeleteId(null);
}
};
const columns = useMemo<ColumnDef<RecurringItem, unknown>[]>(
() => [
{
accessorKey: 'name',
header: ({ column }) => <DataTableColumnHeader column={column} title="Nombre" />,
cell: ({ row }) => (
<div>
<span className="font-medium">{row.original.name}</span>
{!row.original.is_active && (
<Badge variant="outline" className="ml-2 text-[10px]">inactivo</Badge>
)}
</div>
),
},
{
accessorKey: 'item_type',
header: ({ column }) => <DataTableColumnHeader column={column} title="Tipo" />,
cell: ({ row }) => {
const meta = TYPE_LABELS[row.original.item_type];
return <Badge variant={meta?.variant ?? 'secondary'}>{meta?.label ?? row.original.item_type}</Badge>;
},
},
{
accessorKey: 'frequency',
header: ({ column }) => <DataTableColumnHeader column={column} title="Frecuencia" />,
cell: ({ row }) => (
<span className="text-sm">{FREQ_LABELS[row.original.frequency] ?? row.original.frequency}</span>
),
},
{
accessorKey: 'amount',
meta: { className: 'text-right' },
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Monto" className="justify-end" />
),
cell: ({ row }) => (
<span className="font-mono text-sm">
{formatAmount(row.original.amount, row.original.currency)}
</span>
),
},
{
id: 'actions',
meta: { className: 'text-right' },
size: 80,
enableSorting: false,
cell: ({ row }) => (
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon"
title="Editar"
aria-label="Editar"
onClick={() => handleEdit(row.original)}
>
<Pencil className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
title="Eliminar"
aria-label="Eliminar"
onClick={() => setDeleteId(row.original.id)}
className="hover:text-destructive"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
),
},
],
[],
);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Items Recurrentes</h3>
<Button size="sm" onClick={handleAdd}>
<Plus className="w-4 h-4 mr-1" />
Nuevo
</Button>
</div>
<DataTable
columns={columns}
data={items}
pagination
pageSize={20}
initialSorting={[{ id: 'item_type', desc: false }]}
emptyMessage="No hay items recurrentes."
/>
<RecurringItemDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
item={editItem}
onSave={handleSave}
/>
{deleteId != null && (
<ConfirmDialog
title="Eliminar item"
message="Esta acción no se puede deshacer."
confirmLabel="Eliminar"
onConfirm={handleDelete}
onCancel={() => setDeleteId(null)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,97 @@
import { type MonthlyProjection } from '@/api';
import { formatAmount } from '@/lib/format';
import { cn } from '@/lib/utils';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
const MONTH_NAMES = [
'', 'Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun',
'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic',
];
interface YearlyOverviewProps {
months: MonthlyProjection[];
selectedMonth: number;
onSelectMonth: (month: number) => void;
}
export default function YearlyOverview({
months,
selectedMonth,
onSelectMonth,
}: YearlyOverviewProps) {
const currentMonth = new Date().getMonth() + 1;
const currentYear = new Date().getFullYear();
return (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Mes</TableHead>
<TableHead className="text-right">Ingresos</TableHead>
<TableHead className="text-right">Egresos Fijos</TableHead>
<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>
</TableRow>
</TableHeader>
<TableBody>
{months.map((m) => {
const isSelected = m.month === selectedMonth;
const isCurrent = m.month === currentMonth && m.year === currentYear;
return (
<TableRow
key={m.month}
className={cn(
'cursor-pointer transition-colors',
isSelected && 'bg-accent',
isCurrent && !isSelected && 'bg-accent/40',
)}
onClick={() => onSelectMonth(m.month)}
>
<TableCell className="font-medium">
{MONTH_NAMES[m.month]}
{isCurrent && (
<span className="ml-1.5 inline-block w-1.5 h-1.5 rounded-full bg-primary" />
)}
</TableCell>
<TableCell className="text-right font-mono text-sm text-primary">
{formatAmount(m.projected_income, 'CRC')}
</TableCell>
<TableCell className="text-right font-mono text-sm">
{formatAmount(m.projected_fixed_expenses, 'CRC')}
</TableCell>
<TableCell className="text-right font-mono text-sm text-muted-foreground">
{formatAmount(m.uncovered_actual, 'CRC')}
</TableCell>
<TableCell className="text-right font-mono text-sm font-medium">
{formatAmount(m.gran_total_egresos, 'CRC')}
</TableCell>
<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 font-semibold',
m.net_balance >= 0 ? 'text-primary' : 'text-destructive',
)}
>
{m.net_balance >= 0 ? '+' : ''}
{formatAmount(m.net_balance, 'CRC')}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
);
}