BillTracker/client/components/snowball/PlanHistoryPanel.jsx

209 lines
9.8 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
<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>
)}
{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>
);
}