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),
}))
: [],
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,
]),
);
setNotes(item.notes || '');
} else {
setName('');
setAmount('');
setItemType('EXPENSE');
setFrequency('MONTHLY');
setDayOfMonth('');
setMonthOfYear('');
setOverrides([]);
setNotes('');
}
}
}, [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}>

View File

@@ -518,3 +518,15 @@ export interface SyncStatusResponse {
}
export const getSyncStatus = () => api.get<SyncStatusResponse>('/sync-status/');
// --- User Settings ---
export type UserSettingsData = Record<string, unknown>;
export interface UserSettingsRead {
key: string;
data: UserSettingsData;
updated_at: string;
}
export const getUserSettings = () => api.get<UserSettingsRead>('/settings/');

View File

@@ -1,6 +1,7 @@
import { useState, useMemo, useCallback, useRef } from 'react';
import { useQuery } from '@tanstack/react-query';
import ErrorState from '@/components/ErrorState';
import PensionAssumptionsDialog, { type PensionAssumptions } from '@/components/PensionAssumptionsDialog';
import {
LineChart,
Line,
@@ -31,6 +32,7 @@ import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import {
getUserSettings,
uploadPensionPDFs,
getPensionFundSummary,
getPensionSnapshots,
@@ -38,7 +40,7 @@ import {
type PensionUploadResult,
} from '@/lib/api';
import PensionManualEntryModal from '@/components/PensionManualEntryModal';
import { ClipboardPaste } from 'lucide-react';
import { ClipboardPaste, SlidersHorizontal } from 'lucide-react';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -196,8 +198,9 @@ function calcProjection(
/** Merge API snapshots into the default fund definitions. */
function applySnapshots(
snapshots: PensionSnapshot[],
base: Record<FundKey, FundDef> = FUNDS_DEFAULT,
): Record<FundKey, FundDef> {
const funds = { ...FUNDS_DEFAULT };
const funds = { ...base };
for (const snap of snapshots) {
const key = snap.fund as FundKey;
if (key in funds) {
@@ -273,7 +276,46 @@ export default function Pensions() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pensionQ.refetch]);
const FUNDS = useMemo(() => applySnapshots(fundSummary), [fundSummary]);
const settingsQ = useQuery({
queryKey: ['settings'],
queryFn: () => getUserSettings().then((r) => r.data),
});
const storedAssumptions = (settingsQ.data?.data?.pension_assumptions ??
{}) as PensionAssumptions;
const FUNDS = useMemo(() => {
const base = { ...FUNDS_DEFAULT };
for (const key of FUND_KEYS) {
const stored = storedAssumptions[key];
if (stored) {
base[key] = {
...base[key],
monthlyContribution: stored.monthlyContribution,
annualRate: stored.annualRate,
};
}
}
return applySnapshots(fundSummary, base);
}, [fundSummary, storedAssumptions]);
const currentAssumptions = useMemo(
() => ({
FCL: {
monthlyContribution: FUNDS.FCL.monthlyContribution,
annualRate: FUNDS.FCL.annualRate,
},
ROP: {
monthlyContribution: FUNDS.ROP.monthlyContribution,
annualRate: FUNDS.ROP.annualRate,
},
VOL: {
monthlyContribution: FUNDS.VOL.monthlyContribution,
annualRate: FUNDS.VOL.annualRate,
},
}),
[FUNDS],
);
const [showAssumptions, setShowAssumptions] = useState(false);
// Build a map of fund -> latest snapshot for rendimientos display
const snapshotByFund = useMemo(() => {
@@ -628,10 +670,16 @@ export default function Pensions() {
{/* ── Section 4: Projections ───────────────────────────────────────── */}
<section className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold font-heading flex items-center gap-2 text-muted-foreground uppercase tracking-wider">
<TrendingUp className="w-4 h-4" />
Proyecciones
</h2>
<Button variant="outline" size="sm" onClick={() => setShowAssumptions(true)}>
<SlidersHorizontal className="w-4 h-4 mr-2" aria-hidden="true" />
Supuestos
</Button>
</div>
<p className="text-sm text-muted-foreground">
Basado en edad actual de {CURRENT_AGE} años. Edita los campos para simular escenarios.
</p>
@@ -710,6 +758,14 @@ export default function Pensions() {
</section>
{/* ── Manual Entry Modal ──────────────────────────────────────────── */}
{showAssumptions && (
<PensionAssumptionsDialog
current={currentAssumptions}
settingsData={settingsQ.data?.data ?? {}}
onClose={() => setShowAssumptions(false)}
/>
)}
{showManualEntry && (
<PensionManualEntryModal
onClose={() => setShowManualEntry(false)}