refactor(ts): convert MobileTrackerRow to TSX (B5)

The mobile tracker row (consumes branded TrackerRow + shared payment hooks).
Supporting: TrackerRow.sparkline typed number[]|null; ROW_STATUS_CLS typed
Record<string,string> (it has no `skipped` key, so indexing by TrackerStatus
needed a string index). DragProps/MoveControls interfaces exported. Notable: the
HTML5 drag handlers conflict with framer-motion's own onDrag* gesture types on
motion.div — cast via ComponentProps<typeof motion.div>; quick-pay/remaining
amounts need asDollars at the boundary. typecheck 0, build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-04 19:36:01 -05:00
parent ee31513dc9
commit f4f1dadaab
3 changed files with 52 additions and 16 deletions

View File

@ -3,7 +3,8 @@ import { motion } from 'framer-motion';
import { ArrowDown, ArrowUp, GripVertical, Pencil, TrendingUp } from 'lucide-react'; import { ArrowDown, ArrowUp, GripVertical, Pencil, TrendingUp } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '@/api'; import { api } from '@/api';
import { cn, fmt, fmtDate } from '@/lib/utils'; import { cn, fmt, fmtDate, errMessage } from '@/lib/utils';
import { asDollars } from '@/lib/money';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { import {
@ -19,8 +20,9 @@ import { LowerThisMonthButton } from '@/components/tracker/LowerThisMonthButton'
import { PaymentLedgerDialog } from '@/components/tracker/PaymentLedgerDialog'; import { PaymentLedgerDialog } from '@/components/tracker/PaymentLedgerDialog';
import { NotesCell } from '@/components/tracker/NotesCell'; import { NotesCell } from '@/components/tracker/NotesCell';
import { AutopaySuggestionActions } from '@/components/tracker/AutopaySuggestionActions'; import { AutopaySuggestionActions } from '@/components/tracker/AutopaySuggestionActions';
import type { Payment, TrackerRow } from '@/types';
function MiniSparkline({ values }) { function MiniSparkline({ values }: { values?: number[] | null }) {
if (!values || values.length < 2) return null; if (!values || values.length < 2) return null;
const min = Math.min(...values); const min = Math.min(...values);
const max = Math.max(...values); const max = Math.max(...values);
@ -40,9 +42,43 @@ function MiniSparkline({ values }) {
); );
} }
export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill, moveControls, dragProps, isDrifted = false }) { // HTML5 drag-and-drop wiring supplied by the parent bucket. Cast onto motion.div
const amountRef = useRef(null); // (framer-motion redefines its own onDrag* gesture handlers, which conflict).
const [editPayment, setEditPayment] = useState(null); export interface DragProps {
draggable?: boolean;
onDragStart?: React.DragEventHandler<HTMLDivElement>;
onDragEnter?: React.DragEventHandler<HTMLDivElement>;
onDragOver?: React.DragEventHandler<HTMLDivElement>;
onDragEnd?: React.DragEventHandler<HTMLDivElement>;
onDrop?: React.DragEventHandler<HTMLDivElement>;
isDragging?: boolean;
isDropTarget?: boolean;
}
export interface MoveControls {
enabled?: boolean;
onMoveUp?: () => void;
onMoveDown?: () => void;
canMoveUp?: boolean;
canMoveDown?: boolean;
moving?: boolean;
}
interface MobileTrackerRowProps {
row: TrackerRow;
year: number;
month: number;
refresh: () => void;
index: number;
onEditBill?: (row: TrackerRow) => void;
moveControls?: MoveControls;
dragProps?: DragProps;
isDrifted?: boolean;
}
export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill, moveControls, dragProps, isDrifted = false }: MobileTrackerRowProps) {
const amountRef = useRef<HTMLInputElement>(null);
const [editPayment, setEditPayment] = useState<Payment | null>(null);
const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false); const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false);
const [confirmUnpay, setConfirmUnpay] = useState(false); const [confirmUnpay, setConfirmUnpay] = useState(false);
const [suggestionLoading, setSuggestionLoading] = useState(false); const [suggestionLoading, setSuggestionLoading] = useState(false);
@ -50,7 +86,7 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill,
const togglePaid = useTogglePaid(year, month); const togglePaid = useTogglePaid(year, month);
// Optimistic paid/unpaid flip — instant on tap, rolled back on error, cleared // Optimistic paid/unpaid flip — instant on tap, rolled back on error, cleared
// when fresh server data arrives (effect below). // when fresh server data arrives (effect below).
const [optimisticPaid, setOptimisticPaid] = useState(undefined); const [optimisticPaid, setOptimisticPaid] = useState<boolean | undefined>(undefined);
const threshold = row.actual_amount != null ? row.actual_amount : row.expected_amount; const threshold = row.actual_amount != null ? row.actual_amount : row.expected_amount;
const defaultPaymentDate = paymentDateForTrackerMonth(year, month, row.due_day); const defaultPaymentDate = paymentDateForTrackerMonth(year, month, row.due_day);
@ -78,9 +114,9 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill,
function handleQuickPay() { function handleQuickPay() {
if (quickPay.isPending) return; if (quickPay.isPending) return;
const val = parseFloat(amountRef.current?.value); const val = parseFloat(amountRef.current?.value ?? '');
if (!val || val <= 0) { toast.error('Enter a payment amount'); return; } if (!val || val <= 0) { toast.error('Enter a payment amount'); return; }
quickPay.run(row, val, defaultPaymentDate); quickPay.run(row, asDollars(val), defaultPaymentDate);
} }
function performTogglePaid() { function performTogglePaid() {
@ -98,11 +134,11 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill,
async function handleConfirmSuggestion() { async function handleConfirmSuggestion() {
setSuggestionLoading(true); setSuggestionLoading(true);
try { try {
const result = await api.confirmAutopaySuggestion(row.id, { year, month }); const result = await api.confirmAutopaySuggestion(row.id, { year, month }) as { created?: boolean };
toast.success(result.created ? 'Autopay payment confirmed' : 'Autopay already recorded'); toast.success(result.created ? 'Autopay payment confirmed' : 'Autopay already recorded');
refresh(); refresh();
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to confirm autopay suggestion'); toast.error(errMessage(err, 'Failed to confirm autopay suggestion'));
} finally { } finally {
setSuggestionLoading(false); setSuggestionLoading(false);
} }
@ -115,7 +151,7 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill,
toast.success('Autopay suggestion dismissed'); toast.success('Autopay suggestion dismissed');
refresh(); refresh();
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to dismiss autopay suggestion'); toast.error(errMessage(err, 'Failed to dismiss autopay suggestion'));
} finally { } finally {
setSuggestionLoading(false); setSuggestionLoading(false);
} }
@ -127,10 +163,10 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill,
layout="position" layout="position"
transition={{ layout: { duration: 0.2, ease: [0.22, 1, 0.36, 1] } }} transition={{ layout: { duration: 0.2, ease: [0.22, 1, 0.36, 1] } }}
draggable={dragProps?.draggable} draggable={dragProps?.draggable}
onDragStart={dragProps?.onDragStart} onDragStart={dragProps?.onDragStart as React.ComponentProps<typeof motion.div>['onDragStart']}
onDragEnter={dragProps?.onDragEnter} onDragEnter={dragProps?.onDragEnter}
onDragOver={dragProps?.onDragOver} onDragOver={dragProps?.onDragOver}
onDragEnd={dragProps?.onDragEnd} onDragEnd={dragProps?.onDragEnd as React.ComponentProps<typeof motion.div>['onDragEnd']}
onDrop={dragProps?.onDrop} onDrop={dragProps?.onDrop}
className={cn( className={cn(
'rounded-lg border border-border/60 bg-background/60 p-3 shadow-sm', 'rounded-lg border border-border/60 bg-background/60 p-3 shadow-sm',
@ -267,7 +303,7 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill,
<div> <div>
<p className="uppercase tracking-wide text-muted-foreground/60">Remaining</p> <p className="uppercase tracking-wide text-muted-foreground/60">Remaining</p>
<p className={cn('tracker-number mt-0.5 text-sm font-semibold', remaining > 0 ? 'text-foreground' : 'text-emerald-300')}> <p className={cn('tracker-number mt-0.5 text-sm font-semibold', remaining > 0 ? 'text-foreground' : 'text-emerald-300')}>
{fmt(remaining)} {fmt(asDollars(remaining))}
</p> </p>
</div> </div>
</div> </div>

View File

@ -40,7 +40,7 @@ export const TRACKER_SORT_DEFAULT_DIRS = Object.fromEntries(
TRACKER_SORT_OPTIONS.map(option => [option.key, option.defaultDir]) TRACKER_SORT_OPTIONS.map(option => [option.key, option.defaultDir])
); );
export const ROW_STATUS_CLS = { export const ROW_STATUS_CLS: Record<string, string> = {
paid: 'bg-emerald-500/[0.04] dark:bg-emerald-400/[0.02]', paid: 'bg-emerald-500/[0.04] dark:bg-emerald-400/[0.02]',
autodraft: 'bg-sky-500/[0.04] dark:bg-sky-400/[0.018]', autodraft: 'bg-sky-500/[0.04] dark:bg-sky-400/[0.018]',
upcoming: '', upcoming: '',

View File

@ -168,7 +168,7 @@ export interface TrackerRow {
autopay_verified_at: string | null; autopay_verified_at: string | null;
inactive_reason: string | null; inactive_reason: string | null;
inactivated_at: string | null; inactivated_at: string | null;
sparkline: unknown; sparkline: number[] | null;
autopay_stats: unknown; autopay_stats: unknown;
payments: Payment[]; payments: Payment[];
// Augmented by getTracker: // Augmented by getTracker: