mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 16:28:47 +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:
205
frontend/src/pages/Budget.tsx
Normal file
205
frontend/src/pages/Budget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user