mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
e302fb91f0
- 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>
779 lines
34 KiB
TypeScript
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>
|
|
);
|
|
}
|