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 { toast } from 'sonner';
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 { Input } from '@/components/ui/input';
import {
@ -19,8 +20,9 @@ import { LowerThisMonthButton } from '@/components/tracker/LowerThisMonthButton'
import { PaymentLedgerDialog } from '@/components/tracker/PaymentLedgerDialog';
import { NotesCell } from '@/components/tracker/NotesCell';
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;
const min = Math.min(...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 }) {
const amountRef = useRef(null);
const [editPayment, setEditPayment] = useState(null);
// HTML5 drag-and-drop wiring supplied by the parent bucket. Cast onto motion.div
// (framer-motion redefines its own onDrag* gesture handlers, which conflict).
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 [confirmUnpay, setConfirmUnpay] = 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);
// Optimistic paid/unpaid flip — instant on tap, rolled back on error, cleared
// 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 defaultPaymentDate = paymentDateForTrackerMonth(year, month, row.due_day);
@ -78,9 +114,9 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill,
function handleQuickPay() {
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; }
quickPay.run(row, val, defaultPaymentDate);
quickPay.run(row, asDollars(val), defaultPaymentDate);
}
function performTogglePaid() {
@ -98,11 +134,11 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill,
async function handleConfirmSuggestion() {
setSuggestionLoading(true);
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');
refresh();
} catch (err) {
toast.error(err.message || 'Failed to confirm autopay suggestion');
toast.error(errMessage(err, 'Failed to confirm autopay suggestion'));
} finally {
setSuggestionLoading(false);
}
@ -115,7 +151,7 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill,
toast.success('Autopay suggestion dismissed');
refresh();
} catch (err) {
toast.error(err.message || 'Failed to dismiss autopay suggestion');
toast.error(errMessage(err, 'Failed to dismiss autopay suggestion'));
} finally {
setSuggestionLoading(false);
}
@ -127,10 +163,10 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill,
layout="position"
transition={{ layout: { duration: 0.2, ease: [0.22, 1, 0.36, 1] } }}
draggable={dragProps?.draggable}
onDragStart={dragProps?.onDragStart}
onDragStart={dragProps?.onDragStart as React.ComponentProps<typeof motion.div>['onDragStart']}
onDragEnter={dragProps?.onDragEnter}
onDragOver={dragProps?.onDragOver}
onDragEnd={dragProps?.onDragEnd}
onDragEnd={dragProps?.onDragEnd as React.ComponentProps<typeof motion.div>['onDragEnd']}
onDrop={dragProps?.onDrop}
className={cn(
'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>
<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')}>
{fmt(remaining)}
{fmt(asDollars(remaining))}
</p>
</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])
);
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]',
autodraft: 'bg-sky-500/[0.04] dark:bg-sky-400/[0.018]',
upcoming: '',

View File

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