diff --git a/src/pages/BillDetailPage.tsx b/src/pages/BillDetailPage.tsx index 032d20c..9fa6332 100644 --- a/src/pages/BillDetailPage.tsx +++ b/src/pages/BillDetailPage.tsx @@ -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 }) => ( +
{children}
+ ); + const Field = ({ label, value }: { label: string; value: ReactNode }) => ( +
{label}
{value || "—"}
+ ); + const SectionCard = ({ title, action, children, bodyClass = "pt-5" }: any) => ( + + + {title} + {action} + + {children} + + ); + return ( -
- {/* Back + Header */} -
-
- +
+ {/* Header */} +
+ +
-
-

Bill Details

- - {displayStatus.charAt(0).toUpperCase() + displayStatus.slice(1).replace("_", " ")} - +
+

{vendorName}

+ {cap(displayStatus)}
-

ID: {bill.id}

+

+ Bill · {money(bill.amount)} · Due {bill.due_date || "—"} +

+ {!isBoardView && ( +
+ {bill.status === "pending" && ( + + )} + {isAdmin && (bill.status === "pending" || bill.status === "approved") && ( + + )} + {isAdmin && bill.status === "paid" && ( + + )} +
+ )}
- {!isBoardView && ( -
- {bill.status === "pending" && ( - - )} - {isAdmin && (bill.status === "pending" || bill.status === "approved") && ( - - )} - {isAdmin && bill.status === "paid" && ( - - )} -
- )}
{/* Main content grid */} -
+
{/* Left column */} -
- {/* General Info */} - - - - General Info - - - +
+ {/* Bill details */} + +
+ + + + +
-
- Association -
-

{bill.associations?.name || "—"}

-
- -
-
- GL Account -
+ GL account {isBoardView ? ( -

+

{bill.chart_of_accounts ? `${bill.chart_of_accounts.account_number} - ${bill.chart_of_accounts.account_name}` - : Not assigned} -

+ : Not assigned} +
) : ( )}
- - - -
-

Vendor

-

{vendorName}

-
- -
-
-

Total Amount

-

- - {Number(bill.amount).toLocaleString("en-US", { minimumFractionDigits: 2 })} -

-
-
-

Due Date

-

- - {bill.due_date || "—"} -

-
-
- - {bill.invoice_number && ( -
-

Invoice #

-

{bill.invoice_number}

-
- )} - - {bill.description && ( -
-

Description

-

{bill.description}

-
- )} - - +
+
+ Memo +
{bill.description || "—"}
+
+
{(lineItems.length > 0 || canEditLineItems) && ( - - -
- - Invoice Line Items - - {canEditLineItems && ( -
- - -
- )} + + +
-
- + )} + > - - Item - Description - GL Account - Qty - Unit Price - Amount + + Item + Description + Account + Qty + Unit Price + Amount {canEditLineItems && } @@ -539,20 +509,20 @@ export default function BillDetailPage({ boardAssociationIds }: { boardAssociati )} + {lineItems.length > 0 && ( + + Total + {money(lineTotal)} + {canEditLineItems && } + + )}
-
-
+ )} {/* Comments & Discussion */} - - - - Comments & Discussion - - - + {comments.length === 0 && (

No comments yet.

)} @@ -588,22 +558,32 @@ export default function BillDetailPage({ boardAssociationIds }: { boardAssociati
-
-
+
{/* Right column */} -
+
+ {/* Bill amount */} + + Remaining +

{money(remaining)}

+ {Number(bill.amount_paid || 0) > 0 && ( +

+ {money(bill.amount_paid)} paid of {money(bill.amount)} +

+ )} +
+ {/* Attachment Preview */} {attachmentUrl ? ( - - - - - {attachmentFilename} + + + + + {attachmentFilename} - @@ -631,95 +611,53 @@ export default function BillDetailPage({ boardAssociationIds }: { boardAssociati )} - {/* Approvers */} - - - - Approvers - - - - {approvals.length === 0 ? ( -
- No approvers requested yet. Use the “Request Approval” button above to add one. -
- ) : ( - - - - Approver - Status - Comments - Date - Actions - - - - {approvals.map((a) => ( - - -
-

{a.approver_name || "—"}

-
-
- - - {a.status?.charAt(0).toUpperCase() + a.status?.slice(1)} - - - {a.notes || "—"} - - {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", - })} - - -
- {a.status === "pending" && (isBoardView ? userBoardMemberNames.includes(a.approver_name) : true) && ( - <> - - - - )} - {!isBoardView && ( - - )} -
-
-
- ))} -
-
- )} -
-
+ {/* Approval */} + + {approvals.length === 0 ? ( +
+ No approvers requested yet. Use the “Request Approval” button above to add one. +
+ ) : ( +
+ {approvals.map((a) => { + const when = a.approved_date || a.created_at; + return ( +
+
+
+ {a.status === "approved" && } + {(a.status === "denied" || a.status === "rejected") && } + {a.status === "pending" && } + {a.approver_name || "—"} +
+
+ {cap(a.status)} · {new Date(when).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })} +
+ {a.notes &&

{a.notes}

} +
+
+ {a.status === "pending" && (isBoardView ? userBoardMemberNames.includes(a.approver_name) : true) && ( + <> + + + + )} + {!isBoardView && ( + + )} +
+
+ ); + })} +
+ )} +
diff --git a/src/pages/accounting/AccountingBillsPage.tsx b/src/pages/accounting/AccountingBillsPage.tsx index 6078620..eab97ee 100644 --- a/src/pages/accounting/AccountingBillsPage.tsx +++ b/src/pages/accounting/AccountingBillsPage.tsx @@ -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: {payBill.vendors?.name ?? "—"} · Balance:{" "} {money(payBill.balance, cur)}
+ + {/* Mode toggle: create a new withdrawal, or settle with a payment + that's already in the bank register. */} +
+ + +
+ + {payMode === "existing" ? ( +
+
+ 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. +
+
+ {(() => { + 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
No unmatched bank payments found.
; + 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 ( + + ); + }); + })()} +
+ {existingTxnId && ( +
+ Applies {money(Number((unmatchedPayments as any[]).find((t) => t.id === existingTxnId)?.amount ?? 0), cur)} to this bill. +
+ )} +
+ ) : (
@@ -938,13 +1061,20 @@ export default function AccountingBillsPage() {
)}
+ )}
)} - + {payMode === "existing" ? ( + + ) : ( + + )}