fix(snowball): plan history and bill modal updates

This commit is contained in:
null 2026-06-07 19:13:16 -05:00
parent 3f93a7dca2
commit 68aa5eff31
4 changed files with 199 additions and 115 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
);
// Deactivate dialog state
const [deactivateOpen, setDeactivateOpen] = useState(false);
const [deactivateReason, setDeactivateReason] = useState('');
// Unmatch dialog state
const [unmatchTarget, setUnmatchTarget] = useState(null);
const [unmatchConfirmOpen, setUnmatchConfirmOpen] = useState(false);
@ -596,6 +600,20 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
}
}, null);
async function handleDeactivate() {
if (!bill?.id) return;
try {
const payload = { active: bill.active ? 0 : 1 };
if (bill.active && deactivateReason) payload.inactive_reason = deactivateReason;
await api.updateBill(bill.id, payload);
toast.success(bill.active ? 'Bill deactivated' : 'Bill reactivated');
onSave?.();
onClose();
} catch (err) {
toast.error(err.message);
}
}
const inp = 'bg-background/50 border-border/60 h-9 text-sm w-full';
return (
@ -1374,29 +1392,79 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
)}
<DialogFooter className="mt-2 gap-2 sm:justify-between">
{!isNew && onDuplicate && (
<Button
type="button"
variant="outline"
disabled={isPending}
onClick={() => onDuplicate(bill)}
className="gap-2 text-xs"
>
<Copy className="h-3.5 w-3.5" />
Duplicate
</Button>
)}
<div className="flex gap-2">
<Button type="button" variant="ghost" disabled={isPending} onClick={onClose} className="text-xs">
Cancel
</Button>
<Button type="submit" form="bill-modal-form" disabled={isPending} className="gap-1.5 text-xs">
{isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
{isNew ? 'Add Bill' : 'Save Changes'}
</Button>
{!isNew && onDuplicate && (
<Button
type="button"
variant="outline"
disabled={isPending}
onClick={() => onDuplicate(bill)}
className="gap-2 text-xs"
>
<Copy className="h-3.5 w-3.5" />
Duplicate
</Button>
)}
{!isNew && (
<Button
type="button"
variant="outline"
disabled={isPending}
onClick={() => bill?.active ? setDeactivateOpen(true) : handleDeactivate()}
className={cn('gap-1.5 text-xs', bill?.active ? 'border-destructive/40 text-destructive hover:bg-destructive/10 hover:border-destructive/60' : 'border-emerald-500/40 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500/10')}
>
{bill?.active ? 'Deactivate' : 'Reactivate'}
</Button>
)}
</div>
<div className="flex gap-2">
<Button type="button" variant="ghost" disabled={isPending} onClick={onClose} className="text-xs">
Cancel
</Button>
<Button type="submit" form="bill-modal-form" disabled={isPending} className="gap-1.5 text-xs">
{isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
{isNew ? 'Add Bill' : 'Save Changes'}
</Button>
</div>
</DialogFooter>
<AlertDialog open={deactivateOpen} onOpenChange={open => { if (!open) { setDeactivateOpen(false); setDeactivateReason(''); } }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Deactivate "{bill?.name}"?</AlertDialogTitle>
<AlertDialogDescription>
This bill will be hidden from the tracker. You can reactivate it at any time.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-1.5 py-1">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Reason (optional)
</label>
<select
value={deactivateReason}
onChange={e => setDeactivateReason(e.target.value)}
className="w-full rounded-md border border-border/60 bg-background px-3 py-2 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring"
>
<option value="">Select a reason</option>
<option value="Moved to spouse">Moved to spouse</option>
<option value="Switched providers">Switched providers</option>
<option value="Paid off">Paid off</option>
<option value="Cancelled">Cancelled</option>
<option value="Other">Other</option>
</select>
</div>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => { setDeactivateOpen(false); setDeactivateReason(''); }}>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => { setDeactivateOpen(false); handleDeactivate(); }}
>
Deactivate
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog
open={!!deletePaymentTarget}
onOpenChange={open => {

View File

@ -87,6 +87,8 @@ function PlanDetail({ plan }) {
{/* Per-debt snapshot table */}
{debts.length > 0 && (
<div className="space-y-1.5">
<p className="text-[10px] font-bold uppercase tracking-[0.1em] text-muted-foreground/50">Debt snapshot at start of plan</p>
<div className="rounded-lg border border-border/50 overflow-hidden">
<table className="w-full text-xs">
<thead>
@ -126,6 +128,7 @@ function PlanDetail({ plan }) {
</tbody>
</table>
</div>
</div>
)}
{plan.notes && (

View File

@ -1,5 +1,4 @@
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/components/ui/button';
@ -26,23 +25,18 @@ function AlertDialogContent({ className, children, ...props }) {
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
asChild
role="dialog"
aria-modal="true"
{...props}
>
<motion.div
initial={{ opacity: 0, x: '-50%', y: '-48%', scale: 0.98 }}
animate={{ opacity: 1, x: '-50%', y: '-50%', scale: 1 }}
exit={{ opacity: 0, x: '-50%', y: '-48%', scale: 0.98 }}
transition={{ duration: 0.16, ease: [0.22, 1, 0.36, 1] }}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-1rem)] max-w-md max-h-[calc(100svh-1rem)] gap-4 overflow-y-auto rounded-2xl border border-border/70 bg-card p-4 text-card-foreground shadow-xl duration-200 sm:w-full sm:max-h-[calc(100svh-2rem)] sm:p-6',
'fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-1rem)] max-w-md max-h-[calc(100svh-1rem)] gap-4 overflow-y-auto rounded-2xl border border-border/70 bg-card p-4 text-card-foreground shadow-xl',
'-translate-x-1/2 -translate-y-1/2',
'duration-200 ease-[0.22,1,0.36,1]',
'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-[0.98] data-[state=open]:slide-in-from-top-[1%]',
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-[0.98] data-[state=closed]:slide-out-to-top-[1%]',
'sm:w-full sm:max-h-[calc(100svh-2rem)] sm:p-6',
className
)}
>
{children}
</motion.div>
>
{children}
</AlertDialogPrimitive.Content>
</AlertDialogPortal>
);

View File

@ -51,6 +51,18 @@ function isRamseyOrdered(debts) {
}
// SectionDivider
function SectionDivider({ label }) {
return (
<div className="flex items-center gap-3">
<span className="shrink-0 text-[10px] font-bold uppercase tracking-[0.12em] text-muted-foreground/50">
{label}
</span>
<div className="h-px flex-1 bg-border/50" />
</div>
);
}
// StatCard
function StatCard({ label, value, sub, highlight }) {
return (
@ -648,96 +660,100 @@ export default function SnowballPage() {
{/* Stats */}
{bills.length > 0 && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<StatCard label="Total Debt" value={fmt(totalBalance)}
sub={unknownCount > 0 ? `+ ${unknownCount} unknown` : undefined} />
<StatCard label="Monthly Minimums" value={fmt(totalMinPayment)} />
<StatCard label="Extra / Month" value={extraAmt > 0 ? fmt(extraAmt) : '—'} sub="snowball accelerator" />
<StatCard label="Next Win" value={liveAttackPayoff || 'Add details'}
sub={attackBill ? `${attackBill.name} · attacking ${fmt(attackAmount)}/mo` : undefined}
highlight={!!liveAttackPayoff} />
</div>
<>
<SectionDivider label="Overview" />
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<StatCard label="Total Debt" value={fmt(totalBalance)}
sub={unknownCount > 0 ? `+ ${unknownCount} unknown` : undefined} />
<StatCard label="Monthly Minimums" value={fmt(totalMinPayment)} />
<StatCard label="Extra / Month" value={extraAmt > 0 ? fmt(extraAmt) : '—'} sub="snowball accelerator" />
<StatCard label="Next Win" value={liveAttackPayoff || 'Add details'}
sub={attackBill ? `${attackBill.name} · attacking ${fmt(attackAmount)}/mo` : undefined}
highlight={!!liveAttackPayoff} />
</div>
</>
)}
{/* Toolbar */}
{/* Settings + Readiness */}
{bills.length > 0 && (
<div className="flex flex-wrap items-end gap-3">
<div className="surface-elevated rounded-xl px-4 py-3 flex items-center gap-3 min-h-[58px]">
<Switch
id="ramsey-mode"
checked={ramseyMode}
onCheckedChange={handleRamseyModeChange}
<>
<SectionDivider label="Configuration" />
<div className="space-y-3">
<div className="flex flex-wrap items-end gap-3">
<div className="surface-elevated rounded-xl px-4 py-3 flex items-center gap-3 min-h-[58px]">
<Switch
id="ramsey-mode"
checked={ramseyMode}
onCheckedChange={handleRamseyModeChange}
disabled={savingSettings}
/>
<div>
<Label htmlFor="ramsey-mode" className="text-xs font-semibold cursor-pointer">Ramsey Mode</Label>
<p className="text-[10px] text-muted-foreground">
{ramseyMode ? 'Smallest balance first' : 'Custom drag order'}
</p>
</div>
</div>
<div className="surface-elevated min-h-[58px] rounded-xl border border-primary/25 bg-primary/[0.04] px-4 py-3 shadow-sm shadow-primary/5">
<div className="flex items-center justify-between gap-4">
<div>
<Label htmlFor="extra-payment" className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-primary">
<Zap className="h-3.5 w-3.5" />
Extra applied monthly
</Label>
<p className="mt-0.5 text-[10px] text-muted-foreground">Added to the current target debt.</p>
</div>
<div className="text-right">
<p className="tracker-number text-base font-bold text-primary">{extraAmt > 0 ? fmt(extraAmt) : '$0'}</p>
<p className="text-[10px] text-muted-foreground">per month</p>
</div>
</div>
<Input
id="extra-payment"
type="number" min="0" step="1" placeholder="0.00"
value={extraPayment}
onChange={e => setExtraPayment(e.target.value)}
onBlur={handleSaveExtraPayment}
className={cn(inp, 'mt-2 w-full border-primary/25 bg-background/75')}
disabled={savingSettings}
/>
</div>
<div className="flex items-center gap-2 pb-0.5">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAutoArrange}
className="gap-2"
disabled={ramseyMode}
>
<Zap className="h-3.5 w-3.5" /> Restore Ramsey Order
</Button>
<Button type="button" size="sm" disabled={ramseyMode || !dirty || saving} onClick={handleSaveOrder} className="gap-2">
<Save className="h-3.5 w-3.5" /> {saving ? 'Saving…' : 'Save Order'}
</Button>
{dirty && <span className="text-xs text-amber-400">Unsaved changes</span>}
</div>
</div>
<ReadinessStrip
items={readinessItems}
readyCount={readinessReadyCount}
totalCount={readinessItems.length}
allReady={readinessAllReady}
onToggle={handleReadinessToggle}
disabled={savingSettings}
/>
<div>
<Label htmlFor="ramsey-mode" className="text-xs font-semibold cursor-pointer">Ramsey Mode</Label>
<p className="text-[10px] text-muted-foreground">
{ramseyMode ? 'Smallest balance first' : 'Custom drag order'}
</p>
</div>
</div>
<div className="surface-elevated min-h-[58px] rounded-xl border border-primary/25 bg-primary/[0.04] px-4 py-3 shadow-sm shadow-primary/5">
<div className="flex items-center justify-between gap-4">
<div>
<Label htmlFor="extra-payment" className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-primary">
{!activePlan && readinessReadyCount >= 3 && (
<div className="flex justify-end">
<Button size="sm" onClick={handleStartPlanClick} disabled={startingPlan} className="gap-1.5">
<Zap className="h-3.5 w-3.5" />
Extra applied monthly
</Label>
<p className="mt-0.5 text-[10px] text-muted-foreground">Added to the current target debt.</p>
{startingPlan ? 'Starting…' : 'Start Snowball Plan'}
</Button>
</div>
<div className="text-right">
<p className="tracker-number text-base font-bold text-primary">{extraAmt > 0 ? fmt(extraAmt) : '$0'}</p>
<p className="text-[10px] text-muted-foreground">per month</p>
</div>
</div>
<Input
id="extra-payment"
type="number" min="0" step="1" placeholder="0.00"
value={extraPayment}
onChange={e => setExtraPayment(e.target.value)}
onBlur={handleSaveExtraPayment}
className={cn(inp, 'mt-2 w-full border-primary/25 bg-background/75')}
disabled={savingSettings}
/>
)}
</div>
<div className="flex items-center gap-2 pb-0.5">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAutoArrange}
className="gap-2"
disabled={ramseyMode}
>
<Zap className="h-3.5 w-3.5" /> Restore Ramsey Order
</Button>
<Button type="button" size="sm" disabled={ramseyMode || !dirty || saving} onClick={handleSaveOrder} className="gap-2">
<Save className="h-3.5 w-3.5" /> {saving ? 'Saving…' : 'Save Order'}
</Button>
{dirty && <span className="text-xs text-amber-400">Unsaved changes</span>}
</div>
</div>
)}
{bills.length > 0 && (
<div className="space-y-3">
<ReadinessStrip
items={readinessItems}
readyCount={readinessReadyCount}
totalCount={readinessItems.length}
allReady={readinessAllReady}
onToggle={handleReadinessToggle}
disabled={savingSettings}
/>
{!activePlan && readinessReadyCount >= 3 && (
<div className="flex justify-end">
<Button size="sm" onClick={handleStartPlanClick} disabled={startingPlan} className="gap-1.5">
<Zap className="h-3.5 w-3.5" />
{startingPlan ? 'Starting…' : 'Start Snowball Plan'}
</Button>
</div>
)}
</div>
</>
)}
{bills.length > 0 && (customOrderDrift || missingMinCount > 0) && (
@ -771,6 +787,8 @@ export default function SnowballPage() {
{/* Cards + projection */}
{bills.length > 0 && (
<>
<SectionDivider label="Attack Order" />
<div className="grid gap-6 lg:grid-cols-[1fr_340px]">
{/* Cards list */}
@ -997,6 +1015,7 @@ export default function SnowballPage() {
/>
</div>
</div>
</>
)}
{/* Plan history */}