From 8ac0edfbd9b3adf06f697caf90243de778673ada Mon Sep 17 00:00:00 2001 From: renee-png Date: Mon, 1 Jun 2026 21:21:48 -0400 Subject: [PATCH] Admin-only Accounting tab + platform COA consolidation Phase 1: gate the Accounting sidebar item and /dashboard/accounting route behind isAdmin via a RequireAdmin guard; Financial Reports stay visible. Phase 2: platform associations now read the Chart of Accounts from accounting.accounts (single source) instead of public.chart_of_accounts. Shared fetchChartOfAccounts() normalizes both sources; central COA hooks and ChartOfAccountsDropdown route through it (reads only, no migration). Co-Authored-By: Claude Opus 4.8 --- src/App.tsx | 3 +- src/components/ChartOfAccountsDropdown.jsx | 26 +++---- src/components/RequireAdmin.tsx | 23 ++++++ src/components/dashboard/AppSidebar.tsx | 8 +- src/hooks/useCentralChartOfAccounts.js | 29 ++----- src/hooks/useCentralChartOfAccounts.ts | 37 ++++----- src/lib/chartOfAccountsSource.ts | 91 ++++++++++++++++++++++ 7 files changed, 156 insertions(+), 61 deletions(-) create mode 100644 src/components/RequireAdmin.tsx create mode 100644 src/lib/chartOfAccountsSource.ts diff --git a/src/App.tsx b/src/App.tsx index 99216d9..df8fcf8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import { Toaster } from "@/components/ui/toaster"; import { TooltipProvider } from "@/components/ui/tooltip"; import { AuthProvider } from "@/contexts/AuthContext"; import { ViewAsBanner } from "@/components/ViewAsBanner"; +import { RequireAdmin } from "@/components/RequireAdmin"; import Index from "./pages/Index"; import Auth from "./pages/Auth"; import ResetPasswordPage from "./pages/ResetPasswordPage"; @@ -374,7 +375,7 @@ const App = () => ( } /> } /> } /> - }> + }> } /> } /> } /> diff --git a/src/components/ChartOfAccountsDropdown.jsx b/src/components/ChartOfAccountsDropdown.jsx index 7ad7846..014afaa 100644 --- a/src/components/ChartOfAccountsDropdown.jsx +++ b/src/components/ChartOfAccountsDropdown.jsx @@ -2,9 +2,9 @@ import React, { useState, useEffect, useMemo } from 'react'; import { supabase } from '@/integrations/supabase/client'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { cn } from '@/lib/utils'; +import { fetchChartOfAccounts } from '@/lib/chartOfAccountsSource'; const NONE_VALUE = '__none__'; -const PAGE_SIZE = 1000; const normalizeType = (type) => String(type || '').trim().toLowerCase(); @@ -33,32 +33,24 @@ export default function ChartOfAccountsDropdown({ value, onChange, className, pl useEffect(() => { const fetch = async () => { - // Resolve which accounting system this association uses + // Resolve which accounting system this association uses (platform / zoho / buildium) let system = 'buildium'; if (associationId) { const { data: assoc } = await supabase .from('associations') - .select('zoho_organization_id') + .select('accounting_system, zoho_organization_id') .eq('id', associationId) .maybeSingle(); - if (assoc?.zoho_organization_id && String(assoc.zoho_organization_id).trim() !== '') { + const explicit = assoc?.accounting_system; + if (explicit === 'platform' || explicit === 'zoho' || explicit === 'buildium') { + system = explicit; + } else if (assoc?.zoho_organization_id && String(assoc.zoho_organization_id).trim() !== '') { system = 'zoho'; } } - const allAccounts = []; - for (let from = 0; ; from += PAGE_SIZE) { - const { data, error } = await supabase - .from('chart_of_accounts') - .select('id, account_name, account_number, account_type, parent_account_id, association_id, association_ids, accounting_system') - .eq('is_active', true) - .order('account_number', { ascending: true }) - .range(from, from + PAGE_SIZE - 1); - - if (error || !data) break; - allAccounts.push(...data); - if (data.length < PAGE_SIZE) break; - } + // Single normalized source: accounting.accounts for platform, else public COA. + const allAccounts = await fetchChartOfAccounts(associationId, system); const filtered = allAccounts.filter((account) => { const matchesType = accountTypeMatches(account.account_type, accountType); diff --git a/src/components/RequireAdmin.tsx b/src/components/RequireAdmin.tsx new file mode 100644 index 0000000..564c017 --- /dev/null +++ b/src/components/RequireAdmin.tsx @@ -0,0 +1,23 @@ +import { Navigate } from "react-router-dom"; +import { useAuth } from "@/contexts/AuthContext"; + +/** + * Route guard for admin-only areas (e.g. the Accounting platform). + * Shows a spinner while auth resolves, then redirects non-admins to the + * dashboard. Admin status respects "view as" (uses effective `isAdmin`). + */ +export function RequireAdmin({ children }: { children: React.ReactNode }) { + const { loading, isAdmin } = useAuth(); + + if (loading) { + return ( +
+
+
+ ); + } + + if (!isAdmin) return ; + + return <>{children}; +} diff --git a/src/components/dashboard/AppSidebar.tsx b/src/components/dashboard/AppSidebar.tsx index bd41748..b3370a1 100644 --- a/src/components/dashboard/AppSidebar.tsx +++ b/src/components/dashboard/AppSidebar.tsx @@ -353,6 +353,12 @@ export function AppSidebar() { return true; }); + // The Accounting platform is admin-only (Financial Reports/Overview stay visible to all) + const visibleCoreItems = coreItems.filter((item) => { + if (item.url === "/dashboard/accounting" && !isAdmin) return false; + return true; + }); + const handleSignOut = async () => { await supabase.auth.signOut(); navigate("/"); @@ -380,7 +386,7 @@ export function AppSidebar() { - {coreItems.map((item) => ( + {visibleCoreItems.map((item) => ( ))} diff --git a/src/hooks/useCentralChartOfAccounts.js b/src/hooks/useCentralChartOfAccounts.js index 4e2b0d3..f0122f9 100644 --- a/src/hooks/useCentralChartOfAccounts.js +++ b/src/hooks/useCentralChartOfAccounts.js @@ -1,9 +1,11 @@ import { useState, useEffect } from 'react'; -import { supabase } from '@/integrations/supabase/client'; import { useAssociationAccountingSystem } from './useAssociationAccountingSystem'; +import { fetchChartOfAccounts } from '@/lib/chartOfAccountsSource'; /** - * JS variant. Pass `associationId` to scope to that association's COA system (zoho vs buildium). + * JS variant. Pass `associationId` to scope to that association's COA system. + * - `platform` associations read from the Accounting module (`accounting.accounts`). + * - `zoho` / `buildium` read the public `chart_of_accounts`. * When omitted, returns the Buildium (default) set. */ export function useCentralChartOfAccounts(associationId) { @@ -20,24 +22,9 @@ export function useCentralChartOfAccounts(associationId) { setLoading(true); setError(null); try { - const { data, error: fetchError } = await supabase - .from('chart_of_accounts') - .select('id, account_number, account_name, account_type, is_active, accounting_system') - .eq('is_active', true) - .eq('accounting_system', system) - .order('account_number', { ascending: true }); - - if (fetchError) throw fetchError; - - if (isMounted) { - const processedData = (data || []).map(acc => ({ - ...acc, - account_number: acc.account_number || '', - account_name: acc.account_name || 'Unnamed Account', - account_type: acc.account_type || 'Uncategorized' - })); - setAccounts(processedData); - } + const rows = await fetchChartOfAccounts(associationId, system); + // Match prior JS behavior: only active accounts surface here. + if (isMounted) setAccounts(rows.filter((a) => a.is_active)); } catch (err) { console.error('[useCentralChartOfAccounts] Fetch Error:', err); if (isMounted) setError(err.message); @@ -48,7 +35,7 @@ export function useCentralChartOfAccounts(associationId) { fetchAccounts(); return () => { isMounted = false; }; - }, [system, systemLoading]); + }, [associationId, system, systemLoading]); return { accounts, loading, error, system }; } diff --git a/src/hooks/useCentralChartOfAccounts.ts b/src/hooks/useCentralChartOfAccounts.ts index 46fd8b4..cfd6b57 100644 --- a/src/hooks/useCentralChartOfAccounts.ts +++ b/src/hooks/useCentralChartOfAccounts.ts @@ -1,17 +1,15 @@ import { useState, useEffect } from "react"; -import { supabase } from "@/integrations/supabase/client"; -import type { Tables } from "@/integrations/supabase/types"; import { useAssociationAccountingSystem } from "./useAssociationAccountingSystem"; - -type ChartOfAccount = Tables<"chart_of_accounts">; +import { fetchChartOfAccounts, type NormalizedAccount } from "@/lib/chartOfAccountsSource"; /** * Loads the Chart of Accounts scoped to the association's accounting system. - * - If associationId is provided, only accounts matching that system (zoho vs buildium) are returned. - * - If associationId is omitted/null, returns the Buildium set (default global system). + * - `platform` associations read from the Accounting module (`accounting.accounts`). + * - `zoho` / `buildium` read the public `chart_of_accounts`, scoped by system. + * - If associationId is omitted/null, returns the Buildium (default) set. */ export function useCentralChartOfAccounts(associationId?: string | null) { - const [accounts, setAccounts] = useState([]); + const [accounts, setAccounts] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const { system, loading: systemLoading } = useAssociationAccountingSystem(associationId); @@ -21,28 +19,25 @@ export function useCentralChartOfAccounts(associationId?: string | null) { let cancelled = false; const fetchAccounts = async () => { setLoading(true); - const { data, error: fetchError } = await supabase - .from("chart_of_accounts") - .select("*") - .eq("accounting_system", system) - .order("account_number"); - - if (cancelled) return; - if (fetchError) { - console.error("[useCentralChartOfAccounts] Error:", fetchError); - setError(fetchError.message); - } else { - setAccounts(data ?? []); + try { + const rows = await fetchChartOfAccounts(associationId, system); + if (cancelled) return; + setAccounts(rows); setError(null); + } catch (fetchError: any) { + if (cancelled) return; + console.error("[useCentralChartOfAccounts] Error:", fetchError); + setError(fetchError?.message ?? "Failed to load accounts"); + } finally { + if (!cancelled) setLoading(false); } - setLoading(false); }; fetchAccounts(); return () => { cancelled = true; }; - }, [system, systemLoading]); + }, [associationId, system, systemLoading]); return { accounts, loading, error, system }; } diff --git a/src/lib/chartOfAccountsSource.ts b/src/lib/chartOfAccountsSource.ts new file mode 100644 index 0000000..bfc31ef --- /dev/null +++ b/src/lib/chartOfAccountsSource.ts @@ -0,0 +1,91 @@ +import { supabase } from "@/integrations/supabase/client"; +import { accounting } from "@/lib/accountingClient"; + +/** + * The Chart of Accounts shape every consumer in the app expects, regardless of + * whether the underlying source is the public `chart_of_accounts` table or the + * Accounting platform's `accounting.accounts` table. + */ +export type NormalizedAccount = { + id: string; + account_number: string; + account_name: string; + account_type: string; + parent_account_id: string | null; + is_active: boolean; + association_id: string | null; + association_ids: string[] | null; + accounting_system: string; + description: string | null; +}; + +function fromPublic(row: any): NormalizedAccount { + return { + id: String(row.id), + account_number: row.account_number ?? "", + account_name: row.account_name ?? "Unnamed Account", + account_type: row.account_type ?? "Uncategorized", + parent_account_id: row.parent_account_id ?? null, + is_active: row.is_active ?? true, + association_id: row.association_id ?? null, + association_ids: Array.isArray(row.association_ids) ? row.association_ids : null, + accounting_system: row.accounting_system ?? "buildium", + description: row.description ?? null, + }; +} + +function fromPlatform(row: any, associationId: string): NormalizedAccount { + return { + id: String(row.id), + account_number: row.code ?? "", + account_name: row.name ?? "Unnamed Account", + account_type: row.type ?? "Uncategorized", + parent_account_id: row.parent_account_id ?? null, + is_active: true, // accounting.accounts has no active flag; all are active + association_id: associationId, + association_ids: [associationId], + accounting_system: "platform", + description: row.subtype ?? row.description ?? null, + }; +} + +/** + * Loads the Chart of Accounts for an association's accounting system. + * - `platform` → the Accounting module's `accounting.accounts` (single source of + * truth once an association is on the platform). Returns [] if the association + * has no `accounting.companies` row yet. + * - `zoho` / `buildium` → the public `chart_of_accounts`, scoped by system. + * + * Returned rows are normalized to {@link NormalizedAccount} so callers never + * branch on the source. + */ +export async function fetchChartOfAccounts( + associationId: string | null | undefined, + system: string, +): Promise { + if (system === "platform" && associationId) { + const { data: company, error: companyError } = await accounting + .from("companies") + .select("id") + .eq("association_id", associationId) + .maybeSingle(); + if (companyError) throw companyError; + if (!company?.id) return []; + + const { data, error } = await accounting + .from("accounts") + .select("id, code, name, type, subtype, parent_account_id, description") + .eq("company_id", company.id) + .order("code", { ascending: true }); + if (error) throw error; + return (data ?? []).map((row) => fromPlatform(row, associationId)); + } + + const { data, error } = await supabase + .from("chart_of_accounts") + .select("*") + .eq("accounting_system", system) + .order("account_number", { ascending: true }); + if (error) throw error; + return (data ?? []).map(fromPublic); +}