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:
parent
301bb152ef
commit
ad68d965b6
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue