Files
acmcc/src/pages/BillDetailPage.tsx
T
admin e302fb91f0 Accounting platform: remove Zoho, unify reports, board access, vendor sharing
- Remove the Zoho Books integration (edge functions, sync libs, settings,
  reports/overview, banking links, fees tab, import dialog); preserve fee
  rules as a standalone FeesTab and the COA accounting_system classification.
- Financial Overview/Reports (staff + board) render the Accounting dashboard
  and reports; board reports mirror the rich Accounting Reports.
- New Reserve Fund Schedule report + an is_reserve flag on accounts.
- Unify all report exports to a branded format (logo + centered header +
  footer): shared ReportSheet (on-screen) and reportHeader (PDF). Budget vs
  Actuals and Bank Reconciliation PDFs now match the reference layout.
- Render financial reports inline (no preview pop-up).
- Budget Management mirrors Accounting Budgeting (staff-accessible) with SPA
  navigation; editable bills in the Accounting Bills page.
- Negative opening balances flow through to the GL and reports (allow negative
  input; keep non-zero on save; signed CSV import).
- Upload a per-account trial balance via CSV on Opening Balances.
- Board members: read-only RLS access to their association's accounting ledger;
  editable board-members panel on the association page; share vendor contacts
  with the board (toggle + directory section).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 18:29:31 -04:00

779 lines
34 KiB
TypeScript

