mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -7,20 +7,31 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
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 {
|
interface BuildiumGLAccount {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
account_number: string | null;
|
account_number: string | null;
|
||||||
type: string;
|
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 {
|
interface FlagRow {
|
||||||
buildium_gl_id: string;
|
buildium_gl_id: string;
|
||||||
buildium_name: string | null;
|
buildium_name: string | null;
|
||||||
buildium_number: string | null;
|
buildium_number: string | null;
|
||||||
|
buildium_type: string | null;
|
||||||
context: string | null;
|
context: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,55 +45,61 @@ const normalize = (v: string | null | undefined) =>
|
|||||||
const normalizeName = (v: string | null | undefined) =>
|
const normalizeName = (v: string | null | undefined) =>
|
||||||
normalize(String(v ?? "").replace(/^\s*\d{3,6}(?:[-.]\d+)?\s+/, ""));
|
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.
|
* Precise Buildium GL account ↔ dashboard accounting account mapping.
|
||||||
* Both the nightly GL pull and charge push/pull resolve accounts ONLY through
|
* Both the nightly GL pull and charge push/pull resolve accounts ONLY through
|
||||||
* these links — unmapped accounts hold their transactions until linked here.
|
* 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) {
|
export default function BuildiumGLAccountMapCard({ associations }: Props) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [selectedAssociation, setSelectedAssociation] = useState("");
|
const [selectedAssociation, setSelectedAssociation] = useState("");
|
||||||
const [buildiumAccounts, setBuildiumAccounts] = useState<BuildiumGLAccount[]>([]);
|
const [buildiumAccounts, setBuildiumAccounts] = useState<BuildiumGLAccount[]>([]);
|
||||||
const [dashboardAccounts, setDashboardAccounts] = useState<NormalizedAccount[]>([]);
|
const [dashboardAccounts, setDashboardAccounts] = useState<NormalizedAccount[]>([]);
|
||||||
// buildium_gl_id -> dashboard account id ("" = unmapped)
|
const [links, setLinks] = useState<LinkRow[]>([]);
|
||||||
const [selection, setSelection] = useState<Record<string, string>>({});
|
|
||||||
// dashboard account id -> buildium_gl_id chosen as push target (when several map to it)
|
|
||||||
const [pushTargets, setPushTargets] = useState<Record<string, string>>({});
|
|
||||||
const [flags, setFlags] = useState<FlagRow[]>([]);
|
const [flags, setFlags] = useState<FlagRow[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!selectedAssociation) {
|
if (!selectedAssociation) {
|
||||||
setBuildiumAccounts([]); setDashboardAccounts([]); setSelection({}); setPushTargets({}); setFlags([]);
|
setBuildiumAccounts([]); setDashboardAccounts([]); setLinks([]); setFlags([]);
|
||||||
|
setAddGlId(""); setAddAccountId("");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
loadAll(selectedAssociation);
|
loadAll(selectedAssociation);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [selectedAssociation]);
|
}, [selectedAssociation]);
|
||||||
|
|
||||||
const loadAll = async (assocId: string) => {
|
const loadAll = async (assocId: string) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setAddGlId(""); setAddAccountId("");
|
||||||
try {
|
try {
|
||||||
const [glRes, dashAccounts, linksRes, flagsRes] = await Promise.all([
|
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"),
|
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_gl_account_links")
|
||||||
supabase.from("buildium_unmapped_gl_accounts").select("buildium_gl_id, buildium_name, buildium_number, context").eq("association_id", assocId),
|
.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 (glRes.error) throw glRes.error;
|
||||||
if (linksRes.error) throw linksRes.error;
|
if (linksRes.error) throw linksRes.error;
|
||||||
|
|
||||||
setBuildiumAccounts((glRes.data?.gl_accounts || []) as BuildiumGLAccount[]);
|
setBuildiumAccounts((glRes.data?.gl_accounts || []) as BuildiumGLAccount[]);
|
||||||
setDashboardAccounts(dashAccounts);
|
setDashboardAccounts(dashAccounts);
|
||||||
|
setLinks((linksRes.data || []) as LinkRow[]);
|
||||||
const sel: Record<string, string> = {};
|
|
||||||
const targets: Record<string, string> = {};
|
|
||||||
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[]);
|
setFlags((flagsRes.data || []) as FlagRow[]);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
toast({ variant: "destructive", title: "Error loading GL account map", description: err.message });
|
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 flaggedIds = useMemo(() => new Set(flags.map((f) => f.buildium_gl_id)), [flags]);
|
||||||
|
|
||||||
const sortedAccounts = useMemo(() => {
|
// Buildium dropdown options: active unmapped accounts, plus any flagged
|
||||||
const rank = (a: BuildiumGLAccount) => (flaggedIds.has(a.id) ? 0 : selection[a.id] ? 2 : 1);
|
// accounts the syncs hit that aren't in the active list (e.g. inactive in
|
||||||
return [...buildiumAccounts].sort((a, b) => {
|
// Buildium but still referenced by history).
|
||||||
const r = rank(a) - rank(b);
|
const addOptions = useMemo(() => {
|
||||||
if (r !== 0) return r;
|
const opts: BuildiumGLAccount[] = buildiumAccounts.filter((a) => !mappedGlIds.has(a.id));
|
||||||
const an = a.account_number || "";
|
const have = new Set(opts.map((o) => o.id));
|
||||||
const bn = b.account_number || "";
|
for (const f of flags) {
|
||||||
if (an !== bn) return an.localeCompare(bn, undefined, { numeric: true });
|
if (mappedGlIds.has(f.buildium_gl_id) || have.has(f.buildium_gl_id)) continue;
|
||||||
return a.name.localeCompare(b.name);
|
opts.push({
|
||||||
});
|
id: f.buildium_gl_id,
|
||||||
}, [buildiumAccounts, selection, flaggedIds]);
|
name: `${f.buildium_name || `Buildium GL ${f.buildium_gl_id}`} (inactive)`,
|
||||||
|
account_number: f.buildium_number,
|
||||||
// dashboard account id -> buildium ids currently mapped to it (for push-target radios)
|
type: f.buildium_type || "",
|
||||||
const duplicateGroups = useMemo(() => {
|
});
|
||||||
const groups = new Map<string, string[]>();
|
have.add(f.buildium_gl_id);
|
||||||
for (const [glId, acctId] of Object.entries(selection)) {
|
|
||||||
if (!acctId) continue;
|
|
||||||
groups.set(acctId, [...(groups.get(acctId) || []), glId]);
|
|
||||||
}
|
}
|
||||||
return groups;
|
return opts.sort((a, b) =>
|
||||||
}, [selection]);
|
(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 sortedLinks = useMemo(
|
||||||
const unmappedCount = buildiumAccounts.length - buildiumAccounts.filter((a) => selection[a.id]).length;
|
() => [...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) => {
|
const clearFlag = async (glId: string) => {
|
||||||
setSelection((prev) => {
|
await supabase.from("buildium_unmapped_gl_accounts").delete()
|
||||||
const next = { ...prev };
|
.eq("association_id", selectedAssociation).eq("buildium_gl_id", glId);
|
||||||
if (accountId) next[glId] = accountId; else delete next[glId];
|
setFlags((prev) => prev.filter((f) => f.buildium_gl_id !== glId));
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const suggestMatches = () => {
|
const insertLink = async (gl: BuildiumGLAccount, accountId: string): Promise<LinkRow | null> => {
|
||||||
const byCode = new Map<string, NormalizedAccount>();
|
// Only one link per dashboard account can be the push target
|
||||||
const byName = new Map<string, NormalizedAccount>();
|
const hasPushTarget = links.some((l) => l.account_id === accountId && l.is_push_target);
|
||||||
for (const a of dashboardAccounts) {
|
const { data, error } = await supabase
|
||||||
if (a.account_number) byCode.set(normalize(a.account_number), a);
|
.from("buildium_gl_account_links")
|
||||||
byName.set(normalizeName(a.account_name), a);
|
.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;
|
return data as LinkRow;
|
||||||
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 () => {
|
const handleAdd = async () => {
|
||||||
if (!selectedAssociation) return;
|
const gl = addOptions.find((o) => o.id === addGlId);
|
||||||
setSaving(true);
|
if (!gl || !addAccountId) return;
|
||||||
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const glById = new Map(buildiumAccounts.map((a) => [a.id, a]));
|
const row = await insertLink(gl, addAccountId);
|
||||||
// One push target per dashboard account: honor the chosen radio, default first.
|
if (row) {
|
||||||
const pushTargetByGl = new Set<string>();
|
setLinks((prev) => [...prev, row]);
|
||||||
for (const [acctId, glIds] of duplicateGroups.entries()) {
|
await clearFlag(gl.id);
|
||||||
const chosen = pushTargets[acctId] && glIds.includes(pushTargets[acctId]) ? pushTargets[acctId] : glIds[0];
|
setAddGlId(""); setAddAccountId("");
|
||||||
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 {
|
} 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<string, NormalizedAccount>();
|
||||||
|
const byName = new Map<string, NormalizedAccount>();
|
||||||
|
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<string, number>();
|
||||||
|
for (const l of links) m.set(l.account_id, (m.get(l.account_id) ?? 0) + 1);
|
||||||
|
return m;
|
||||||
|
}, [links]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -225,12 +303,7 @@ export default function BuildiumGLAccountMapCard({ associations }: Props) {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
{selectedAssociation && !loading && (
|
{selectedAssociation && !loading && (
|
||||||
<>
|
<Badge variant="outline" className="text-xs">{links.length} mapped</Badge>
|
||||||
<Badge variant="outline" className="text-xs">{mappedCount} mapped</Badge>
|
|
||||||
{unmappedCount > 0 && (
|
|
||||||
<Badge variant="outline" className="text-xs text-amber-600 border-amber-200 bg-amber-50">{unmappedCount} unmapped</Badge>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -250,85 +323,106 @@ export default function BuildiumGLAccountMapCard({ associations }: Props) {
|
|||||||
{flags.length > 0 && (
|
{flags.length > 0 && (
|
||||||
<div className="border border-amber-200 bg-amber-50 rounded-lg p-3 text-sm">
|
<div className="border border-amber-200 bg-amber-50 rounded-lg p-3 text-sm">
|
||||||
<p className="font-medium text-amber-800 flex items-center gap-2">
|
<p className="font-medium text-amber-800 flex items-center gap-2">
|
||||||
<AlertTriangle className="h-4 w-4" /> {flags.length} account(s) blocked a recent sync
|
<AlertTriangle className="h-4 w-4" /> {flags.length} account(s) blocked a recent sync — map them below
|
||||||
</p>
|
</p>
|
||||||
<ul className="mt-1 text-xs text-amber-700 list-disc pl-5">
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
{flags.slice(0, 8).map((f) => (
|
{flags.map((f) => (
|
||||||
<li key={f.buildium_gl_id}>
|
<button
|
||||||
{[f.buildium_number, f.buildium_name || `Buildium GL ${f.buildium_gl_id}`].filter(Boolean).join(" - ")}
|
key={f.buildium_gl_id}
|
||||||
{f.context ? ` (${f.context.replace(/_/g, " ")})` : ""}
|
type="button"
|
||||||
</li>
|
onClick={() => setAddGlId(f.buildium_gl_id)}
|
||||||
|
className="px-2 py-0.5 rounded border border-amber-300 bg-white text-xs text-amber-800 hover:bg-amber-100 transition-colors"
|
||||||
|
title="Select this account in the Buildium dropdown"
|
||||||
|
>
|
||||||
|
{glLabel(f)}
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
{flags.length > 8 && <li>…and {flags.length - 8} more</li>}
|
</div>
|
||||||
</ul>
|
|
||||||
<p className="text-xs text-amber-700 mt-1">They're sorted to the top below. Map and save, then re-run the sync.</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end">
|
{/* Add a mapping: Buildium dropdown → system dropdown → Add */}
|
||||||
<Button variant="outline" size="sm" className="gap-2" onClick={suggestMatches} disabled={dashboardAccounts.length === 0}>
|
<div className="border rounded-lg p-3 bg-muted/30">
|
||||||
<Wand2 className="h-3.5 w-3.5" /> Suggest matches
|
<p className="text-xs font-semibold uppercase text-muted-foreground mb-2">Add Mapping</p>
|
||||||
</Button>
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Select value={addGlId || "__none__"} onValueChange={(v) => setAddGlId(v === "__none__" ? "" : v)}>
|
||||||
|
<SelectTrigger className="w-[320px] h-9 text-xs">
|
||||||
|
<SelectValue placeholder="Buildium account..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">— Buildium account —</SelectItem>
|
||||||
|
{addOptions.map((gl) => (
|
||||||
|
<SelectItem key={gl.id} value={gl.id}>
|
||||||
|
{glLabel(gl)}{gl.type ? ` (${gl.type})` : ""}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<ArrowRight className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
|
<Select value={addAccountId || "__none__"} onValueChange={(v) => setAddAccountId(v === "__none__" ? "" : v)}>
|
||||||
|
<SelectTrigger className="w-[320px] h-9 text-xs">
|
||||||
|
<SelectValue placeholder="System account..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">— System account —</SelectItem>
|
||||||
|
{dashboardAccounts.map((a) => (
|
||||||
|
<SelectItem key={a.id} value={a.id}>
|
||||||
|
{[a.account_number, a.account_name].filter(Boolean).join(" - ")} ({a.account_type})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button size="sm" className="gap-1 h-9" onClick={handleAdd} disabled={!addGlId || !addAccountId || busy}>
|
||||||
|
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plus className="h-3.5 w-3.5" />} Add
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" className="gap-1 h-9 ml-auto" onClick={suggestMatches} disabled={busy || dashboardAccounts.length === 0}>
|
||||||
|
<Wand2 className="h-3.5 w-3.5" /> Auto-map exact matches
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border rounded-lg divide-y max-h-[480px] overflow-y-auto">
|
{/* Existing mappings */}
|
||||||
{sortedAccounts.map((gl) => {
|
{sortedLinks.length === 0 ? (
|
||||||
const acctId = selection[gl.id] || "";
|
<p className="text-sm text-muted-foreground text-center py-4">No mappings yet — add the accounts you sync above.</p>
|
||||||
const group = acctId ? duplicateGroups.get(acctId) || [] : [];
|
) : (
|
||||||
const showPushRadio = group.length > 1;
|
<div className="border rounded-lg divide-y max-h-[480px] overflow-y-auto">
|
||||||
const isPushTarget = showPushRadio
|
{sortedLinks.map((l) => {
|
||||||
? (pushTargets[acctId] && group.includes(pushTargets[acctId]) ? pushTargets[acctId] : group[0]) === gl.id
|
const acct = accountById.get(l.account_id);
|
||||||
: false;
|
const showPushRadio = (accountGroupSizes.get(l.account_id) ?? 0) > 1;
|
||||||
return (
|
return (
|
||||||
<div key={gl.id} className="flex items-center gap-2 px-3 py-2">
|
<div key={l.id} className="flex items-center gap-2 px-3 py-1.5 text-sm">
|
||||||
<div className="w-[40%] min-w-0">
|
<span className="w-[42%] truncate">{glLabel(l)}</span>
|
||||||
<p className="text-sm truncate">
|
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||||
{[gl.account_number, gl.name].filter(Boolean).join(" - ")}
|
<span className="flex-1 truncate text-muted-foreground">
|
||||||
</p>
|
{acct ? `${[acct.account_number, acct.account_name].filter(Boolean).join(" - ")}` : "Unknown account"}
|
||||||
<p className="text-[11px] text-muted-foreground">
|
</span>
|
||||||
{gl.type}{!gl.is_active && " · inactive"}
|
{showPushRadio && (
|
||||||
{flaggedIds.has(gl.id) && <span className="text-amber-600"> · blocked a sync</span>}
|
<label className="flex items-center gap-1 text-[11px] text-muted-foreground shrink-0 cursor-pointer" title="Several Buildium accounts map to this dashboard account — pick which one charges are pushed to.">
|
||||||
</p>
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`push-${l.account_id}`}
|
||||||
|
checked={l.is_push_target}
|
||||||
|
onChange={() => setPushTarget(l)}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
push
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 shrink-0 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() => handleDelete(l)}
|
||||||
|
disabled={busy}
|
||||||
|
title="Remove mapping"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<ArrowRight className="h-4 w-4 text-muted-foreground shrink-0" />
|
);
|
||||||
<Select value={acctId || "__none__"} onValueChange={(v) => setMapping(gl.id, v === "__none__" ? "" : v)}>
|
})}
|
||||||
<SelectTrigger className="flex-1 h-8 text-xs">
|
</div>
|
||||||
<SelectValue placeholder="Select dashboard account..." />
|
)}
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="__none__">— Not mapped —</SelectItem>
|
|
||||||
{dashboardAccounts.map((a) => (
|
|
||||||
<SelectItem key={a.id} value={a.id}>
|
|
||||||
{[a.account_number, a.account_name].filter(Boolean).join(" - ")} ({a.account_type})
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
{showPushRadio && (
|
|
||||||
<label className="flex items-center gap-1 text-[11px] text-muted-foreground shrink-0 cursor-pointer" title="Several Buildium accounts map to this dashboard account — pick which one charges are pushed to.">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name={`push-${acctId}`}
|
|
||||||
checked={isPushTarget}
|
|
||||||
onChange={() => setPushTargets((prev) => ({ ...prev, [acctId]: gl.id }))}
|
|
||||||
/>
|
|
||||||
push
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{sortedAccounts.length === 0 && (
|
|
||||||
<p className="text-sm text-muted-foreground p-4">No Buildium GL accounts returned. Check the Buildium connection.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end pt-1">
|
|
||||||
<Button onClick={saveLinks} disabled={saving} className="gap-2">
|
|
||||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
|
||||||
Save GL Account Map
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user