diff --git a/frontend/src/components/PensionAssumptionsDialog.tsx b/frontend/src/components/PensionAssumptionsDialog.tsx new file mode 100644 index 0000000..5c62593 --- /dev/null +++ b/frontend/src/components/PensionAssumptionsDialog.tsx @@ -0,0 +1,139 @@ +import { useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; + +import api, { type UserSettingsData } from '@/lib/api'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; + +export interface FundAssumption { + monthlyContribution: number; + annualRate: number; +} + +export type PensionAssumptions = Partial< + Record<'FCL' | 'ROP' | 'VOL', FundAssumption> +>; + +interface Props { + current: Record<'FCL' | 'ROP' | 'VOL', FundAssumption>; + settingsData: UserSettingsData; + onClose: () => void; +} + +const FUND_LABELS: Record<'FCL' | 'ROP' | 'VOL', string> = { + FCL: 'FCL — Capitalización Laboral', + ROP: 'ROP — Régimen Obligatorio', + VOL: 'VOL — Fondo Voluntario', +}; + +/** Per-fund projection assumptions, persisted server-side in UserSettings so + * they survive devices and reloads (review UX-12). */ +export default function PensionAssumptionsDialog({ + current, + settingsData, + onClose, +}: Props) { + const queryClient = useQueryClient(); + const [form, setForm] = useState(() => ({ + FCL: { ...current.FCL }, + ROP: { ...current.ROP }, + VOL: { ...current.VOL }, + })); + const [saving, setSaving] = useState(false); + + const setField = ( + fund: 'FCL' | 'ROP' | 'VOL', + field: keyof FundAssumption, + value: string, + ) => { + const num = parseFloat(value); + setForm((prev) => ({ + ...prev, + [fund]: { ...prev[fund], [field]: isNaN(num) ? 0 : num }, + })); + }; + + const handleSave = async () => { + setSaving(true); + try { + // The settings PATCH replaces the whole blob — merge over what exists. + await api.patch('/settings/', { + data: { ...settingsData, pension_assumptions: form }, + }); + toast.success('Supuestos de pensión guardados'); + queryClient.invalidateQueries({ queryKey: ['settings'] }); + onClose(); + } catch { + toast.error('No se pudieron guardar los supuestos'); + } finally { + setSaving(false); + } + }; + + return ( + { if (!open) onClose(); }}> + + + Supuestos de proyección + +

+ Usados para proyectar el crecimiento de cada fondo. El saldo inicial + siempre viene del último estado de cuenta. +

+
+ {(Object.keys(FUND_LABELS) as Array<'FCL' | 'ROP' | 'VOL'>).map( + (fund) => ( +
+

{FUND_LABELS[fund]}

+
+
+ + + setField(fund, 'monthlyContribution', e.target.value) + } + /> +
+
+ + + setField(fund, 'annualRate', e.target.value) + } + /> +
+
+
+ ), + )} +
+ + + + +
+
+ ); +} diff --git a/frontend/src/components/TransactionList.tsx b/frontend/src/components/TransactionList.tsx index 73c62c0..8000098 100644 --- a/frontend/src/components/TransactionList.tsx +++ b/frontend/src/components/TransactionList.tsx @@ -293,7 +293,16 @@ export default function TransactionList({ {empty ? (
{emptyIcon || } - {emptyMessage} +

{emptyMessage}

+
) : ( visibleTransactions.map((tx) => ( diff --git a/frontend/src/components/budget/RecurringItemDialog.tsx b/frontend/src/components/budget/RecurringItemDialog.tsx index 135226a..55ee2cc 100644 --- a/frontend/src/components/budget/RecurringItemDialog.tsx +++ b/frontend/src/components/budget/RecurringItemDialog.tsx @@ -68,36 +68,70 @@ export default function RecurringItemDialog({ const [overrides, setOverrides] = useState<{ month: string; amount: string }[]>([]); const [notes, setNotes] = useState(''); const [saving, setSaving] = useState(false); + const [baseline, setBaseline] = useState(''); + + const snapshot = () => + JSON.stringify([name, amount, itemType, frequency, dayOfMonth, monthOfYear, overrides, notes]); + + const handleOpenChange = (next: boolean) => { + if (!next && !saving && snapshot() !== baseline) { + // Closing with unsaved edits — confirm before discarding (UX-06). + const discard = window.confirm('Hay cambios sin guardar. ¿Descartarlos?'); + if (!discard) return; + } + onOpenChange(next); + }; 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 + if (!open) return; + const init = item + ? { + name: item.name, + amount: String(item.amount), + itemType: item.item_type, + frequency: item.frequency, + dayOfMonth: item.day_of_month != null ? String(item.day_of_month) : '', + monthOfYear: + item.month_of_year != null ? String(item.month_of_year) : '', + overrides: 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(''); - } - } + notes: item.notes || '', + } + : { + name: '', + amount: '', + itemType: 'EXPENSE' as RecurringItemType, + frequency: 'MONTHLY' as RecurringFrequency, + dayOfMonth: '', + monthOfYear: '', + overrides: [] as { month: string; amount: string }[], + notes: '', + }; + setName(init.name); + setAmount(init.amount); + setItemType(init.itemType); + setFrequency(init.frequency); + setDayOfMonth(init.dayOfMonth); + setMonthOfYear(init.monthOfYear); + setOverrides(init.overrides); + setNotes(init.notes); + // Baseline mirrors snapshot()'s field order exactly. + setBaseline( + JSON.stringify([ + init.name, + init.amount, + init.itemType, + init.frequency, + init.dayOfMonth, + init.monthOfYear, + init.overrides, + init.notes, + ]), + ); }, [open, item]); const showDayOfMonth = frequency === 'MONTHLY' || frequency === 'WEEKLY'; @@ -135,7 +169,7 @@ export default function RecurringItemDialog({ }; return ( - + {isEdit ? 'Editar' : 'Nuevo'} Item Recurrente @@ -296,7 +330,7 @@ export default function RecurringItemDialog({ - +

Basado en edad actual de {CURRENT_AGE} años. Edita los campos para simular escenarios.

@@ -710,6 +758,14 @@ export default function Pensions() { {/* ── Manual Entry Modal ──────────────────────────────────────────── */} + {showAssumptions && ( + setShowAssumptions(false)} + /> + )} + {showManualEntry && ( setShowManualEntry(false)}