mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
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:
+170
-232
@@ -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 “Request Approval” 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 “Request Approval” 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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user