2026-07-04 20:39:30 -05:00
|
|
|
|
import { useState } from 'react';
|
2026-05-30 17:27:15 -05:00
|
|
|
|
import { ChevronDown, History } from 'lucide-react';
|
|
|
|
|
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
|
|
|
|
|
import { cn } from '@/lib/utils';
|
2026-07-03 12:46:22 -05:00
|
|
|
|
import { formatUSD, formatUSDWhole } from '@/lib/money';
|
2026-07-04 20:39:30 -05:00
|
|
|
|
import type { Dollars } from '@/lib/money';
|
|
|
|
|
|
|
|
|
|
|
|
interface SnowballPlanDebt {
|
|
|
|
|
|
bill_id: number;
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
starting_balance: Dollars;
|
|
|
|
|
|
projected_payoff_date?: string | null;
|
|
|
|
|
|
projected_total_interest?: Dollars | null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface SnowballPlanSnapshot {
|
|
|
|
|
|
debts?: SnowballPlanDebt[];
|
|
|
|
|
|
projected_months?: number;
|
|
|
|
|
|
projected_payoff_date?: string | null;
|
|
|
|
|
|
interest_saved?: Dollars;
|
|
|
|
|
|
minimum_only_months?: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface CurrentDebt {
|
|
|
|
|
|
bill_id: number;
|
|
|
|
|
|
current_balance?: Dollars | null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export interface SnowballPlan {
|
|
|
|
|
|
id: number;
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
status: string;
|
|
|
|
|
|
method: string;
|
|
|
|
|
|
started_at?: string | null;
|
|
|
|
|
|
completed_at?: string | null;
|
|
|
|
|
|
paused_at?: string | null;
|
|
|
|
|
|
extra_payment?: Dollars;
|
|
|
|
|
|
notes?: string | null;
|
|
|
|
|
|
plan_snapshot?: SnowballPlanSnapshot;
|
|
|
|
|
|
current_debts?: CurrentDebt[];
|
|
|
|
|
|
}
|
2026-05-30 17:27:15 -05:00
|
|
|
|
|
2026-07-04 20:39:30 -05:00
|
|
|
|
function fmt(v: Dollars | null | undefined): string {
|
2026-07-03 12:46:22 -05:00
|
|
|
|
return formatUSDWhole(v);
|
2026-05-30 17:27:15 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 20:39:30 -05:00
|
|
|
|
function fmtFull(v: Dollars | null | undefined): string {
|
2026-07-03 12:46:22 -05:00
|
|
|
|
return formatUSD(v);
|
2026-05-30 17:27:15 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 20:39:30 -05:00
|
|
|
|
function dateRange(plan: SnowballPlan): string {
|
2026-05-30 17:27:15 -05:00
|
|
|
|
const start = plan.started_at
|
|
|
|
|
|
? new Date(plan.started_at).toLocaleDateString(undefined, { month: 'short', year: 'numeric' })
|
|
|
|
|
|
: '—';
|
2026-07-04 20:39:30 -05:00
|
|
|
|
const endDate = plan.completed_at || plan.paused_at;
|
|
|
|
|
|
const end = endDate
|
|
|
|
|
|
? new Date(endDate).toLocaleDateString(undefined, { month: 'short', year: 'numeric' })
|
2026-07-06 14:23:53 -05:00
|
|
|
|
: plan.status === 'abandoned'
|
|
|
|
|
|
? 'abandoned'
|
|
|
|
|
|
: 'present';
|
2026-05-30 17:27:15 -05:00
|
|
|
|
return `${start} – ${end}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 20:39:30 -05:00
|
|
|
|
function StatusBadge({ status }: { status: string }) {
|
2026-05-30 17:27:15 -05:00
|
|
|
|
const map = {
|
2026-07-06 14:23:53 -05:00
|
|
|
|
active: 'bg-emerald-500/12 text-emerald-600 dark:text-emerald-400',
|
|
|
|
|
|
paused: 'bg-amber-500/12 text-amber-600 dark:text-amber-400',
|
2026-05-30 17:27:15 -05:00
|
|
|
|
completed: 'bg-indigo-500/12 text-indigo-600 dark:text-indigo-400',
|
|
|
|
|
|
abandoned: 'bg-rose-500/12 text-rose-600 dark:text-rose-400',
|
|
|
|
|
|
};
|
2026-07-06 14:23:53 -05:00
|
|
|
|
const labels: Record<string, string> = {
|
|
|
|
|
|
active: 'Active',
|
|
|
|
|
|
paused: 'Paused',
|
|
|
|
|
|
completed: 'Completed',
|
|
|
|
|
|
abandoned: 'Abandoned',
|
|
|
|
|
|
};
|
2026-05-30 17:27:15 -05:00
|
|
|
|
return (
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<span
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
'rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide',
|
|
|
|
|
|
map[status as keyof typeof map] ?? map.abandoned,
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
2026-05-30 17:27:15 -05:00
|
|
|
|
{labels[status] ?? status}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 20:39:30 -05:00
|
|
|
|
function MethodBadge({ method }: { method: string }) {
|
2026-07-06 14:23:53 -05:00
|
|
|
|
const map: Record<string, string> = {
|
|
|
|
|
|
snowball: 'Snowball',
|
|
|
|
|
|
avalanche: 'Avalanche',
|
|
|
|
|
|
custom: 'Custom',
|
|
|
|
|
|
};
|
2026-05-30 17:27:15 -05:00
|
|
|
|
return (
|
|
|
|
|
|
<span className="rounded-full px-2 py-0.5 text-[10px] font-medium bg-muted/60 text-muted-foreground">
|
|
|
|
|
|
{map[method] ?? method}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Expanded plan detail ─────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-07-04 20:39:30 -05:00
|
|
|
|
function PlanDetail({ plan }: { plan: SnowballPlan }) {
|
2026-05-30 17:27:15 -05:00
|
|
|
|
const snapshot = plan.plan_snapshot ?? {};
|
2026-07-06 14:23:53 -05:00
|
|
|
|
const debts = snapshot.debts ?? [];
|
2026-05-30 17:27:15 -05:00
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="px-4 pb-4 space-y-3">
|
|
|
|
|
|
{/* Summary stats */}
|
|
|
|
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 pt-1">
|
|
|
|
|
|
{snapshot.projected_months && (
|
|
|
|
|
|
<div className="rounded-lg bg-muted/30 px-3 py-2 text-center">
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<p className="text-[10px] text-muted-foreground uppercase tracking-wide">
|
|
|
|
|
|
Projected payoff
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<p className="text-xs font-mono font-semibold mt-0.5">
|
|
|
|
|
|
{snapshot.projected_payoff_date ?? '—'}
|
|
|
|
|
|
</p>
|
2026-05-30 17:27:15 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-07-04 20:39:30 -05:00
|
|
|
|
{(snapshot.interest_saved ?? 0) > 0 && (
|
2026-05-30 17:27:15 -05:00
|
|
|
|
<div className="rounded-lg bg-emerald-500/8 border border-emerald-400/15 px-3 py-2 text-center">
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<p className="text-[10px] text-muted-foreground uppercase tracking-wide">
|
|
|
|
|
|
Interest saved
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<p className="text-xs font-mono font-semibold text-emerald-600 dark:text-emerald-400 mt-0.5">
|
|
|
|
|
|
{fmt(snapshot.interest_saved)}
|
|
|
|
|
|
</p>
|
2026-05-30 17:27:15 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{snapshot.minimum_only_months && (
|
|
|
|
|
|
<div className="rounded-lg bg-muted/30 px-3 py-2 text-center">
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<p className="text-[10px] text-muted-foreground uppercase tracking-wide">
|
|
|
|
|
|
Minimum-only months
|
|
|
|
|
|
</p>
|
2026-05-30 17:27:15 -05:00
|
|
|
|
<p className="text-xs font-mono font-semibold mt-0.5">{snapshot.minimum_only_months}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-07-04 20:39:30 -05:00
|
|
|
|
{(plan.extra_payment ?? 0) > 0 && (
|
2026-05-30 17:27:15 -05:00
|
|
|
|
<div className="rounded-lg bg-muted/30 px-3 py-2 text-center">
|
|
|
|
|
|
<p className="text-[10px] text-muted-foreground uppercase tracking-wide">Extra/mo</p>
|
|
|
|
|
|
<p className="text-xs font-mono font-semibold mt-0.5">{fmtFull(plan.extra_payment)}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Per-debt snapshot table */}
|
|
|
|
|
|
{debts.length > 0 && (
|
2026-06-07 19:13:16 -05:00
|
|
|
|
<div className="space-y-1.5">
|
2026-07-10 18:03:15 -05:00
|
|
|
|
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground/50">
|
2026-07-06 14:23:53 -05:00
|
|
|
|
Debt snapshot at start of plan
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<div className="rounded-lg border border-border/50 overflow-hidden">
|
|
|
|
|
|
<table className="w-full text-xs">
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr className="bg-muted/30 border-b border-border/50">
|
|
|
|
|
|
<th className="text-left px-3 py-2 font-semibold text-muted-foreground uppercase tracking-wide text-[10px]">
|
|
|
|
|
|
Debt
|
|
|
|
|
|
</th>
|
|
|
|
|
|
<th className="text-right px-3 py-2 font-semibold text-muted-foreground uppercase tracking-wide text-[10px]">
|
|
|
|
|
|
Starting balance
|
|
|
|
|
|
</th>
|
|
|
|
|
|
<th className="text-right px-3 py-2 font-semibold text-muted-foreground uppercase tracking-wide text-[10px] hidden sm:table-cell">
|
|
|
|
|
|
Projected payoff
|
|
|
|
|
|
</th>
|
|
|
|
|
|
<th className="text-right px-3 py-2 font-semibold text-muted-foreground uppercase tracking-wide text-[10px] hidden sm:table-cell">
|
|
|
|
|
|
Projected interest
|
|
|
|
|
|
</th>
|
|
|
|
|
|
<th className="text-right px-3 py-2 font-semibold text-muted-foreground uppercase tracking-wide text-[10px]">
|
|
|
|
|
|
Current balance
|
|
|
|
|
|
</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody className="divide-y divide-border/30">
|
|
|
|
|
|
{debts.map((d) => {
|
|
|
|
|
|
const current = plan.current_debts?.find((c) => c.bill_id === d.bill_id);
|
|
|
|
|
|
const curBal = current?.current_balance;
|
|
|
|
|
|
const isPaidOff = curBal !== null && curBal !== undefined && curBal <= 0;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<tr key={d.bill_id} className="hover:bg-muted/10 transition-colors">
|
|
|
|
|
|
<td className="px-3 py-2 font-medium">{d.name}</td>
|
|
|
|
|
|
<td className="px-3 py-2 text-right font-mono tabular-nums">
|
|
|
|
|
|
{fmtFull(d.starting_balance)}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td className="px-3 py-2 text-right font-mono hidden sm:table-cell">
|
|
|
|
|
|
{d.projected_payoff_date ?? '—'}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td className="px-3 py-2 text-right font-mono text-rose-500 hidden sm:table-cell">
|
|
|
|
|
|
{d.projected_total_interest != null
|
|
|
|
|
|
? fmtFull(d.projected_total_interest)
|
|
|
|
|
|
: '—'}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td className="px-3 py-2 text-right font-mono tabular-nums">
|
|
|
|
|
|
{isPaidOff ? (
|
|
|
|
|
|
<span className="text-emerald-600 dark:text-emerald-400 font-semibold">
|
|
|
|
|
|
Paid off ✓
|
|
|
|
|
|
</span>
|
|
|
|
|
|
) : curBal != null ? (
|
|
|
|
|
|
fmtFull(curBal)
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<span className="text-muted-foreground italic">removed</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
2026-06-07 19:13:16 -05:00
|
|
|
|
</div>
|
2026-05-30 17:27:15 -05:00
|
|
|
|
)}
|
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
|
{plan.notes && <p className="text-xs text-muted-foreground italic">{plan.notes}</p>}
|
2026-05-30 17:27:15 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Single plan row ──────────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-07-04 20:39:30 -05:00
|
|
|
|
function PlanRow({ plan }: { plan: SnowballPlan }) {
|
2026-05-30 17:27:15 -05:00
|
|
|
|
const [expanded, setExpanded] = useState(false);
|
2026-07-06 14:23:53 -05:00
|
|
|
|
const snapshot = plan.plan_snapshot ?? {};
|
2026-05-30 17:27:15 -05:00
|
|
|
|
const debtCount = (snapshot.debts ?? []).length;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Collapsible open={expanded} onOpenChange={setExpanded}>
|
|
|
|
|
|
<CollapsibleTrigger asChild>
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="w-full text-left hover:bg-muted/20 transition-colors px-4 py-3 flex items-center gap-3"
|
|
|
|
|
|
>
|
2026-05-30 17:27:15 -05:00
|
|
|
|
<StatusBadge status={plan.status} />
|
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
|
|
|
|
<span className="text-sm font-medium">{plan.name}</span>
|
|
|
|
|
|
<MethodBadge method={plan.method} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p className="text-[11px] text-muted-foreground mt-0.5">
|
|
|
|
|
|
{dateRange(plan)}
|
|
|
|
|
|
{debtCount > 0 && ` · ${debtCount} debt${debtCount !== 1 ? 's' : ''}`}
|
2026-07-06 14:23:53 -05:00
|
|
|
|
{(snapshot.interest_saved ?? 0) > 0 &&
|
|
|
|
|
|
` · ${fmt(snapshot.interest_saved)} interest saved`}
|
2026-05-30 17:27:15 -05:00
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<ChevronDown
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
'h-4 w-4 text-muted-foreground transition-transform shrink-0',
|
|
|
|
|
|
expanded && 'rotate-180',
|
|
|
|
|
|
)}
|
|
|
|
|
|
/>
|
2026-05-30 17:27:15 -05:00
|
|
|
|
</button>
|
|
|
|
|
|
</CollapsibleTrigger>
|
|
|
|
|
|
<CollapsibleContent>
|
|
|
|
|
|
<div className="border-t border-border/30">
|
|
|
|
|
|
<PlanDetail plan={plan} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</CollapsibleContent>
|
|
|
|
|
|
</Collapsible>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── PlanHistoryPanel ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-07-04 20:39:30 -05:00
|
|
|
|
export default function PlanHistoryPanel({ plans = [] }: { plans?: SnowballPlan[] }) {
|
2026-05-30 17:27:15 -05:00
|
|
|
|
const [open, setOpen] = useState(false);
|
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
|
const historical = plans.filter((p) => !['active', 'paused'].includes(p.status));
|
2026-05-30 17:27:15 -05:00
|
|
|
|
if (historical.length === 0) return null;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="mt-6">
|
|
|
|
|
|
<Collapsible open={open} onOpenChange={setOpen}>
|
|
|
|
|
|
<div className="rounded-xl border border-border/60 overflow-hidden">
|
|
|
|
|
|
<CollapsibleTrigger asChild>
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="w-full text-left px-4 py-3 flex items-center gap-3 bg-muted/10 hover:bg-muted/20 transition-colors"
|
|
|
|
|
|
>
|
2026-05-30 17:27:15 -05:00
|
|
|
|
<History className="h-4 w-4 text-muted-foreground shrink-0" />
|
|
|
|
|
|
<span className="text-xs font-bold uppercase tracking-widest text-muted-foreground flex-1">
|
|
|
|
|
|
Plan History · {historical.length} plan{historical.length !== 1 ? 's' : ''}
|
|
|
|
|
|
</span>
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<ChevronDown
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
'h-4 w-4 text-muted-foreground transition-transform',
|
|
|
|
|
|
open && 'rotate-180',
|
|
|
|
|
|
)}
|
|
|
|
|
|
/>
|
2026-05-30 17:27:15 -05:00
|
|
|
|
</button>
|
|
|
|
|
|
</CollapsibleTrigger>
|
|
|
|
|
|
<CollapsibleContent>
|
|
|
|
|
|
<div className="divide-y divide-border/40">
|
2026-07-06 14:23:53 -05:00
|
|
|
|
{historical.map((plan) => (
|
2026-05-30 17:27:15 -05:00
|
|
|
|
<PlanRow key={plan.id} plan={plan} />
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</CollapsibleContent>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Collapsible>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|