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 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 21:21:48 -04:00
parent 64aad1d283
commit 8ac0edfbd9
7 changed files with 156 additions and 61 deletions
+2 -1
View File
@@ -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 = () => (
<Route path="accounting-reports" element={<AccountingReportsPage />} />
<Route path="financial-reports" element={<ZohoFinancialReportsPage />} />
<Route path="financial-overview" element={<FinancialOverviewPage />} />
<Route path="accounting" element={<AccountingLayout />}>
<Route path="accounting" element={<RequireAdmin><AccountingLayout /></RequireAdmin>}>
<Route index element={<AccountingDashboardPage />} />
<Route path="chart-of-accounts" element={<AccountingChartOfAccountsPage />} />
<Route path="journal-entries" element={<AccountingJournalEntriesPage />} />
+9 -17
View File
@@ -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);
+23
View File
@@ -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 (
<div className="flex min-h-[40vh] items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
);
}
if (!isAdmin) return <Navigate to="/dashboard" replace />;
return <>{children}</>;
}
+7 -1
View File
@@ -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() {
<SidebarGroup className="py-0">
<SidebarGroupContent>
<SidebarMenu className="gap-0.5 px-1">
{coreItems.map((item) => (
{visibleCoreItems.map((item) => (
<NavItem key={item.title} item={item} collapsed={collapsed} />
))}
</SidebarMenu>
+8 -21
View File
@@ -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 };
}
+15 -20
View File
@@ -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<ChartOfAccount[]>([]);
const [accounts, setAccounts] = useState<NormalizedAccount[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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");
try {
const rows = await fetchChartOfAccounts(associationId, system);
if (cancelled) return;
if (fetchError) {
console.error("[useCentralChartOfAccounts] Error:", fetchError);
setError(fetchError.message);
} else {
setAccounts(data ?? []);
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 };
}
+91
View File
@@ -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<NormalizedAccount[]> {
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);
}