mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-05-19 11:28:49 +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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user