import { useEffect, useMemo, useState } from "react"; import { supabase } from "@/integrations/supabase/client"; import { invokeEdgeFunction } from "@/lib/edgeFunctionUtils"; import { fetchChartOfAccounts, NormalizedAccount } from "@/lib/chartOfAccountsSource"; import { useToast } from "@/hooks/use-toast"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Loader2, Save, ArrowRight, AlertTriangle, Wand2 } from "lucide-react"; interface BuildiumGLAccount { id: string; name: string; account_number: string | null; type: string; is_active: boolean; } interface FlagRow { buildium_gl_id: string; buildium_name: string | null; buildium_number: string | null; context: string | null; } interface Props { associations: { id: string; name: string }[]; } const normalize = (v: string | null | undefined) => String(v ?? "").toLowerCase().replace(/[^a-z0-9]+/g, " ").replace(/\s+/g, " ").trim(); // Dashboard names sometimes carry a leading code ("2010 Prepayments") const normalizeName = (v: string | null | undefined) => normalize(String(v ?? "").replace(/^\s*\d{3,6}(?:[-.]\d+)?\s+/, "")); /** * Precise Buildium GL account ↔ dashboard accounting account mapping. * Both the nightly GL pull and charge push/pull resolve accounts ONLY through * these links — unmapped accounts hold their transactions until linked here. */ export default function BuildiumGLAccountMapCard({ associations }: Props) { const { toast } = useToast(); const [selectedAssociation, setSelectedAssociation] = useState(""); const [buildiumAccounts, setBuildiumAccounts] = useState([]); const [dashboardAccounts, setDashboardAccounts] = useState([]); // buildium_gl_id -> dashboard account id ("" = unmapped) const [selection, setSelection] = useState>({}); // dashboard account id -> buildium_gl_id chosen as push target (when several map to it) const [pushTargets, setPushTargets] = useState>({}); const [flags, setFlags] = useState([]); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); useEffect(() => { if (!selectedAssociation) { setBuildiumAccounts([]); setDashboardAccounts([]); setSelection({}); setPushTargets({}); setFlags([]); return; } loadAll(selectedAssociation); }, [selectedAssociation]); const loadAll = async (assocId: string) => { setLoading(true); try { const [glRes, dashAccounts, linksRes, flagsRes] = await Promise.all([ invokeEdgeFunction("buildium-sync", { body: { syncType: "gl_accounts", includeAll: true, selectedAssociationIds: [assocId] } }), fetchChartOfAccounts(assocId, "platform"), supabase.from("buildium_gl_account_links").select("buildium_gl_id, account_id, is_push_target").eq("association_id", assocId), supabase.from("buildium_unmapped_gl_accounts").select("buildium_gl_id, buildium_name, buildium_number, context").eq("association_id", assocId), ]); if (glRes.error) throw glRes.error; if (linksRes.error) throw linksRes.error; setBuildiumAccounts((glRes.data?.gl_accounts || []) as BuildiumGLAccount[]); setDashboardAccounts(dashAccounts); const sel: Record = {}; const targets: Record = {}; for (const row of linksRes.data || []) { sel[row.buildium_gl_id] = row.account_id; if (row.is_push_target) targets[row.account_id] = row.buildium_gl_id; } setSelection(sel); setPushTargets(targets); setFlags((flagsRes.data || []) as FlagRow[]); } catch (err: any) { toast({ variant: "destructive", title: "Error loading GL account map", description: err.message }); } finally { setLoading(false); } }; const flaggedIds = useMemo(() => new Set(flags.map((f) => f.buildium_gl_id)), [flags]); const sortedAccounts = useMemo(() => { const rank = (a: BuildiumGLAccount) => (flaggedIds.has(a.id) ? 0 : selection[a.id] ? 2 : 1); return [...buildiumAccounts].sort((a, b) => { const r = rank(a) - rank(b); if (r !== 0) return r; const an = a.account_number || ""; const bn = b.account_number || ""; if (an !== bn) return an.localeCompare(bn, undefined, { numeric: true }); return a.name.localeCompare(b.name); }); }, [buildiumAccounts, selection, flaggedIds]); // dashboard account id -> buildium ids currently mapped to it (for push-target radios) const duplicateGroups = useMemo(() => { const groups = new Map(); for (const [glId, acctId] of Object.entries(selection)) { if (!acctId) continue; groups.set(acctId, [...(groups.get(acctId) || []), glId]); } return groups; }, [selection]); const mappedCount = Object.values(selection).filter(Boolean).length; const unmappedCount = buildiumAccounts.length - buildiumAccounts.filter((a) => selection[a.id]).length; const setMapping = (glId: string, accountId: string) => { setSelection((prev) => { const next = { ...prev }; if (accountId) next[glId] = accountId; else delete next[glId]; return next; }); }; const suggestMatches = () => { const byCode = new Map(); const byName = new Map(); for (const a of dashboardAccounts) { if (a.account_number) byCode.set(normalize(a.account_number), a); byName.set(normalizeName(a.account_name), a); } let suggested = 0; setSelection((prev) => { const next = { ...prev }; for (const gl of buildiumAccounts) { if (next[gl.id]) continue; // never overwrite an existing choice const codeMatch = gl.account_number ? byCode.get(normalize(gl.account_number)) : undefined; const nameMatch = byName.get(normalizeName(gl.name)); const match = codeMatch || nameMatch; if (match) { next[gl.id] = match.id; suggested++; } } return next; }); toast({ title: suggested > 0 ? `${suggested} match(es) suggested` : "No new matches found", description: suggested > 0 ? "Review the prefilled rows, then Save to apply." : "Remaining accounts need to be mapped by hand.", }); }; const saveLinks = async () => { if (!selectedAssociation) return; setSaving(true); try { const glById = new Map(buildiumAccounts.map((a) => [a.id, a])); // One push target per dashboard account: honor the chosen radio, default first. const pushTargetByGl = new Set(); for (const [acctId, glIds] of duplicateGroups.entries()) { const chosen = pushTargets[acctId] && glIds.includes(pushTargets[acctId]) ? pushTargets[acctId] : glIds[0]; pushTargetByGl.add(chosen); } const rows = Object.entries(selection) .filter(([, acctId]) => acctId) .map(([glId, acctId]) => ({ association_id: selectedAssociation, buildium_gl_id: glId, buildium_name: glById.get(glId)?.name ?? null, buildium_number: glById.get(glId)?.account_number ?? null, buildium_type: glById.get(glId)?.type ?? null, account_id: acctId, is_push_target: pushTargetByGl.has(glId), updated_at: new Date().toISOString(), })); // Replace the association's links wholesale — simplest way to honor the // unique push-target index across re-mappings. const { error: delErr } = await supabase.from("buildium_gl_account_links").delete().eq("association_id", selectedAssociation); if (delErr) throw delErr; if (rows.length > 0) { const { error: insErr } = await supabase.from("buildium_gl_account_links").insert(rows); if (insErr) throw insErr; } // Clear "needs mapping" flags that are now resolved. const mappedIds = rows.map((r) => r.buildium_gl_id); if (mappedIds.length > 0) { await supabase.from("buildium_unmapped_gl_accounts").delete() .eq("association_id", selectedAssociation) .in("buildium_gl_id", mappedIds); } setFlags((prev) => prev.filter((f) => !mappedIds.includes(f.buildium_gl_id))); toast({ title: "GL account map saved", description: `${rows.length} link(s) saved.` }); } catch (err: any) { toast({ variant: "destructive", title: "Save failed", description: err.message }); } finally { setSaving(false); } }; return ( Buildium ↔ Dashboard GL Account Map Link each Buildium GL account to the exact dashboard accounting account it represents. Pull and push syncs resolve accounts only through these links — transactions touching an unmapped Buildium account are held until you map it here.
{selectedAssociation && !loading && ( <> {mappedCount} mapped {unmappedCount > 0 && ( {unmappedCount} unmapped )} )}
{selectedAssociation && ( loading ? (
Loading accounts and links...
) : (
{dashboardAccounts.length === 0 && (

No platform accounting accounts found for this association — it may not be on the accounting platform yet.

)} {flags.length > 0 && (

{flags.length} account(s) blocked a recent sync

    {flags.slice(0, 8).map((f) => (
  • {[f.buildium_number, f.buildium_name || `Buildium GL ${f.buildium_gl_id}`].filter(Boolean).join(" - ")} {f.context ? ` (${f.context.replace(/_/g, " ")})` : ""}
  • ))} {flags.length > 8 &&
  • …and {flags.length - 8} more
  • }

They're sorted to the top below. Map and save, then re-run the sync.

)}
{sortedAccounts.map((gl) => { const acctId = selection[gl.id] || ""; const group = acctId ? duplicateGroups.get(acctId) || [] : []; const showPushRadio = group.length > 1; const isPushTarget = showPushRadio ? (pushTargets[acctId] && group.includes(pushTargets[acctId]) ? pushTargets[acctId] : group[0]) === gl.id : false; return (

{[gl.account_number, gl.name].filter(Boolean).join(" - ")}

{gl.type}{!gl.is_active && " · inactive"} {flaggedIds.has(gl.id) && · blocked a sync}

{showPushRadio && ( )}
); })} {sortedAccounts.length === 0 && (

No Buildium GL accounts returned. Check the Buildium connection.

)}
) )}
); }