refactor(bill-modal): extract PaymentHistoryList (BM2, 3/n)
The payment-history list (rows with source badges + edit/remove for manual payments) and its 4 display helpers (isTransactionLinkedPayment, isHistoryOnlyPayment, paymentSourceLabel/Tone) move to their own presentational component; the parent keeps payment state + add/edit/delete handlers and passes them as props. Behavior-preserving — BillModal 1603 -> 1502 lines; build + client tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
9d670985fe
commit
afba78e86b
|
|
@ -1,5 +1,5 @@
|
|||
import { useActionState, useEffect, useState } from 'react';
|
||||
import { Copy, Layers, Link2, Link2Off, Loader2, Pencil, Plus, RefreshCw, Trash2 } from 'lucide-react';
|
||||
import { Copy, Layers, Link2, Link2Off, Loader2, RefreshCw } from 'lucide-react';
|
||||
import { formatCentsUSD, validateNonNegativeMoney } from '@/lib/money';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
|
@ -21,6 +21,7 @@ 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 {
|
||||
BILLING_SCHEDULE_OPTIONS,
|
||||
billingCycleForSchedule,
|
||||
|
|
@ -76,36 +77,6 @@ function transactionTitle(tx) {
|
|||
return tx?.payee || tx?.description || tx?.memo || 'Transaction';
|
||||
}
|
||||
|
||||
function isTransactionLinkedPayment(payment) {
|
||||
return payment?.payment_source === 'transaction_match' || payment?.transaction_id != null;
|
||||
}
|
||||
|
||||
function isHistoryOnlyPayment(payment) {
|
||||
return !!payment?.accounting_excluded;
|
||||
}
|
||||
|
||||
function paymentSourceLabel(source) {
|
||||
const labels = {
|
||||
manual: 'Manual',
|
||||
file_import: 'File import',
|
||||
provider_sync: 'Sync',
|
||||
transaction_match: 'Transaction',
|
||||
auto_match: 'SimpleFIN',
|
||||
};
|
||||
return labels[source] || source || 'Manual';
|
||||
}
|
||||
|
||||
function paymentSourceTone(source) {
|
||||
const tones = {
|
||||
manual: 'border-emerald-500/25 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
|
||||
file_import: 'border-sky-500/25 bg-sky-500/10 text-sky-600 dark:text-sky-400',
|
||||
provider_sync: 'border-violet-500/25 bg-violet-500/10 text-violet-600 dark:text-violet-400',
|
||||
transaction_match: 'border-primary/25 bg-primary/10 text-primary',
|
||||
auto_match: 'border-violet-500/25 bg-violet-500/10 text-violet-600 dark:text-violet-400',
|
||||
};
|
||||
return tones[source] || tones.manual;
|
||||
}
|
||||
|
||||
function isDebtCat(categories, catId) {
|
||||
if (!catId || catId === CAT_NONE) return false;
|
||||
const cat = categories.find(c => String(c.id) === catId);
|
||||
|
|
@ -968,86 +939,14 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
|
|||
|
||||
{!isNew && (
|
||||
<div className="mt-4 border-t border-border/50 pt-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Payment history</p>
|
||||
<p className="text-[11px] text-muted-foreground/75">{payments.length} recorded</p>
|
||||
</div>
|
||||
<Button type="button" size="sm" variant="outline" disabled={paymentBusy} onClick={startAddPayment} className="h-8 gap-2 text-xs">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add Payment
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{paymentsLoading ? (
|
||||
<div className="rounded-lg border border-border/60 bg-muted/20 px-3 py-8 text-center text-sm text-muted-foreground">
|
||||
Loading payment history...
|
||||
</div>
|
||||
) : payments.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-1.5 rounded-lg bg-muted/20 px-3 py-8 text-center">
|
||||
<p className="text-sm font-medium text-muted-foreground">No payments yet</p>
|
||||
<p className="text-xs text-muted-foreground/60">Use the form below to record the first payment.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-52 space-y-2 overflow-y-auto pr-1">
|
||||
{payments.map(payment => {
|
||||
const linkedPayment = isTransactionLinkedPayment(payment);
|
||||
const historyOnly = isHistoryOnlyPayment(payment);
|
||||
return (
|
||||
<div key={payment.id} className={cn(
|
||||
'flex items-start justify-between gap-3 rounded-lg border px-3 py-2.5',
|
||||
historyOnly
|
||||
? 'border-amber-500/25 bg-amber-500/[0.06] opacity-85'
|
||||
: 'border-border/60 bg-background/35'
|
||||
)}>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className={cn('font-mono text-sm font-semibold', historyOnly ? 'text-muted-foreground line-through decoration-amber-500/70' : 'text-foreground')}>{fmt(payment.amount)}</p>
|
||||
<span className={cn(
|
||||
'rounded-md border px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide',
|
||||
paymentSourceTone(payment.payment_source),
|
||||
)}>
|
||||
{paymentSourceLabel(payment.payment_source)}
|
||||
</span>
|
||||
{historyOnly && (
|
||||
<span className="rounded-md border border-amber-500/30 bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-600 dark:text-amber-300">
|
||||
History only
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||
{fmtDate(payment.paid_date)} · {payment.method || (payment.payment_source === 'transaction_match' ? 'Synced' : 'manual')}
|
||||
</p>
|
||||
{payment.notes && (
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground/80">{payment.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-1">
|
||||
{historyOnly ? (
|
||||
<span className="inline-flex h-8 items-center rounded-md border border-amber-500/25 bg-amber-500/10 px-2 text-[11px] font-medium text-amber-600 dark:text-amber-300">
|
||||
Overridden
|
||||
</span>
|
||||
) : linkedPayment ? (
|
||||
<span className="inline-flex h-8 items-center gap-1.5 rounded-md border border-primary/20 bg-primary/5 px-2 text-[11px] font-medium text-primary">
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
Matched
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<Button type="button" size="icon" variant="ghost" disabled={paymentBusy} onClick={() => startEditPayment(payment)} className="h-8 w-8" aria-label="Edit payment">
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button type="button" size="icon" variant="ghost" disabled={paymentBusy} onClick={() => setDeletePaymentTarget(payment)} className="h-8 w-8 text-destructive hover:bg-destructive/10 hover:text-destructive" aria-label="Remove payment">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<PaymentHistoryList
|
||||
payments={payments}
|
||||
paymentsLoading={paymentsLoading}
|
||||
paymentBusy={paymentBusy}
|
||||
onAdd={startAddPayment}
|
||||
onEdit={startEditPayment}
|
||||
onDelete={setDeletePaymentTarget}
|
||||
/>
|
||||
|
||||
{/* Bank Matching Rules */}
|
||||
{!isNew && (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,131 @@
|
|||
import { Plus, Link2, Pencil, Trash2 } from 'lucide-react';
|
||||
import { cn, fmt, fmtDate } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
function isTransactionLinkedPayment(payment) {
|
||||
return payment?.payment_source === 'transaction_match' || payment?.transaction_id != null;
|
||||
}
|
||||
|
||||
function isHistoryOnlyPayment(payment) {
|
||||
return !!payment?.accounting_excluded;
|
||||
}
|
||||
|
||||
function paymentSourceLabel(source) {
|
||||
const labels = {
|
||||
manual: 'Manual',
|
||||
file_import: 'File import',
|
||||
provider_sync: 'Sync',
|
||||
transaction_match: 'Transaction',
|
||||
auto_match: 'SimpleFIN',
|
||||
};
|
||||
return labels[source] || source || 'Manual';
|
||||
}
|
||||
|
||||
function paymentSourceTone(source) {
|
||||
const tones = {
|
||||
manual: 'border-emerald-500/25 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
|
||||
file_import: 'border-sky-500/25 bg-sky-500/10 text-sky-600 dark:text-sky-400',
|
||||
provider_sync: 'border-violet-500/25 bg-violet-500/10 text-violet-600 dark:text-violet-400',
|
||||
transaction_match: 'border-primary/25 bg-primary/10 text-primary',
|
||||
auto_match: 'border-violet-500/25 bg-violet-500/10 text-violet-600 dark:text-violet-400',
|
||||
};
|
||||
return tones[source] || tones.manual;
|
||||
}
|
||||
|
||||
// Payment-history list for a bill (edit mode): each recorded payment with its
|
||||
// source badge, plus edit/remove actions for manual payments (matched and
|
||||
// history-only rows are read-only). Presentational — the parent owns the
|
||||
// payment state and the add/edit/delete handlers.
|
||||
export default function PaymentHistoryList({
|
||||
payments,
|
||||
paymentsLoading,
|
||||
paymentBusy,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Payment history</p>
|
||||
<p className="text-[11px] text-muted-foreground/75">{payments.length} recorded</p>
|
||||
</div>
|
||||
<Button type="button" size="sm" variant="outline" disabled={paymentBusy} onClick={onAdd} className="h-8 gap-2 text-xs">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add Payment
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{paymentsLoading ? (
|
||||
<div className="rounded-lg border border-border/60 bg-muted/20 px-3 py-8 text-center text-sm text-muted-foreground">
|
||||
Loading payment history...
|
||||
</div>
|
||||
) : payments.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-1.5 rounded-lg bg-muted/20 px-3 py-8 text-center">
|
||||
<p className="text-sm font-medium text-muted-foreground">No payments yet</p>
|
||||
<p className="text-xs text-muted-foreground/60">Use the form below to record the first payment.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-52 space-y-2 overflow-y-auto pr-1">
|
||||
{payments.map(payment => {
|
||||
const linkedPayment = isTransactionLinkedPayment(payment);
|
||||
const historyOnly = isHistoryOnlyPayment(payment);
|
||||
return (
|
||||
<div key={payment.id} className={cn(
|
||||
'flex items-start justify-between gap-3 rounded-lg border px-3 py-2.5',
|
||||
historyOnly
|
||||
? 'border-amber-500/25 bg-amber-500/[0.06] opacity-85'
|
||||
: 'border-border/60 bg-background/35'
|
||||
)}>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className={cn('font-mono text-sm font-semibold', historyOnly ? 'text-muted-foreground line-through decoration-amber-500/70' : 'text-foreground')}>{fmt(payment.amount)}</p>
|
||||
<span className={cn(
|
||||
'rounded-md border px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide',
|
||||
paymentSourceTone(payment.payment_source),
|
||||
)}>
|
||||
{paymentSourceLabel(payment.payment_source)}
|
||||
</span>
|
||||
{historyOnly && (
|
||||
<span className="rounded-md border border-amber-500/30 bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-600 dark:text-amber-300">
|
||||
History only
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||
{fmtDate(payment.paid_date)} · {payment.method || (payment.payment_source === 'transaction_match' ? 'Synced' : 'manual')}
|
||||
</p>
|
||||
{payment.notes && (
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground/80">{payment.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-1">
|
||||
{historyOnly ? (
|
||||
<span className="inline-flex h-8 items-center rounded-md border border-amber-500/25 bg-amber-500/10 px-2 text-[11px] font-medium text-amber-600 dark:text-amber-300">
|
||||
Overridden
|
||||
</span>
|
||||
) : linkedPayment ? (
|
||||
<span className="inline-flex h-8 items-center gap-1.5 rounded-md border border-primary/20 bg-primary/5 px-2 text-[11px] font-medium text-primary">
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
Matched
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<Button type="button" size="icon" variant="ghost" disabled={paymentBusy} onClick={() => onEdit(payment)} className="h-8 w-8" aria-label="Edit payment">
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button type="button" size="icon" variant="ghost" disabled={paymentBusy} onClick={() => onDelete(payment)} className="h-8 w-8 text-destructive hover:bg-destructive/10 hover:text-destructive" aria-label="Remove payment">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue