From c2097efc4997bf38593124ed8984939b8b514320 Mon Sep 17 00:00:00 2001 From: null Date: Sat, 4 Jul 2026 19:42:26 -0500 Subject: [PATCH] =?UTF-8?q?refactor(ts):=20convert=20TrackerRow=20+=20Trac?= =?UTF-8?q?kerBucket=20to=20TSX=20(B6)=20=E2=80=94=20tracker/=20dir=20done?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two largest tracker components. TrackerRow (726 ln): typed threshold/ optimistic-actual as branded Dollars, amount_suggestion/autopay_stats now real @/types (AmountSuggestion/AutopayStats) with null-safe `stats?.` access, querySelectorAll for keyboard nav, Date.now()-getTime() arithmetic, drag-handler casts for the motion.tr TableRow. TrackerBucket: typed dragPropsFor/moveControlsFor returns (DragProps/MoveControls), bulkPay result cast, asDollars at every total's fmt boundary. client/components/tracker/ is now 100% .tsx (18 components). typecheck 0, lint 0 errors (40 warns), build green, 48 client tests. Co-Authored-By: Claude Opus 4.8 --- .../{TrackerBucket.jsx => TrackerBucket.tsx} | 73 ++++++++++----- .../{TrackerRow.jsx => TrackerRow.tsx} | 91 ++++++++++++------- client/types.ts | 18 +++- 3 files changed, 123 insertions(+), 59 deletions(-) rename client/components/tracker/{TrackerBucket.jsx => TrackerBucket.tsx} (89%) rename client/components/tracker/{TrackerRow.jsx => TrackerRow.tsx} (90%) diff --git a/client/components/tracker/TrackerBucket.jsx b/client/components/tracker/TrackerBucket.tsx similarity index 89% rename from client/components/tracker/TrackerBucket.jsx rename to client/components/tracker/TrackerBucket.tsx index f1add05..871b975 100644 --- a/client/components/tracker/TrackerBucket.jsx +++ b/client/components/tracker/TrackerBucket.tsx @@ -1,9 +1,10 @@ -import { useState } from 'react'; +import { useState, type ReactNode } from 'react'; import { LayoutGroup } from 'framer-motion'; import { ArrowDown, ArrowUp, CheckCircle2, Loader2 } from 'lucide-react'; import { toast } from 'sonner'; import { api } from '@/api'; -import { cn, fmt } from '@/lib/utils'; +import { cn, fmt, errMessage } from '@/lib/utils'; +import { asDollars } from '@/lib/money'; import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell, } from '@/components/ui/table'; @@ -17,9 +18,19 @@ import { } from '@/lib/trackerUtils'; import { DEFAULT_TRACKER_TABLE_COLUMNS } from '@/lib/trackerTableColumns'; import { TrackerRow as Row } from '@/components/tracker/TrackerRow'; -import { MobileTrackerRow } from '@/components/tracker/MobileTrackerRow'; +import { MobileTrackerRow, type DragProps, type MoveControls } from '@/components/tracker/MobileTrackerRow'; +import type { Payment, TrackerRow } from '@/types'; -function SortableHead({ sortKey, activeSortKey, sortDir, onSort, children, className }) { +interface SortableHeadProps { + sortKey: string; + activeSortKey?: string; + sortDir?: string; + onSort?: (key: string) => void; + children?: ReactNode; + className?: string; +} + +function SortableHead({ sortKey, activeSortKey, sortDir, onSort, children, className }: SortableHeadProps) { const active = activeSortKey === sortKey; const Icon = sortDir === TRACKER_SORT_ASC ? ArrowUp : ArrowDown; return ( @@ -49,6 +60,24 @@ function SortableHead({ sortKey, activeSortKey, sortDir, onSort, children, class ); } +interface TrackerBucketProps { + label: string; + rows: TrackerRow[]; + year: number; + month: number; + refresh: () => void; + onEditBill?: (row: TrackerRow) => void; + loading?: boolean; + onReorderRows?: (rows: TrackerRow[]) => void; + reorderEnabled?: boolean; + movingBillId?: number | null; + sortKey?: string; + sortDir?: string; + onSort?: (key: string) => void; + driftedIds?: Set; + visibleColumns?: string[]; +} + export function TrackerBucket({ label, rows, @@ -63,13 +92,13 @@ export function TrackerBucket({ sortKey = TRACKER_SORT_DEFAULT, sortDir = TRACKER_SORT_ASC, onSort, - driftedIds = new Set(), + driftedIds = new Set(), visibleColumns = DEFAULT_TRACKER_TABLE_COLUMNS, -}) { - const [draggingId, setDraggingId] = useState(null); - const [dropTargetId, setDropTargetId] = useState(null); +}: TrackerBucketProps) { + const [draggingId, setDraggingId] = useState(null); + const [dropTargetId, setDropTargetId] = useState(null); const visibleColumnSet = new Set(visibleColumns); - const showColumn = key => visibleColumnSet.has(key); + const showColumn = (key: string) => visibleColumnSet.has(key); const tableColSpan = 1 + visibleColumns.length; // Use actual_amount (if set) as the per-row threshold; exclude skipped rows from totals const activeRows = rows.filter(r => !r.is_skipped); @@ -107,13 +136,13 @@ export function TrackerBucket({ async function handlePayAllDue() { setPayingAll(true); try { - const result = await api.bulkPay(payAllItems.map(({ name, ...item }) => item)); + const result = await api.bulkPay(payAllItems.map(({ name, ...item }) => item)) as { created?: Payment[] }; const created = result.created || []; setPayAllOpen(false); if (created.length === 0) { toast.info('Those bills were already paid.'); } else { - toast.success(`Paid ${created.length} bill${created.length === 1 ? '' : 's'} — ${fmt(payAllTotal)}`, { + toast.success(`Paid ${created.length} bill${created.length === 1 ? '' : 's'} — ${fmt(asDollars(payAllTotal))}`, { action: { label: 'Undo', onClick: async () => { @@ -122,7 +151,7 @@ export function TrackerBucket({ toast.success('Payments removed'); refresh?.(); } catch (err) { - toast.error(err.message || 'Failed to undo payments.'); + toast.error(errMessage(err, 'Failed to undo payments.')); } }, }, @@ -130,18 +159,18 @@ export function TrackerBucket({ } refresh?.(); } catch (err) { - toast.error(err.message || 'Failed to pay bills.'); + toast.error(errMessage(err, 'Failed to pay bills.')); } finally { setPayingAll(false); } } - function reorderByIndex(fromIndex, toIndex) { + function reorderByIndex(fromIndex: number, toIndex: number) { if (!reorderEnabled || fromIndex === toIndex || fromIndex < 0 || toIndex < 0) return; onReorderRows?.(moveInArray(rows, fromIndex, toIndex)); } - function dragPropsFor(row, index) { + function dragPropsFor(row: TrackerRow, index: number): DragProps { if (!reorderEnabled) return { draggable: false }; return { draggable: true, @@ -175,7 +204,7 @@ export function TrackerBucket({ }; } - function moveControlsFor(row, index) { + function moveControlsFor(row: TrackerRow, index: number): MoveControls { return { enabled: !!reorderEnabled, moving: movingBillId === row.id, @@ -220,16 +249,16 @@ export function TrackerBucket({
- {fmt(totalPaidTowardDue)} + {fmt(asDollars(totalPaidTowardDue))} / - {fmt(totalThreshold)} + {fmt(asDollars(totalThreshold))} {totalOverpaid > 0 && ( - +{fmt(totalOverpaid)} + +{fmt(asDollars(totalOverpaid))} )} {!allPaid && totalRemaining > 0 && ( - {fmt(totalRemaining)} left + {fmt(asDollars(totalRemaining))} left )} {allPaid && ( Done @@ -259,13 +288,13 @@ export function TrackerBucket({ Pay all due in {label}? This records a payment for {payAllItems.length} unpaid bill{payAllItems.length === 1 ? '' : 's'} in this - bucket, totalling {fmt(payAllTotal)}. You can undo it right after. + bucket, totalling {fmt(asDollars(payAllTotal))}. You can undo it right after. Cancel { e.preventDefault(); handlePayAllDue(); }} disabled={payingAll}> - {payingAll ? 'Paying…' : `Pay ${fmt(payAllTotal)}`} + {payingAll ? 'Paying…' : `Pay ${fmt(asDollars(payAllTotal))}`} diff --git a/client/components/tracker/TrackerRow.jsx b/client/components/tracker/TrackerRow.tsx similarity index 90% rename from client/components/tracker/TrackerRow.jsx rename to client/components/tracker/TrackerRow.tsx index afd9df5..144cfb0 100644 --- a/client/components/tracker/TrackerRow.jsx +++ b/client/components/tracker/TrackerRow.tsx @@ -1,9 +1,10 @@ -import React, { useState, useRef, useTransition, useEffect } from 'react'; -import { ArrowDown, ArrowUp, CheckCircle2, Clock, GripVertical, Pencil, TrendingUp, X } from 'lucide-react'; +import { useState, useRef, useTransition, useEffect } from 'react'; +import { ArrowDown, ArrowUp, Clock, GripVertical, Pencil, TrendingUp, X } from 'lucide-react'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; 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, type Dollars } from '@/lib/money'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { @@ -23,25 +24,44 @@ import { PaymentProgress } from '@/components/tracker/PaymentProgress'; import { PaymentLedgerDialog } from '@/components/tracker/PaymentLedgerDialog'; import { NotesCell } from '@/components/tracker/NotesCell'; import { AutopaySuggestionActions } from '@/components/tracker/AutopaySuggestionActions'; +import type { Payment, TrackerRow as TrackerRowData } from '@/types'; +import type { DragProps, MoveControls } from '@/components/tracker/MobileTrackerRow'; -export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveControls, dragProps, isDrifted, visibleColumns = DEFAULT_TRACKER_TABLE_COLUMNS }) { - const amountRef = useRef(null); - const [editPayment, setEditPayment] = useState(null); +// Cast type for the HTML5 drag handlers, which conflict with framer-motion's own +// onDrag* gesture types on the motion.tr-backed TableRow. +type TableRowDrag = React.ComponentProps['onDragStart']; + +interface TrackerRowProps { + row: TrackerRowData; + year: number; + month: number; + refresh: () => void; + index: number; + onEditBill?: (row: TrackerRowData) => void; + moveControls?: MoveControls; + dragProps?: DragProps; + isDrifted?: boolean; + visibleColumns?: string[]; +} + +export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveControls, dragProps, isDrifted, visibleColumns = DEFAULT_TRACKER_TABLE_COLUMNS }: TrackerRowProps) { + const amountRef = useRef(null); + const [editPayment, setEditPayment] = useState(null); const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false); const [showMbs, setShowMbs] = useState(false); const [confirmUnpay, setConfirmUnpay] = useState(false); const [suggestionLoading, setSuggestionLoading] = useState(false); - const [optimisticActual, setOptimisticActual] = useState(undefined); + const [optimisticActual, setOptimisticActual] = useState(undefined); // Optimistic paid/unpaid flip: the row updates instantly on pay/skip and rolls // back on error. Cleared when real data arrives (see effect below). - const [optimisticPaid, setOptimisticPaid] = useState(undefined); + const [optimisticPaid, setOptimisticPaid] = useState(undefined); const [showUpdateNudge, setShowUpdateNudge] = useState(false); - const [nudgeAmount, setNudgeAmount] = useState(null); + const [nudgeAmount, setNudgeAmount] = useState(null); const [, startTransition] = useTransition(); const quickPay = useQuickPay(); const togglePaid = useTogglePaid(year, month); const visibleColumnSet = new Set(visibleColumns); - const showColumn = key => visibleColumnSet.has(key); + const showColumn = (key: string) => visibleColumnSet.has(key); const [editingExpected, setEditingExpected] = useState(false); const [expectedDraft, setExpectedDraft] = useState(''); @@ -61,6 +81,7 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC const serverIsPaid = row.status === 'paid' || (row.status === 'autodraft' && !hasAutopaySuggestion) || isPaidByThreshold; const isPaid = optimisticPaid !== undefined ? optimisticPaid : serverIsPaid; const summary = paymentSummary(row, threshold); + const bankPending = row.bank_pending_count ?? 0; // Effective status to show: // skipped > optimistic flip > paid (threshold-based) > backend status @@ -82,9 +103,9 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC const rowBg = isSkipped ? '' : (ROW_STATUS_CLS[effectiveStatus] || ''); function handleQuickPay() { - 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() { @@ -99,7 +120,7 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC performTogglePaid(); } - function handleRowKeyDown(e) { + function handleRowKeyDown(e: React.KeyboardEvent) { // Only act on the row element itself, not on interactive children if (e.target !== e.currentTarget) return; @@ -107,7 +128,7 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC case 'ArrowDown': case 'j': { e.preventDefault(); - const all = [...document.querySelectorAll('[data-tracker-row]')]; + const all = [...document.querySelectorAll('[data-tracker-row]')]; const next = all[all.indexOf(e.currentTarget) + 1]; next?.focus(); break; @@ -115,7 +136,7 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC case 'ArrowUp': case 'k': { e.preventDefault(); - const all = [...document.querySelectorAll('[data-tracker-row]')]; + const all = [...document.querySelectorAll('[data-tracker-row]')]; const prev = all[all.indexOf(e.currentTarget) - 1]; prev?.focus(); break; @@ -156,7 +177,7 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC refresh?.(); } catch (err) { setOptimisticActual(undefined); - toast.error(err.message || 'Failed to update amount'); + toast.error(errMessage(err, 'Failed to update amount')); } } @@ -169,12 +190,12 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC toast.success(`Default updated to ${fmt(amount)}`); refresh?.(); } catch (err) { - toast.error(err.message || 'Failed to update default'); + toast.error(errMessage(err, 'Failed to update default')); } }); } - async function handleApplySuggestion(amount) { + async function handleApplySuggestion(amount: Dollars) { setOptimisticActual(amount); try { await api.saveBillMonthlyState(row.id, { @@ -186,7 +207,7 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC refresh?.(); } catch (err) { setOptimisticActual(undefined); - toast.error(err.message || 'Failed to apply suggestion'); + toast.error(errMessage(err, 'Failed to apply suggestion')); } } @@ -198,7 +219,7 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC if (val === current) return; if (effectiveActual != null) { - setOptimisticActual(val); + setOptimisticActual(asDollars(val)); try { await api.saveBillMonthlyState(row.id, { year, month, @@ -209,14 +230,14 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC refresh?.(); } catch (err) { setOptimisticActual(undefined); - toast.error(err.message || 'Failed to update amount'); + toast.error(errMessage(err, 'Failed to update amount')); } } else { try { await api.updateBill(row.id, { name: row.name, due_day: row.due_day, expected_amount: val }); refresh?.(); } catch (err) { - toast.error(err.message || 'Failed to update expected amount'); + toast.error(errMessage(err, 'Failed to update expected amount')); } } } @@ -230,18 +251,18 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC await api.updateBill(row.id, { name: row.name, due_day: day, expected_amount: row.expected_amount }); refresh?.(); } catch (err) { - toast.error(err.message || 'Failed to update due date'); + toast.error(errMessage(err, 'Failed to update due date')); } } 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); } @@ -254,7 +275,7 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC 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); } @@ -271,10 +292,10 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC aria-label={`${row.name}, ${effectiveStatus}, ${row.due_day ? `due ${row.due_day}` : ''}`} onKeyDown={handleRowKeyDown} draggable={dragProps?.draggable} - onDragStart={dragProps?.onDragStart} + onDragStart={dragProps?.onDragStart as TableRowDrag} onDragEnter={dragProps?.onDragEnter} onDragOver={dragProps?.onDragOver} - onDragEnd={dragProps?.onDragEnd} + onDragEnd={dragProps?.onDragEnd as TableRowDrag} onDrop={dragProps?.onDrop} className={cn( 'group border-border/65 transition-colors duration-150', @@ -344,10 +365,10 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC {row.autopay_enabled && (() => { const stats = row.autopay_stats; - const hasFailed = stats && stats.failures > 0; + const hasFailed = !!(stats && stats.failures > 0); const verifiedAt = row.autopay_verified_at ? new Date(row.autopay_verified_at) : null; const daysSinceVerify = verifiedAt - ? Math.floor((Date.now() - verifiedAt) / 86400000) + ? Math.floor((Date.now() - verifiedAt.getTime()) / 86400000) : null; const needsVerify = daysSinceVerify === null || daysSinceVerify > 90; const badgeCls = hasFailed @@ -369,10 +390,10 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC ) : (

Autopay enabled

)} - {hasFailed && stats.last_failure_date && ( + {hasFailed && stats?.last_failure_date && (

Last failed: {stats.last_failure_date}

)} - {hasFailed && stats.last_failure_notes && ( + {hasFailed && stats?.last_failure_notes && (

{stats.last_failure_notes}

)} {needsVerify ? ( @@ -523,7 +544,7 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC Math.abs(row.amount_suggestion.suggestion - row.expected_amount) / row.expected_amount > 0.05 && (