BillTracker/client/components/snowball/PlanStatusBanner.jsx

272 lines
12 KiB
JavaScript

import React, { useMemo, useState } from 'react';
import { CheckCircle2, ChevronDown, Circle, Clock, Pause, Play, TrendingUp, X, Zap } from 'lucide-react';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
function fmt(v) {
return (Number(v) || 0).toLocaleString(undefined, {
style: 'currency', currency: 'USD', maximumFractionDigits: 0,
});
}
function dateLabel(iso) {
if (!iso) return '—';
return new Date(iso).toLocaleDateString(undefined, { month: 'short', year: 'numeric' });
}
function months(n) {
if (!n || n <= 0) return 'just started';
const y = Math.floor(n / 12);
const m = n % 12;
if (y === 0) return `${m} mo`;
if (m === 0) return `${y} yr`;
return `${y} yr ${m} mo`;
}
// ─── On-track indicator ───────────────────────────────────────────────────────
function computeOnTrack(debt, monthsElapsed) {
if (!debt.projected_payoff_month || debt.current_balance === null) return null;
if (debt.starting_balance <= 0) return null;
const remaining = debt.projected_payoff_month - monthsElapsed;
if (remaining <= 0) return debt.current_balance <= 0 ? 'done' : 'behind';
const progressExpected = monthsElapsed / debt.projected_payoff_month;
const progressActual = debt.starting_balance > 0
? 1 - (debt.current_balance / debt.starting_balance)
: 0;
const diff = progressActual - progressExpected;
if (diff > 0.05) return 'ahead';
if (diff < -0.05) return 'behind';
return 'on_track';
}
function OnTrackPill({ status }) {
if (!status) return null;
const map = {
ahead: { label: '↑ Ahead', cls: 'bg-teal-500/12 text-teal-600 dark:text-teal-400' },
on_track: { label: '→ On track', cls: 'bg-muted/60 text-muted-foreground' },
behind: { label: '↓ Behind', cls: 'bg-amber-500/12 text-amber-600 dark:text-amber-400' },
done: { label: '✓ Paid off', cls: 'bg-emerald-500/12 text-emerald-600 dark:text-emerald-400' },
};
const { label, cls } = map[status] ?? map.on_track;
return (
<span className={cn('rounded-full px-2 py-0.5 text-[10px] font-semibold', cls)}>
{label}
</span>
);
}
// ─── Per-debt progress row ────────────────────────────────────────────────────
function DebtProgressRow({ debt, snapshotDebt, monthsElapsed }) {
const startBal = debt.starting_balance ?? 0;
const curBal = debt.current_balance ?? startBal;
const pct = debt.progress_pct ?? 0;
const trackStatus = computeOnTrack({ ...debt, ...snapshotDebt }, monthsElapsed);
return (
<div className="flex items-center gap-3 px-4 py-2.5 text-sm">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium truncate">{debt.name}</span>
<OnTrackPill status={trackStatus} />
</div>
<div className="flex items-center gap-2">
<div className="flex-1 h-2 rounded-full bg-muted/50 overflow-hidden">
<div
className="h-full rounded-full bg-emerald-500 transition-all duration-500"
style={{ width: `${pct}%` }}
/>
</div>
<span className="text-[11px] font-mono text-muted-foreground tabular-nums w-8 text-right">{pct}%</span>
</div>
</div>
<div className="shrink-0 text-right">
{debt.current_balance !== null ? (
<>
<p className="text-xs font-mono font-semibold tabular-nums">{fmt(curBal)}</p>
{startBal > 0 && <p className="text-[10px] text-muted-foreground">{fmt(startBal)} start</p>}
</>
) : (
<p className="text-xs text-muted-foreground italic">removed</p>
)}
</div>
{snapshotDebt?.projected_payoff_date && (
<div className="shrink-0 text-right hidden sm:block">
<p className="text-[10px] text-muted-foreground">proj.</p>
<p className="text-xs font-mono">{snapshotDebt.projected_payoff_date.slice(0, 7)}</p>
</div>
)}
</div>
);
}
// ─── PlanStatusBanner ─────────────────────────────────────────────────────────
export default function PlanStatusBanner({ plan, onPause, onResume, onComplete, onAbandon, onNewPlan }) {
const [open, setOpen] = useState(true);
const [confirmDialog, setConfirmDialog] = useState(null);
const snapshot = plan?.plan_snapshot ?? {};
const snapshotMap = useMemo(() => {
const m = {};
(snapshot.debts ?? []).forEach(d => { m[d.bill_id] = d; });
return m;
}, [snapshot.debts]);
const currentDebts = plan?.current_debts ?? [];
const monthsElapsed = plan?.months_elapsed ?? 0;
const totalStart = currentDebts.reduce((s, d) => s + (d.starting_balance ?? 0), 0);
const totalCur = currentDebts.reduce((s, d) => s + (d.current_balance ?? d.starting_balance ?? 0), 0);
const totalPaid = Math.max(0, totalStart - totalCur);
const overallPct = totalStart > 0 ? Math.min(100, Math.round(totalPaid / totalStart * 100)) : 0;
const isActive = plan?.status === 'active';
const isPaused = plan?.status === 'paused';
function confirm(action, title, description, onConfirm) {
setConfirmDialog({ title, description, onConfirm });
}
if (!plan) return null;
return (
<>
<Collapsible open={open} onOpenChange={setOpen}>
<div className="mb-4 rounded-xl border border-emerald-400/25 bg-emerald-500/[0.05] dark:bg-emerald-400/[0.04] shadow-sm overflow-hidden">
{/* Header */}
<CollapsibleTrigger asChild>
<button type="button" className="w-full text-left">
<div className="flex items-center gap-3 px-4 py-3">
{/* Status dot + name */}
<div className="flex items-center gap-2 min-w-0 flex-1">
{isActive ? (
<span className="relative flex h-2.5 w-2.5 shrink-0">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-emerald-500" />
</span>
) : (
<Pause className="h-3 w-3 text-amber-500 shrink-0" />
)}
<span className="text-sm font-semibold truncate">{plan.name}</span>
<span className={cn(
'hidden sm:inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide',
isActive ? 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400' : 'bg-amber-500/15 text-amber-600 dark:text-amber-400',
)}>
{isActive ? 'Active' : 'Paused'}
</span>
<span className="text-[11px] text-muted-foreground shrink-0">
Started {dateLabel(plan.started_at)} · {months(monthsElapsed)} in
</span>
</div>
{/* Overall progress bar */}
<div className="hidden md:flex items-center gap-2 w-36 shrink-0">
<div className="flex-1 h-1.5 rounded-full bg-muted/40 overflow-hidden">
<div className="h-full rounded-full bg-emerald-500 transition-all duration-500" style={{ width: `${overallPct}%` }} />
</div>
<span className="text-xs font-mono text-muted-foreground">{overallPct}%</span>
</div>
{/* Actions */}
<div className="flex items-center gap-1 shrink-0" onClick={e => e.stopPropagation()}>
{isActive && (
<>
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs gap-1" onClick={onPause}>
<Pause className="h-3 w-3" /> Pause
</Button>
<Button size="sm" variant="outline" className="h-7 px-2 text-xs gap-1 border-emerald-400/40 text-emerald-600 hover:bg-emerald-500/10 dark:text-emerald-400" onClick={() => confirm('complete', 'Mark plan as complete?', 'This will record your plan as successfully completed.', onComplete)}>
<CheckCircle2 className="h-3 w-3" /> Complete
</Button>
</>
)}
{isPaused && (
<>
<Button size="sm" variant="outline" className="h-7 px-2 text-xs gap-1 border-emerald-400/40 text-emerald-600 hover:bg-emerald-500/10 dark:text-emerald-400" onClick={onResume}>
<Play className="h-3 w-3" /> Resume
</Button>
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs gap-1 text-destructive hover:bg-destructive/10" onClick={() => confirm('abandon', 'Abandon this plan?', 'This plan will be moved to history. Your debt data stays unchanged.', onAbandon)}>
<X className="h-3 w-3" /> Abandon
</Button>
</>
)}
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs gap-1 text-muted-foreground" onClick={() => confirm('new', 'Start a new plan?', 'Your current plan will be abandoned and moved to history. Your debt data stays unchanged.', onNewPlan)}>
<Zap className="h-3 w-3" /> New Plan
</Button>
<ChevronDown className={cn('h-4 w-4 text-muted-foreground transition-transform', open && 'rotate-180')} />
</div>
</div>
</button>
</CollapsibleTrigger>
{/* Collapsible body — per-debt rows */}
<CollapsibleContent>
<div className="border-t border-emerald-400/15 divide-y divide-border/30">
{currentDebts.length === 0 ? (
<p className="px-4 py-3 text-sm text-muted-foreground">No debt data in this plan.</p>
) : (
currentDebts.map(debt => (
<DebtProgressRow
key={debt.bill_id}
debt={debt}
snapshotDebt={snapshotMap[debt.bill_id]}
monthsElapsed={monthsElapsed}
/>
))
)}
{/* Summary row */}
{totalStart > 0 && (
<div className="px-4 py-2.5 flex items-center justify-between bg-muted/20">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Total progress
</span>
<div className="flex items-center gap-4">
<span className="text-xs text-muted-foreground">
{fmt(totalPaid)} paid of {fmt(totalStart)}
</span>
{snapshot.interest_saved > 0 && (
<span className="text-xs text-emerald-600 dark:text-emerald-400 font-medium">
{fmt(snapshot.interest_saved)} interest saved vs minimum
</span>
)}
</div>
</div>
)}
</div>
</CollapsibleContent>
</div>
</Collapsible>
{/* Confirmation dialog */}
<AlertDialog open={!!confirmDialog} onOpenChange={open => { if (!open) setConfirmDialog(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{confirmDialog?.title}</AlertDialogTitle>
<AlertDialogDescription>{confirmDialog?.description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setConfirmDialog(null)}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => { confirmDialog?.onConfirm(); setConfirmDialog(null); }}>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}