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