Files
acmcc/src/pages/accounting/AccountingExpensesPage.tsx
T
admin dedcbb8889 Bill Approvals: Buildium two-column form + require vendor on all backend payments
- Bill Approvals Create/Edit dialogs redesigned to the same professional
  two-column Buildium layout used on the accounting Bills page: left
  attachment panel (drag-drop + live image/PDF preview), right grouped form,
  prominent blue total bar, primary-action-first footer
- Accounting backend payments now require a vendor chosen from the dropdown:
  - Expenses: vendor is required; removed the free-text vendor fallback
  - Bills: must pick a 'Pay to' vendor before saving
  - Banking payments already enforced this
  (Reconciliation bank adjustments — interest/service charges — intentionally
   still allow no vendor, as they are not vendor payments)
- Board members remain locked to approving/denying their own assigned rows
  plus commenting and submitting invoices (per request); all bill edits, GL,
  line items, status, and deletes stay read-only for board users

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 00:14:13 -04:00

312 lines
15 KiB
TypeScript

import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { accounting } from "@/lib/accountingClient";
import { supabase } from "@/integrations/supabase/client";
import { useCompanyId } from "./lib/useCompanyId";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Plus, Trash2, Upload, FileText, Wallet, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { money, fmtDate } from "./lib/format";
import { EmptyState } from "./components/EmptyState";
import { TableSkeleton } from "./components/TableSkeleton";
import { handlePayment, applyPaymentToBill } from "./lib/autoBill";
import { MatchBillDialog } from "./components/MatchBillDialog";
const CATEGORIES = [
"Advertising", "Travel", "Meals & Entertainment", "Office Supplies",
"Software", "Utilities", "Rent", "Payroll", "Professional Services",
"Equipment", "Insurance", "Bank Fees", "Other",
];
const CURRENCIES = ["USD", "EUR", "GBP", "CAD", "AUD", "INR", "JPY"];
export default function AccountingExpensesPage() {
const { companyId, loading: companyLoading, error: companyError, associationId } = useCompanyId();
const cid = companyId ?? "";
const cur = "USD";
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const [uploading, setUploading] = useState(false);
const [date, setDate] = useState(new Date().toLocaleDateString("en-CA", { timeZone: "America/New_York" }));
const [category, setCategory] = useState("");
const [vendorId, setVendorId] = useState<string>("");
const [vendorName, setVendorName] = useState("");
const [amount, setAmount] = useState<number>(0);
const [currency, setCurrency] = useState(cur);
const [paidThrough, setPaidThrough] = useState<string>("");
const [reimbursable, setReimbursable] = useState(false);
const [receiptUrl, setReceiptUrl] = useState("");
const [notes, setNotes] = useState("");
const [matchOpen, setMatchOpen] = useState(false);
const [matchCandidates, setMatchCandidates] = useState<any[]>([]);
const [pendingPayment, setPendingPayment] = useState<{ amount: number; vendorId: string; date: string; category: string; sourceId: string } | null>(null);
const { data: expenses = [], isLoading } = useQuery({
queryKey: ["expenses", cid],
enabled: !!cid,
queryFn: async () => (await accounting
.from("expenses")
.select("*, vendors(name), accounts(name)")
.eq("company_id", cid)
.order("date", { ascending: false })).data ?? [],
});
const { data: vendors = [] } = useQuery({
queryKey: ["vendors-lookup", cid],
enabled: !!cid,
queryFn: async () => (await accounting.from("vendors").select("id,name").eq("company_id", cid).order("name")).data ?? [],
});
const { data: accounts = [] } = useQuery({
queryKey: ["bank-accounts", cid, "expenses-payment"],
enabled: !!cid,
// Paid-through accounts: banks plus equity accounts; archived hidden.
queryFn: async () => (await accounting.from("accounts").select("id,name").eq("company_id", cid).or("is_bank.eq.true,type.eq.equity").eq("is_archived", false).order("name")).data ?? [],
});
const reset = () => {
setDate(new Date().toLocaleDateString("en-CA", { timeZone: "America/New_York" }));
setCategory(""); setVendorId(""); setVendorName(""); setAmount(0);
setCurrency(cur); setPaidThrough("");
setReimbursable(false); setReceiptUrl(""); setNotes("");
};
const uploadReceipt = async (file: File) => {
if (file.size > 10 * 1024 * 1024) return toast.error("File must be under 10MB");
setUploading(true);
const path = `${cid}/${Date.now()}-${file.name}`;
const { error } = await supabase.storage.from("receipts").upload(path, file);
setUploading(false);
if (error) return toast.error(error.message);
const { data } = supabase.storage.from("receipts").getPublicUrl(path);
setReceiptUrl(data.publicUrl);
toast.success("Receipt uploaded");
};
const save = async () => {
if (!category) return toast.error("Category is required");
if (!vendorId) return toast.error("Vendor is required — select one from the list");
if (!amount || amount <= 0) return toast.error("Amount must be greater than 0");
const selectedVendor = (vendors as any[]).find((v: any) => v.id === vendorId);
const { data: inserted, error } = await accounting.from("expenses").insert({
company_id: cid, date, category,
vendor_id: vendorId || null,
vendor_name: selectedVendor?.name ?? vendorName ?? null,
amount, currency,
paid_through_account_id: paidThrough || null,
reimbursable, receipt_url: receiptUrl || null,
notes: notes || null,
}).select("id").single();
if (error || !inserted) return toast.error(error?.message ?? "Failed");
toast.success("Expense recorded");
setOpen(false);
reset();
qc.invalidateQueries({ queryKey: ["expenses", cid] });
// Auto-bill creation
if (vendorId) {
const result = await handlePayment({
companyId: cid, vendorId, amount, date,
category, description: notes,
sourceKind: "expense", sourceId: inserted.id,
});
if (result.kind === "created") {
toast.warning(`No existing bill found — a bill was automatically created and marked as paid for ${selectedVendor?.name ?? "vendor"}.`, {
action: { label: "View Bill", onClick: () => window.location.assign("/dashboard/accounting/bills") },
duration: 8000,
});
qc.invalidateQueries({ queryKey: ["bills", cid] });
} else if (result.kind === "matched") {
setMatchCandidates(result.candidates);
setPendingPayment({ amount, vendorId, date, category, sourceId: inserted.id });
setMatchOpen(true);
}
}
};
const remove = async (id: string) => {
await accounting.from("expenses").delete().eq("id", id);
qc.invalidateQueries({ queryKey: ["expenses", cid] });
};
const total = (expenses as any[]).reduce((s: number, e: any) => s + Number(e.amount), 0);
if (!associationId) return <p className="text-sm text-muted-foreground">Select an association.</p>;
if (companyLoading) return <div className="flex justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>;
if (companyError || !companyId) return <p className="text-sm text-muted-foreground text-center py-12">{companyError || "Accounting setup is not ready."}</p>;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold">Expenses</h1>
<p className="text-sm text-muted-foreground">{(expenses as any[]).length} expenses · {money(total, cur)} total</p>
</div>
<Dialog open={open} onOpenChange={(v) => { setOpen(v); if (!v) reset(); }}>
<DialogTrigger asChild>
<Button><Plus className="mr-1 h-4 w-4" /> New Expense</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader><DialogTitle>Record expense</DialogTitle></DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Date</Label>
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
</div>
<div>
<Label>Category</Label>
<Select value={category} onValueChange={setCategory}>
<SelectTrigger><SelectValue placeholder="Select category" /></SelectTrigger>
<SelectContent>{CATEGORIES.map(c => <SelectItem key={c} value={c}>{c}</SelectItem>)}</SelectContent>
</Select>
</div>
<div>
<Label>Vendor <span className="text-destructive">*</span></Label>
<Select value={vendorId} onValueChange={setVendorId}>
<SelectTrigger><SelectValue placeholder="Select vendor" /></SelectTrigger>
<SelectContent>{(vendors as any[]).map((v: any) => <SelectItem key={v.id} value={v.id}>{v.name}</SelectItem>)}</SelectContent>
</Select>
{(vendors as any[]).length === 0 && (
<p className="mt-1 text-xs text-muted-foreground">No vendors yet add one on the Vendors page first.</p>
)}
</div>
<div>
<Label>Paid through</Label>
<Select value={paidThrough} onValueChange={setPaidThrough}>
<SelectTrigger><SelectValue placeholder="Bank / cash / card" /></SelectTrigger>
<SelectContent>{(accounts as any[]).map((a: any) => <SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>)}</SelectContent>
</Select>
</div>
<div>
<Label>Amount</Label>
<Input type="number" min={0} step="0.01" value={amount} onChange={(e) => setAmount(Number(e.target.value))} />
</div>
<div>
<Label>Currency</Label>
<Select value={currency} onValueChange={setCurrency}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>{CURRENCIES.map(c => <SelectItem key={c} value={c}>{c}</SelectItem>)}</SelectContent>
</Select>
</div>
</div>
<div>
<Label>Receipt</Label>
<label className="mt-1 flex cursor-pointer items-center justify-center gap-2 rounded-md border-2 border-dashed border-border bg-muted/30 px-4 py-6 text-sm text-muted-foreground hover:bg-muted/50">
<Upload className="h-4 w-4" />
{uploading ? "Uploading…" : receiptUrl ? "Replace receipt" : "Click to upload receipt (PDF or image, max 10MB)"}
<input type="file" className="hidden" accept="image/*,.pdf" onChange={(e) => e.target.files?.[0] && uploadReceipt(e.target.files[0])} />
</label>
{receiptUrl && (
<a href={receiptUrl} target="_blank" rel="noreferrer" className="mt-2 inline-flex items-center gap-1 text-xs text-primary hover:underline">
<FileText className="h-3 w-3" /> View uploaded receipt
</a>
)}
</div>
<div>
<Label>Notes</Label>
<Textarea maxLength={500} value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="Optional notes" />
</div>
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={reimbursable} onCheckedChange={(v) => setReimbursable(!!v)} />
Reimbursable
</label>
</div>
<DialogFooter><Button onClick={save}>Save expense</Button></DialogFooter>
</DialogContent>
</Dialog>
</div>
<Card>
<CardHeader><CardTitle>All expenses</CardTitle></CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Date</TableHead>
<TableHead>Category</TableHead>
<TableHead>Vendor</TableHead>
<TableHead>Paid through</TableHead>
<TableHead className="text-right">Amount</TableHead>
<TableHead>Reimbursable</TableHead>
<TableHead>Receipt</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading && <TableSkeleton columns={8} />}
{!isLoading && (expenses as any[]).map((e: any) => (
<TableRow key={e.id}>
<TableCell>{fmtDate(e.date)}</TableCell>
<TableCell><Badge variant="outline">{e.category}</Badge></TableCell>
<TableCell>{e.vendors?.name ?? e.vendor_name ?? "—"}</TableCell>
<TableCell>{e.accounts?.name ?? "—"}</TableCell>
<TableCell className="text-right font-medium">{money(e.amount, e.currency)}</TableCell>
<TableCell>
{e.reimbursable
? <Badge className="bg-amber-100 text-amber-700 hover:bg-amber-100 border-0">Reimbursable</Badge>
: <span className="text-xs text-muted-foreground"></span>}
</TableCell>
<TableCell>
{e.receipt_url
? <a href={e.receipt_url} target="_blank" rel="noreferrer" className="text-primary hover:underline text-xs inline-flex items-center gap-1"><FileText className="h-3 w-3" />View</a>
: <span className="text-xs text-muted-foreground"></span>}
</TableCell>
<TableCell>
<Button size="icon" variant="ghost" onClick={() => remove(e.id)}><Trash2 className="h-4 w-4" /></Button>
</TableCell>
</TableRow>
))}
{!isLoading && (expenses as any[]).length === 0 && (
<TableRow className="hover:bg-transparent"><TableCell colSpan={8} className="p-0">
<EmptyState icon={Wallet} title="No expenses yet" description="Track money spent — categorize, attach receipts, and mark reimbursable." />
</TableCell></TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
<MatchBillDialog
open={matchOpen}
onOpenChange={setMatchOpen}
candidates={matchCandidates}
currency={cur}
onMatch={async (billId) => {
if (!pendingPayment) return;
await applyPaymentToBill(billId, pendingPayment.amount);
toast.success("Payment applied to existing bill");
qc.invalidateQueries({ queryKey: ["bills", cid] });
setMatchOpen(false);
}}
onCreateNew={async () => {
if (!pendingPayment) return;
const { createAutoBill } = await import("./lib/autoBill");
const bill = await createAutoBill({
companyId: cid, vendorId: pendingPayment.vendorId,
amount: pendingPayment.amount, date: pendingPayment.date,
category: pendingPayment.category, sourceKind: "expense", sourceId: pendingPayment.sourceId,
});
if (bill) {
toast.success(`Bill ${bill.number} auto-created.`);
qc.invalidateQueries({ queryKey: ["bills", cid] });
}
setMatchOpen(false);
}}
/>
</div>
);
}