mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
dedcbb8889
- 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>
312 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|