132 lines
5.9 KiB
React
132 lines
5.9 KiB
React
|
|
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>
|
||
|
|
</>
|
||
|
|
);
|
||
|
|
}
|