272 lines
12 KiB
JavaScript
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>
|
|
</>
|
|
);
|
|
}
|