Pension assumptions editable + dirty-form guard + empty-state CTA

Per-fund contribution and annual-rate assumptions are now editable in a
Supuestos dialog on Pensiones and persisted server-side in UserSettings
so projections survive devices and reloads; saldo inicial keeps coming
from the latest snapshot (UX-12). The recurring-item dialog confirms
before discarding unsaved edits, with the dirty baseline computed from
the same values the populate effect sets (UX-06). Empty transaction
lists now offer an inline add button (UX-14).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-06-11 21:14:44 -06:00
parent 0dfac6a285
commit 8713eaa4d8
5 changed files with 283 additions and 33 deletions

View File

@@ -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 (
<Dialog open onOpenChange={(open) => { if (!open) onClose(); }}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Supuestos de proyección</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground -mt-2">
Usados para proyectar el crecimiento de cada fondo. El saldo inicial
siempre viene del último estado de cuenta.
</p>
<div className="space-y-4">
{(Object.keys(FUND_LABELS) as Array<'FCL' | 'ROP' | 'VOL'>).map(
(fund) => (
<div key={fund} className="space-y-2">
<p className="text-sm font-medium">{FUND_LABELS[fund]}</p>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs">Aporte mensual ()</Label>
<Input
type="number"
min="0"
step="1000"
value={form[fund].monthlyContribution}
onChange={(e) =>
setField(fund, 'monthlyContribution', e.target.value)
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Rendimiento anual (%)</Label>
<Input
type="number"
min="0"
max="30"
step="0.1"
value={form[fund].annualRate}
onChange={(e) =>
setField(fund, 'annualRate', e.target.value)
}
/>
</div>
</div>
</div>
),
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancelar
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? 'Guardando...' : 'Guardar'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -293,7 +293,16 @@ export default function TransactionList({
{empty ? (
<div className="px-5 py-16 text-center text-muted-foreground text-sm">
{emptyIcon || <ArrowLeftRight className="w-8 h-8 mx-auto mb-3 text-muted-foreground/50" />}
{emptyMessage}
<p>{emptyMessage}</p>
<Button
variant="outline"
size="sm"
className="mt-4"
onClick={() => { setEditing(null); setModalOpen(true); }}
>
<Plus className="w-4 h-4 mr-1" aria-hidden="true" />
{addLabel}
</Button>
</div>
) : (
visibleTransactions.map((tx) => (

View File

@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{isEdit ? 'Editar' : 'Nuevo'} Item Recurrente</DialogTitle>
@@ -296,7 +330,7 @@ export default function RecurringItemDialog({
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
<Button variant="outline" onClick={() => handleOpenChange(false)}>
Cancelar
</Button>
<Button onClick={handleSubmit} disabled={saving || !name || !amount}>