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 { 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<number>;
|
||||
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<number>(),
|
||||
visibleColumns = DEFAULT_TRACKER_TABLE_COLUMNS,
|
||||
}) {
|
||||
const [draggingId, setDraggingId] = useState(null);
|
||||
const [dropTargetId, setDropTargetId] = useState(null);
|
||||
}: TrackerBucketProps) {
|
||||
const [draggingId, setDraggingId] = useState<number | null>(null);
|
||||
const [dropTargetId, setDropTargetId] = useState<number | null>(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({
|
|||
<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={cn(allPaid ? 'text-emerald-500' : 'text-foreground')}>
|
||||
{fmt(totalPaidTowardDue)}
|
||||
{fmt(asDollars(totalPaidTowardDue))}
|
||||
</span>
|
||||
<span className="text-muted-foreground/50 mx-1">/</span>
|
||||
{fmt(totalThreshold)}
|
||||
{fmt(asDollars(totalThreshold))}
|
||||
{totalOverpaid > 0 && (
|
||||
<span className="ml-1 text-emerald-500">+{fmt(totalOverpaid)}</span>
|
||||
<span className="ml-1 text-emerald-500">+{fmt(asDollars(totalOverpaid))}</span>
|
||||
)}
|
||||
</span>
|
||||
{!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 && (
|
||||
<span className="text-[11px] text-emerald-500">Done</span>
|
||||
|
|
@ -259,13 +288,13 @@ export function TrackerBucket({
|
|||
<AlertDialogTitle>Pay all due in {label}?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
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>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={payingAll}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={(e) => { e.preventDefault(); handlePayAllDue(); }} disabled={payingAll}>
|
||||
{payingAll ? 'Paying…' : `Pay ${fmt(payAllTotal)}`}
|
||||
{payingAll ? 'Paying…' : `Pay ${fmt(asDollars(payAllTotal))}`}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
|
@ -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<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 [showMbs, setShowMbs] = useState(false);
|
||||
const [confirmUnpay, setConfirmUnpay] = 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
|
||||
// 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 [nudgeAmount, setNudgeAmount] = useState(null);
|
||||
const [nudgeAmount, setNudgeAmount] = useState<Dollars | null>(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<HTMLTableRowElement>) {
|
||||
// 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<HTMLElement>('[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<HTMLElement>('[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
|
|||
<TooltipProvider delayDuration={300}>
|
||||
{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
|
|||
) : (
|
||||
<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>
|
||||
)}
|
||||
{hasFailed && stats.last_failure_notes && (
|
||||
{hasFailed && stats?.last_failure_notes && (
|
||||
<p className="text-xs opacity-70 italic">{stats.last_failure_notes}</p>
|
||||
)}
|
||||
{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 && (
|
||||
<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"
|
||||
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') && (
|
||||
<TableCell className="w-[9%] py-2.5">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
{row.bank_pending_count > 0 ? (
|
||||
{bankPending > 0 ? (
|
||||
<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"
|
||||
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
|
||||
</span>
|
||||
|
|
@ -35,6 +35,20 @@ export interface AutopaySuggestion {
|
|||
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
|
||||
// spreads the full DB row, so extra columns pass through as `unknown`.
|
||||
export interface Payment {
|
||||
|
|
@ -169,7 +183,7 @@ export interface TrackerRow {
|
|||
inactive_reason: string | null;
|
||||
inactivated_at: string | null;
|
||||
sparkline: number[] | null;
|
||||
autopay_stats: unknown;
|
||||
autopay_stats: AutopayStats | null;
|
||||
payments: Payment[];
|
||||
// Augmented by getTracker:
|
||||
actual_amount: Dollars | null;
|
||||
|
|
@ -177,7 +191,7 @@ export interface TrackerRow {
|
|||
snoozed_until: string | null;
|
||||
monthly_notes?: string | null;
|
||||
autopay_suggestion?: AutopaySuggestion | null;
|
||||
amount_suggestion?: unknown;
|
||||
amount_suggestion?: AmountSuggestion | null;
|
||||
previous_month_paid: Dollars;
|
||||
// Bank-mode augmentation (present only when bank_tracking.enabled):
|
||||
pending_cleared?: boolean;
|
||||
|
|
|
|||
Loading…
Reference in New Issue