fix(tracker): BillModal save/close race and pending badge logic

- Use controlled Dialog state (setDialogOpen) instead of immediate onClose()
  to let Radix cleanup properly before unmount
- Amber 'Pending' badge now only shows for bank-linked bills — unlinked
  bills skip the pending-cleared check and show 'Paid' directly
- TrackerPage onSave no longer nullifies edit state before BillModal can
  animate closed

(batch 0.37.4)
This commit is contained in:
null 2026-06-08 16:33:48 -05:00
parent fab4945d50
commit ca514e5f26
3 changed files with 13 additions and 6 deletions

View File

@ -190,6 +190,10 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
bill?.autopay_verified_at ? new Date(bill.autopay_verified_at) : null bill?.autopay_verified_at ? new Date(bill.autopay_verified_at) : null
); );
// Controls the outer Dialog's open state so it closes via its own animation
// rather than being abruptly unmounted, which can leave Radix cleanup in a broken state.
const [dialogOpen, setDialogOpen] = useState(true);
// Deactivate dialog state // Deactivate dialog state
const [deactivateOpen, setDeactivateOpen] = useState(false); const [deactivateOpen, setDeactivateOpen] = useState(false);
const [deactivateReason, setDeactivateReason] = useState(''); const [deactivateReason, setDeactivateReason] = useState('');
@ -594,7 +598,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
toast.success('Template saved'); toast.success('Template saved');
} }
onSave(savedBill); onSave(savedBill);
onClose(); setDialogOpen(false);
} catch (err) { } catch (err) {
toast.error(err.message); toast.error(err.message);
} }
@ -608,7 +612,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
await api.updateBill(bill.id, payload); await api.updateBill(bill.id, payload);
toast.success(bill.active ? 'Bill deactivated' : 'Bill reactivated'); toast.success(bill.active ? 'Bill deactivated' : 'Bill reactivated');
onSave?.(); onSave?.();
onClose(); setDialogOpen(false);
} catch (err) { } catch (err) {
toast.error(err.message); toast.error(err.message);
} }
@ -617,7 +621,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
const inp = 'bg-background/50 border-border/60 h-9 text-sm w-full'; const inp = 'bg-background/50 border-border/60 h-9 text-sm w-full';
return ( return (
<Dialog open onOpenChange={v => { if (!v) onClose(); }}> <Dialog open={dialogOpen} onOpenChange={v => { if (!v) onClose(); }}>
<DialogContent className="max-h-[92svh] overflow-y-auto border-border/60 bg-card/95 backdrop-blur-xl sm:max-w-2xl"> <DialogContent className="max-h-[92svh] overflow-y-auto border-border/60 bg-card/95 backdrop-blur-xl sm:max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-base font-semibold tracking-tight"> <DialogTitle className="text-base font-semibold tracking-tight">
@ -1422,7 +1426,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
)} )}
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button type="button" variant="ghost" disabled={isPending} onClick={onClose} className="text-xs"> <Button type="button" variant="ghost" disabled={isPending} onClick={() => setDialogOpen(false)} className="text-xs">
Cancel Cancel
</Button> </Button>
<Button type="submit" form="bill-modal-form" disabled={isPending} className="gap-1.5 text-xs"> <Button type="submit" form="bill-modal-form" disabled={isPending} className="gap-1.5 text-xs">

View File

@ -961,7 +961,7 @@ export default function TrackerPage() {
initialBill={editBillData.initialBill} initialBill={editBillData.initialBill}
categories={editBillData.categories} categories={editBillData.categories}
onClose={() => setEditBillData(null)} onClose={() => setEditBillData(null)}
onSave={() => { setEditBillData(null); refetch(); }} onSave={() => refetch()}
onDuplicate={bill => setEditBillData({ onDuplicate={bill => setEditBillData({
bill: null, bill: null,
initialBill: makeBillDraft(bill, { copy: true, categories: editBillData.categories }), initialBill: makeBillDraft(bill, { copy: true, categories: editBillData.categories }),

View File

@ -575,7 +575,10 @@ function getTracker(userId, query = {}, now = new Date()) {
// Only flag manually-entered payments as pending-cleared — bank-synced // Only flag manually-entered payments as pending-cleared — bank-synced
// or bank-matched payments are already settled so they don't need the badge. // or bank-matched payments are already settled so they don't need the badge.
const isManualPayment = r.payment_source !== 'provider_sync' && r.payment_source !== 'transaction_match'; const isManualPayment = r.payment_source !== 'provider_sync' && r.payment_source !== 'transaction_match';
if (r.status === 'paid' && r.last_paid_date && isManualPayment) { // Amber "Pending" only makes sense for bills linked to the bank — for unlinked bills
// there's no bank clearing to wait for, so they should just show "Paid".
const isBankLinked = !!(r.has_merchant_rule || r.has_linked_transactions);
if (r.status === 'paid' && r.last_paid_date && isManualPayment && isBankLinked) {
const cutoff = new Date(); const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - bankTracking.pending_days); cutoff.setDate(cutoff.getDate() - bankTracking.pending_days);
const paidAt = new Date(r.last_paid_date); const paidAt = new Date(r.last_paid_date);