mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-05-19 10:28:48 +02:00
Add budget module and push notifications for transactions
All checks were successful
Deploy to VPS / deploy (push) Successful in 34s
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:
310
frontend/src/components/budget/RecurringItemDialog.tsx
Normal file
310
frontend/src/components/budget/RecurringItemDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user