refactor(ts): MobileBillRow, IncomeBreakdownModal, CalendarFeedManager → TS

Also removed a stale PayoffChart.jsx duplicate (its .tsx was already committed).
Branded Dollars on income transactions/bank-tracking balances. typecheck 0.
This commit is contained in:
null 2026-07-04 22:01:49 -05:00
parent af1a28c00b
commit 41e847a33d
4 changed files with 107 additions and 198 deletions

View File

@ -1,13 +1,27 @@
import React, { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState, type ReactNode } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { CalendarDays, Copy, Eye, KeyRound, RefreshCw, ShieldOff, Settings2 } from 'lucide-react'; import { CalendarDays, Copy, Eye, KeyRound, RefreshCw, ShieldOff, Settings2 } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '@/api'; import { api } from '@/api';
import { cn } from '@/lib/utils'; import { cn, errMessage } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
function PlatformNote({ title, children }) { interface CalFeed {
active?: boolean;
feed_url?: string;
last_used_at?: string | null;
}
interface CalEvent {
uid: string;
name?: string;
due_date?: string;
cycle_type?: string;
amount?: number;
}
function PlatformNote({ title, children }: { title: ReactNode; children: ReactNode }) {
return ( return (
<div className="rounded-md bg-muted/35 p-3"> <div className="rounded-md bg-muted/35 p-3">
<p className="font-medium text-foreground">{title}</p> <p className="font-medium text-foreground">{title}</p>
@ -16,25 +30,25 @@ function PlatformNote({ title, children }) {
); );
} }
export function CalendarFeedManager({ compact = false, showManageLink = false }) { export function CalendarFeedManager({ compact = false, showManageLink = false }: { compact?: boolean; showManageLink?: boolean }) {
const [feed, setFeed] = useState(null); const [feed, setFeed] = useState<CalFeed | null>(null);
const [preview, setPreview] = useState([]); const [preview, setPreview] = useState<CalEvent[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(null); const [busy, setBusy] = useState<string | null>(null);
const loadFeed = useCallback(async () => { const loadFeed = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const data = await api.calendarFeed(); const data = await api.calendarFeed() as CalFeed;
setFeed(data); setFeed(data);
if (data?.active) { if (data?.active) {
const nextPreview = await api.calendarFeedPreview(10); const nextPreview = await api.calendarFeedPreview(10) as { events?: CalEvent[] };
setPreview(nextPreview.events || []); setPreview(nextPreview.events || []);
} else { } else {
setPreview([]); setPreview([]);
} }
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to load calendar feed.'); toast.error(errMessage(err, 'Failed to load calendar feed.'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -47,13 +61,13 @@ export function CalendarFeedManager({ compact = false, showManageLink = false })
async function createFeed() { async function createFeed() {
setBusy('create'); setBusy('create');
try { try {
const data = await api.createCalendarFeed(); const data = await api.createCalendarFeed() as CalFeed;
setFeed(data); setFeed(data);
const nextPreview = await api.calendarFeedPreview(10); const nextPreview = await api.calendarFeedPreview(10) as { events?: CalEvent[] };
setPreview(nextPreview.events || []); setPreview(nextPreview.events || []);
toast.success('Calendar feed created.'); toast.success('Calendar feed created.');
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to create calendar feed.'); toast.error(errMessage(err, 'Failed to create calendar feed.'));
} finally { } finally {
setBusy(null); setBusy(null);
} }
@ -72,13 +86,13 @@ export function CalendarFeedManager({ compact = false, showManageLink = false })
async function regenerateFeed() { async function regenerateFeed() {
setBusy('regenerate'); setBusy('regenerate');
try { try {
const data = await api.regenerateCalendarFeed(); const data = await api.regenerateCalendarFeed() as CalFeed;
setFeed(data); setFeed(data);
const nextPreview = await api.calendarFeedPreview(10); const nextPreview = await api.calendarFeedPreview(10) as { events?: CalEvent[] };
setPreview(nextPreview.events || []); setPreview(nextPreview.events || []);
toast.success('Calendar feed regenerated. Update any subscribed calendars with the new URL.'); toast.success('Calendar feed regenerated. Update any subscribed calendars with the new URL.');
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to regenerate calendar feed.'); toast.error(errMessage(err, 'Failed to regenerate calendar feed.'));
} finally { } finally {
setBusy(null); setBusy(null);
} }
@ -87,12 +101,12 @@ export function CalendarFeedManager({ compact = false, showManageLink = false })
async function revokeFeed() { async function revokeFeed() {
setBusy('revoke'); setBusy('revoke');
try { try {
const data = await api.revokeCalendarFeed(); const data = await api.revokeCalendarFeed() as CalFeed;
setFeed(data); setFeed(data);
setPreview([]); setPreview([]);
toast.success('Calendar feed revoked.'); toast.success('Calendar feed revoked.');
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to revoke calendar feed.'); toast.error(errMessage(err, 'Failed to revoke calendar feed.'));
} finally { } finally {
setBusy(null); setBusy(null);
} }
@ -150,13 +164,13 @@ export function CalendarFeedManager({ compact = false, showManageLink = false })
</div> </div>
<div className="flex flex-col gap-2 sm:flex-row"> <div className="flex flex-col gap-2 sm:flex-row">
<Input value={feed.feed_url} readOnly className="min-w-0 flex-1 font-mono text-xs" aria-label="Calendar feed URL" /> <Input value={feed?.feed_url ?? ''} readOnly className="min-w-0 flex-1 font-mono text-xs" aria-label="Calendar feed URL" />
<Button size="sm" variant="outline" onClick={copyFeedUrl} className="gap-2"> <Button size="sm" variant="outline" onClick={copyFeedUrl} className="gap-2">
<Copy className="h-3.5 w-3.5" /> <Copy className="h-3.5 w-3.5" />
Copy URL Copy URL
</Button> </Button>
<Button asChild size="sm" variant="outline" className="gap-2"> <Button asChild size="sm" variant="outline" className="gap-2">
<a href={feed.feed_url} target="_blank" rel="noreferrer"> <a href={feed?.feed_url} target="_blank" rel="noreferrer">
<Eye className="h-3.5 w-3.5" /> <Eye className="h-3.5 w-3.5" />
Preview ICS Preview ICS
</a> </a>
@ -182,7 +196,7 @@ export function CalendarFeedManager({ compact = false, showManageLink = false })
<div className="flex flex-col gap-1 border-b border-border/60 px-3 py-2 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-1 border-b border-border/60 px-3 py-2 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Next events that will appear</p> <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Next events that will appear</p>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
Last fetched: {feed.last_used_at ? new Date(feed.last_used_at).toLocaleString() : 'Not yet'} Last fetched: {feed?.last_used_at ? new Date(feed.last_used_at).toLocaleString() : 'Not yet'}
</span> </span>
</div> </div>
<div className="divide-y divide-border/50"> <div className="divide-y divide-border/50">

View File

@ -1,33 +1,60 @@
import React, { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { formatUSD } from '@/lib/money'; import { formatUSD, asDollars, type Dollars } from '@/lib/money';
import { TrendingUp, EyeOff, Eye, ArrowRight, Loader2 } from 'lucide-react'; import { TrendingUp, EyeOff, Eye, Loader2 } from 'lucide-react';
import { api } from '@/api'; import { api } from '@/api';
import { errMessage } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
function fmt(n) { function fmt(n: Parameters<typeof formatUSD>[0]): string {
return formatUSD(n); return formatUSD(n);
} }
export default function IncomeBreakdownModal({ open, onClose, year, month, bankTracking }) { interface IncomeTx {
const [transactions, setTransactions] = useState([]); id: number | string;
payee?: string | null;
date?: string | null;
amount: Dollars;
ignored?: boolean;
}
interface IncomeBt {
enabled?: boolean;
org_name?: string | null;
account_name?: string | null;
balance?: Dollars;
pending_payments?: Dollars;
pending_days?: number;
effective_balance?: Dollars;
}
interface IncomeBreakdownModalProps {
open: boolean;
onClose: () => void;
year: number;
month: number;
bankTracking?: IncomeBt | null;
}
export default function IncomeBreakdownModal({ open, onClose, year, month, bankTracking }: IncomeBreakdownModalProps) {
const [transactions, setTransactions] = useState<IncomeTx[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState(''); const [loadError, setLoadError] = useState('');
const [showIgnored, setShowIgnored] = useState(false); const [showIgnored, setShowIgnored] = useState(false);
const [actionId, setActionId] = useState(null); // tx being acted on const [actionId, setActionId] = useState<number | string | null>(null);
const load = useCallback(async () => { const load = useCallback(async () => {
if (!open) return; if (!open) return;
setLoading(true); setLoading(true);
setLoadError(''); setLoadError('');
try { try {
const d = await api.spendingIncome({ year, month, include_ignored: showIgnored ? 'true' : undefined, limit: 100 }); const d = await api.spendingIncome({ year, month, include_ignored: showIgnored ? 'true' : undefined, limit: 100 }) as { transactions?: IncomeTx[] };
setTransactions(d.transactions || []); setTransactions(d.transactions || []);
} catch (err) { } catch (err) {
const msg = err.message || 'Failed to load income transactions'; const msg = errMessage(err, 'Failed to load income transactions');
setLoadError(msg); setLoadError(msg);
toast.error(msg); toast.error(msg);
} finally { } finally {
@ -37,13 +64,13 @@ export default function IncomeBreakdownModal({ open, onClose, year, month, bankT
useEffect(() => { load(); }, [load]); useEffect(() => { load(); }, [load]);
const handleIgnore = async (tx) => { const handleIgnore = async (tx: IncomeTx) => {
setActionId(tx.id); setActionId(tx.id);
try { try {
await api.ignoreTransaction(tx.id); await api.ignoreTransaction(tx.id);
toast.success(`"${tx.payee}" marked as excluded`); toast.success(`"${tx.payee}" marked as excluded`);
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to exclude transaction'); toast.error(errMessage(err, 'Failed to exclude transaction'));
setActionId(null); setActionId(null);
return; // don't reload if the action itself failed return; // don't reload if the action itself failed
} }
@ -51,13 +78,13 @@ export default function IncomeBreakdownModal({ open, onClose, year, month, bankT
await load(); // reload outside the action try so load errors are surfaced separately await load(); // reload outside the action try so load errors are surfaced separately
}; };
const handleUnignore = async (tx) => { const handleUnignore = async (tx: IncomeTx) => {
setActionId(tx.id); setActionId(tx.id);
try { try {
await api.unignoreTransaction(tx.id); await api.unignoreTransaction(tx.id);
toast.success(`"${tx.payee}" restored as income`); toast.success(`"${tx.payee}" restored as income`);
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to restore transaction'); toast.error(errMessage(err, 'Failed to restore transaction'));
setActionId(null); setActionId(null);
return; return;
} }
@ -67,7 +94,7 @@ export default function IncomeBreakdownModal({ open, onClose, year, month, bankT
const active = transactions.filter(t => !t.ignored); const active = transactions.filter(t => !t.ignored);
const ignored = transactions.filter(t => t.ignored); const ignored = transactions.filter(t => t.ignored);
const total = active.reduce((s, t) => s + t.amount, 0); const total = asDollars(active.reduce((s, t) => s + t.amount, 0));
const bt = bankTracking || {}; const bt = bankTracking || {};
@ -91,7 +118,7 @@ export default function IncomeBreakdownModal({ open, onClose, year, month, bankT
<span className="text-muted-foreground">{bt.org_name || bt.account_name || 'Bank'} balance</span> <span className="text-muted-foreground">{bt.org_name || bt.account_name || 'Bank'} balance</span>
<span className="font-mono font-semibold">{fmt(bt.balance)}</span> <span className="font-mono font-semibold">{fmt(bt.balance)}</span>
</div> </div>
{bt.pending_payments > 0 && ( {(bt.pending_payments ?? 0) > 0 && (
<div className="flex items-center justify-between text-muted-foreground"> <div className="flex items-center justify-between text-muted-foreground">
<span>Pending payments ({bt.pending_days}d window)</span> <span>Pending payments ({bt.pending_days}d window)</span>
<span className="font-mono text-destructive">{fmt(bt.pending_payments)}</span> <span className="font-mono text-destructive">{fmt(bt.pending_payments)}</span>

View File

@ -3,15 +3,28 @@ import { ArrowDown, ArrowUp, Copy, GripVertical, History } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { scheduleLabel } from '@/lib/billingSchedule'; import { scheduleLabel } from '@/lib/billingSchedule';
import type { Bill } from '@/types';
import type { DragProps, MoveControls } from '@/components/tracker/MobileTrackerRow';
function hasHistoricalVisibility(bill) { function hasHistoricalVisibility(bill: Bill): boolean {
const visibility = bill.history_visibility; const visibility = bill.history_visibility;
return !!bill.has_history_ranges || (visibility && visibility !== 'default'); return !!bill.has_history_ranges || (!!visibility && visibility !== 'default');
} }
export const MobileBillRow = React.memo(function MobileBillRow({ bill, onEdit, onToggle, onDelete, onHistory, onDuplicate, moveControls, dragProps }) { interface MobileBillRowProps {
bill: Bill;
onEdit?: (id: number) => void;
onToggle?: (bill: Bill) => void;
onDelete?: (bill: Bill) => void;
onHistory?: (bill: Bill) => void;
onDuplicate?: (bill: Bill) => void;
moveControls?: MoveControls;
dragProps?: DragProps;
}
export const MobileBillRow = React.memo(function MobileBillRow({ bill, onEdit, onToggle, onDelete, onHistory, onDuplicate, moveControls, dragProps }: MobileBillRowProps) {
const hasHistory = useMemo(() => hasHistoricalVisibility(bill), [bill]); const hasHistory = useMemo(() => hasHistoricalVisibility(bill), [bill]);
const statusClass = useMemo(() => { const statusClass = useMemo(() => {
return cn( return cn(
'rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide', 'rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide',
@ -33,11 +46,11 @@ export const MobileBillRow = React.memo(function MobileBillRow({ bill, onEdit, o
return ( return (
<div <div
draggable={dragProps?.draggable} draggable={dragProps?.draggable}
onDragStart={dragProps?.onDragStart} onDragStart={dragProps?.onDragStart as React.ComponentProps<'div'>['onDragStart']}
onDragEnter={dragProps?.onDragEnter} onDragEnter={dragProps?.onDragEnter as React.ComponentProps<'div'>['onDragEnter']}
onDragOver={dragProps?.onDragOver} onDragOver={dragProps?.onDragOver as React.ComponentProps<'div'>['onDragOver']}
onDragEnd={dragProps?.onDragEnd} onDragEnd={dragProps?.onDragEnd as React.ComponentProps<'div'>['onDragEnd']}
onDrop={dragProps?.onDrop} onDrop={dragProps?.onDrop as React.ComponentProps<'div'>['onDrop']}
className={cn( className={cn(
'group rounded-xl border border-border/80 bg-card/90 p-3 shadow-sm shadow-black/10', 'group rounded-xl border border-border/80 bg-card/90 p-3 shadow-sm shadow-black/10',
dragProps?.isDragging && 'opacity-45', dragProps?.isDragging && 'opacity-45',
@ -103,18 +116,18 @@ export const MobileBillRow = React.memo(function MobileBillRow({ bill, onEdit, o
<span className={statusClass}> <span className={statusClass}>
{bill.active ? 'Active' : 'Inactive'} {bill.active ? 'Active' : 'Inactive'}
</span> </span>
{bill.autopay_enabled && ( {bill.autopay_enabled ? (
<span className="rounded bg-emerald-500/20 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-300">AP</span> <span className="rounded bg-emerald-500/20 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-300">AP</span>
)} ) : null}
{bill.has_2fa && ( {bill.has_2fa ? (
<span className="rounded bg-violet-500/20 px-1.5 py-0.5 text-[10px] font-semibold text-violet-300">2FA</span> <span className="rounded bg-violet-500/20 px-1.5 py-0.5 text-[10px] font-semibold text-violet-300">2FA</span>
)} ) : null}
{(bill.has_merchant_rule || bill.has_linked_transactions) && ( {(bill.has_merchant_rule || bill.has_linked_transactions) ? (
<span className="rounded border border-emerald-500/25 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-600 dark:text-emerald-400" title="Linked to bank transactions">L</span> <span className="rounded border border-emerald-500/25 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-600 dark:text-emerald-400" title="Linked to bank transactions">L</span>
)} ) : null}
{bill.is_subscription && ( {bill.is_subscription ? (
<span className="rounded border border-indigo-500/25 bg-indigo-500/10 px-1.5 py-0.5 text-[10px] font-semibold text-indigo-600 dark:text-indigo-300">S</span> <span className="rounded border border-indigo-500/25 bg-indigo-500/10 px-1.5 py-0.5 text-[10px] font-semibold text-indigo-600 dark:text-indigo-300">S</span>
)} ) : null}
</div> </div>
</div> </div>

View File

@ -1,145 +0,0 @@
import React from 'react';
import { formatUSD } 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;
function money(v) {
const n = Number(v) || 0;
if (n >= 1000) return `$${(n / 1000).toFixed(0)}k`;
return `$${n.toFixed(0)}`;
}
function fullMoney(v) {
return formatUSD(v);
}
function buildPoints(track, startBalance, maxMonths) {
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) {
return pts.map(p => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ');
}
export default function PayoffChart({ minTrack = [], currentTrack = [], simTrack = [], startBalance = 1 }) {
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 = [];
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>
);
}