Bills: apply existing payment + Buildium-style approval detail view

1) AccountingBillsPage: add an 'Apply existing payment' mode to the
   Record Payment dialog. Instead of creating a new withdrawal, pick an
   unmatched outgoing bank transaction already in the register; it links
   to the bill (bill_id + coa cleared => Dr A/P / Cr Bank) and marks it
   paid. Same-vendor / exact-amount candidates surface first.

2) BillDetailPage (also used by the board view): redesign the bill
   detail layout to match Buildium - vendor-name header with status +
   amount/due subline, grey-headed 'Bill details' / 'Item details'
   (with Total row) / 'Approval' cards, and a right-hand 'Bill amount'
   (Remaining) card. All editing/approval/comment functionality intact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-18 13:08:28 -04:00
parent acd99f04ce
commit 66469c541f
2 changed files with 303 additions and 235 deletions
+170 -232
View File
@@ -1,11 +1,10 @@
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, type ReactNode } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { supabase } from "@/integrations/supabase/client";
import { useToast } from "@/hooks/use-toast";
import { useAuth } from "@/contexts/AuthContext";
import {
ArrowLeft, Building2, CreditCard, Calendar, DollarSign,
FileText, Download, ChevronLeft, ChevronRight, Send, Save, Plus,
ArrowLeft, FileText, Download, Send, Save, Plus,
CheckCircle, XCircle, Trash2, Clock
} from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -13,7 +12,6 @@ import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Separator } from "@/components/ui/separator";
import BillApprovalRequestDialog from "@/components/BillApprovalRequestDialog";
import ChartOfAccountsDropdown from "@/components/ChartOfAccountsDropdown.jsx";
@@ -332,74 +330,85 @@ export default function BillDetailPage({ boardAssociationIds }: { boardAssociati
const lineItems = editableLineItems;
const canEditLineItems = !isBoardView && !!bill.source_invoice_id;
const money = (n: any) => `$${Number(n || 0).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
const cap = (s: string) => (s ? s.charAt(0).toUpperCase() + s.slice(1).replace("_", " ") : "—");
const remaining = Number(bill.amount || 0) - Number(bill.amount_paid || 0);
const lineTotal = lineItems.reduce((s: number, li: any) => s + Number(li.amount ?? li.unit_price ?? 0), 0);
const Lbl = ({ children }: { children: ReactNode }) => (
<div className="text-[11px] uppercase tracking-wide text-muted-foreground font-semibold">{children}</div>
);
const Field = ({ label, value }: { label: string; value: ReactNode }) => (
<div><Lbl>{label}</Lbl><div className="text-sm mt-0.5 font-medium text-foreground">{value || "—"}</div></div>
);
const SectionCard = ({ title, action, children, bodyClass = "pt-5" }: any) => (
<Card className="overflow-hidden shadow-sm">
<CardHeader className="py-3 bg-muted/40 border-b flex-row items-center justify-between space-y-0">
<CardTitle className="text-sm font-semibold tracking-wide">{title}</CardTitle>
{action}
</CardHeader>
<CardContent className={bodyClass}>{children}</CardContent>
</Card>
);
return (
<div className="space-y-6 max-w-7xl mx-auto">
{/* Back + Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-5 w-5" />
</Button>
<div className="space-y-5 max-w-7xl mx-auto">
{/* Header */}
<div>
<Button variant="ghost" size="sm" onClick={() => navigate(-1)} className="gap-1 -ml-2 mb-2 text-muted-foreground">
<ArrowLeft className="h-4 w-4" /> Back
</Button>
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-foreground">Bill Details</h1>
<Badge className={statusColors[displayStatus] || "bg-muted text-muted-foreground"}>
{displayStatus.charAt(0).toUpperCase() + displayStatus.slice(1).replace("_", " ")}
</Badge>
<div className="flex items-center gap-3 flex-wrap">
<h1 className="text-2xl font-bold tracking-tight text-foreground">{vendorName}</h1>
<Badge className={statusColors[displayStatus] || "bg-muted text-muted-foreground"}>{cap(displayStatus)}</Badge>
</div>
<p className="text-sm text-muted-foreground font-mono mt-0.5">ID: {bill.id}</p>
<p className="text-sm text-muted-foreground mt-1">
Bill · <span className="font-medium text-foreground">{money(bill.amount)}</span> · Due {bill.due_date || "—"}
</p>
</div>
{!isBoardView && (
<div className="flex gap-2">
{bill.status === "pending" && (
<Button variant="outline" onClick={requestApproval} className="gap-2">
<CheckCircle className="h-4 w-4" /> Request Approval
</Button>
)}
{isAdmin && (bill.status === "pending" || bill.status === "approved") && (
<Button onClick={() => updateBillStatus("paid")} className="gap-2">
<CheckCircle className="h-4 w-4" /> Mark Paid
</Button>
)}
{isAdmin && bill.status === "paid" && (
<Button variant="outline" onClick={() => updateBillStatus("approved")} className="gap-2">
<XCircle className="h-4 w-4" /> Mark Unpaid
</Button>
)}
</div>
)}
</div>
{!isBoardView && (
<div className="flex gap-2">
{bill.status === "pending" && (
<Button variant="outline" onClick={requestApproval} className="gap-2">
<CheckCircle className="h-4 w-4" /> Request Approval
</Button>
)}
{isAdmin && (bill.status === "pending" || bill.status === "approved") && (
<Button onClick={() => updateBillStatus("paid")} className="gap-2">
<CheckCircle className="h-4 w-4" /> Mark Paid
</Button>
)}
{isAdmin && bill.status === "paid" && (
<Button variant="outline" onClick={() => updateBillStatus("approved")} className="gap-2">
<XCircle className="h-4 w-4" /> Mark Unpaid
</Button>
)}
</div>
)}
</div>
{/* Main content grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
{/* Left column */}
<div className="space-y-6">
{/* General Info */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<FileText className="h-5 w-5 text-primary" /> General Info
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="lg:col-span-2 space-y-5">
{/* Bill details */}
<SectionCard title="Bill details">
<div className="grid grid-cols-2 md:grid-cols-3 gap-y-5 gap-x-4">
<Field label="Date" value={bill.bill_date} />
<Field label="Due" value={bill.due_date} />
<Field label="Pay to" value={vendorName} />
<Field label="Reference number" value={bill.invoice_number} />
<Field label="Association" value={bill.associations?.name} />
<div>
<div className="flex items-center gap-2 text-xs text-muted-foreground uppercase font-semibold mb-1">
<Building2 className="h-3.5 w-3.5" /> Association
</div>
<p className="text-sm font-medium">{bill.associations?.name || "—"}</p>
</div>
<div>
<div className="flex items-center gap-2 text-xs text-muted-foreground uppercase font-semibold mb-1">
<CreditCard className="h-3.5 w-3.5" /> GL Account
</div>
<Lbl>GL account</Lbl>
{isBoardView ? (
<p className="text-sm">
<div className="text-sm mt-0.5 font-medium">
{bill.chart_of_accounts
? `${bill.chart_of_accounts.account_number} - ${bill.chart_of_accounts.account_name}`
: <span className="italic text-muted-foreground">Not assigned</span>}
</p>
: <span className="italic text-muted-foreground font-normal">Not assigned</span>}
</div>
) : (
<ChartOfAccountsDropdown
value={bill.expense_account_id || ""}
@@ -407,80 +416,41 @@ export default function BillDetailPage({ boardAssociationIds }: { boardAssociati
associationId={bill.association_id}
accountType="expense"
placeholder={savingAccount ? "Saving..." : "Select GL account"}
className="max-w-md"
className="mt-1"
/>
)}
</div>
<Separator />
<div>
<p className="text-xs text-muted-foreground uppercase font-semibold mb-1">Vendor</p>
<p className="text-sm font-medium">{vendorName}</p>
</div>
<div className="flex gap-8">
<div>
<p className="text-xs text-muted-foreground uppercase font-semibold mb-1">Total Amount</p>
<p className="text-lg font-bold flex items-center gap-1">
<DollarSign className="h-4 w-4 text-muted-foreground" />
{Number(bill.amount).toLocaleString("en-US", { minimumFractionDigits: 2 })}
</p>
</div>
<div>
<p className="text-xs text-muted-foreground uppercase font-semibold mb-1">Due Date</p>
<p className="text-sm flex items-center gap-1">
<Calendar className="h-3.5 w-3.5 text-muted-foreground" />
{bill.due_date || "—"}
</p>
</div>
</div>
{bill.invoice_number && (
<div>
<p className="text-xs text-muted-foreground uppercase font-semibold mb-1">Invoice #</p>
<p className="text-sm">{bill.invoice_number}</p>
</div>
)}
{bill.description && (
<div>
<p className="text-xs text-muted-foreground uppercase font-semibold mb-1">Description</p>
<p className="text-sm">{bill.description}</p>
</div>
)}
</CardContent>
</Card>
</div>
<div className="mt-5">
<Lbl>Memo</Lbl>
<div className="text-sm mt-0.5">{bill.description || "—"}</div>
</div>
</SectionCard>
{(lineItems.length > 0 || canEditLineItems) && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<FileText className="h-5 w-5 text-primary" /> Invoice Line Items
</CardTitle>
{canEditLineItems && (
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={addLineItem} className="gap-1">
<Plus className="h-3 w-3" /> Add Line
</Button>
<Button size="sm" onClick={saveLineItems} disabled={savingLineItems} className="gap-1">
<Save className="h-3 w-3" /> {savingLineItems ? "Saving..." : "Save Lines"}
</Button>
</div>
)}
<SectionCard
title="Item details"
bodyClass="p-0"
action={canEditLineItems && (
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={addLineItem} className="gap-1 h-8">
<Plus className="h-3 w-3" /> Add Line
</Button>
<Button size="sm" onClick={saveLineItems} disabled={savingLineItems} className="gap-1 h-8">
<Save className="h-3 w-3" /> {savingLineItems ? "Saving..." : "Save Lines"}
</Button>
</div>
</CardHeader>
<CardContent className="p-0">
)}
>
<Table>
<TableHeader>
<TableRow>
<TableHead>Item</TableHead>
<TableHead>Description</TableHead>
<TableHead className="min-w-[200px]">GL Account</TableHead>
<TableHead className="text-right">Qty</TableHead>
<TableHead className="text-right">Unit Price</TableHead>
<TableHead className="text-right">Amount</TableHead>
<TableRow className="bg-muted/20 hover:bg-muted/20">
<TableHead className="text-[11px] uppercase tracking-wide text-muted-foreground">Item</TableHead>
<TableHead className="text-[11px] uppercase tracking-wide text-muted-foreground">Description</TableHead>
<TableHead className="text-[11px] uppercase tracking-wide text-muted-foreground min-w-[200px]">Account</TableHead>
<TableHead className="text-[11px] uppercase tracking-wide text-muted-foreground text-right">Qty</TableHead>
<TableHead className="text-[11px] uppercase tracking-wide text-muted-foreground text-right">Unit Price</TableHead>
<TableHead className="text-[11px] uppercase tracking-wide text-muted-foreground text-right">Amount</TableHead>
{canEditLineItems && <TableHead></TableHead>}
</TableRow>
</TableHeader>
@@ -539,20 +509,20 @@ export default function BillDetailPage({ boardAssociationIds }: { boardAssociati
</TableCell>
</TableRow>
)}
{lineItems.length > 0 && (
<TableRow className="border-t-2 bg-muted/30 hover:bg-muted/30 font-semibold">
<TableCell colSpan={5} className="text-right">Total</TableCell>
<TableCell className="text-right">{money(lineTotal)}</TableCell>
{canEditLineItems && <TableCell></TableCell>}
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</SectionCard>
)}
{/* Comments & Discussion */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<FileText className="h-5 w-5 text-primary" /> Comments & Discussion
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<SectionCard title="Comments & discussion" bodyClass="pt-4 space-y-4">
{comments.length === 0 && (
<p className="text-sm text-muted-foreground italic">No comments yet.</p>
)}
@@ -588,22 +558,32 @@ export default function BillDetailPage({ boardAssociationIds }: { boardAssociati
<Send className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
</SectionCard>
</div>
{/* Right column */}
<div className="space-y-6">
<div className="space-y-5">
{/* Bill amount */}
<SectionCard title="Bill amount">
<Lbl>Remaining</Lbl>
<p className="text-3xl font-bold mt-1 tracking-tight">{money(remaining)}</p>
{Number(bill.amount_paid || 0) > 0 && (
<p className="text-xs text-muted-foreground mt-1.5">
{money(bill.amount_paid)} paid of {money(bill.amount)}
</p>
)}
</SectionCard>
{/* Attachment Preview */}
{attachmentUrl ? (
<Card>
<CardHeader className="flex-row items-center justify-between space-y-0">
<CardTitle className="text-base flex items-center gap-2">
<FileText className="h-4 w-4" />
<span className="truncate max-w-[300px]">{attachmentFilename}</span>
<Card className="overflow-hidden shadow-sm">
<CardHeader className="py-3 bg-muted/40 border-b flex-row items-center justify-between space-y-0">
<CardTitle className="text-sm font-semibold flex items-center gap-2 min-w-0">
<FileText className="h-4 w-4 shrink-0" />
<span className="truncate">{attachmentFilename}</span>
</CardTitle>
<a href={attachmentUrl} target="_blank" rel="noopener noreferrer">
<Button variant="outline" size="sm" className="gap-2">
<Button variant="outline" size="sm" className="gap-2 h-8">
<Download className="h-4 w-4" /> Download
</Button>
</a>
@@ -631,95 +611,53 @@ export default function BillDetailPage({ boardAssociationIds }: { boardAssociati
</Card>
)}
{/* Approvers */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Clock className="h-5 w-5 text-primary" /> Approvers
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{approvals.length === 0 ? (
<div className="px-6 pb-6 text-sm text-muted-foreground italic">
No approvers requested yet. Use the &ldquo;Request Approval&rdquo; button above to add one.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Approver</TableHead>
<TableHead>Status</TableHead>
<TableHead>Comments</TableHead>
<TableHead>Date</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{approvals.map((a) => (
<TableRow key={a.id}>
<TableCell>
<div>
<p className="text-sm font-medium">{a.approver_name || "—"}</p>
</div>
</TableCell>
<TableCell>
<Badge className={statusColors[a.status] || "bg-muted text-muted-foreground"}>
{a.status?.charAt(0).toUpperCase() + a.status?.slice(1)}
</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">{a.notes || "—"}</TableCell>
<TableCell className="text-sm">
{a.approved_date
? new Date(a.approved_date).toLocaleString("en-US", {
month: "short", day: "numeric", year: "numeric",
hour: "numeric", minute: "2-digit",
})
: new Date(a.created_at).toLocaleString("en-US", {
month: "short", day: "numeric", year: "numeric",
hour: "numeric", minute: "2-digit",
})}
</TableCell>
<TableCell>
<div className="flex gap-1">
{a.status === "pending" && (isBoardView ? userBoardMemberNames.includes(a.approver_name) : true) && (
<>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-primary hover:text-primary/80"
onClick={() => handleApprovalAction(a.id, "approved")}
>
<CheckCircle className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive/80"
onClick={() => handleApprovalAction(a.id, "denied")}
>
<XCircle className="h-4 w-4" />
</Button>
</>
)}
{!isBoardView && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
onClick={() => handleDeleteApproval(a.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* Approval */}
<SectionCard title="Approval" bodyClass="p-0">
{approvals.length === 0 ? (
<div className="px-5 py-5 text-sm text-muted-foreground italic">
No approvers requested yet. Use the &ldquo;Request Approval&rdquo; button above to add one.
</div>
) : (
<div className="divide-y">
{approvals.map((a) => {
const when = a.approved_date || a.created_at;
return (
<div key={a.id} className="px-5 py-3 flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
{a.status === "approved" && <CheckCircle className="h-4 w-4 text-emerald-600 shrink-0" />}
{(a.status === "denied" || a.status === "rejected") && <XCircle className="h-4 w-4 text-red-600 shrink-0" />}
{a.status === "pending" && <Clock className="h-4 w-4 text-amber-500 shrink-0" />}
<span className="text-sm font-medium truncate">{a.approver_name || "—"}</span>
</div>
<div className="text-xs text-muted-foreground mt-0.5">
{cap(a.status)} · {new Date(when).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
</div>
{a.notes && <p className="text-xs text-muted-foreground mt-1">{a.notes}</p>}
</div>
<div className="flex gap-1 shrink-0">
{a.status === "pending" && (isBoardView ? userBoardMemberNames.includes(a.approver_name) : true) && (
<>
<Button variant="ghost" size="icon" className="h-7 w-7 text-primary hover:text-primary/80" onClick={() => handleApprovalAction(a.id, "approved")}>
<CheckCircle className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive hover:text-destructive/80" onClick={() => handleApprovalAction(a.id, "denied")}>
<XCircle className="h-4 w-4" />
</Button>
</>
)}
{!isBoardView && (
<Button variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:text-destructive" onClick={() => handleDeleteApproval(a.id)}>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</div>
);
})}
</div>
)}
</SectionCard>
</div>
</div>
+133 -3
View File
@@ -384,6 +384,26 @@ export default function AccountingBillsPage() {
const [payMemo, setPayMemo] = useState("");
const [printCheck, setPrintCheck] = useState(true);
const [paying, setPaying] = useState(false);
// "new" = create a fresh withdrawal; "existing" = settle the bill with a bank
// payment that's already in the register (no new transaction created).
const [payMode, setPayMode] = useState<"new" | "existing">("new");
const [existingTxnId, setExistingTxnId] = useState("");
// Unmatched outgoing bank payments (no bill linked yet) that could settle a bill.
const { data: unmatchedPayments = [] } = useQuery({
queryKey: ["unmatched-payments", cid, payBill?.id],
enabled: !!cid && !!payBill,
queryFn: async () =>
(await accounting
.from("transactions")
.select("id,date,amount,description,reference,vendor_id,account_id,coa_account_id")
.eq("company_id", cid)
.eq("type", "debit")
.is("bill_id", null)
.or("voided.is.null,voided.eq.false")
.order("date", { ascending: false })
.limit(300)).data ?? [],
});
const { data: bankAccounts = [] } = useQuery({
queryKey: ["bank-accounts", cid, "bills-payment"],
@@ -401,6 +421,8 @@ export default function AccountingBillsPage() {
setPayMethod("check");
setPayMemo("");
setPrintCheck(true);
setPayMode("new");
setExistingTxnId("");
const def = (bankAccounts as any[])[0]?.id ?? "";
setPayAccountId(def);
const { data: cs } = await accounting.from("check_settings").select("next_check_number").eq("company_id", cid).maybeSingle();
@@ -564,6 +586,44 @@ export default function AccountingBillsPage() {
}
};
// Settle a bill by linking an existing bank payment instead of creating a new
// withdrawal. Setting bill_id + clearing coa_account_id reclassifies the
// transaction to Dr Accounts Payable / Cr Bank (clears the payable, no second
// P&L hit). No check record is created — the money already left the bank.
const applyExisting = async () => {
if (!payBill) return;
const txn = (unmatchedPayments as any[]).find((t) => t.id === existingTxnId);
if (!txn) return toast.error("Select a payment to apply");
setPaying(true);
try {
const vendorName = payBill.vendors?.name ?? "Vendor";
await accounting.from("transactions").update({
bill_id: payBill.id,
coa_account_id: null, // bill-linked ⇒ posts against A/P
vendor_id: payBill.vendor_id ?? txn.vendor_id ?? null,
category: `Bill Payment · ${vendorName} · Bill ${payBill.number}`,
}).eq("id", txn.id);
const newPaid = Number(payBill.paid_amount ?? 0) + Number(txn.amount);
await accounting.from("bills").update({
paid_amount: newPaid,
status: newPaid >= Number(payBill.total) - 0.005 ? "paid" : "partially_paid",
}).eq("id", payBill.id);
toast.success("Existing payment applied to bill");
setPayBill(null);
qc.invalidateQueries({ queryKey: ["bills", cid] });
qc.invalidateQueries({ queryKey: ["bank-accounts", cid] });
qc.invalidateQueries({ queryKey: ["transactions", cid] });
qc.invalidateQueries({ queryKey: ["accounts", cid] });
qc.invalidateQueries({ queryKey: ["unmatched-payments", cid] });
} catch (e: any) {
toast.error(e?.message ?? "Failed to apply payment");
} finally {
setPaying(false);
}
};
const remove = async (id: string) => {
await accounting.from("bills").delete().eq("id", id);
qc.invalidateQueries({ queryKey: ["bills", cid] });
@@ -883,6 +943,69 @@ export default function AccountingBillsPage() {
Vendor: <b>{payBill.vendors?.name ?? "—"}</b> · Balance:{" "}
<b>{money(payBill.balance, cur)}</b>
</div>
{/* Mode toggle: create a new withdrawal, or settle with a payment
that's already in the bank register. */}
<div className="grid grid-cols-2 gap-2">
<Button type="button" size="sm" variant={payMode === "new" ? "default" : "outline"} onClick={() => setPayMode("new")}>
New payment
</Button>
<Button type="button" size="sm" variant={payMode === "existing" ? "default" : "outline"} onClick={() => setPayMode("existing")}>
Apply existing payment
</Button>
</div>
{payMode === "existing" ? (
<div className="space-y-2">
<div className="text-xs text-muted-foreground">
Pick a bank payment already in the register to settle this bill no new withdrawal is created. Same-vendor and exact-amount matches are listed first.
</div>
<div className="max-h-72 overflow-y-auto rounded-md border divide-y">
{(() => {
const bal = Number(payBill.balance);
const cands = [...(unmatchedPayments as any[])].sort((a, b) => {
const score = (t: any) =>
(payBill.vendor_id && t.vendor_id === payBill.vendor_id ? 2 : 0) +
(Math.abs(Number(t.amount) - bal) < 0.005 ? 1 : 0);
return score(b) - score(a);
});
if (cands.length === 0)
return <div className="p-4 text-sm text-muted-foreground text-center">No unmatched bank payments found.</div>;
return cands.map((t) => {
const acct = (bankAccounts as any[]).find((a) => a.id === t.account_id);
const sel = existingTxnId === t.id;
const sameVendor = payBill.vendor_id && t.vendor_id === payBill.vendor_id;
const sameAmt = Math.abs(Number(t.amount) - bal) < 0.005;
return (
<button
key={t.id} type="button" onClick={() => setExistingTxnId(t.id)}
className={cn("w-full text-left p-2.5 hover:bg-muted/50 flex items-start justify-between gap-3", sel && "bg-primary/10")}
>
<div className="min-w-0">
<div className="text-sm font-medium truncate">{t.description || "Payment"}</div>
<div className="text-xs text-muted-foreground">
{fmtDate(t.date)}{acct ? ` · ${acct.name}` : ""}{t.reference ? ` · ${t.reference}` : ""}
</div>
{(sameVendor || sameAmt) && (
<div className="flex gap-1 mt-1">
{sameVendor && <span className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-500/15 text-emerald-700">Same vendor</span>}
{sameAmt && <span className="text-[10px] px-1.5 py-0.5 rounded bg-primary/15 text-primary">Exact amount</span>}
</div>
)}
</div>
<div className="text-sm font-semibold whitespace-nowrap">{money(t.amount, cur)}</div>
</button>
);
});
})()}
</div>
{existingTxnId && (
<div className="text-xs text-muted-foreground">
Applies {money(Number((unmatchedPayments as any[]).find((t) => t.id === existingTxnId)?.amount ?? 0), cur)} to this bill.
</div>
)}
</div>
) : (
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Payment date</Label>
@@ -938,13 +1061,20 @@ export default function AccountingBillsPage() {
</div>
)}
</div>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setPayBill(null)}>Cancel</Button>
<Button onClick={savePayment} disabled={paying}>
{paying ? "Saving…" : payMethod === "check" && printCheck ? "Save & Print" : "Save payment"}
</Button>
{payMode === "existing" ? (
<Button onClick={applyExisting} disabled={paying || !existingTxnId}>
{paying ? "Applying…" : "Apply payment"}
</Button>
) : (
<Button onClick={savePayment} disabled={paying}>
{paying ? "Saving…" : payMethod === "check" && printCheck ? "Save & Print" : "Save payment"}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>