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

@@ -27,6 +27,9 @@ self.addEventListener('fetch', (event) => {
return;
}
// Only handle http(s) requests — skip chrome-extension:// etc.
if (!url.protocol.startsWith('http')) return;
// Cache-first for static assets
if (url.pathname.startsWith('/assets/')) {
event.respondWith(
@@ -50,3 +53,46 @@ self.addEventListener('fetch', (event) => {
// Default: network with cache fallback
event.respondWith(fetch(request).catch(() => caches.match(request)));
});
// --- Push Notifications ---
self.addEventListener('push', (event) => {
if (!event.data) return;
let data;
try {
data = event.data.json();
} catch {
// Fallback for plain-text pushes (e.g. browser test pushes)
data = { title: 'WealthySmart', body: event.data.text() };
}
const options = {
body: data.body,
icon: '/icons/icon-192.png',
badge: '/icons/icon-192.png',
data: { url: data.url || '/' },
vibrate: [200, 100, 200],
tag: 'transaction',
renotify: true,
};
event.waitUntil(self.registration.showNotification(data.title, options));
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const url = event.notification.data?.url || '/';
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => {
for (const client of windowClients) {
if (client.url.includes(self.location.origin) && 'focus' in client) {
client.navigate(url);
return client.focus();
}
}
return clients.openWindow(url);
})
);
});

View File

@@ -4,8 +4,7 @@ import { ThemeProvider } from './ThemeContext';
import Layout from './components/Layout';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import Transactions from './pages/Transactions';
import Transfers from './pages/Transfers';
import Budget from './pages/Budget';
import Analytics from './pages/Analytics';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
@@ -30,9 +29,11 @@ function AppRoutes() {
}
>
<Route path="/" element={<Dashboard />} />
<Route path="/transactions" element={<Transactions />} />
<Route path="/budget" element={<Budget />} />
<Route path="/analytics" element={<Analytics />} />
<Route path="/transfers" element={<Transfers />} />
{/* Redirect old routes */}
<Route path="/transactions" element={<Navigate to="/budget" replace />} />
<Route path="/transfers" element={<Navigate to="/budget" replace />} />
</Route>
</Routes>
);

View File

@@ -103,3 +103,125 @@ export interface Transaction {
category: Category | null;
created_at: string;
}
// --- Budget / Recurring Items ---
export type RecurringItemType = 'INCOME' | 'EXPENSE' | 'SAVINGS';
export type RecurringFrequency = 'WEEKLY' | 'MONTHLY' | 'QUARTERLY' | 'BIANNUAL' | 'YEARLY';
export interface RecurringItem {
id: number;
name: string;
amount: number;
currency: string;
item_type: RecurringItemType;
frequency: RecurringFrequency;
day_of_month: number | null;
month_of_year: number | null;
override_amounts: Record<string, number> | null;
category_id: number | null;
is_active: boolean;
notes: string | null;
created_at: string;
category: Category | null;
}
export interface RecurringItemCreate {
name: string;
amount: number;
currency?: string;
item_type: RecurringItemType;
frequency?: RecurringFrequency;
day_of_month?: number | null;
month_of_year?: number | null;
override_amounts?: Record<string, number> | null;
category_id?: number | null;
is_active?: boolean;
notes?: string | null;
}
export interface RecurringItemUpdate {
name?: string;
amount?: number;
currency?: string;
item_type?: RecurringItemType;
frequency?: RecurringFrequency;
day_of_month?: number | null;
month_of_year?: number | null;
override_amounts?: Record<string, number> | null;
category_id?: number | null;
is_active?: boolean;
notes?: string | null;
}
export interface RecurringItemDetail {
id: number;
name: string;
amount: number;
projected_amount: number | null;
used_actual: boolean;
item_type: string;
frequency: string;
category_name: string | null;
category_id: number | null;
}
export interface ActualsBySource {
source: string;
total_compra: number;
total_devolucion: number;
net: number;
count: number;
}
export interface MonthlyProjection {
month: number;
year: number;
projected_income: number;
projected_fixed_expenses: number;
projected_savings: number;
actual_credit_card: number;
actual_cash: number;
actual_transfers: number;
uncovered_actual: number;
gran_total_egresos: number;
net_balance: number;
}
export interface YearlyProjection {
year: number;
months: MonthlyProjection[];
annual_income: number;
annual_expenses: number;
annual_savings: number;
annual_net: number;
}
export interface MonthlyDetail {
year: number;
month: number;
income_items: RecurringItemDetail[];
expense_items: RecurringItemDetail[];
savings_items: RecurringItemDetail[];
actuals_by_source: ActualsBySource[];
total_projected_income: number;
total_projected_expenses: number;
total_projected_savings: number;
uncovered_actual: number;
gran_total_egresos: number;
net_balance: number;
}
// Budget API functions
export const getRecurringItems = (params?: { item_type?: string; is_active?: boolean }) =>
api.get<RecurringItem[]>('/budget/recurring', { params });
export const createRecurringItem = (data: RecurringItemCreate) =>
api.post<RecurringItem>('/budget/recurring', data);
export const updateRecurringItem = (id: number, data: RecurringItemUpdate) =>
api.patch<RecurringItem>(`/budget/recurring/${id}`, data);
export const deleteRecurringItem = (id: number) =>
api.delete(`/budget/recurring/${id}`);
export const getYearlyProjection = (year: number) =>
api.get<YearlyProjection>(`/budget/projection/${year}`);
export const getMonthlyDetail = (year: number, month: number) =>
api.get<MonthlyDetail>(`/budget/month/${year}/${month}`);

