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
+125 -187
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 { useParams, useNavigate } from "react-router-dom";
import { supabase } from "@/integrations/supabase/client"; import { supabase } from "@/integrations/supabase/client";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import { import {
ArrowLeft, Building2, CreditCard, Calendar, DollarSign, ArrowLeft, FileText, Download, Send, Save, Plus,
FileText, Download, ChevronLeft, ChevronRight, Send, Save, Plus,
CheckCircle, XCircle, Trash2, Clock CheckCircle, XCircle, Trash2, Clock
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; 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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Separator } from "@/components/ui/separator";
import BillApprovalRequestDialog from "@/components/BillApprovalRequestDialog"; import BillApprovalRequestDialog from "@/components/BillApprovalRequestDialog";
import ChartOfAccountsDropdown from "@/components/ChartOfAccountsDropdown.jsx"; import ChartOfAccountsDropdown from "@/components/ChartOfAccountsDropdown.jsx";
@@ -332,23 +330,42 @@ export default function BillDetailPage({ boardAssociationIds }: { boardAssociati
const lineItems = editableLineItems; const lineItems = editableLineItems;
const canEditLineItems = !isBoardView && !!bill.source_invoice_id; 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 ( return (
<div className="space-y-6 max-w-7xl mx-auto"> <div className="space-y-5 max-w-7xl mx-auto">
{/* Back + Header */} {/* 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> <div>
<div className="flex items-center gap-3"> <Button variant="ghost" size="sm" onClick={() => navigate(-1)} className="gap-1 -ml-2 mb-2 text-muted-foreground">
<h1 className="text-2xl font-bold text-foreground">Bill Details</h1> <ArrowLeft className="h-4 w-4" /> Back
<Badge className={statusColors[displayStatus] || "bg-muted text-muted-foreground"}> </Button>
{displayStatus.charAt(0).toUpperCase() + displayStatus.slice(1).replace("_", " ")} <div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
</Badge> <div>
</div> <div className="flex items-center gap-3 flex-wrap">
<p className="text-sm text-muted-foreground font-mono mt-0.5">ID: {bill.id}</p> <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> </div>
<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> </div>
{!isBoardView && ( {!isBoardView && (
<div className="flex gap-2"> <div className="flex gap-2">
@@ -370,36 +387,28 @@ export default function BillDetailPage({ boardAssociationIds }: { boardAssociati
</div> </div>
)} )}
</div> </div>
</div>
{/* Main content grid */} {/* 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 */} {/* Left column */}
<div className="space-y-6"> <div className="lg:col-span-2 space-y-5">
{/* General Info */} {/* Bill details */}
<Card> <SectionCard title="Bill details">
<CardHeader> <div className="grid grid-cols-2 md:grid-cols-3 gap-y-5 gap-x-4">
<CardTitle className="text-lg flex items-center gap-2"> <Field label="Date" value={bill.bill_date} />
<FileText className="h-5 w-5 text-primary" /> General Info <Field label="Due" value={bill.due_date} />
</CardTitle> <Field label="Pay to" value={vendorName} />
</CardHeader> <Field label="Reference number" value={bill.invoice_number} />
<CardContent className="space-y-4"> <Field label="Association" value={bill.associations?.name} />
<div> <div>
<div className="flex items-center gap-2 text-xs text-muted-foreground uppercase font-semibold mb-1"> <Lbl>GL account</Lbl>
<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>
{isBoardView ? ( {isBoardView ? (
<p className="text-sm"> <div className="text-sm mt-0.5 font-medium">
{bill.chart_of_accounts {bill.chart_of_accounts
? `${bill.chart_of_accounts.account_number} - ${bill.chart_of_accounts.account_name}` ? `${bill.chart_of_accounts.account_number} - ${bill.chart_of_accounts.account_name}`
: <span className="italic text-muted-foreground">Not assigned</span>} : <span className="italic text-muted-foreground font-normal">Not assigned</span>}
</p> </div>
) : ( ) : (
<ChartOfAccountsDropdown <ChartOfAccountsDropdown
value={bill.expense_account_id || ""} value={bill.expense_account_id || ""}
@@ -407,80 +416,41 @@ export default function BillDetailPage({ boardAssociationIds }: { boardAssociati
associationId={bill.association_id} associationId={bill.association_id}
accountType="expense" accountType="expense"
placeholder={savingAccount ? "Saving..." : "Select GL account"} placeholder={savingAccount ? "Saving..." : "Select GL account"}
className="max-w-md" className="mt-1"
/> />
)} )}
</div> </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>
<div className="mt-5">
<div className="flex gap-8"> <Lbl>Memo</Lbl>
<div> <div className="text-sm mt-0.5">{bill.description || "—"}</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>
<div> </SectionCard>
<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>
{(lineItems.length > 0 || canEditLineItems) && ( {(lineItems.length > 0 || canEditLineItems) && (
<Card> <SectionCard
<CardHeader> title="Item details"
<div className="flex items-center justify-between"> bodyClass="p-0"
<CardTitle className="text-lg flex items-center gap-2"> action={canEditLineItems && (
<FileText className="h-5 w-5 text-primary" /> Invoice Line Items
</CardTitle>
{canEditLineItems && (
<div className="flex gap-2"> <div className="flex gap-2">
<Button size="sm" variant="outline" onClick={addLineItem} className="gap-1"> <Button size="sm" variant="outline" onClick={addLineItem} className="gap-1 h-8">
<Plus className="h-3 w-3" /> Add Line <Plus className="h-3 w-3" /> Add Line
</Button> </Button>
<Button size="sm" onClick={saveLineItems} disabled={savingLineItems} className="gap-1"> <Button size="sm" onClick={saveLineItems} disabled={savingLineItems} className="gap-1 h-8">
<Save className="h-3 w-3" /> {savingLineItems ? "Saving..." : "Save Lines"} <Save className="h-3 w-3" /> {savingLineItems ? "Saving..." : "Save Lines"}
</Button> </Button>
</div> </div>
)} )}
</div> >
</CardHeader>
<CardContent className="p-0">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow className="bg-muted/20 hover:bg-muted/20">
<TableHead>Item</TableHead> <TableHead className="text-[11px] uppercase tracking-wide text-muted-foreground">Item</TableHead>
<TableHead>Description</TableHead> <TableHead className="text-[11px] uppercase tracking-wide text-muted-foreground">Description</TableHead>
<TableHead className="min-w-[200px]">GL Account</TableHead> <TableHead className="text-[11px] uppercase tracking-wide text-muted-foreground min-w-[200px]">Account</TableHead>
<TableHead className="text-right">Qty</TableHead> <TableHead className="text-[11px] uppercase tracking-wide text-muted-foreground text-right">Qty</TableHead>
<TableHead className="text-right">Unit Price</TableHead> <TableHead className="text-[11px] uppercase tracking-wide text-muted-foreground text-right">Unit Price</TableHead>
<TableHead className="text-right">Amount</TableHead> <TableHead className="text-[11px] uppercase tracking-wide text-muted-foreground text-right">Amount</TableHead>
{canEditLineItems && <TableHead></TableHead>} {canEditLineItems && <TableHead></TableHead>}
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -539,20 +509,20 @@ export default function BillDetailPage({ boardAssociationIds }: { boardAssociati
</TableCell> </TableCell>
</TableRow> </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> </TableBody>
</Table> </Table>
</CardContent> </SectionCard>
</Card>
)} )}
{/* Comments & Discussion */} {/* Comments & Discussion */}
<Card> <SectionCard title="Comments & discussion" bodyClass="pt-4 space-y-4">
<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">
{comments.length === 0 && ( {comments.length === 0 && (
<p className="text-sm text-muted-foreground italic">No comments yet.</p> <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" /> <Send className="h-4 w-4" />
</Button> </Button>
</div> </div>
</CardContent> </SectionCard>
</Card>
</div> </div>
{/* Right column */} {/* 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 */} {/* Attachment Preview */}
{attachmentUrl ? ( {attachmentUrl ? (
<Card> <Card className="overflow-hidden shadow-sm">
<CardHeader className="flex-row items-center justify-between space-y-0"> <CardHeader className="py-3 bg-muted/40 border-b flex-row items-center justify-between space-y-0">
<CardTitle className="text-base flex items-center gap-2"> <CardTitle className="text-sm font-semibold flex items-center gap-2 min-w-0">
<FileText className="h-4 w-4" /> <FileText className="h-4 w-4 shrink-0" />
<span className="truncate max-w-[300px]">{attachmentFilename}</span> <span className="truncate">{attachmentFilename}</span>
</CardTitle> </CardTitle>
<a href={attachmentUrl} target="_blank" rel="noopener noreferrer"> <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 <Download className="h-4 w-4" /> Download
</Button> </Button>
</a> </a>
@@ -631,95 +611,53 @@ export default function BillDetailPage({ boardAssociationIds }: { boardAssociati
</Card> </Card>
)} )}
{/* Approvers */} {/* Approval */}
<Card> <SectionCard title="Approval" bodyClass="p-0">
<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 ? ( {approvals.length === 0 ? (
<div className="px-6 pb-6 text-sm text-muted-foreground italic"> <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. No approvers requested yet. Use the &ldquo;Request Approval&rdquo; button above to add one.
</div> </div>
) : ( ) : (
<Table> <div className="divide-y">
<TableHeader> {approvals.map((a) => {
<TableRow> const when = a.approved_date || a.created_at;
<TableHead>Approver</TableHead> return (
<TableHead>Status</TableHead> <div key={a.id} className="px-5 py-3 flex items-start justify-between gap-3">
<TableHead>Comments</TableHead> <div className="min-w-0">
<TableHead>Date</TableHead> <div className="flex items-center gap-2">
<TableHead>Actions</TableHead> {a.status === "approved" && <CheckCircle className="h-4 w-4 text-emerald-600 shrink-0" />}
</TableRow> {(a.status === "denied" || a.status === "rejected") && <XCircle className="h-4 w-4 text-red-600 shrink-0" />}
</TableHeader> {a.status === "pending" && <Clock className="h-4 w-4 text-amber-500 shrink-0" />}
<TableBody> <span className="text-sm font-medium truncate">{a.approver_name || "—"}</span>
{approvals.map((a) => (
<TableRow key={a.id}>
<TableCell>
<div>
<p className="text-sm font-medium">{a.approver_name || "—"}</p>
</div> </div>
</TableCell> <div className="text-xs text-muted-foreground mt-0.5">
<TableCell> {cap(a.status)} · {new Date(when).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
<Badge className={statusColors[a.status] || "bg-muted text-muted-foreground"}> </div>
{a.status?.charAt(0).toUpperCase() + a.status?.slice(1)} {a.notes && <p className="text-xs text-muted-foreground mt-1">{a.notes}</p>}
</Badge> </div>
</TableCell> <div className="flex gap-1 shrink-0">
<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) && ( {a.status === "pending" && (isBoardView ? userBoardMemberNames.includes(a.approver_name) : true) && (
<> <>
<Button <Button variant="ghost" size="icon" className="h-7 w-7 text-primary hover:text-primary/80" onClick={() => handleApprovalAction(a.id, "approved")}>
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" /> <CheckCircle className="h-4 w-4" />
</Button> </Button>
<Button <Button variant="ghost" size="icon" className="h-7 w-7 text-destructive hover:text-destructive/80" onClick={() => handleApprovalAction(a.id, "denied")}>
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" /> <XCircle className="h-4 w-4" />
</Button> </Button>
</> </>
)} )}
{!isBoardView && ( {!isBoardView && (
<Button <Button variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:text-destructive" onClick={() => handleDeleteApproval(a.id)}>
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" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
)} )}
</div> </div>
</TableCell> </div>
</TableRow> );
))} })}
</TableBody> </div>
</Table>
)} )}
</CardContent> </SectionCard>
</Card>
</div> </div>
</div> </div>
@@ -384,6 +384,26 @@ export default function AccountingBillsPage() {
const [payMemo, setPayMemo] = useState(""); const [payMemo, setPayMemo] = useState("");
const [printCheck, setPrintCheck] = useState(true); const [printCheck, setPrintCheck] = useState(true);
const [paying, setPaying] = useState(false); 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({ const { data: bankAccounts = [] } = useQuery({
queryKey: ["bank-accounts", cid, "bills-payment"], queryKey: ["bank-accounts", cid, "bills-payment"],
@@ -401,6 +421,8 @@ export default function AccountingBillsPage() {
setPayMethod("check"); setPayMethod("check");
setPayMemo(""); setPayMemo("");
setPrintCheck(true); setPrintCheck(true);
setPayMode("new");
setExistingTxnId("");
const def = (bankAccounts as any[])[0]?.id ?? ""; const def = (bankAccounts as any[])[0]?.id ?? "";
setPayAccountId(def); setPayAccountId(def);
const { data: cs } = await accounting.from("check_settings").select("next_check_number").eq("company_id", cid).maybeSingle(); 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) => { const remove = async (id: string) => {
await accounting.from("bills").delete().eq("id", id); await accounting.from("bills").delete().eq("id", id);
qc.invalidateQueries({ queryKey: ["bills", cid] }); qc.invalidateQueries({ queryKey: ["bills", cid] });
@@ -883,6 +943,69 @@ export default function AccountingBillsPage() {
Vendor: <b>{payBill.vendors?.name ?? "—"}</b> · Balance:{" "} Vendor: <b>{payBill.vendors?.name ?? "—"}</b> · Balance:{" "}
<b>{money(payBill.balance, cur)}</b> <b>{money(payBill.balance, cur)}</b>
</div> </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 className="grid grid-cols-2 gap-3">
<div> <div>
<Label>Payment date</Label> <Label>Payment date</Label>
@@ -938,13 +1061,20 @@ export default function AccountingBillsPage() {
</div> </div>
)} )}
</div> </div>
)}
</div> </div>
)} )}
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setPayBill(null)}>Cancel</Button> <Button variant="outline" onClick={() => setPayBill(null)}>Cancel</Button>
{payMode === "existing" ? (
<Button onClick={applyExisting} disabled={paying || !existingTxnId}>
{paying ? "Applying…" : "Apply payment"}
</Button>
) : (
<Button onClick={savePayment} disabled={paying}> <Button onClick={savePayment} disabled={paying}>
{paying ? "Saving…" : payMethod === "check" && printCheck ? "Save & Print" : "Save payment"} {paying ? "Saving…" : payMethod === "check" && printCheck ? "Save & Print" : "Save payment"}
</Button> </Button>
)}
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>