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,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>
);
}