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 (
+
+ );
+}
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 (
-