2026-05-30 17:27:15 -05:00
|
|
|
|
import React, { useState } from 'react';
|
|
|
|
|
|
import { ChevronDown, History } from 'lucide-react';
|
|
|
|
|
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
|
|
|
|
|
import { cn } from '@/lib/utils';
|
|
|
|
|
|
|
|
|
|
|
|
function fmt(v) {
|
|
|
|
|
|
return (Number(v) || 0).toLocaleString(undefined, {
|
|
|
|
|
|
style: 'currency', currency: 'USD', maximumFractionDigits: 0,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function fmtFull(v) {
|
|
|
|
|
|
return (Number(v) || 0).toLocaleString(undefined, {
|
|
|
|
|
|
style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function dateRange(plan) {
|
|
|
|
|
|
const start = plan.started_at
|
|
|
|
|
|
? new Date(plan.started_at).toLocaleDateString(undefined, { month: 'short', year: 'numeric' })
|
|
|
|
|
|
: '—';
|
|
|
|
|
|
const end = plan.completed_at || plan.paused_at
|
|
|
|
|
|
? new Date(plan.completed_at || plan.paused_at).toLocaleDateString(undefined, { month: 'short', year: 'numeric' })
|
|
|
|
|
|
: plan.status === 'abandoned' ? 'abandoned' : 'present';
|
|
|
|
|
|
return `${start} – ${end}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function StatusBadge({ status }) {
|
|
|
|
|
|
const map = {
|
|
|
|
|
|
active: 'bg-emerald-500/12 text-emerald-600 dark:text-emerald-400',
|
|
|
|
|
|
paused: 'bg-amber-500/12 text-amber-600 dark:text-amber-400',
|
|
|
|
|
|
completed: 'bg-indigo-500/12 text-indigo-600 dark:text-indigo-400',
|
|
|
|
|
|
abandoned: 'bg-rose-500/12 text-rose-600 dark:text-rose-400',
|
|
|
|
|
|
};
|
|
|
|
|
|
const labels = { active: 'Active', paused: 'Paused', completed: 'Completed', abandoned: 'Abandoned' };
|
|
|
|
|
|
return (
|
|
|
|
|
|
<span className={cn('rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide', map[status] ?? map.abandoned)}>
|
|
|
|
|
|
{labels[status] ?? status}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function MethodBadge({ method }) {
|
|
|
|
|
|
const map = { snowball: 'Snowball', avalanche: 'Avalanche', custom: 'Custom' };
|
|
|
|
|
|
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 ─────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
function PlanDetail({ plan }) {
|
|
|
|
|
|
const snapshot = plan.plan_snapshot ?? {};
|
|
|
|
|
|
const debts = snapshot.debts ?? [];
|
|
|
|
|
|
|
|
|
|
|
|
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">
|
|
|
|
|
|
<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>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{snapshot.interest_saved > 0 && (
|
|
|
|
|
|
<div className="rounded-lg bg-emerald-500/8 border border-emerald-400/15 px-3 py-2 text-center">
|
|
|
|
|
|
<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>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{snapshot.minimum_only_months && (
|
|
|
|
|
|
<div className="rounded-lg bg-muted/30 px-3 py-2 text-center">
|
|
|
|
|
|
<p className="text-[10px] text-muted-foreground uppercase tracking-wide">Minimum-only months</p>
|
|
|
|
|
|
<p className="text-xs font-mono font-semibold mt-0.5">{snapshot.minimum_only_months}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{plan.extra_payment > 0 && (
|
|
|
|
|
|
<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">
|
|
|
|
|
|
<p className="text-[10px] font-bold uppercase tracking-[0.1em] text-muted-foreground/50">Debt snapshot at start of plan</p>
|
2026-05-30 17:27:15 -05:00
|
|
|
|
<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
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{plan.notes && (
|
|
|
|
|
|
<p className="text-xs text-muted-foreground italic">{plan.notes}</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Single plan row ──────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
function PlanRow({ plan }) {
|
|
|
|
|
|
const [expanded, setExpanded] = useState(false);
|
|
|
|
|
|
const snapshot = plan.plan_snapshot ?? {};
|
|
|
|
|
|
const debtCount = (snapshot.debts ?? []).length;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Collapsible open={expanded} onOpenChange={setExpanded}>
|
|
|
|
|
|
<CollapsibleTrigger asChild>
|
|
|
|
|
|
<button type="button" className="w-full text-left hover:bg-muted/20 transition-colors px-4 py-3 flex items-center gap-3">
|
|
|
|
|
|
<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' : ''}`}
|
|
|
|
|
|
{snapshot.interest_saved > 0 && ` · ${fmt(snapshot.interest_saved)} interest saved`}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<ChevronDown className={cn('h-4 w-4 text-muted-foreground transition-transform shrink-0', expanded && 'rotate-180')} />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</CollapsibleTrigger>
|
|
|
|
|
|
<CollapsibleContent>
|
|
|
|
|
|
<div className="border-t border-border/30">
|
|
|
|
|
|
<PlanDetail plan={plan} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</CollapsibleContent>
|
|
|
|
|
|
</Collapsible>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── PlanHistoryPanel ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
export default function PlanHistoryPanel({ plans = [] }) {
|
|
|
|
|
|
const [open, setOpen] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
const historical = plans.filter(p => !['active', 'paused'].includes(p.status));
|
|
|
|
|
|
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>
|
|
|
|
|
|
<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">
|
|
|
|
|
|
<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>
|
|
|
|
|
|
<ChevronDown className={cn('h-4 w-4 text-muted-foreground transition-transform', open && 'rotate-180')} />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</CollapsibleTrigger>
|
|
|
|
|
|
<CollapsibleContent>
|
|
|
|
|
|
<div className="divide-y divide-border/40">
|
|
|
|
|
|
{historical.map(plan => (
|
|
|
|
|
|
<PlanRow key={plan.id} plan={plan} />
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</CollapsibleContent>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Collapsible>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|