BillTracker/client/components/snowball/PlanHistoryPanel.jsx

209 lines
9.8 KiB
React
Raw Permalink Normal View History

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 && (
<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>
</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>
);
}