View File

@@ -1,18 +1,18 @@
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import {
LayoutDashboard,
CreditCard,
Calculator,
BarChart3,
ArrowLeftRight,
LogOut,
Wallet,
Menu,
Sun,
Moon,
} from 'lucide-react';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useAuth } from '../AuthContext';
import { useTheme } from '../ThemeContext';
import { subscribeToPush } from '../pushNotifications';
import { Button } from '@/components/ui/button';
import {
Sheet,
@@ -26,9 +26,8 @@ import { cn } from '@/lib/utils';
const navItems = [
{ to: '/', icon: LayoutDashboard, label: 'Dashboard' },
{ to: '/transactions', icon: CreditCard, label: 'Transactions' },
{ to: '/budget', icon: Calculator, label: 'Presupuesto' },
{ to: '/analytics', icon: BarChart3, label: 'Analytics' },
{ to: '/transfers', icon: ArrowLeftRight, label: 'Cash & Transfers' },
];
export default function Layout() {
@@ -37,6 +36,10 @@ export default function Layout() {
const navigate = useNavigate();
const [mobileOpen, setMobileOpen] = useState(false);
useEffect(() => {
subscribeToPush();
}, []);
const handleLogout = () => {
logout();
navigate('/login');

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

View File

@@ -0,0 +1,89 @@
import { useState, useEffect, useCallback } from 'react';
import {
type YearlyProjection,
type MonthlyDetail,
type RecurringItem,
type RecurringItemCreate,
type RecurringItemUpdate,
getYearlyProjection,
getMonthlyDetail,
getRecurringItems,
createRecurringItem,
updateRecurringItem as apiUpdateItem,
deleteRecurringItem as apiDeleteItem,
} from '@/api';
export function useBudget(initialYear: number) {
const [year, setYear] = useState(initialYear);
const [selectedMonth, setSelectedMonth] = useState<number>(new Date().getMonth() + 1);
const [projection, setProjection] = useState<YearlyProjection | null>(null);
const [monthDetail, setMonthDetail] = useState<MonthlyDetail | null>(null);
const [recurringItems, setRecurringItems] = useState<RecurringItem[]>([]);
const [loading, setLoading] = useState(true);
const [monthLoading, setMonthLoading] = useState(false);
const fetchProjection = useCallback(async () => {
setLoading(true);
try {
const { data } = await getYearlyProjection(year);
setProjection(data);
} finally {
setLoading(false);
}
}, [year]);
const fetchMonthDetail = useCallback(async () => {
setMonthLoading(true);
try {
const { data } = await getMonthlyDetail(year, selectedMonth);
setMonthDetail(data);
} finally {
setMonthLoading(false);
}
}, [year, selectedMonth]);
const fetchRecurringItems = useCallback(async () => {
const { data } = await getRecurringItems();
setRecurringItems(data);
}, []);
useEffect(() => {
fetchProjection();
fetchRecurringItems();
}, [fetchProjection, fetchRecurringItems]);
useEffect(() => {
fetchMonthDetail();
}, [fetchMonthDetail]);
const addItem = async (data: RecurringItemCreate) => {
await createRecurringItem(data);
await Promise.all([fetchProjection(), fetchMonthDetail(), fetchRecurringItems()]);
};
const updateItem = async (id: number, data: RecurringItemUpdate) => {
await apiUpdateItem(id, data);
await Promise.all([fetchProjection(), fetchMonthDetail(), fetchRecurringItems()]);
};
const deleteItem = async (id: number) => {
await apiDeleteItem(id);
await Promise.all([fetchProjection(), fetchMonthDetail(), fetchRecurringItems()]);
};
return {
year,
setYear,
selectedMonth,
setSelectedMonth,
projection,
monthDetail,
recurringItems,
loading,
monthLoading,
addItem,
updateItem,
deleteItem,
refresh: () => Promise.all([fetchProjection(), fetchMonthDetail(), fetchRecurringItems()]),
};
}

View File

@@ -0,0 +1,205 @@
import { useState, useEffect, useCallback } from 'react';
import { ChevronLeft, ChevronRight, Calculator, Loader2 } from 'lucide-react';
import api, { type Transaction } from '@/api';
import { useBudget } from '@/hooks/useBudget';
import { formatAmount } from '@/lib/format';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import YearlyOverview from '@/components/budget/YearlyOverview';
import MonthlyDetail from '@/components/budget/MonthlyDetail';
import RecurringItemsManager from '@/components/budget/RecurringItemsManager';
import TransactionList from '@/components/TransactionList';
const MONTH_NAMES = [
'', 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre',
];
export default function Budget() {
const currentYear = new Date().getFullYear();
const {
year,
setYear,
selectedMonth,
setSelectedMonth,
projection,
monthDetail,
recurringItems,
loading,
monthLoading,
addItem,
updateItem,
deleteItem,
refresh,
} = useBudget(currentYear);
// Transaction list state for the selected month
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [txLoading, setTxLoading] = useState(false);
const [txSearch, setTxSearch] = useState('');
const [txSource, setTxSource] = useState<'CREDIT_CARD' | 'CASH' | 'TRANSFER'>('CREDIT_CARD');
const fetchTransactions = useCallback(async () => {
setTxLoading(true);
try {
// Use calendar month date range
const startDate = `${year}-${String(selectedMonth).padStart(2, '0')}-01`;
const endMonth = selectedMonth === 12 ? 1 : selectedMonth + 1;
const endYear = selectedMonth === 12 ? year + 1 : year;
const endDate = `${endYear}-${String(endMonth).padStart(2, '0')}-01`;
const { data } = await api.get<Transaction[]>('/transactions/', {
params: {
source: txSource,
search: txSearch || undefined,
limit: 200,
start_date: startDate,
end_date: endDate,
},
});
setTransactions(data);
} finally {
setTxLoading(false);
}
}, [year, selectedMonth, txSource, txSearch]);
useEffect(() => {
fetchTransactions();
}, [fetchTransactions]);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Calculator className="w-6 h-6 text-primary" />
<h1 className="text-2xl font-bold tracking-tight">Presupuesto</h1>
</div>
<div className="flex items-center gap-1">
<Button variant="outline" size="icon" onClick={() => setYear(year - 1)}>
<ChevronLeft className="w-4 h-4" />
</Button>
<span className="w-16 text-center font-semibold tabular-nums">{year}</span>
<Button variant="outline" size="icon" onClick={() => setYear(year + 1)}>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Resumen</TabsTrigger>
<TabsTrigger value="items">Items Recurrentes</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-6 mt-4">
{/* Annual Summary */}
{projection && (
<div className="grid gap-3 grid-cols-2 md:grid-cols-4">
<Card>
<CardContent className="pt-4 pb-3 px-4">
<p className="text-xs text-muted-foreground">Ingresos Anuales</p>
<p className="text-lg font-bold font-mono text-primary">
{formatAmount(projection.annual_income, 'CRC')}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4 pb-3 px-4">
<p className="text-xs text-muted-foreground">Egresos Anuales</p>
<p className="text-lg font-bold font-mono">
{formatAmount(projection.annual_expenses, 'CRC')}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4 pb-3 px-4">
<p className="text-xs text-muted-foreground">Ahorro Anual</p>
<p className="text-lg font-bold font-mono">
{formatAmount(projection.annual_savings, 'CRC')}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4 pb-3 px-4">
<p className="text-xs text-muted-foreground">Balance Neto Anual</p>
<p
className={cn(
'text-lg font-bold font-mono',
projection.annual_net >= 0 ? 'text-primary' : 'text-destructive',
)}
>
{projection.annual_net >= 0 ? '+' : ''}
{formatAmount(projection.annual_net, 'CRC')}
</p>
</CardContent>
</Card>
</div>
)}
{/* Yearly Overview Table */}
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : projection ? (
<Card>
<CardContent className="p-0">
<YearlyOverview
months={projection.months}
selectedMonth={selectedMonth}
onSelectMonth={setSelectedMonth}
/>
</CardContent>
</Card>
) : null}
{/* Monthly Detail */}
{monthDetail && <MonthlyDetail detail={monthDetail} loading={monthLoading} />}
{/* Transactions for selected month */}
<div className="space-y-3">
<h3 className="text-lg font-semibold">
Transacciones {MONTH_NAMES[selectedMonth]} {year}
</h3>
<Tabs
value={txSource}
onValueChange={(v) => setTxSource(v as typeof txSource)}
>
<TabsList>
<TabsTrigger value="CREDIT_CARD">Tarjeta</TabsTrigger>
<TabsTrigger value="CASH">Efectivo</TabsTrigger>
<TabsTrigger value="TRANSFER">Transferencias</TabsTrigger>
</TabsList>
</Tabs>
<TransactionList
transactions={transactions}
loading={txLoading}
source={txSource}
search={txSearch}
onSearchChange={setTxSearch}
onRefresh={() => {
fetchTransactions();
refresh();
}}
showCategory={txSource === 'CREDIT_CARD'}
emptyMessage={`Sin transacciones de ${txSource === 'CREDIT_CARD' ? 'tarjeta' : txSource === 'CASH' ? 'efectivo' : 'transferencia'} en ${MONTH_NAMES[selectedMonth]}`}
/>
</div>
</TabsContent>
<TabsContent value="items" className="mt-4">
<RecurringItemsManager
items={recurringItems}
onAdd={addItem}
onUpdate={updateItem}
onDelete={deleteItem}
/>
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -9,6 +9,7 @@ import {
Pencil,
Check,
X,
BellRing,
} from 'lucide-react';
import api, { type Account, type Transaction } from '../api';
@@ -124,6 +125,7 @@ export default function Dashboard() {
const [editValue, setEditValue] = useState('');
const [exchangeRate, setExchangeRate] = useState<{ buy_rate: number; sell_rate: number } | null>(null);
const [configSection, setConfigSection] = useState<string | null>(null);
const [testingPush, setTestingPush] = useState(false);
const { settings, patchSection } = useSettings();
@@ -316,6 +318,48 @@ export default function Dashboard() {
</CardContent>
</Card>
{/* Test push notification */}
<Card className="border-dashed border-yellow-500/50">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-sm font-medium">Test Push Notification</p>
<p className="text-xs text-muted-foreground">Creates a mock transaction to trigger a push notification</p>
</div>
<Button
variant="outline"
size="sm"
disabled={testingPush}
onClick={async () => {
setTestingPush(true);
try {
const merchants = ['Walmart', 'AutoMercado', 'Uber Eats', 'Amazon', 'PriceSmart'];
const amounts = [4500, 12350, 8900, 25000, 67800];
const i = Math.floor(Math.random() * merchants.length);
await api.post('/transactions/', {
merchant: merchants[i],
amount: amounts[i],
currency: 'CRC',
date: new Date().toISOString(),
bank: 'BAC',
source: 'CREDIT_CARD',
transaction_type: 'COMPRA',
reference: `test-push-${Date.now()}`,
notes: '[TEST] Push notification test — safe to delete',
});
fetchData();
} catch (e) {
console.error('Test push failed:', e);
} finally {
setTestingPush(false);
}
}}
>
<BellRing className="w-4 h-4 mr-2" />
{testingPush ? 'Sending...' : 'Send test'}
</Button>
</CardContent>
</Card>
{/* Section config dialog */}
{configSection && settings.dashboard.sections[configSection] && (
<SectionConfigDialog

View File

@@ -4,6 +4,7 @@ import { Wallet, ArrowRight, AlertCircle } from 'lucide-react';
import { login } from '../api';
import { useAuth } from '../AuthContext';
import { subscribeToPush } from '../pushNotifications';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -24,6 +25,7 @@ export default function Login() {
try {
await login(username, password);
setAuthenticated(true);
subscribeToPush();
navigate('/');
} catch {
setError('Invalid credentials');

View File

@@ -0,0 +1,51 @@
import api from './api';
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
export async function subscribeToPush(): Promise<void> {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
return;
}
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
return;
}
try {
const { data } = await api.get<{ publicKey: string }>('/notifications/vapid-public-key');
const registration = await navigator.serviceWorker.ready;
const existing = await registration.pushManager.getSubscription();
if (existing) {
await sendSubscriptionToServer(existing);
return;
}
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(data.publicKey),
});
await sendSubscriptionToServer(subscription);
} catch (err) {
console.warn('Push subscription failed:', err);
}
}
async function sendSubscriptionToServer(subscription: PushSubscription): Promise<void> {
const json = subscription.toJSON();
await api.post('/notifications/subscribe', {
endpoint: json.endpoint,
keys: json.keys,
});
}