refactor(bill-modal): extract LinkedTransactionsSection (BM2, 6/n)

Bank-matching rules (+ Sync action) and the linked-transaction list (+ Unmatch)
move to LinkedTransactionsSection. The big inline sync onClick is lifted to a
named parent handler (handleSyncBillPayments) sharing a refreshAfterImport helper
with handleRulesChanged. Dropped now-unused imports (BillMerchantRules, Link2,
Link2Off, RefreshCw, transactionDate, fmtTransactionAmount). Behavior-preserving
— BillModal 1193 -> 1105 lines; build + client tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-03 19:22:58 -05:00
parent 301bb152ef
commit ad68d965b6
2 changed files with 186 additions and 144 deletions

View File

@ -1,5 +1,5 @@
import { useActionState, useEffect, useState } from 'react';
import { Copy, Link2, Link2Off, Loader2, RefreshCw } from 'lucide-react';
import { Copy, Loader2 } from 'lucide-react';
import { validateNonNegativeMoney } from '@/lib/money';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
@ -17,13 +17,13 @@ import {
} from '@/components/ui/select';
import { api } from '@/api';
import { cn, fmt, fmtDate, todayStr } from '@/lib/utils';
import BillMerchantRules from '@/components/BillMerchantRules';
import DebtDetailsSection from '@/components/bill-modal/DebtDetailsSection';
import AutopayTrustIndicator from '@/components/bill-modal/AutopayTrustIndicator';
import PaymentHistoryList from '@/components/bill-modal/PaymentHistoryList';
import PaymentFormFields from '@/components/bill-modal/PaymentFormFields';
import UnmatchDialogs from '@/components/bill-modal/UnmatchDialogs';
import { transactionTitle, transactionDate, fmtTransactionAmount, isSimilarPayee } from '@/components/bill-modal/transactionDisplay';
import LinkedTransactionsSection from '@/components/bill-modal/LinkedTransactionsSection';
import { transactionTitle, isSimilarPayee } from '@/components/bill-modal/transactionDisplay';
import {
BILLING_SCHEDULE_OPTIONS,
billingCycleForSchedule,
@ -177,6 +177,44 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
loadLinkedTransactions();
}, [bill?.id]);
// Imported payments (via sync or a merchant-rule historical import) must
// refresh the payment list AND the Tracker behind the modal, not just the
// linked transactions matching the unmatch handlers.
async function refreshAfterImport() {
await Promise.all([loadPayments(), loadLinkedTransactions()]);
onSave?.();
}
async function handleSyncBillPayments() {
setSyncingPayments(true);
const promise = api.syncBillSimplefinPayments(sourceBill.id);
toast.promise(promise, {
loading: 'Scanning bank history…',
success: (result) => result.added > 0
? `${result.added} payment${result.added !== 1 ? 's' : ''} imported from bank history.`
: 'No new matching transactions found.',
error: (err) => err.message || 'Sync failed.',
});
try {
const result = await promise;
if (result.added > 0) await refreshAfterImport();
if (result.late_attributions?.length) {
window.dispatchEvent(new CustomEvent('tracker:late-attributions', {
detail: { attributions: result.late_attributions },
}));
}
} catch {
// toast.promise already surfaced the error
} finally {
setSyncingPayments(false);
}
}
async function handleRulesChanged() {
setLocalHasRules(true);
await refreshAfterImport();
}
const validateName = (val) => {
if (!val || val.trim() === '') return 'Name is required';
if (val.trim().length < 2) return 'Name must be at least 2 characters';
@ -917,147 +955,20 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
onDelete={setDeletePaymentTarget}
/>
{/* Bank Matching Rules */}
{!isNew && (
<div className="mt-4 rounded-lg border border-border/60 bg-background/35">
<div className="flex items-center justify-between gap-3 border-b border-border/50 px-3 py-2.5">
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Bank matching rules</p>
<p className="mt-0.5 text-[11px] text-muted-foreground/70">
Transactions whose description contains these patterns are automatically imported as payments.
</p>
</div>
{localHasRules && (
<Button
type="button"
size="sm"
variant="outline"
className="shrink-0 gap-1.5 text-xs"
disabled={syncingPayments}
title="Scan unmatched bank transactions and import any matching payments for this bill"
onClick={async () => {
setSyncingPayments(true);
// One toast that transitions loading done.
const promise = api.syncBillSimplefinPayments(sourceBill.id);
toast.promise(promise, {
loading: 'Scanning bank history…',
success: (result) => result.added > 0
? `${result.added} payment${result.added !== 1 ? 's' : ''} imported from bank history.`
: 'No new matching transactions found.',
error: (err) => err.message || 'Sync failed.',
});
try {
const result = await promise;
if (result.added > 0) {
// Imported payments must refresh the payment list AND the
// Tracker behind the modal (the row may now be covered).
await Promise.all([loadPayments(), loadLinkedTransactions()]);
onSave?.();
}
// Surface late-attribution prompts to the tracker page
if (result.late_attributions?.length) {
window.dispatchEvent(new CustomEvent('tracker:late-attributions', {
detail: { attributions: result.late_attributions },
}));
}
} catch {
// toast.promise already surfaced the error
} finally {
setSyncingPayments(false);
}
}}
>
{syncingPayments
? <><Loader2 className="h-3.5 w-3.5 animate-spin" />Syncing</>
: <><RefreshCw className="h-3.5 w-3.5" />Sync</>}
</Button>
)}
</div>
<div className="px-3 py-3">
<BillMerchantRules
billId={sourceBill?.id}
billName={sourceBill?.name}
onRulesChanged={async () => {
setLocalHasRules(true);
// A historical import (fired after adding a rule) creates
// payments, so refresh the payment list AND the Tracker too
// not just the linked transactions.
await Promise.all([loadPayments(), loadLinkedTransactions()]);
onSave?.();
}}
/>
</div>
</div>
)}
<div className="mt-4 rounded-lg border border-border/60 bg-background/35">
<div className="flex items-center justify-between gap-3 border-b border-border/50 px-3 py-2.5">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Linked transactions</p>
<p className="text-[11px] text-muted-foreground/75">{linkedTransactions.length} confirmed matches</p>
</div>
<span className={cn(
'inline-flex h-7 items-center gap-1.5 rounded-md border px-2 text-[11px] font-medium',
linkedTransactions.length > 0
? 'border-primary/20 bg-primary/5 text-primary'
: 'border-border/60 bg-muted/30 text-muted-foreground',
)}>
<Link2 className="h-3.5 w-3.5" />
{linkedTransactions.length}
</span>
</div>
{linkedTransactionsLoading ? (
<div className="px-3 py-8 text-center text-sm text-muted-foreground">
Loading linked transactions...
</div>
) : linkedTransactions.length === 0 ? (
<div className="flex items-center justify-center gap-2 px-3 py-8 text-sm text-muted-foreground">
<Link2Off className="h-4 w-4" />
No transactions linked to this bill yet.
</div>
) : (
<div className="max-h-56 divide-y divide-border/40 overflow-y-auto">
{linkedTransactions.map(transaction => (
<div key={transaction.id} className="flex items-start justify-between gap-3 px-3 py-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="truncate text-sm font-medium text-foreground">{transactionTitle(transaction)}</p>
<span className="rounded-md border border-border/60 bg-muted/40 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
{transaction.source_label || transaction.source_type_label || 'Transaction'}
</span>
</div>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{transactionDate(transaction) ? fmtDate(transactionDate(transaction)) : 'No date'} · {transaction.description || transaction.memo || 'No description'}
</p>
{transaction.account_name && (
<p className="mt-0.5 truncate text-[11px] text-muted-foreground/75">{transaction.account_name}</p>
)}
</div>
<div className="flex shrink-0 flex-col items-end gap-2 sm:flex-row sm:items-center">
<p className={cn(
'font-mono text-sm font-semibold tabular-nums',
Number(transaction.amount) < 0 ? 'text-destructive' : 'text-emerald-600',
)}>
{fmtTransactionAmount(transaction.amount, transaction.currency)}
</p>
<Button
type="button"
size="sm"
variant="outline"
disabled={transactionBusyId === transaction.id}
onClick={() => openUnmatch(transaction)}
className="h-8 gap-1.5 text-xs"
>
<Link2Off className="h-3.5 w-3.5" />
{transactionBusyId === transaction.id ? 'Unmatching...' : 'Unmatch'}
</Button>
</div>
</div>
))}
</div>
)}
</div>
{/* Bank matching rules + linked transactions */}
<LinkedTransactionsSection
isNew={isNew}
billId={sourceBill?.id}
billName={sourceBill?.name}
localHasRules={localHasRules}
syncingPayments={syncingPayments}
onSync={handleSyncBillPayments}
onRulesChanged={handleRulesChanged}
linkedTransactions={linkedTransactions}
linkedTransactionsLoading={linkedTransactionsLoading}
transactionBusyId={transactionBusyId}
onUnmatch={openUnmatch}
/>
{paymentFormOpen && (
<PaymentFormFields

View File

@ -0,0 +1,131 @@
import { Link2, Link2Off, Loader2, RefreshCw } from 'lucide-react';
import { cn, fmtDate } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import BillMerchantRules from '@/components/BillMerchantRules';
import { transactionTitle, transactionDate, fmtTransactionAmount } from '@/components/bill-modal/transactionDisplay';
// Bank-matching rules (with a Sync-now action) + the list of transactions
// confirmed as matched to this bill (each with an Unmatch action). Presentational
// the parent owns the sync/rules-changed handlers and the unmatch state.
export default function LinkedTransactionsSection({
isNew,
billId,
billName,
localHasRules,
syncingPayments,
onSync,
onRulesChanged,
linkedTransactions,
linkedTransactionsLoading,
transactionBusyId,
onUnmatch,
}) {
return (
<>
{/* Bank Matching Rules */}
{!isNew && (
<div className="mt-4 rounded-lg border border-border/60 bg-background/35">
<div className="flex items-center justify-between gap-3 border-b border-border/50 px-3 py-2.5">
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Bank matching rules</p>
<p className="mt-0.5 text-[11px] text-muted-foreground/70">
Transactions whose description contains these patterns are automatically imported as payments.
</p>
</div>
{localHasRules && (
<Button
type="button"
size="sm"
variant="outline"
className="shrink-0 gap-1.5 text-xs"
disabled={syncingPayments}
title="Scan unmatched bank transactions and import any matching payments for this bill"
onClick={onSync}
>
{syncingPayments
? <><Loader2 className="h-3.5 w-3.5 animate-spin" />Syncing</>
: <><RefreshCw className="h-3.5 w-3.5" />Sync</>}
</Button>
)}
</div>
<div className="px-3 py-3">
<BillMerchantRules
billId={billId}
billName={billName}
onRulesChanged={onRulesChanged}
/>
</div>
</div>
)}
<div className="mt-4 rounded-lg border border-border/60 bg-background/35">
<div className="flex items-center justify-between gap-3 border-b border-border/50 px-3 py-2.5">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Linked transactions</p>
<p className="text-[11px] text-muted-foreground/75">{linkedTransactions.length} confirmed matches</p>
</div>
<span className={cn(
'inline-flex h-7 items-center gap-1.5 rounded-md border px-2 text-[11px] font-medium',
linkedTransactions.length > 0
? 'border-primary/20 bg-primary/5 text-primary'
: 'border-border/60 bg-muted/30 text-muted-foreground',
)}>
<Link2 className="h-3.5 w-3.5" />
{linkedTransactions.length}
</span>
</div>
{linkedTransactionsLoading ? (
<div className="px-3 py-8 text-center text-sm text-muted-foreground">
Loading linked transactions...
</div>
) : linkedTransactions.length === 0 ? (
<div className="flex items-center justify-center gap-2 px-3 py-8 text-sm text-muted-foreground">
<Link2Off className="h-4 w-4" />
No transactions linked to this bill yet.
</div>
) : (
<div className="max-h-56 divide-y divide-border/40 overflow-y-auto">
{linkedTransactions.map(transaction => (
<div key={transaction.id} className="flex items-start justify-between gap-3 px-3 py-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="truncate text-sm font-medium text-foreground">{transactionTitle(transaction)}</p>
<span className="rounded-md border border-border/60 bg-muted/40 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
{transaction.source_label || transaction.source_type_label || 'Transaction'}
</span>
</div>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{transactionDate(transaction) ? fmtDate(transactionDate(transaction)) : 'No date'} · {transaction.description || transaction.memo || 'No description'}
</p>
{transaction.account_name && (
<p className="mt-0.5 truncate text-[11px] text-muted-foreground/75">{transaction.account_name}</p>
)}
</div>
<div className="flex shrink-0 flex-col items-end gap-2 sm:flex-row sm:items-center">
<p className={cn(
'font-mono text-sm font-semibold tabular-nums',
Number(transaction.amount) < 0 ? 'text-destructive' : 'text-emerald-600',
)}>
{fmtTransactionAmount(transaction.amount, transaction.currency)}
</p>
<Button
type="button"
size="sm"
variant="outline"
disabled={transactionBusyId === transaction.id}
onClick={() => onUnmatch(transaction)}
className="h-8 gap-1.5 text-xs"
>
<Link2Off className="h-3.5 w-3.5" />
{transactionBusyId === transaction.id ? 'Unmatching...' : 'Unmatch'}
</Button>
</div>
</div>
))}
</div>
)}
</div>
</>
);
}