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:
parent
ee31513dc9
commit
f4f1dadaab
|
|
@ -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>
|
||||||
|
|
@ -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: '',
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue