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:
null 2026-07-04 20:39:30 -05:00
parent f34f03af51
commit 6a9b8b3cb8
5 changed files with 315 additions and 52 deletions

View File

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

View File

@ -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));

View File

@ -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>

View File

@ -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]);

View File

@ -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">