Accounting: unify vendor roster + COA across bill-approvals and accounting bills

Single vendor source (public.vendors) and single COA source (accounting.accounts)
across both bill flows:

- Forward sync now carries public.bills.expense_account_id into the mirrored
  accounting.bill_items.account_id (when it resolves to accounting.accounts).
- Reverse trigger flows a GL change on a mirrored accounting bill line back to
  public.bills.expense_account_id (loop-guarded).
- New public.ensure_accounting_vendor RPC resolves a chosen public vendor to its
  accounting.vendors row; one-time backfill of mirrored line account_id.
- BillApprovalsPage GL pickers now use ChartOfAccountsDropdown (accounting.accounts).
- AccountingBillsPage vendor picker now lists public.vendors scoped to the
  company's association and maps to accounting.vendors on save.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 18:57:32 -04:00
parent 84541a6813
commit 84c8483169
3 changed files with 198 additions and 37 deletions
+15 -31
View File
@@ -18,6 +18,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
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",
@@ -604,19 +605,6 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci
return bill.status;
};
const selectedSystem = associations.find((a: any) => a.id === form.association_id)?.accounting_system ?? null;
const filteredAccounts = form.association_id
? accounts.filter((a: any) => {
// Platform associations use the accounts managed in the accounting
// dashboard (synced into chart_of_accounts as accounting_system 'platform').
if (selectedSystem === "platform") {
return a.accounting_system === "platform" && a.association_id === form.association_id;
}
// Buildium / Zoho keep their existing scoping; never surface platform-only rows.
return a.accounting_system !== "platform" && (!a.association_id || a.association_id === form.association_id);
})
: [];
const filteredVendors = form.association_id
? vendors.filter((v: any) => v.association_id === form.association_id || (Array.isArray(v.association_ids) && v.association_ids.includes(form.association_id)))
: [];
@@ -1162,20 +1150,14 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci
{/* GL Account */}
<div>
<Label>GL Account (Expense) <span className="text-destructive">*</span></Label>
<Select
<ChartOfAccountsDropdown
accountType="expense"
associationId={form.association_id || null}
value={form.expense_account_id}
onValueChange={(v) => setForm({ ...form, expense_account_id: v })}
onChange={(v: string) => setForm({ ...form, expense_account_id: v })}
disabled={!form.association_id}
>
<SelectTrigger>
<SelectValue placeholder={form.association_id ? "Select GL Account" : "Select a client first"} />
</SelectTrigger>
<SelectContent>
{filteredAccounts.map((a: any) => (
<SelectItem key={a.id} value={a.id}>{a.account_number} - {a.account_name}</SelectItem>
))}
</SelectContent>
</Select>
placeholder={form.association_id ? "Select GL Account" : "Select a client first"}
/>
</div>
{/* Request Approval From */}
@@ -1410,12 +1392,14 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci
</div>
<div>
<Label>GL Account (Expense)</Label>
<Select value={form.expense_account_id} onValueChange={(v) => setForm({ ...form, expense_account_id: v })} disabled={!form.association_id}>
<SelectTrigger><SelectValue placeholder="Select GL Account" /></SelectTrigger>
<SelectContent>
{filteredAccounts.map((a: any) => <SelectItem key={a.id} value={a.id}>{a.account_number} - {a.account_name}</SelectItem>)}
</SelectContent>
</Select>
<ChartOfAccountsDropdown
accountType="expense"
associationId={form.association_id || null}
value={form.expense_account_id}
onChange={(v: string) => setForm({ ...form, expense_account_id: v })}
disabled={!form.association_id}
placeholder="Select GL Account"
/>
</div>
{vendorNotFound && (
<Alert variant="destructive" className="border-amber-300 bg-amber-50 text-amber-900">
+30 -6
View File
@@ -74,10 +74,16 @@ export default function AccountingBillsPage() {
enabled: !!cid,
queryFn: async () => (await accounting.from("bills").select("*, vendors(name,address)").eq("company_id", cid).order("issue_date", { ascending: false })).data ?? [],
});
// Single vendor roster = public.vendors, scoped to this company's association.
// The chosen public vendor is mapped to its accounting.vendors row on save.
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 ?? [],
queryKey: ["vendors-lookup", associationId],
enabled: !!associationId,
queryFn: async () => (await supabase.from("vendors")
.select("id,name")
.eq("is_active", true)
.or(`association_id.eq.${associationId},association_ids.cs.{${associationId}}`)
.order("name")).data ?? [],
});
const { data: expenseAccounts = [] } = useQuery({
queryKey: ["expense-accounts", cid],
@@ -128,7 +134,14 @@ export default function AccountingBillsPage() {
const openEdit = async (b: any) => {
setEditId(b.id);
setVendorId(b.vendor_id ?? "");
// The dropdown is keyed by public vendor id; map the stored accounting
// vendor back to its source public vendor when one exists.
let pubVendorId = "";
if (b.vendor_id) {
const { data: av } = await accounting.from("vendors").select("external_source, external_id").eq("id", b.vendor_id).maybeSingle();
if (av?.external_source === "acmacc_vendor" && av?.external_id) pubVendorId = String(av.external_id);
}
setVendorId(pubVendorId);
setNumber(b.number ?? "");
setIssueDate(b.issue_date ?? issueDate);
setDueDate(b.due_date ?? "");
@@ -245,6 +258,17 @@ export default function AccountingBillsPage() {
let attachmentUrl = uploadedUrl;
if (file && !attachmentUrl) attachmentUrl = await uploadFileObj(file);
// Resolve the chosen public vendor to its accounting.vendors row (find-or-create).
let acctVendorId: string | null = null;
if (vendorId) {
const { data: mapped, error: mapErr } = await supabase.rpc("ensure_accounting_vendor", {
_association_id: associationId,
_public_vendor_id: vendorId,
});
if (mapErr) return toast.error(mapErr.message);
acctVendorId = (mapped as string) ?? null;
}
const itemRows = (billId: string) => items.map(i => ({
bill_id: billId, description: i.description, quantity: i.quantity, rate: i.rate,
amount: +(Number(i.quantity) * Number(i.rate)).toFixed(2),
@@ -253,7 +277,7 @@ export default function AccountingBillsPage() {
if (editId) {
const { error } = await accounting.from("bills").update({
vendor_id: vendorId || null, number,
vendor_id: acctVendorId, number,
issue_date: issueDate, due_date: dueDate || null,
subtotal, tax, total,
notes: notes || null,
@@ -265,7 +289,7 @@ export default function AccountingBillsPage() {
toast.success("Bill updated");
} else {
const { data: bill, error } = await accounting.from("bills").insert({
company_id: cid, vendor_id: vendorId || null, number,
company_id: cid, vendor_id: acctVendorId, number,
issue_date: issueDate, due_date: dueDate || null,
subtotal, tax, total, status: "open",
notes: notes || null,