refactor(ts): convert transactions/ + snowball/ dirs to TSX (B14)
transactions/: CategoryPicker (Category[], Node-cast for outside-click), MatchBillDialog (BankTransaction/Bill, advisory_filter cast). snowball/: PayoffChart (TrackPoint chart geometry, asDollars for formatUSD), PlanHistory Panel + PlanStatusBanner (SnowballPlan/ActivePlan types with Dollars-branded debt balances; asDollars at fmt boundaries, ?? guards for optional-money comparisons). typecheck 0, lint 0 errors, build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
f34f03af51
commit
6a9b8b3cb8
|
|
@ -0,0 +1,163 @@
|
||||||
|
import { formatUSD, asDollars } from '@/lib/money';
|
||||||
|
|
||||||
|
const W = 720;
|
||||||
|
const H = 300;
|
||||||
|
const PAD = { left: 68, right: 24, top: 20, bottom: 56 };
|
||||||
|
const CW = W - PAD.left - PAD.right;
|
||||||
|
const CH = H - PAD.top - PAD.bottom;
|
||||||
|
|
||||||
|
interface TrackPoint {
|
||||||
|
month: number;
|
||||||
|
balance: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChartPoint {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
month: number;
|
||||||
|
balance: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function money(v: number | string | null | undefined): string {
|
||||||
|
const n = Number(v) || 0;
|
||||||
|
if (n >= 1000) return `$${(n / 1000).toFixed(0)}k`;
|
||||||
|
return `$${n.toFixed(0)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fullMoney(v: number): string {
|
||||||
|
return formatUSD(asDollars(v));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPoints(track: TrackPoint[], startBalance: number, maxMonths: number): ChartPoint[] {
|
||||||
|
const all = [{ month: 0, balance: startBalance }, ...track];
|
||||||
|
return all.map(({ month, balance }) => ({
|
||||||
|
x: PAD.left + (month / maxMonths) * CW,
|
||||||
|
y: PAD.top + CH - (balance / startBalance) * CH,
|
||||||
|
month,
|
||||||
|
balance,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLine(pts: { x: number; y: number }[]): string {
|
||||||
|
return pts.map(p => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PayoffChartProps {
|
||||||
|
minTrack?: TrackPoint[];
|
||||||
|
currentTrack?: TrackPoint[];
|
||||||
|
simTrack?: TrackPoint[];
|
||||||
|
startBalance?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PayoffChart({ minTrack = [], currentTrack = [], simTrack = [], startBalance = 1 }: PayoffChartProps) {
|
||||||
|
const maxMonths = Math.max(minTrack.length, currentTrack.length, simTrack.length, 12);
|
||||||
|
const bal = Math.max(startBalance, 1);
|
||||||
|
|
||||||
|
const minPts = buildPoints(minTrack, bal, maxMonths);
|
||||||
|
const currentPts = buildPoints(currentTrack, bal, maxMonths);
|
||||||
|
const simPts = buildPoints(simTrack, bal, maxMonths);
|
||||||
|
|
||||||
|
const xStep = maxMonths <= 24 ? 6 : maxMonths <= 60 ? 12 : 24;
|
||||||
|
const xLabels: number[] = [];
|
||||||
|
for (let m = xStep; m <= maxMonths; m += xStep) {
|
||||||
|
xLabels.push(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
const yTicks = [0, 0.25, 0.5, 0.75, 1];
|
||||||
|
|
||||||
|
const showCurrent = currentTrack.length > 0 &&
|
||||||
|
currentTrack.some((c, i) => (minTrack[i]?.balance ?? null) !== c.balance);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full overflow-hidden rounded-xl border border-border/60 bg-background/60">
|
||||||
|
<svg viewBox={`0 0 ${W} ${H}`} role="img" aria-label="Loan payoff chart" className="h-auto w-full">
|
||||||
|
|
||||||
|
{/* Grid + Y axis */}
|
||||||
|
{yTicks.map(tick => {
|
||||||
|
const y = PAD.top + CH - tick * CH;
|
||||||
|
return (
|
||||||
|
<g key={tick}>
|
||||||
|
<line x1={PAD.left} x2={PAD.left + CW} y1={y} y2={y}
|
||||||
|
stroke="currentColor" opacity="0.08" strokeWidth="1" />
|
||||||
|
<text x={PAD.left - 6} y={y + 4} fontSize="11" fill="currentColor"
|
||||||
|
opacity="0.5" textAnchor="end">
|
||||||
|
{money(bal * tick)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* X axis labels */}
|
||||||
|
{xLabels.map(m => {
|
||||||
|
const x = PAD.left + (m / maxMonths) * CW;
|
||||||
|
return (
|
||||||
|
<text key={m} x={x} y={H - 38} fontSize="11" fill="currentColor"
|
||||||
|
opacity="0.5" textAnchor="middle">
|
||||||
|
{m}mo
|
||||||
|
</text>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* X axis baseline */}
|
||||||
|
<line x1={PAD.left} x2={PAD.left + CW} y1={PAD.top + CH} y2={PAD.top + CH}
|
||||||
|
stroke="currentColor" opacity="0.12" strokeWidth="1" />
|
||||||
|
|
||||||
|
{/* Min-only track (slate dashed) */}
|
||||||
|
{minPts.length > 1 && (
|
||||||
|
<polyline points={toLine(minPts)} fill="none"
|
||||||
|
stroke="#94a3b8" strokeWidth="1.5"
|
||||||
|
strokeDasharray="6,4" strokeLinecap="round" strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Current snowball plan (indigo dashed) */}
|
||||||
|
{showCurrent && currentPts.length > 1 && (
|
||||||
|
<polyline points={toLine(currentPts)} fill="none"
|
||||||
|
stroke="#818cf8" strokeWidth="1.5"
|
||||||
|
strokeDasharray="9,5" strokeLinecap="round" strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Simulation track (amber solid, prominent) */}
|
||||||
|
{simPts.length > 1 && (
|
||||||
|
<>
|
||||||
|
<polyline points={toLine(simPts)} fill="none"
|
||||||
|
stroke="#f59e0b" strokeWidth="3"
|
||||||
|
strokeLinecap="round" strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
{/* Endpoint dot */}
|
||||||
|
{(() => {
|
||||||
|
const last = simPts[simPts.length - 1]!;
|
||||||
|
return <circle cx={last.x} cy={last.y} r="4" fill="#f59e0b" />;
|
||||||
|
})()}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tooltips at 6-month intervals on sim track */}
|
||||||
|
{simPts.filter(p => p.month > 0 && p.month % 6 === 0).map(p => (
|
||||||
|
<circle key={p.month} cx={p.x} cy={p.y} r="3" fill="#f59e0b" opacity="0.7">
|
||||||
|
<title>{`Month ${p.month}: ${fullMoney(p.balance)} remaining`}</title>
|
||||||
|
</circle>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<g transform={`translate(${PAD.left}, ${H - 22})`} fontSize="11" fill="currentColor" opacity="0.7">
|
||||||
|
<line x1="0" x2="16" y1="0" y2="0" stroke="#94a3b8" strokeWidth="1.5" strokeDasharray="4,3" />
|
||||||
|
<text x="20" y="4">Min only</text>
|
||||||
|
|
||||||
|
{showCurrent && (
|
||||||
|
<>
|
||||||
|
<line x1="80" x2="96" y1="0" y2="0" stroke="#818cf8" strokeWidth="1.5" strokeDasharray="6,4" />
|
||||||
|
<text x="100" y="4">Snowball plan</text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<line x1={showCurrent ? 198 : 80} x2={showCurrent ? 214 : 96} y1="0" y2="0"
|
||||||
|
stroke="#f59e0b" strokeWidth="2.5" />
|
||||||
|
<text x={showCurrent ? 218 : 100} y="4">Simulation</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,44 +1,81 @@
|
||||||
import React, { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { ChevronDown, History } from 'lucide-react';
|
import { ChevronDown, History } from 'lucide-react';
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { formatUSD, formatUSDWhole } from '@/lib/money';
|
import { formatUSD, formatUSDWhole } from '@/lib/money';
|
||||||
|
import type { Dollars } from '@/lib/money';
|
||||||
|
|
||||||
function fmt(v) {
|
interface SnowballPlanDebt {
|
||||||
|
bill_id: number;
|
||||||
|
name: string;
|
||||||
|
starting_balance: Dollars;
|
||||||
|
projected_payoff_date?: string | null;
|
||||||
|
projected_total_interest?: Dollars | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SnowballPlanSnapshot {
|
||||||
|
debts?: SnowballPlanDebt[];
|
||||||
|
projected_months?: number;
|
||||||
|
projected_payoff_date?: string | null;
|
||||||
|
interest_saved?: Dollars;
|
||||||
|
minimum_only_months?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CurrentDebt {
|
||||||
|
bill_id: number;
|
||||||
|
current_balance?: Dollars | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SnowballPlan {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
method: string;
|
||||||
|
started_at?: string | null;
|
||||||
|
completed_at?: string | null;
|
||||||
|
paused_at?: string | null;
|
||||||
|
extra_payment?: Dollars;
|
||||||
|
notes?: string | null;
|
||||||
|
plan_snapshot?: SnowballPlanSnapshot;
|
||||||
|
current_debts?: CurrentDebt[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(v: Dollars | null | undefined): string {
|
||||||
return formatUSDWhole(v);
|
return formatUSDWhole(v);
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtFull(v) {
|
function fmtFull(v: Dollars | null | undefined): string {
|
||||||
return formatUSD(v);
|
return formatUSD(v);
|
||||||
}
|
}
|
||||||
|
|
||||||
function dateRange(plan) {
|
function dateRange(plan: SnowballPlan): string {
|
||||||
const start = plan.started_at
|
const start = plan.started_at
|
||||||
? new Date(plan.started_at).toLocaleDateString(undefined, { month: 'short', year: 'numeric' })
|
? new Date(plan.started_at).toLocaleDateString(undefined, { month: 'short', year: 'numeric' })
|
||||||
: '—';
|
: '—';
|
||||||
const end = plan.completed_at || plan.paused_at
|
const endDate = plan.completed_at || plan.paused_at;
|
||||||
? new Date(plan.completed_at || plan.paused_at).toLocaleDateString(undefined, { month: 'short', year: 'numeric' })
|
const end = endDate
|
||||||
|
? new Date(endDate).toLocaleDateString(undefined, { month: 'short', year: 'numeric' })
|
||||||
: plan.status === 'abandoned' ? 'abandoned' : 'present';
|
: plan.status === 'abandoned' ? 'abandoned' : 'present';
|
||||||
return `${start} – ${end}`;
|
return `${start} – ${end}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusBadge({ status }) {
|
function StatusBadge({ status }: { status: string }) {
|
||||||
const map = {
|
const map = {
|
||||||
active: 'bg-emerald-500/12 text-emerald-600 dark:text-emerald-400',
|
active: 'bg-emerald-500/12 text-emerald-600 dark:text-emerald-400',
|
||||||
paused: 'bg-amber-500/12 text-amber-600 dark:text-amber-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',
|
completed: 'bg-indigo-500/12 text-indigo-600 dark:text-indigo-400',
|
||||||
abandoned: 'bg-rose-500/12 text-rose-600 dark:text-rose-400',
|
abandoned: 'bg-rose-500/12 text-rose-600 dark:text-rose-400',
|
||||||
};
|
};
|
||||||
const labels = { active: 'Active', paused: 'Paused', completed: 'Completed', abandoned: 'Abandoned' };
|
const labels: Record<string, string> = { active: 'Active', paused: 'Paused', completed: 'Completed', abandoned: 'Abandoned' };
|
||||||
return (
|
return (
|
||||||
<span className={cn('rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide', map[status] ?? map.abandoned)}>
|
<span className={cn('rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide', map[status as keyof typeof map] ?? map.abandoned)}>
|
||||||
{labels[status] ?? status}
|
{labels[status] ?? status}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MethodBadge({ method }) {
|
function MethodBadge({ method }: { method: string }) {
|
||||||
const map = { snowball: 'Snowball', avalanche: 'Avalanche', custom: 'Custom' };
|
const map: Record<string, string> = { snowball: 'Snowball', avalanche: 'Avalanche', custom: 'Custom' };
|
||||||
return (
|
return (
|
||||||
<span className="rounded-full px-2 py-0.5 text-[10px] font-medium bg-muted/60 text-muted-foreground">
|
<span className="rounded-full px-2 py-0.5 text-[10px] font-medium bg-muted/60 text-muted-foreground">
|
||||||
{map[method] ?? method}
|
{map[method] ?? method}
|
||||||
|
|
@ -48,7 +85,7 @@ function MethodBadge({ method }) {
|
||||||
|
|
||||||
// ─── Expanded plan detail ─────────────────────────────────────────────────────
|
// ─── Expanded plan detail ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
function PlanDetail({ plan }) {
|
function PlanDetail({ plan }: { plan: SnowballPlan }) {
|
||||||
const snapshot = plan.plan_snapshot ?? {};
|
const snapshot = plan.plan_snapshot ?? {};
|
||||||
const debts = snapshot.debts ?? [];
|
const debts = snapshot.debts ?? [];
|
||||||
|
|
||||||
|
|
@ -62,7 +99,7 @@ function PlanDetail({ plan }) {
|
||||||
<p className="text-xs font-mono font-semibold mt-0.5">{snapshot.projected_payoff_date ?? '—'}</p>
|
<p className="text-xs font-mono font-semibold mt-0.5">{snapshot.projected_payoff_date ?? '—'}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{snapshot.interest_saved > 0 && (
|
{(snapshot.interest_saved ?? 0) > 0 && (
|
||||||
<div className="rounded-lg bg-emerald-500/8 border border-emerald-400/15 px-3 py-2 text-center">
|
<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-[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>
|
<p className="text-xs font-mono font-semibold text-emerald-600 dark:text-emerald-400 mt-0.5">{fmt(snapshot.interest_saved)}</p>
|
||||||
|
|
@ -74,7 +111,7 @@ function PlanDetail({ plan }) {
|
||||||
<p className="text-xs font-mono font-semibold mt-0.5">{snapshot.minimum_only_months}</p>
|
<p className="text-xs font-mono font-semibold mt-0.5">{snapshot.minimum_only_months}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{plan.extra_payment > 0 && (
|
{(plan.extra_payment ?? 0) > 0 && (
|
||||||
<div className="rounded-lg bg-muted/30 px-3 py-2 text-center">
|
<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-[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>
|
<p className="text-xs font-mono font-semibold mt-0.5">{fmtFull(plan.extra_payment)}</p>
|
||||||
|
|
@ -137,7 +174,7 @@ function PlanDetail({ plan }) {
|
||||||
|
|
||||||
// ─── Single plan row ──────────────────────────────────────────────────────────
|
// ─── Single plan row ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function PlanRow({ plan }) {
|
function PlanRow({ plan }: { plan: SnowballPlan }) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const snapshot = plan.plan_snapshot ?? {};
|
const snapshot = plan.plan_snapshot ?? {};
|
||||||
const debtCount = (snapshot.debts ?? []).length;
|
const debtCount = (snapshot.debts ?? []).length;
|
||||||
|
|
@ -155,7 +192,7 @@ function PlanRow({ plan }) {
|
||||||
<p className="text-[11px] text-muted-foreground mt-0.5">
|
<p className="text-[11px] text-muted-foreground mt-0.5">
|
||||||
{dateRange(plan)}
|
{dateRange(plan)}
|
||||||
{debtCount > 0 && ` · ${debtCount} debt${debtCount !== 1 ? 's' : ''}`}
|
{debtCount > 0 && ` · ${debtCount} debt${debtCount !== 1 ? 's' : ''}`}
|
||||||
{snapshot.interest_saved > 0 && ` · ${fmt(snapshot.interest_saved)} interest saved`}
|
{(snapshot.interest_saved ?? 0) > 0 && ` · ${fmt(snapshot.interest_saved)} interest saved`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ChevronDown className={cn('h-4 w-4 text-muted-foreground transition-transform shrink-0', expanded && 'rotate-180')} />
|
<ChevronDown className={cn('h-4 w-4 text-muted-foreground transition-transform shrink-0', expanded && 'rotate-180')} />
|
||||||
|
|
@ -172,7 +209,7 @@ function PlanRow({ plan }) {
|
||||||
|
|
||||||
// ─── PlanHistoryPanel ─────────────────────────────────────────────────────────
|
// ─── PlanHistoryPanel ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function PlanHistoryPanel({ plans = [] }) {
|
export default function PlanHistoryPanel({ plans = [] }: { plans?: SnowballPlan[] }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const historical = plans.filter(p => !['active', 'paused'].includes(p.status));
|
const historical = plans.filter(p => !['active', 'paused'].includes(p.status));
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { CheckCircle2, ChevronDown, Circle, Clock, Pause, Play, TrendingUp, X, Zap } from 'lucide-react';
|
import { CheckCircle2, ChevronDown, Pause, Play, X, Zap } from 'lucide-react';
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||||
import {
|
import {
|
||||||
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
||||||
|
|
@ -7,18 +7,48 @@ import {
|
||||||
} from '@/components/ui/alert-dialog';
|
} from '@/components/ui/alert-dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { formatUSDWhole } from '@/lib/money';
|
import { formatUSDWhole, asDollars, type Dollars } from '@/lib/money';
|
||||||
|
|
||||||
function fmt(v) {
|
interface CurrentDebt {
|
||||||
|
bill_id: number;
|
||||||
|
name: string;
|
||||||
|
starting_balance?: Dollars;
|
||||||
|
current_balance?: Dollars | null;
|
||||||
|
progress_pct?: number;
|
||||||
|
projected_payoff_month?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SnapshotDebt {
|
||||||
|
bill_id: number;
|
||||||
|
projected_payoff_date?: string | null;
|
||||||
|
projected_payoff_month?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActivePlan {
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
started_at?: string | null;
|
||||||
|
months_elapsed?: number;
|
||||||
|
current_debts?: CurrentDebt[];
|
||||||
|
plan_snapshot?: { debts?: SnapshotDebt[]; interest_saved?: Dollars };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfirmState {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
onConfirm?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(v: Dollars | null | undefined): string {
|
||||||
return formatUSDWhole(v);
|
return formatUSDWhole(v);
|
||||||
}
|
}
|
||||||
|
|
||||||
function dateLabel(iso) {
|
function dateLabel(iso: string | null | undefined): string {
|
||||||
if (!iso) return '—';
|
if (!iso) return '—';
|
||||||
return new Date(iso).toLocaleDateString(undefined, { month: 'short', year: 'numeric' });
|
return new Date(iso).toLocaleDateString(undefined, { month: 'short', year: 'numeric' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function months(n) {
|
function months(n: number | null | undefined): string {
|
||||||
if (!n || n <= 0) return 'just started';
|
if (!n || n <= 0) return 'just started';
|
||||||
const y = Math.floor(n / 12);
|
const y = Math.floor(n / 12);
|
||||||
const m = n % 12;
|
const m = n % 12;
|
||||||
|
|
@ -29,16 +59,23 @@ function months(n) {
|
||||||
|
|
||||||
// ─── On-track indicator ───────────────────────────────────────────────────────
|
// ─── On-track indicator ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
function computeOnTrack(debt, monthsElapsed) {
|
interface OnTrackDebt {
|
||||||
if (!debt.projected_payoff_month || debt.current_balance === null) return null;
|
projected_payoff_month?: number;
|
||||||
if (debt.starting_balance <= 0) return null;
|
current_balance?: Dollars | null;
|
||||||
|
starting_balance?: Dollars;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeOnTrack(debt: OnTrackDebt, monthsElapsed: number): string | null {
|
||||||
|
if (!debt.projected_payoff_month || debt.current_balance == null) return null;
|
||||||
|
const startBal = debt.starting_balance ?? asDollars(0);
|
||||||
|
if (startBal <= 0) return null;
|
||||||
|
|
||||||
const remaining = debt.projected_payoff_month - monthsElapsed;
|
const remaining = debt.projected_payoff_month - monthsElapsed;
|
||||||
if (remaining <= 0) return debt.current_balance <= 0 ? 'done' : 'behind';
|
if (remaining <= 0) return debt.current_balance <= 0 ? 'done' : 'behind';
|
||||||
|
|
||||||
const progressExpected = monthsElapsed / debt.projected_payoff_month;
|
const progressExpected = monthsElapsed / debt.projected_payoff_month;
|
||||||
const progressActual = debt.starting_balance > 0
|
const progressActual = startBal > 0
|
||||||
? 1 - (debt.current_balance / debt.starting_balance)
|
? 1 - (debt.current_balance / startBal)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
const diff = progressActual - progressExpected;
|
const diff = progressActual - progressExpected;
|
||||||
|
|
@ -47,7 +84,7 @@ function computeOnTrack(debt, monthsElapsed) {
|
||||||
return 'on_track';
|
return 'on_track';
|
||||||
}
|
}
|
||||||
|
|
||||||
function OnTrackPill({ status }) {
|
function OnTrackPill({ status }: { status: string | null }) {
|
||||||
if (!status) return null;
|
if (!status) return null;
|
||||||
const map = {
|
const map = {
|
||||||
ahead: { label: '↑ Ahead', cls: 'bg-teal-500/12 text-teal-600 dark:text-teal-400' },
|
ahead: { label: '↑ Ahead', cls: 'bg-teal-500/12 text-teal-600 dark:text-teal-400' },
|
||||||
|
|
@ -55,7 +92,7 @@ function OnTrackPill({ status }) {
|
||||||
behind: { label: '↓ Behind', cls: 'bg-amber-500/12 text-amber-600 dark:text-amber-400' },
|
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' },
|
done: { label: '✓ Paid off', cls: 'bg-emerald-500/12 text-emerald-600 dark:text-emerald-400' },
|
||||||
};
|
};
|
||||||
const { label, cls } = map[status] ?? map.on_track;
|
const { label, cls } = map[status as keyof typeof map] ?? map.on_track;
|
||||||
return (
|
return (
|
||||||
<span className={cn('rounded-full px-2 py-0.5 text-[10px] font-semibold', cls)}>
|
<span className={cn('rounded-full px-2 py-0.5 text-[10px] font-semibold', cls)}>
|
||||||
{label}
|
{label}
|
||||||
|
|
@ -65,8 +102,8 @@ function OnTrackPill({ status }) {
|
||||||
|
|
||||||
// ─── Per-debt progress row ────────────────────────────────────────────────────
|
// ─── Per-debt progress row ────────────────────────────────────────────────────
|
||||||
|
|
||||||
function DebtProgressRow({ debt, snapshotDebt, monthsElapsed }) {
|
function DebtProgressRow({ debt, snapshotDebt, monthsElapsed }: { debt: CurrentDebt; snapshotDebt?: SnapshotDebt; monthsElapsed: number }) {
|
||||||
const startBal = debt.starting_balance ?? 0;
|
const startBal = debt.starting_balance ?? asDollars(0);
|
||||||
const curBal = debt.current_balance ?? startBal;
|
const curBal = debt.current_balance ?? startBal;
|
||||||
const pct = debt.progress_pct ?? 0;
|
const pct = debt.progress_pct ?? 0;
|
||||||
const trackStatus = computeOnTrack({ ...debt, ...snapshotDebt }, monthsElapsed);
|
const trackStatus = computeOnTrack({ ...debt, ...snapshotDebt }, monthsElapsed);
|
||||||
|
|
@ -110,13 +147,22 @@ function DebtProgressRow({ debt, snapshotDebt, monthsElapsed }) {
|
||||||
|
|
||||||
// ─── PlanStatusBanner ─────────────────────────────────────────────────────────
|
// ─── PlanStatusBanner ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function PlanStatusBanner({ plan, onPause, onResume, onComplete, onAbandon, onNewPlan }) {
|
interface PlanStatusBannerProps {
|
||||||
|
plan?: ActivePlan | null;
|
||||||
|
onPause?: () => void;
|
||||||
|
onResume?: () => void;
|
||||||
|
onComplete?: () => void;
|
||||||
|
onAbandon?: () => void;
|
||||||
|
onNewPlan?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PlanStatusBanner({ plan, onPause, onResume, onComplete, onAbandon, onNewPlan }: PlanStatusBannerProps) {
|
||||||
const [open, setOpen] = useState(true);
|
const [open, setOpen] = useState(true);
|
||||||
const [confirmDialog, setConfirmDialog] = useState(null);
|
const [confirmDialog, setConfirmDialog] = useState<ConfirmState | null>(null);
|
||||||
|
|
||||||
const snapshot = plan?.plan_snapshot ?? {};
|
const snapshot = plan?.plan_snapshot ?? {};
|
||||||
const snapshotMap = useMemo(() => {
|
const snapshotMap = useMemo(() => {
|
||||||
const m = {};
|
const m: Record<number, SnapshotDebt> = {};
|
||||||
(snapshot.debts ?? []).forEach(d => { m[d.bill_id] = d; });
|
(snapshot.debts ?? []).forEach(d => { m[d.bill_id] = d; });
|
||||||
return m;
|
return m;
|
||||||
}, [snapshot.debts]);
|
}, [snapshot.debts]);
|
||||||
|
|
@ -132,7 +178,7 @@ export default function PlanStatusBanner({ plan, onPause, onResume, onComplete,
|
||||||
const isActive = plan?.status === 'active';
|
const isActive = plan?.status === 'active';
|
||||||
const isPaused = plan?.status === 'paused';
|
const isPaused = plan?.status === 'paused';
|
||||||
|
|
||||||
function confirm(action, title, description, onConfirm) {
|
function confirm(_action: string, title: string, description: string, onConfirm?: () => void) {
|
||||||
setConfirmDialog({ title, description, onConfirm });
|
setConfirmDialog({ title, description, onConfirm });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -143,7 +189,6 @@ export default function PlanStatusBanner({ plan, onPause, onResume, onComplete,
|
||||||
<Collapsible open={open} onOpenChange={setOpen}>
|
<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">
|
<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 */}
|
|
||||||
{/* Header row. The name/progress area and the chevron are the collapsible
|
{/* Header row. The name/progress area and the chevron are the collapsible
|
||||||
toggles; the action buttons are siblings (not nested inside a trigger
|
toggles; the action buttons are siblings (not nested inside a trigger
|
||||||
button) so they don't trip axe nested-interactive (a11y QA-B14-02). */}
|
button) so they don't trip axe nested-interactive (a11y QA-B14-02). */}
|
||||||
|
|
@ -243,9 +288,9 @@ export default function PlanStatusBanner({ plan, onPause, onResume, onComplete,
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{fmt(totalPaid)} paid of {fmt(totalStart)}
|
{fmt(asDollars(totalPaid))} paid of {fmt(asDollars(totalStart))}
|
||||||
</span>
|
</span>
|
||||||
{snapshot.interest_saved > 0 && (
|
{(snapshot.interest_saved ?? 0) > 0 && (
|
||||||
<span className="text-xs text-emerald-600 dark:text-emerald-400 font-medium">
|
<span className="text-xs text-emerald-600 dark:text-emerald-400 font-medium">
|
||||||
{fmt(snapshot.interest_saved)} interest saved vs minimum
|
{fmt(snapshot.interest_saved)} interest saved vs minimum
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -268,7 +313,7 @@ export default function PlanStatusBanner({ plan, onPause, onResume, onComplete,
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel onClick={() => setConfirmDialog(null)}>Cancel</AlertDialogCancel>
|
<AlertDialogCancel onClick={() => setConfirmDialog(null)}>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogAction onClick={() => { confirmDialog?.onConfirm(); setConfirmDialog(null); }}>
|
<AlertDialogAction onClick={() => { confirmDialog?.onConfirm?.(); setConfirmDialog(null); }}>
|
||||||
Confirm
|
Confirm
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
|
|
@ -1,15 +1,23 @@
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { Tag } from 'lucide-react';
|
import { Tag } from 'lucide-react';
|
||||||
|
import type { Category } from '@/types';
|
||||||
|
|
||||||
// ── Category picker dropdown ─────────────────────────────────────────────────
|
// ── Category picker dropdown ─────────────────────────────────────────────────
|
||||||
|
|
||||||
export function CategoryPicker({ categories, current, currentLabel, onSelect }) {
|
interface CategoryPickerProps {
|
||||||
|
categories: Category[];
|
||||||
|
current?: number | string | null;
|
||||||
|
currentLabel?: string | null;
|
||||||
|
onSelect: (id: number | string | null, replace: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CategoryPicker({ categories, current, currentLabel, onSelect }: CategoryPickerProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const ref = useRef(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const close = e => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
|
const close = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
|
||||||
document.addEventListener('mousedown', close);
|
document.addEventListener('mousedown', close);
|
||||||
return () => document.removeEventListener('mousedown', close);
|
return () => document.removeEventListener('mousedown', close);
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
CheckCircle2, Loader2, Link2, Search, Plus,
|
CheckCircle2, Loader2, Link2, Search, Plus,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
@ -10,20 +10,31 @@ import {
|
||||||
Dialog, DialogContent, DialogDescription, DialogFooter,
|
Dialog, DialogContent, DialogDescription, DialogFooter,
|
||||||
DialogHeader, DialogTitle,
|
DialogHeader, DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
|
import type { BankTransaction, Bill } from '@/types';
|
||||||
|
|
||||||
export function transactionDate(tx) {
|
export function transactionDate(tx: BankTransaction | null | undefined): string {
|
||||||
return tx?.posted_date || String(tx?.transacted_at || '').slice(0, 10) || '—';
|
return tx?.posted_date || String(tx?.transacted_at || '').slice(0, 10) || '—';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function transactionTitle(tx) {
|
export function transactionTitle(tx: BankTransaction | null | undefined): string {
|
||||||
return tx?.payee || tx?.description || tx?.memo || 'Transaction';
|
return tx?.payee || tx?.description || tx?.memo || 'Transaction';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatTransactionAmount(amount, currency = 'USD') {
|
export function formatTransactionAmount(amount: Parameters<typeof formatCentsUSD>[0], currency = 'USD') {
|
||||||
return formatCentsUSD(amount, { signed: true, currency });
|
return formatCentsUSD(amount, { signed: true, currency });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MatchBillDialog({ open, onOpenChange, transaction, bills, onConfirm, loading, onCreateBill }) {
|
interface MatchBillDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
transaction: BankTransaction | null;
|
||||||
|
bills: Bill[];
|
||||||
|
onConfirm: (billId: number | string) => void;
|
||||||
|
loading?: boolean;
|
||||||
|
onCreateBill?: (transaction: BankTransaction | null, query?: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MatchBillDialog({ open, onOpenChange, transaction, bills, onConfirm, loading, onCreateBill }: MatchBillDialogProps) {
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [selectedBillId, setSelectedBillId] = useState('');
|
const [selectedBillId, setSelectedBillId] = useState('');
|
||||||
|
|
||||||
|
|
@ -43,6 +54,7 @@ export function MatchBillDialog({ open, onOpenChange, transaction, bills, onConf
|
||||||
}, [bills, query]);
|
}, [bills, query]);
|
||||||
|
|
||||||
const selectedBill = bills.find(bill => String(bill.id) === String(selectedBillId));
|
const selectedBill = bills.find(bill => String(bill.id) === String(selectedBillId));
|
||||||
|
const af = transaction?.advisory_filter as { confidence?: string } | undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
|
@ -67,7 +79,7 @@ export function MatchBillDialog({ open, onOpenChange, transaction, bills, onConf
|
||||||
'text-sm font-semibold tabular-nums',
|
'text-sm font-semibold tabular-nums',
|
||||||
Number(transaction.amount) < 0 ? 'text-destructive' : 'text-emerald-600',
|
Number(transaction.amount) < 0 ? 'text-destructive' : 'text-emerald-600',
|
||||||
)}>
|
)}>
|
||||||
{formatTransactionAmount(transaction.amount, transaction.currency)}
|
{formatTransactionAmount(transaction.amount, transaction.currency ?? undefined)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{transaction.description && transaction.description !== transactionTitle(transaction) && (
|
{transaction.description && transaction.description !== transactionTitle(transaction) && (
|
||||||
|
|
@ -95,7 +107,6 @@ export function MatchBillDialog({ open, onOpenChange, transaction, bills, onConf
|
||||||
<div className="flex flex-col items-center gap-3 px-4 py-8 text-center">
|
<div className="flex flex-col items-center gap-3 px-4 py-8 text-center">
|
||||||
<p className="text-sm text-muted-foreground">No bills found.</p>
|
<p className="text-sm text-muted-foreground">No bills found.</p>
|
||||||
{onCreateBill && (() => {
|
{onCreateBill && (() => {
|
||||||
const af = transaction?.advisory_filter;
|
|
||||||
const label = query.trim()
|
const label = query.trim()
|
||||||
? `Create "${query.trim()}" as a new bill`
|
? `Create "${query.trim()}" as a new bill`
|
||||||
: 'Create a new bill';
|
: 'Create a new bill';
|
||||||
|
|
@ -158,7 +169,6 @@ export function MatchBillDialog({ open, onOpenChange, transaction, bills, onConf
|
||||||
|
|
||||||
<DialogFooter className="gap-2">
|
<DialogFooter className="gap-2">
|
||||||
{onCreateBill && (() => {
|
{onCreateBill && (() => {
|
||||||
const af = transaction?.advisory_filter;
|
|
||||||
if (af?.confidence === 'high') {
|
if (af?.confidence === 'high') {
|
||||||
return (
|
return (
|
||||||
<span className="mr-auto flex items-center gap-2 text-xs text-muted-foreground">
|
<span className="mr-auto flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
Loading…
Reference in New Issue