import { useState, useEffect, useCallback } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { supabase } from "@/integrations/supabase/client";
import { useToast } from "@/hooks/use-toast";
import {
ArrowLeft, Building2, CreditCard, Calendar, DollarSign,
FileText, Download, ChevronLeft, ChevronRight, Send, Save, Plus,
CheckCircle, XCircle, Trash2, Clock
} from "lucide-react";
import { Button } from "@/components/ui/button";
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";
const statusColors: Record<string, string> = {
pending: "bg-amber-100 text-amber-700",
approved: "bg-emerald-100 text-emerald-700",
rejected: "bg-red-100 text-red-700",
paid: "bg-emerald-100 text-emerald-700",
overdue: "bg-red-100 text-red-700",
draft: "bg-muted text-muted-foreground",
denied: "bg-red-100 text-red-700",
};
export default function BillDetailPage({ boardAssociationIds }: { boardAssociationIds?: string[] } = {}) {
const isBoardView = !!boardAssociationIds?.length;
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { toast } = useToast();
const [bill, setBill] = useState<any>(null);
const [approvals, setApprovals] = useState<any[]>([]);
const [comments, setComments] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [commentText, setCommentText] = useState("");
const [submittingComment, setSubmittingComment] = useState(false);
const [currentUser, setCurrentUser] = useState<any>(null);
const [userProfile, setUserProfile] = useState<any>(null);
const [userBoardMemberNames, setUserBoardMemberNames] = useState<string[]>([]);
const [approvalDialogOpen, setApprovalDialogOpen] = useState(false);
const [savingAccount, setSavingAccount] = useState(false);
const [editableLineItems, setEditableLineItems] = useState<any[]>([]);
const [savingLineItems, setSavingLineItems] = useState(false);
const fetchBill = useCallback(async () => {
if (!id) return;
setLoading(true);
const [billRes, approvalsRes, commentsRes, userRes] = await Promise.all([
supabase
.from("bills")
.select("*, associations(name), chart_of_accounts(account_name, account_number), vendors(name), invoices:source_invoice_id(id, vendor_name, invoice_number, issue_date, due_date, amount, description, raw_pdf_url, line_items)")
.eq("id", id)
.single(),
supabase
.from("bill_approvals")
.select("*")
.or(`bill_id.eq.${id},invoice_id.eq.${id}`)
.order("created_at", { ascending: true }),
supabase
.from("bill_comments" as any)
.select("*")
.eq("bill_id", id)
.order("created_at", { ascending: true }),
supabase.auth.getUser(),
]);
if (billRes.error) {
toast({ variant: "destructive", title: "Error", description: "Bill not found." });
navigate(-1);
return;
}
if (boardAssociationIds?.length && !boardAssociationIds.includes(billRes.data.association_id)) {
toast({ variant: "destructive", title: "Access Denied", description: "You don't have access to this bill." });
navigate(-1);
return;
}
const linkedInvoice = billRes.data?.invoices;
setBill({
...billRes.data,
amount: linkedInvoice?.amount ?? billRes.data.amount,
due_date: linkedInvoice?.due_date ?? billRes.data.due_date,
bill_date: linkedInvoice?.issue_date ?? billRes.data.bill_date,
invoice_number: linkedInvoice?.invoice_number ?? billRes.data.invoice_number,
description: linkedInvoice?.description ?? billRes.data.description,
attachment_url: linkedInvoice?.raw_pdf_url ?? billRes.data.attachment_url,
notes: linkedInvoice?.vendor_name ?? billRes.data.notes,
line_items: linkedInvoice?.line_items ?? [],
});
setApprovals(approvalsRes.data || []);
setComments(commentsRes.data || []);
setEditableLineItems(Array.isArray(linkedInvoice?.line_items) ? linkedInvoice.line_items : []);
setCurrentUser(userRes.data?.user || null);
if (userRes.data?.user?.id) {
const [profileRes, bmRes] = await Promise.all([
supabase.from("profiles").select("full_name, email").eq("id", userRes.data.user.id).single(),
supabase.from("board_members").select("member_name").eq("user_id", userRes.data.user.id),
]);
setUserProfile(profileRes.data);
setUserBoardMemberNames((bmRes.data || []).map((bm: any) => bm.member_name));
}
setLoading(false);
}, [id, toast, navigate, boardAssociationIds]);
useEffect(() => { fetchBill(); }, [fetchBill]);
const handleAddComment = async () => {
if (!commentText.trim() || !id || !currentUser) return;
setSubmittingComment(true);
try {
const { error } = await supabase.from("bill_comments" as any).insert({
bill_id: id,
user_id: currentUser.id,
comment: commentText.trim(),
user_name: userProfile?.full_name || currentUser.email || "Unknown",
});
if (error) throw error;
setCommentText("");
fetchBill();
} catch (err: any) {
toast({ variant: "destructive", title: "Error", description: err.message });
} finally {
setSubmittingComment(false);
}
};
const handleApprovalAction = async (approvalId: string, action: "approved" | "denied") => {
try {
const { error } = await supabase
.from("bill_approvals")
.update({ status: action, approved_date: new Date().toISOString() })
.eq("id", approvalId);
if (error) throw error;
// Re-evaluate overall bill status based on all approval records
try {
const { data: allApprovals } = await supabase
.from("bill_approvals")
.select("status")
.eq("bill_id", id);
if (allApprovals && allApprovals.length > 0) {
const statuses = allApprovals.map((a: any) => a.status);
let newBillStatus: string | null = null;
if (statuses.some((s) => s === "denied")) {
newBillStatus = "denied";
} else if (statuses.every((s) => s === "approved")) {
newBillStatus = "approved";
}
if (newBillStatus && newBillStatus !== bill?.status) {
const updates: any = { status: newBillStatus, updated_at: new Date().toISOString() };
if (newBillStatus === "approved") {
updates.approved_date = new Date().toISOString().split("T")[0];
}
await supabase.from("bills").update(updates).eq("id", id);
}
}
} catch (statusErr) {
console.error("Failed to update bill status:", statusErr);
}
// Notify staff (admins/managers) that a board member has voted
try {
const approvalRecord = (bill?.bill_approvals || []).find((a: any) => a.id === approvalId);
const voterName = approvalRecord?.vendor_name || "A board member";
const billLabel = bill?.invoice_number ? `Bill #${bill.invoice_number}` : `Bill ${(id || "").slice(0, 8)}`;
const associationName = bill?.associations?.name || "an association";
const verb = action === "approved" ? "approved" : "denied";
const { data: staffRoles } = await supabase
.from("user_roles")
.select("user_id")
.in("role", ["admin", "manager"]);
const uniqueStaffIds = Array.from(new Set((staffRoles || []).map((r: any) => r.user_id).filter(Boolean)));
await Promise.allSettled(
uniqueStaffIds.map((uid: string) =>
supabase.rpc("insert_notification", {
p_user_id: uid,
p_type: "bill_approval",
p_title: `Bill ${action === "approved" ? "Approved" : "Denied"} by Board`,
p_message: `${voterName} ${verb} ${billLabel} for ${associationName}.`,
p_related_item_id: id,
p_related_item_type: "bill",
p_link: `/dashboard/bill-approvals/${id}`,
})
)
);
} catch (notifyErr) {
console.error("Failed to send staff notifications:", notifyErr);
}
toast({ title: "Updated", description: `Approval ${action}.` });
fetchBill();
} catch (err: any) {
toast({ variant: "destructive", title: "Error", description: err.message });
}
};
const handleDeleteApproval = async (approvalId: string) => {
try {
const { error } = await supabase.from("bill_approvals").delete().eq("id", approvalId);
if (error) throw error;
toast({ title: "Deleted", description: "Approval record removed." });
fetchBill();
} catch (err: any) {
toast({ variant: "destructive", title: "Error", description: err.message });
}
};
const updateBillStatus = async (status: string) => {
if (!id) return;
const updates: any = { status, updated_at: new Date().toISOString() };
if (status === "approved") updates.approved_date = new Date().toISOString().split("T")[0];
if (status === "paid") {
updates.paid_date = new Date().toISOString().split("T")[0];
updates.amount_paid = bill?.amount || 0;
}
if (status === "pending" || status === "approved") {
// Reverting to unpaid — clear paid metadata
updates.paid_date = null;
updates.amount_paid = 0;
}
const { error } = await supabase.from("bills").update(updates).eq("id", id);
if (error) {
toast({ variant: "destructive", title: "Error", description: error.message });
return;
}
toast({ title: "Updated", description: `Bill marked as ${status}.` });
fetchBill();
};
const requestApproval = () => {
if (!bill) return;
setApprovalDialogOpen(true);
};
const getStatusDisplay = (b: any) => {
if (b.status === "pending" && b.due_date && new Date(b.due_date) < new Date()) return "overdue";
return b.status;
};
const handleAccountChange = async (newAccountId: string) => {
if (!id) return;
setSavingAccount(true);
try {
const { error } = await supabase
.from("bills")
.update({ expense_account_id: newAccountId || null, updated_at: new Date().toISOString() })
.eq("id", id);
if (error) throw error;
toast({ title: "Updated", description: "GL account reassigned." });
fetchBill();
} catch (err: any) {
toast({ variant: "destructive", title: "Error", description: err.message });
} finally {
setSavingAccount(false);
}
};
const updateLineItem = (index: number, field: string, val: any) => {
setEditableLineItems(prev => prev.map((li, i) => i === index ? { ...li, [field]: val } : li));
};
const removeLineItem = (index: number) => {
setEditableLineItems(prev => prev.filter((_, i) => i !== index));
};
const addLineItem = () => {
setEditableLineItems(prev => [...prev, { name: "", description: "", quantity: 1, unit_price: 0, amount: 0, expense_account_id: null }]);
};
const saveLineItems = async () => {
if (!bill?.source_invoice_id) {
toast({ variant: "destructive", title: "Cannot save", description: "This bill has no linked invoice to store line items." });
return;
}
setSavingLineItems(true);
try {
const cleaned = editableLineItems.map(li => ({
...li,
quantity: li.quantity === "" || li.quantity == null ? null : Number(li.quantity),
unit_price: li.unit_price === "" || li.unit_price == null ? null : Number(li.unit_price),
amount: li.amount === "" || li.amount == null ? null : Number(li.amount),
}));
const { error } = await supabase
.from("invoices")
.update({ line_items: cleaned, updated_at: new Date().toISOString() })
.eq("id", bill.source_invoice_id);
if (error) throw error;
toast({ title: "Saved", description: "Line items updated." });
fetchBill();
} catch (err: any) {
toast({ variant: "destructive", title: "Error", description: err.message });
} finally {
setSavingLineItems(false);
}
};
if (loading) {
return (
<div className="flex justify-center py-24">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
);
}
if (!bill) return null;
const displayStatus = getStatusDisplay(bill);
const vendorName = bill.vendors?.name || bill.notes || bill.description || "—";
const attachmentUrl = bill.attachment_url;
const isPdf = attachmentUrl?.toLowerCase().endsWith(".pdf");
const attachmentFilename = attachmentUrl ? decodeURIComponent(attachmentUrl.split("/").pop() || "attachment") : null;
const lineItems = editableLineItems;
const canEditLineItems = !isBoardView && !!bill.source_invoice_id;
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>
<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>
<p className="text-sm text-muted-foreground font-mono mt-0.5">ID: {bill.id}</p>
</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>
)}
{(bill.status === "pending" || bill.status === "approved") && (
<Button onClick={() => updateBillStatus("paid")} className="gap-2">
<CheckCircle className="h-4 w-4" /> Mark Paid
</Button>
)}
{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">
{/* 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>
<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>
{isBoardView ? (
<p className="text-sm">
{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>
) : (
<ChartOfAccountsDropdown
value={bill.expense_account_id || ""}
onChange={handleAccountChange}
associationId={bill.association_id}
accountType="expense"
placeholder={savingAccount ? "Saving..." : "Select GL account"}
className="max-w-md"
/>
)}
</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>
{/* Requested Approvers */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Clock className="h-5 w-5 text-primary" /> Requested Approvers
</CardTitle>
</CardHeader>
<CardContent>
{approvals.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
No specific approvers requested during creation. You can request one above.
</p>
) : (
<div className="space-y-3">
{approvals.map((a) => (
<div key={a.id} className="flex items-center justify-between border rounded-lg p-3">
<div className="flex-1">
<p className="text-sm font-medium">{a.vendor_name}</p>
<p className="text-xs text-muted-foreground">
${Number(a.amount).toFixed(2)} · {new Date(a.created_at).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
</p>
{a.notes && <p className="text-xs text-muted-foreground mt-1">{a.notes}</p>}
</div>
<div className="flex items-center gap-2">
<Badge className={statusColors[a.status] || "bg-muted text-muted-foreground"}>
{a.status.charAt(0).toUpperCase() + a.status.slice(1)}
</Badge>
{a.status === "pending" && (boardAssociationIds ? userBoardMemberNames.includes(a.vendor_name) : true) && (
<>
<Button size="sm" variant="outline" onClick={() => handleApprovalAction(a.id, "approved")} className="h-7 px-2 text-xs gap-1">
<CheckCircle className="h-3 w-3" /> Approve
</Button>
<Button size="sm" variant="outline" onClick={() => handleApprovalAction(a.id, "denied")} className="h-7 px-2 text-xs gap-1 text-destructive">
<XCircle className="h-3 w-3" /> Deny
</Button>
</>
)}
{!boardAssociationIds && (
<Button size="sm" variant="ghost" onClick={() => handleDeleteApproval(a.id)} className="h-7 px-2 text-xs text-destructive">
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{(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>
)}
</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>
{canEditLineItems && <TableHead></TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{lineItems.map((item, index) => (
<TableRow key={index}>
<TableCell className="font-medium">
{canEditLineItems ? (
<Input value={item.name || ""} onChange={(e) => updateLineItem(index, "name", e.target.value)} className="h-8 text-sm" />
) : (item.name || "—")}
</TableCell>
<TableCell>
{canEditLineItems ? (
<Input value={item.description || ""} onChange={(e) => updateLineItem(index, "description", e.target.value)} className="h-8 text-sm" />
) : (item.description || "—")}
</TableCell>
<TableCell>
{canEditLineItems ? (
<ChartOfAccountsDropdown
value={item.expense_account_id || ""}
onChange={(v: string) => updateLineItem(index, "expense_account_id", v || null)}
associationId={bill.association_id}
accountType="expense"
placeholder="Select account"
/>
) : (item.account_name || "—")}
</TableCell>
<TableCell className="text-right">
{canEditLineItems ? (
<Input type="number" value={item.quantity ?? ""} onChange={(e) => updateLineItem(index, "quantity", e.target.value)} className="h-8 text-sm w-20 ml-auto" />
) : (item.quantity ?? "—")}
</TableCell>
<TableCell className="text-right">
{canEditLineItems ? (
<Input type="number" step="0.01" value={item.unit_price ?? ""} onChange={(e) => updateLineItem(index, "unit_price", e.target.value)} className="h-8 text-sm w-24 ml-auto" />
) : (item.unit_price != null ? `$${Number(item.unit_price).toFixed(2)}` : "—")}
</TableCell>
<TableCell className="text-right">
{canEditLineItems ? (
<Input type="number" step="0.01" value={item.amount ?? ""} onChange={(e) => updateLineItem(index, "amount", e.target.value)} className="h-8 text-sm w-24 ml-auto" />
) : `$${Number(item.amount ?? item.unit_price ?? 0).toFixed(2)}`}
</TableCell>
{canEditLineItems && (
<TableCell>
<Button size="icon" variant="ghost" onClick={() => removeLineItem(index)} className="h-7 w-7 text-destructive">
<Trash2 className="h-3.5 w-3.5" />
</Button>
</TableCell>
)}
</TableRow>
))}
{canEditLineItems && lineItems.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="text-center text-sm text-muted-foreground py-6">
No line items. Click "Add Line" to create one.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{/* 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">
{comments.length === 0 && (
<p className="text-sm text-muted-foreground italic">No comments yet.</p>
)}
{comments.map((c) => (
<div key={c.id} className="flex items-start gap-3">
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center text-xs font-bold text-primary shrink-0">
{(c.user_name || "?").charAt(0).toUpperCase()}
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{c.user_name || "Unknown"}</span>
<span className="text-xs text-muted-foreground">
{new Date(c.created_at).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
</span>
</div>
<p className="text-sm mt-0.5">{c.comment}</p>
</div>
</div>
))}
<div className="flex gap-2 pt-2">
<Input
placeholder="Type your comment here..."
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && handleAddComment()}
/>
<Button
size="icon"
onClick={handleAddComment}
disabled={!commentText.trim() || submittingComment}
>
<Send className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
</div>
{/* Right column */}
<div className="space-y-6">
{/* 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>
</CardTitle>
<a href={attachmentUrl} target="_blank" rel="noopener noreferrer">
<Button variant="outline" size="sm" className="gap-2">
<Download className="h-4 w-4" /> Download
</Button>
</a>
</CardHeader>
<CardContent className="p-0">
{isPdf ? (
<iframe
src={`${attachmentUrl}#toolbar=1&navpanes=0`}
className="w-full h-[500px] border-0"
title="PDF Preview"
/>
) : (
<div className="p-4">
<img src={attachmentUrl} alt="Bill attachment" className="w-full rounded-md border" />
</div>
)}
</CardContent>
</Card>
) : (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
<FileText className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p className="text-sm">No attachment uploaded for this bill.</p>
</CardContent>
</Card>
)}
{/* Approval History */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Clock className="h-5 w-5 text-primary" /> Approval History
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{approvals.length === 0 ? (
<div className="px-6 pb-6 text-sm text-muted-foreground italic">
No approval records yet.
</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.vendor_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.vendor_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>
</div>
</div>
<BillApprovalRequestDialog
open={approvalDialogOpen}
onOpenChange={setApprovalDialogOpen}
billId={bill.id}
clientId={bill.association_id}
onSuccess={fetchBill}
/>
</div>
);
}