From 4e77098f8878bba0464ab6fec1014d908b9455a8 Mon Sep 17 00:00:00 2001 From: renee-png Date: Fri, 12 Jun 2026 17:31:40 -0400 Subject: [PATCH] Buildium GL account map: active accounts only, add-one-at-a-time UI - gl_accounts fetched without includeAll (active Buildium accounts only) - replaced per-account row grid (370+ selects) with a single add row: Buildium dropdown -> system account dropdown -> Add - mappings list is plain rows with remove + push-target radio; changes save immediately (no bulk Save); flagged sync-blockers are click-to-select chips - Auto-map exact matches button inserts code/name-identical pairs Co-Authored-By: Claude Opus 4.8 --- .../settings/BuildiumGLAccountMapCard.tsx | 478 +++++++++++------- 1 file changed, 286 insertions(+), 192 deletions(-) diff --git a/src/components/settings/BuildiumGLAccountMapCard.tsx b/src/components/settings/BuildiumGLAccountMapCard.tsx index 4c9a361..057e215 100644 --- a/src/components/settings/BuildiumGLAccountMapCard.tsx +++ b/src/components/settings/BuildiumGLAccountMapCard.tsx @@ -7,20 +7,31 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com 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"; +import { Loader2, Plus, ArrowRight, AlertTriangle, Wand2, Trash2 } from "lucide-react"; interface BuildiumGLAccount { id: string; name: string; account_number: string | null; type: string; - is_active: boolean; + is_active?: boolean; +} + +interface LinkRow { + id: string; + buildium_gl_id: string; + buildium_name: string | null; + buildium_number: string | null; + buildium_type: string | null; + account_id: string; + is_push_target: boolean; } interface FlagRow { buildium_gl_id: string; buildium_name: string | null; buildium_number: string | null; + buildium_type: string | null; context: string | null; } @@ -34,55 +45,61 @@ const normalize = (v: string | null | undefined) => const normalizeName = (v: string | null | undefined) => normalize(String(v ?? "").replace(/^\s*\d{3,6}(?:[-.]\d+)?\s+/, "")); +const glLabel = (a: { account_number?: string | null; buildium_number?: string | null; name?: string | null; buildium_name?: string | null }) => + [a.account_number ?? a.buildium_number, a.name ?? a.buildium_name].filter(Boolean).join(" - ") || "Unknown"; + /** * 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. + * Mappings are added one at a time (Buildium dropdown → system dropdown → Add); + * only ACTIVE Buildium accounts are listed. */ 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 [links, setLinks] = useState([]); const [flags, setFlags] = useState([]); const [loading, setLoading] = useState(false); - const [saving, setSaving] = useState(false); + const [busy, setBusy] = useState(false); + + // The "add a mapping" row + const [addGlId, setAddGlId] = useState(""); + const [addAccountId, setAddAccountId] = useState(""); useEffect(() => { if (!selectedAssociation) { - setBuildiumAccounts([]); setDashboardAccounts([]); setSelection({}); setPushTargets({}); setFlags([]); + setBuildiumAccounts([]); setDashboardAccounts([]); setLinks([]); setFlags([]); + setAddGlId(""); setAddAccountId(""); return; } loadAll(selectedAssociation); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedAssociation]); const loadAll = async (assocId: string) => { setLoading(true); + setAddGlId(""); setAddAccountId(""); try { const [glRes, dashAccounts, linksRes, flagsRes] = await Promise.all([ - invokeEdgeFunction("buildium-sync", { body: { syncType: "gl_accounts", includeAll: true, selectedAssociationIds: [assocId] } }), + // Active Buildium accounts only — inactive ones just clutter the list + invokeEdgeFunction("buildium-sync", { body: { syncType: "gl_accounts", 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), + supabase.from("buildium_gl_account_links") + .select("id, buildium_gl_id, buildium_name, buildium_number, buildium_type, account_id, is_push_target") + .eq("association_id", assocId), + supabase.from("buildium_unmapped_gl_accounts") + .select("buildium_gl_id, buildium_name, buildium_number, buildium_type, 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); + setLinks((linksRes.data || []) as LinkRow[]); setFlags((flagsRes.data || []) as FlagRow[]); } catch (err: any) { toast({ variant: "destructive", title: "Error loading GL account map", description: err.message }); @@ -91,117 +108,178 @@ export default function BuildiumGLAccountMapCard({ associations }: Props) { } }; + const mappedGlIds = useMemo(() => new Set(links.map((l) => l.buildium_gl_id)), [links]); + const accountById = useMemo(() => new Map(dashboardAccounts.map((a) => [a.id, a])), [dashboardAccounts]); 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]); + // Buildium dropdown options: active unmapped accounts, plus any flagged + // accounts the syncs hit that aren't in the active list (e.g. inactive in + // Buildium but still referenced by history). + const addOptions = useMemo(() => { + const opts: BuildiumGLAccount[] = buildiumAccounts.filter((a) => !mappedGlIds.has(a.id)); + const have = new Set(opts.map((o) => o.id)); + for (const f of flags) { + if (mappedGlIds.has(f.buildium_gl_id) || have.has(f.buildium_gl_id)) continue; + opts.push({ + id: f.buildium_gl_id, + name: `${f.buildium_name || `Buildium GL ${f.buildium_gl_id}`} (inactive)`, + account_number: f.buildium_number, + type: f.buildium_type || "", + }); + have.add(f.buildium_gl_id); } - return groups; - }, [selection]); + return opts.sort((a, b) => + (a.account_number || "").localeCompare(b.account_number || "", undefined, { numeric: true }) || a.name.localeCompare(b.name)); + }, [buildiumAccounts, flags, mappedGlIds]); - const mappedCount = Object.values(selection).filter(Boolean).length; - const unmappedCount = buildiumAccounts.length - buildiumAccounts.filter((a) => selection[a.id]).length; + const sortedLinks = useMemo( + () => [...links].sort((a, b) => + (a.buildium_number || "").localeCompare(b.buildium_number || "", undefined, { numeric: true }) + || (a.buildium_name || "").localeCompare(b.buildium_name || "")), + [links], + ); - const setMapping = (glId: string, accountId: string) => { - setSelection((prev) => { - const next = { ...prev }; - if (accountId) next[glId] = accountId; else delete next[glId]; - return next; - }); + const clearFlag = async (glId: string) => { + await supabase.from("buildium_unmapped_gl_accounts").delete() + .eq("association_id", selectedAssociation).eq("buildium_gl_id", glId); + setFlags((prev) => prev.filter((f) => f.buildium_gl_id !== glId)); }; - 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); + const insertLink = async (gl: BuildiumGLAccount, accountId: string): Promise => { + // Only one link per dashboard account can be the push target + const hasPushTarget = links.some((l) => l.account_id === accountId && l.is_push_target); + const { data, error } = await supabase + .from("buildium_gl_account_links") + .insert({ + association_id: selectedAssociation, + buildium_gl_id: gl.id, + buildium_name: gl.name.replace(/ \(inactive\)$/, ""), + buildium_number: gl.account_number, + buildium_type: gl.type || null, + account_id: accountId, + is_push_target: !hasPushTarget, + }) + .select("id, buildium_gl_id, buildium_name, buildium_number, buildium_type, account_id, is_push_target") + .single(); + if (error) { + toast({ variant: "destructive", title: "Could not add mapping", description: error.message }); + return null; } - 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.", - }); + return data as LinkRow; }; - const saveLinks = async () => { - if (!selectedAssociation) return; - setSaving(true); + const handleAdd = async () => { + const gl = addOptions.find((o) => o.id === addGlId); + if (!gl || !addAccountId) return; + setBusy(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 row = await insertLink(gl, addAccountId); + if (row) { + setLinks((prev) => [...prev, row]); + await clearFlag(gl.id); + setAddGlId(""); setAddAccountId(""); } - - 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); + setBusy(false); } }; + const handleDelete = async (row: LinkRow) => { + setBusy(true); + try { + const { error } = await supabase.from("buildium_gl_account_links").delete().eq("id", row.id); + if (error) throw error; + let next = links.filter((l) => l.id !== row.id); + // If the removed row was the push target, promote a remaining sibling + if (row.is_push_target) { + const sibling = next.find((l) => l.account_id === row.account_id); + if (sibling) { + await supabase.from("buildium_gl_account_links").update({ is_push_target: true }).eq("id", sibling.id); + next = next.map((l) => (l.id === sibling.id ? { ...l, is_push_target: true } : l)); + } + } + setLinks(next); + } catch (err: any) { + toast({ variant: "destructive", title: "Could not remove mapping", description: err.message }); + } finally { + setBusy(false); + } + }; + + const setPushTarget = async (row: LinkRow) => { + setBusy(true); + try { + const current = links.find((l) => l.account_id === row.account_id && l.is_push_target && l.id !== row.id); + if (current) { + const { error } = await supabase.from("buildium_gl_account_links").update({ is_push_target: false }).eq("id", current.id); + if (error) throw error; + } + const { error } = await supabase.from("buildium_gl_account_links").update({ is_push_target: true }).eq("id", row.id); + if (error) throw error; + setLinks((prev) => prev.map((l) => + l.id === row.id ? { ...l, is_push_target: true } + : l.account_id === row.account_id ? { ...l, is_push_target: false } + : l)); + } catch (err: any) { + toast({ variant: "destructive", title: "Could not change push target", description: err.message }); + } finally { + setBusy(false); + } + }; + + // Auto-add exact code/name matches for unmapped active accounts + const suggestMatches = async () => { + setBusy(true); + try { + 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 added = 0; + let working = links; + for (const gl of buildiumAccounts) { + if (working.some((l) => l.buildium_gl_id === gl.id)) continue; + const match = (gl.account_number ? byCode.get(normalize(gl.account_number)) : undefined) || byName.get(normalizeName(gl.name)); + if (!match) continue; + const hasPushTarget = working.some((l) => l.account_id === match.id && l.is_push_target); + const { data, error } = await supabase + .from("buildium_gl_account_links") + .insert({ + association_id: selectedAssociation, + buildium_gl_id: gl.id, + buildium_name: gl.name, + buildium_number: gl.account_number, + buildium_type: gl.type || null, + account_id: match.id, + is_push_target: !hasPushTarget, + }) + .select("id, buildium_gl_id, buildium_name, buildium_number, buildium_type, account_id, is_push_target") + .single(); + if (!error && data) { + working = [...working, data as LinkRow]; + added++; + if (flaggedIds.has(gl.id)) await clearFlag(gl.id); + } + } + setLinks(working); + toast({ + title: added > 0 ? `${added} mapping(s) added` : "No new matches found", + description: added > 0 ? "Exact code/name matches were mapped automatically." : "Remaining accounts need to be added by hand.", + }); + } finally { + setBusy(false); + } + }; + + // Dashboard accounts that already have several Buildium accounts mapped (for the push radio) + const accountGroupSizes = useMemo(() => { + const m = new Map(); + for (const l of links) m.set(l.account_id, (m.get(l.account_id) ?? 0) + 1); + return m; + }, [links]); + return ( @@ -225,12 +303,7 @@ export default function BuildiumGLAccountMapCard({ associations }: Props) { {selectedAssociation && !loading && ( - <> - {mappedCount} mapped - {unmappedCount > 0 && ( - {unmappedCount} unmapped - )} - + {links.length} mapped )} @@ -250,85 +323,106 @@ export default function BuildiumGLAccountMapCard({ associations }: Props) { {flags.length > 0 && (

- {flags.length} account(s) blocked a recent sync + {flags.length} account(s) blocked a recent sync — map them below

-
    - {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.map((f) => ( + ))} - {flags.length > 8 &&
  • …and {flags.length - 8} more
  • } -
-

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

+
)} -
- + {/* Add a mapping: Buildium dropdown → system dropdown → Add */} +
+

Add Mapping

+
+ + + + + +
-
- {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} -

+ {/* Existing mappings */} + {sortedLinks.length === 0 ? ( +

No mappings yet — add the accounts you sync above.

+ ) : ( +
+ {sortedLinks.map((l) => { + const acct = accountById.get(l.account_id); + const showPushRadio = (accountGroupSizes.get(l.account_id) ?? 0) > 1; + return ( +
+ {glLabel(l)} + + + {acct ? `${[acct.account_number, acct.account_name].filter(Boolean).join(" - ")}` : "Unknown account"} + + {showPushRadio && ( + + )} +
- - - {showPushRadio && ( - - )} -
- ); - })} - {sortedAccounts.length === 0 && ( -

No Buildium GL accounts returned. Check the Buildium connection.

- )} -
- -
- -
+ ); + })} +
+ )}
) )}