mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-05-19 08:48: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:
@@ -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);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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');
|
||||
|
||||
235
frontend/src/components/budget/MonthlyDetail.tsx
Normal file
235
frontend/src/components/budget/MonthlyDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
184
frontend/src/components/budget/RecurringItemsManager.tsx
Normal file
184
frontend/src/components/budget/RecurringItemsManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
frontend/src/components/budget/YearlyOverview.tsx
Normal file
97
frontend/src/components/budget/YearlyOverview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
89
frontend/src/hooks/useBudget.ts
Normal file
89
frontend/src/hooks/useBudget.ts
Normal 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()]),
|
||||
};
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
51
frontend/src/pushNotifications.ts
Normal file
51
frontend/src/pushNotifications.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user