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