refactor(ts): convert TrackerRow + TrackerBucket to TSX (B6) — tracker/ dir done
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<HTMLElement> 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 <noreply@anthropic.com>
This commit is contained in:
parent
f4f1dadaab
commit
c2097efc49
|
|
@ -1,9 +1,10 @@
|
||||||
import { useState } from 'react';
|
import { useState, type ReactNode } from 'react';
|
||||||
import { LayoutGroup } from 'framer-motion';
|
import { LayoutGroup } from 'framer-motion';
|
||||||
import { ArrowDown, ArrowUp, CheckCircle2, Loader2 } from 'lucide-react';
|
import { ArrowDown, ArrowUp, CheckCircle2, Loader2 } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api';
|
||||||
import { cn, fmt } from '@/lib/utils';
|
import { cn, fmt, errMessage } from '@/lib/utils';
|
||||||
|
import { asDollars } from '@/lib/money';
|
||||||
import {
|
import {
|
||||||
Table, TableHeader, TableBody, TableHead, TableRow, TableCell,
|
Table, TableHeader, TableBody, TableHead, TableRow, TableCell,
|
||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
|
|
@ -17,9 +18,19 @@ import {
|
||||||
} from '@/lib/trackerUtils';
|
} from '@/lib/trackerUtils';
|
||||||
import { DEFAULT_TRACKER_TABLE_COLUMNS } from '@/lib/trackerTableColumns';
|
import { DEFAULT_TRACKER_TABLE_COLUMNS } from '@/lib/trackerTableColumns';
|
||||||
import { TrackerRow as Row } from '@/components/tracker/TrackerRow';
|
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 active = activeSortKey === sortKey;
|
||||||
const Icon = sortDir === TRACKER_SORT_ASC ? ArrowUp : ArrowDown;
|
const Icon = sortDir === TRACKER_SORT_ASC ? ArrowUp : ArrowDown;
|
||||||
return (
|
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<number>;
|
||||||
|
visibleColumns?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export function TrackerBucket({
|
export function TrackerBucket({
|
||||||
label,
|
label,
|
||||||
rows,
|
rows,
|
||||||
|
|
@ -63,13 +92,13 @@ export function TrackerBucket({
|
||||||
sortKey = TRACKER_SORT_DEFAULT,
|
sortKey = TRACKER_SORT_DEFAULT,
|
||||||
sortDir = TRACKER_SORT_ASC,
|
sortDir = TRACKER_SORT_ASC,
|
||||||
onSort,
|
onSort,
|
||||||
driftedIds = new Set(),
|
driftedIds = new Set<number>(),
|
||||||
visibleColumns = DEFAULT_TRACKER_TABLE_COLUMNS,
|
visibleColumns = DEFAULT_TRACKER_TABLE_COLUMNS,
|
||||||
}) {
|
}: TrackerBucketProps) {
|
||||||
const [draggingId, setDraggingId] = useState(null);
|
const [draggingId, setDraggingId] = useState<number | null>(null);
|
||||||
const [dropTargetId, setDropTargetId] = useState(null);
|
const [dropTargetId, setDropTargetId] = useState<number | null>(null);
|
||||||
const visibleColumnSet = new Set(visibleColumns);
|
const visibleColumnSet = new Set(visibleColumns);
|
||||||
const showColumn = key => visibleColumnSet.has(key);
|
const showColumn = (key: string) => visibleColumnSet.has(key);
|
||||||
const tableColSpan = 1 + visibleColumns.length;
|
const tableColSpan = 1 + visibleColumns.length;
|
||||||
// Use actual_amount (if set) as the per-row threshold; exclude skipped rows from totals
|
// Use actual_amount (if set) as the per-row threshold; exclude skipped rows from totals
|
||||||
const activeRows = rows.filter(r => !r.is_skipped);
|
const activeRows = rows.filter(r => !r.is_skipped);
|
||||||
|
|
@ -107,13 +136,13 @@ export function TrackerBucket({
|
||||||
async function handlePayAllDue() {
|
async function handlePayAllDue() {
|
||||||
setPayingAll(true);
|
setPayingAll(true);
|
||||||
try {
|
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 || [];
|
const created = result.created || [];
|
||||||
setPayAllOpen(false);
|
setPayAllOpen(false);
|
||||||
if (created.length === 0) {
|
if (created.length === 0) {
|
||||||
toast.info('Those bills were already paid.');
|
toast.info('Those bills were already paid.');
|
||||||
} else {
|
} 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: {
|
action: {
|
||||||
label: 'Undo',
|
label: 'Undo',
|
||||||
onClick: async () => {
|
onClick: async () => {
|
||||||
|
|
@ -122,7 +151,7 @@ export function TrackerBucket({
|
||||||
toast.success('Payments removed');
|
toast.success('Payments removed');
|
||||||
refresh?.();
|
refresh?.();
|
||||||
} catch (err) {
|
} 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?.();
|
refresh?.();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to pay bills.');
|
toast.error(errMessage(err, 'Failed to pay bills.'));
|
||||||
} finally {
|
} finally {
|
||||||
setPayingAll(false);
|
setPayingAll(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function reorderByIndex(fromIndex, toIndex) {
|
function reorderByIndex(fromIndex: number, toIndex: number) {
|
||||||
if (!reorderEnabled || fromIndex === toIndex || fromIndex < 0 || toIndex < 0) return;
|
if (!reorderEnabled || fromIndex === toIndex || fromIndex < 0 || toIndex < 0) return;
|
||||||
onReorderRows?.(moveInArray(rows, fromIndex, toIndex));
|
onReorderRows?.(moveInArray(rows, fromIndex, toIndex));
|
||||||
}
|
}
|
||||||
|
|
||||||
function dragPropsFor(row, index) {
|
function dragPropsFor(row: TrackerRow, index: number): DragProps {
|
||||||
if (!reorderEnabled) return { draggable: false };
|
if (!reorderEnabled) return { draggable: false };
|
||||||
return {
|
return {
|
||||||
draggable: true,
|
draggable: true,
|
||||||
|
|
@ -175,7 +204,7 @@ export function TrackerBucket({
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function moveControlsFor(row, index) {
|
function moveControlsFor(row: TrackerRow, index: number): MoveControls {
|
||||||
return {
|
return {
|
||||||
enabled: !!reorderEnabled,
|
enabled: !!reorderEnabled,
|
||||||
moving: movingBillId === row.id,
|
moving: movingBillId === row.id,
|
||||||
|
|
@ -220,16 +249,16 @@ export function TrackerBucket({
|
||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs font-mono text-muted-foreground md:justify-end">
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs font-mono text-muted-foreground md:justify-end">
|
||||||
<span className="whitespace-nowrap">
|
<span className="whitespace-nowrap">
|
||||||
<span className={cn(allPaid ? 'text-emerald-500' : 'text-foreground')}>
|
<span className={cn(allPaid ? 'text-emerald-500' : 'text-foreground')}>
|
||||||
{fmt(totalPaidTowardDue)}
|
{fmt(asDollars(totalPaidTowardDue))}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted-foreground/50 mx-1">/</span>
|
<span className="text-muted-foreground/50 mx-1">/</span>
|
||||||
{fmt(totalThreshold)}
|
{fmt(asDollars(totalThreshold))}
|
||||||
{totalOverpaid > 0 && (
|
{totalOverpaid > 0 && (
|
||||||
<span className="ml-1 text-emerald-500">+{fmt(totalOverpaid)}</span>
|
<span className="ml-1 text-emerald-500">+{fmt(asDollars(totalOverpaid))}</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
{!allPaid && totalRemaining > 0 && (
|
{!allPaid && totalRemaining > 0 && (
|
||||||
<span className="text-[11px] text-muted-foreground/70">{fmt(totalRemaining)} left</span>
|
<span className="text-[11px] text-muted-foreground/70">{fmt(asDollars(totalRemaining))} left</span>
|
||||||
)}
|
)}
|
||||||
{allPaid && (
|
{allPaid && (
|
||||||
<span className="text-[11px] text-emerald-500">Done</span>
|
<span className="text-[11px] text-emerald-500">Done</span>
|
||||||
|
|
@ -259,13 +288,13 @@ export function TrackerBucket({
|
||||||
<AlertDialogTitle>Pay all due in {label}?</AlertDialogTitle>
|
<AlertDialogTitle>Pay all due in {label}?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
This records a payment for {payAllItems.length} unpaid bill{payAllItems.length === 1 ? '' : 's'} in this
|
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.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel disabled={payingAll}>Cancel</AlertDialogCancel>
|
<AlertDialogCancel disabled={payingAll}>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogAction onClick={(e) => { e.preventDefault(); handlePayAllDue(); }} disabled={payingAll}>
|
<AlertDialogAction onClick={(e) => { e.preventDefault(); handlePayAllDue(); }} disabled={payingAll}>
|
||||||
{payingAll ? 'Paying…' : `Pay ${fmt(payAllTotal)}`}
|
{payingAll ? 'Paying…' : `Pay ${fmt(asDollars(payAllTotal))}`}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import React, { useState, useRef, useTransition, useEffect } from 'react';
|
import { useState, useRef, useTransition, useEffect } from 'react';
|
||||||
import { ArrowDown, ArrowUp, CheckCircle2, Clock, GripVertical, Pencil, TrendingUp, X } from 'lucide-react';
|
import { ArrowDown, ArrowUp, Clock, GripVertical, Pencil, TrendingUp, X } from 'lucide-react';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
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, type Dollars } 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 {
|
||||||
|
|
@ -23,25 +24,44 @@ import { PaymentProgress } from '@/components/tracker/PaymentProgress';
|
||||||
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 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 }) {
|
// Cast type for the HTML5 drag handlers, which conflict with framer-motion's own
|
||||||
const amountRef = useRef(null);
|
// onDrag* gesture types on the motion.tr-backed TableRow.
|
||||||
const [editPayment, setEditPayment] = useState(null);
|
type TableRowDrag = React.ComponentProps<typeof TableRow>['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<HTMLInputElement>(null);
|
||||||
|
const [editPayment, setEditPayment] = useState<Payment | null>(null);
|
||||||
const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false);
|
const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false);
|
||||||
const [showMbs, setShowMbs] = useState(false);
|
const [showMbs, setShowMbs] = useState(false);
|
||||||
const [confirmUnpay, setConfirmUnpay] = useState(false);
|
const [confirmUnpay, setConfirmUnpay] = useState(false);
|
||||||
const [suggestionLoading, setSuggestionLoading] = useState(false);
|
const [suggestionLoading, setSuggestionLoading] = useState(false);
|
||||||
const [optimisticActual, setOptimisticActual] = useState(undefined);
|
const [optimisticActual, setOptimisticActual] = useState<Dollars | undefined>(undefined);
|
||||||
// Optimistic paid/unpaid flip: the row updates instantly on pay/skip and rolls
|
// Optimistic paid/unpaid flip: the row updates instantly on pay/skip and rolls
|
||||||
// back on error. Cleared when real data arrives (see effect below).
|
// back on error. Cleared when real data arrives (see effect below).
|
||||||
const [optimisticPaid, setOptimisticPaid] = useState(undefined);
|
const [optimisticPaid, setOptimisticPaid] = useState<boolean | undefined>(undefined);
|
||||||
const [showUpdateNudge, setShowUpdateNudge] = useState(false);
|
const [showUpdateNudge, setShowUpdateNudge] = useState(false);
|
||||||
const [nudgeAmount, setNudgeAmount] = useState(null);
|
const [nudgeAmount, setNudgeAmount] = useState<Dollars | null>(null);
|
||||||
const [, startTransition] = useTransition();
|
const [, startTransition] = useTransition();
|
||||||
const quickPay = useQuickPay();
|
const quickPay = useQuickPay();
|
||||||
const togglePaid = useTogglePaid(year, month);
|
const togglePaid = useTogglePaid(year, month);
|
||||||
const visibleColumnSet = new Set(visibleColumns);
|
const visibleColumnSet = new Set(visibleColumns);
|
||||||
const showColumn = key => visibleColumnSet.has(key);
|
const showColumn = (key: string) => visibleColumnSet.has(key);
|
||||||
|
|
||||||
const [editingExpected, setEditingExpected] = useState(false);
|
const [editingExpected, setEditingExpected] = useState(false);
|
||||||
const [expectedDraft, setExpectedDraft] = useState('');
|
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 serverIsPaid = row.status === 'paid' || (row.status === 'autodraft' && !hasAutopaySuggestion) || isPaidByThreshold;
|
||||||
const isPaid = optimisticPaid !== undefined ? optimisticPaid : serverIsPaid;
|
const isPaid = optimisticPaid !== undefined ? optimisticPaid : serverIsPaid;
|
||||||
const summary = paymentSummary(row, threshold);
|
const summary = paymentSummary(row, threshold);
|
||||||
|
const bankPending = row.bank_pending_count ?? 0;
|
||||||
|
|
||||||
// Effective status to show:
|
// Effective status to show:
|
||||||
// skipped > optimistic flip > paid (threshold-based) > backend status
|
// 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] || '');
|
const rowBg = isSkipped ? '' : (ROW_STATUS_CLS[effectiveStatus] || '');
|
||||||
|
|
||||||
function handleQuickPay() {
|
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; }
|
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() {
|
||||||
|
|
@ -99,7 +120,7 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
|
||||||
performTogglePaid();
|
performTogglePaid();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRowKeyDown(e) {
|
function handleRowKeyDown(e: React.KeyboardEvent<HTMLTableRowElement>) {
|
||||||
// Only act on the row element itself, not on interactive children
|
// Only act on the row element itself, not on interactive children
|
||||||
if (e.target !== e.currentTarget) return;
|
if (e.target !== e.currentTarget) return;
|
||||||
|
|
||||||
|
|
@ -107,7 +128,7 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
|
||||||
case 'ArrowDown':
|
case 'ArrowDown':
|
||||||
case 'j': {
|
case 'j': {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const all = [...document.querySelectorAll('[data-tracker-row]')];
|
const all = [...document.querySelectorAll<HTMLElement>('[data-tracker-row]')];
|
||||||
const next = all[all.indexOf(e.currentTarget) + 1];
|
const next = all[all.indexOf(e.currentTarget) + 1];
|
||||||
next?.focus();
|
next?.focus();
|
||||||
break;
|
break;
|
||||||
|
|
@ -115,7 +136,7 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
|
||||||
case 'ArrowUp':
|
case 'ArrowUp':
|
||||||
case 'k': {
|
case 'k': {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const all = [...document.querySelectorAll('[data-tracker-row]')];
|
const all = [...document.querySelectorAll<HTMLElement>('[data-tracker-row]')];
|
||||||
const prev = all[all.indexOf(e.currentTarget) - 1];
|
const prev = all[all.indexOf(e.currentTarget) - 1];
|
||||||
prev?.focus();
|
prev?.focus();
|
||||||
break;
|
break;
|
||||||
|
|
@ -156,7 +177,7 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
|
||||||
refresh?.();
|
refresh?.();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setOptimisticActual(undefined);
|
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)}`);
|
toast.success(`Default updated to ${fmt(amount)}`);
|
||||||
refresh?.();
|
refresh?.();
|
||||||
} catch (err) {
|
} 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);
|
setOptimisticActual(amount);
|
||||||
try {
|
try {
|
||||||
await api.saveBillMonthlyState(row.id, {
|
await api.saveBillMonthlyState(row.id, {
|
||||||
|
|
@ -186,7 +207,7 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
|
||||||
refresh?.();
|
refresh?.();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setOptimisticActual(undefined);
|
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 (val === current) return;
|
||||||
|
|
||||||
if (effectiveActual != null) {
|
if (effectiveActual != null) {
|
||||||
setOptimisticActual(val);
|
setOptimisticActual(asDollars(val));
|
||||||
try {
|
try {
|
||||||
await api.saveBillMonthlyState(row.id, {
|
await api.saveBillMonthlyState(row.id, {
|
||||||
year, month,
|
year, month,
|
||||||
|
|
@ -209,14 +230,14 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
|
||||||
refresh?.();
|
refresh?.();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setOptimisticActual(undefined);
|
setOptimisticActual(undefined);
|
||||||
toast.error(err.message || 'Failed to update amount');
|
toast.error(errMessage(err, 'Failed to update amount'));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
await api.updateBill(row.id, { name: row.name, due_day: row.due_day, expected_amount: val });
|
await api.updateBill(row.id, { name: row.name, due_day: row.due_day, expected_amount: val });
|
||||||
refresh?.();
|
refresh?.();
|
||||||
} catch (err) {
|
} 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 });
|
await api.updateBill(row.id, { name: row.name, due_day: day, expected_amount: row.expected_amount });
|
||||||
refresh?.();
|
refresh?.();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to update due date');
|
toast.error(errMessage(err, 'Failed to update due date'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
@ -254,7 +275,7 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
@ -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}` : ''}`}
|
aria-label={`${row.name}, ${effectiveStatus}, ${row.due_day ? `due ${row.due_day}` : ''}`}
|
||||||
onKeyDown={handleRowKeyDown}
|
onKeyDown={handleRowKeyDown}
|
||||||
draggable={dragProps?.draggable}
|
draggable={dragProps?.draggable}
|
||||||
onDragStart={dragProps?.onDragStart}
|
onDragStart={dragProps?.onDragStart as TableRowDrag}
|
||||||
onDragEnter={dragProps?.onDragEnter}
|
onDragEnter={dragProps?.onDragEnter}
|
||||||
onDragOver={dragProps?.onDragOver}
|
onDragOver={dragProps?.onDragOver}
|
||||||
onDragEnd={dragProps?.onDragEnd}
|
onDragEnd={dragProps?.onDragEnd as TableRowDrag}
|
||||||
onDrop={dragProps?.onDrop}
|
onDrop={dragProps?.onDrop}
|
||||||
className={cn(
|
className={cn(
|
||||||
'group border-border/65 transition-colors duration-150',
|
'group border-border/65 transition-colors duration-150',
|
||||||
|
|
@ -344,10 +365,10 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
|
||||||
<TooltipProvider delayDuration={300}>
|
<TooltipProvider delayDuration={300}>
|
||||||
{row.autopay_enabled && (() => {
|
{row.autopay_enabled && (() => {
|
||||||
const stats = row.autopay_stats;
|
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 verifiedAt = row.autopay_verified_at ? new Date(row.autopay_verified_at) : null;
|
||||||
const daysSinceVerify = verifiedAt
|
const daysSinceVerify = verifiedAt
|
||||||
? Math.floor((Date.now() - verifiedAt) / 86400000)
|
? Math.floor((Date.now() - verifiedAt.getTime()) / 86400000)
|
||||||
: null;
|
: null;
|
||||||
const needsVerify = daysSinceVerify === null || daysSinceVerify > 90;
|
const needsVerify = daysSinceVerify === null || daysSinceVerify > 90;
|
||||||
const badgeCls = hasFailed
|
const badgeCls = hasFailed
|
||||||
|
|
@ -369,10 +390,10 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
|
||||||
) : (
|
) : (
|
||||||
<p className="font-semibold">Autopay enabled</p>
|
<p className="font-semibold">Autopay enabled</p>
|
||||||
)}
|
)}
|
||||||
{hasFailed && stats.last_failure_date && (
|
{hasFailed && stats?.last_failure_date && (
|
||||||
<p className="text-xs opacity-80">Last failed: {stats.last_failure_date}</p>
|
<p className="text-xs opacity-80">Last failed: {stats.last_failure_date}</p>
|
||||||
)}
|
)}
|
||||||
{hasFailed && stats.last_failure_notes && (
|
{hasFailed && stats?.last_failure_notes && (
|
||||||
<p className="text-xs opacity-70 italic">{stats.last_failure_notes}</p>
|
<p className="text-xs opacity-70 italic">{stats.last_failure_notes}</p>
|
||||||
)}
|
)}
|
||||||
{needsVerify ? (
|
{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 && (
|
Math.abs(row.amount_suggestion.suggestion - row.expected_amount) / row.expected_amount > 0.05 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleApplySuggestion(row.amount_suggestion.suggestion)}
|
onClick={() => handleApplySuggestion(row.amount_suggestion!.suggestion)}
|
||||||
className="text-[10px] text-muted-foreground/50 transition-colors hover:text-muted-foreground"
|
className="text-[10px] text-muted-foreground/50 transition-colors hover:text-muted-foreground"
|
||||||
title={`Based on last ${row.amount_suggestion.months_used} months`}
|
title={`Based on last ${row.amount_suggestion.months_used} months`}
|
||||||
>
|
>
|
||||||
|
|
@ -574,10 +595,10 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
|
||||||
{showColumn('status') && (
|
{showColumn('status') && (
|
||||||
<TableCell className="w-[9%] py-2.5">
|
<TableCell className="w-[9%] py-2.5">
|
||||||
<div className="flex flex-col items-center gap-1">
|
<div className="flex flex-col items-center gap-1">
|
||||||
{row.bank_pending_count > 0 ? (
|
{bankPending > 0 ? (
|
||||||
<span
|
<span
|
||||||
className="inline-flex items-center rounded-full border border-emerald-400/40 bg-emerald-500/10 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-400"
|
className="inline-flex items-center rounded-full border border-emerald-400/40 bg-emerald-500/10 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-400"
|
||||||
title={`Bank has ${row.bank_pending_count} pending charge${row.bank_pending_count > 1 ? 's' : ''} matching this bill`}
|
title={`Bank has ${bankPending} pending charge${bankPending > 1 ? 's' : ''} matching this bill`}
|
||||||
>
|
>
|
||||||
Pending
|
Pending
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -35,6 +35,20 @@ export interface AutopaySuggestion {
|
||||||
method: string;
|
method: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A suggested expected-amount for a bill, from its recent payment history.
|
||||||
|
export interface AmountSuggestion {
|
||||||
|
suggestion: Dollars;
|
||||||
|
months_used: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Autopay reliability rollup shown in the tracker-row tooltip.
|
||||||
|
export interface AutopayStats {
|
||||||
|
total: number;
|
||||||
|
failures: number;
|
||||||
|
last_failure_date?: string | null;
|
||||||
|
last_failure_notes?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
// A payment record as serialized by the server (money in dollars). serializePayment
|
// A payment record as serialized by the server (money in dollars). serializePayment
|
||||||
// spreads the full DB row, so extra columns pass through as `unknown`.
|
// spreads the full DB row, so extra columns pass through as `unknown`.
|
||||||
export interface Payment {
|
export interface Payment {
|
||||||
|
|
@ -169,7 +183,7 @@ export interface TrackerRow {
|
||||||
inactive_reason: string | null;
|
inactive_reason: string | null;
|
||||||
inactivated_at: string | null;
|
inactivated_at: string | null;
|
||||||
sparkline: number[] | null;
|
sparkline: number[] | null;
|
||||||
autopay_stats: unknown;
|
autopay_stats: AutopayStats | null;
|
||||||
payments: Payment[];
|
payments: Payment[];
|
||||||
// Augmented by getTracker:
|
// Augmented by getTracker:
|
||||||
actual_amount: Dollars | null;
|
actual_amount: Dollars | null;
|
||||||
|
|
@ -177,7 +191,7 @@ export interface TrackerRow {
|
||||||
snoozed_until: string | null;
|
snoozed_until: string | null;
|
||||||
monthly_notes?: string | null;
|
monthly_notes?: string | null;
|
||||||
autopay_suggestion?: AutopaySuggestion | null;
|
autopay_suggestion?: AutopaySuggestion | null;
|
||||||
amount_suggestion?: unknown;
|
amount_suggestion?: AmountSuggestion | null;
|
||||||
previous_month_paid: Dollars;
|
previous_month_paid: Dollars;
|
||||||
// Bank-mode augmentation (present only when bank_tracking.enabled):
|
// Bank-mode augmentation (present only when bank_tracking.enabled):
|
||||||
pending_cleared?: boolean;
|
pending_cleared?: boolean;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue