mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
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:
+2
-1
@@ -5,6 +5,7 @@ import { Toaster } from "@/components/ui/toaster";
|
|||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import { AuthProvider } from "@/contexts/AuthContext";
|
import { AuthProvider } from "@/contexts/AuthContext";
|
||||||
import { ViewAsBanner } from "@/components/ViewAsBanner";
|
import { ViewAsBanner } from "@/components/ViewAsBanner";
|
||||||
|
import { RequireAdmin } from "@/components/RequireAdmin";
|
||||||
import Index from "./pages/Index";
|
import Index from "./pages/Index";
|
||||||
import Auth from "./pages/Auth";
|
import Auth from "./pages/Auth";
|
||||||
import ResetPasswordPage from "./pages/ResetPasswordPage";
|
import ResetPasswordPage from "./pages/ResetPasswordPage";
|
||||||
@@ -374,7 +375,7 @@ const App = () => (
|
|||||||
<Route path="accounting-reports" element={<AccountingReportsPage />} />
|
<Route path="accounting-reports" element={<AccountingReportsPage />} />
|
||||||
<Route path="financial-reports" element={<ZohoFinancialReportsPage />} />
|
<Route path="financial-reports" element={<ZohoFinancialReportsPage />} />
|
||||||
<Route path="financial-overview" element={<FinancialOverviewPage />} />
|
<Route path="financial-overview" element={<FinancialOverviewPage />} />
|
||||||
<Route path="accounting" element={<AccountingLayout />}>
|
<Route path="accounting" element={<RequireAdmin><AccountingLayout /></RequireAdmin>}>
|
||||||
<Route index element={<AccountingDashboardPage />} />
|
<Route index element={<AccountingDashboardPage />} />
|
||||||
<Route path="chart-of-accounts" element={<AccountingChartOfAccountsPage />} />
|
<Route path="chart-of-accounts" element={<AccountingChartOfAccountsPage />} />
|
||||||
<Route path="journal-entries" element={<AccountingJournalEntriesPage />} />
|
<Route path="journal-entries" element={<AccountingJournalEntriesPage />} />
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import React, { useState, useEffect, useMemo } from 'react';
|
|||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { fetchChartOfAccounts } from '@/lib/chartOfAccountsSource';
|
||||||
|
|
||||||
const NONE_VALUE = '__none__';
|
const NONE_VALUE = '__none__';
|
||||||
const PAGE_SIZE = 1000;
|
|
||||||
|
|
||||||
const normalizeType = (type) => String(type || '').trim().toLowerCase();
|
const normalizeType = (type) => String(type || '').trim().toLowerCase();
|
||||||
|
|
||||||
@@ -33,32 +33,24 @@ export default function ChartOfAccountsDropdown({ value, onChange, className, pl
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetch = async () => {
|
const fetch = async () => {
|
||||||
// Resolve which accounting system this association uses
|
// Resolve which accounting system this association uses (platform / zoho / buildium)
|
||||||
let system = 'buildium';
|
let system = 'buildium';
|
||||||
if (associationId) {
|
if (associationId) {
|
||||||
const { data: assoc } = await supabase
|
const { data: assoc } = await supabase
|
||||||
.from('associations')
|
.from('associations')
|
||||||
.select('zoho_organization_id')
|
.select('accounting_system, zoho_organization_id')
|
||||||
.eq('id', associationId)
|
.eq('id', associationId)
|
||||||
.maybeSingle();
|
.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';
|
system = 'zoho';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const allAccounts = [];
|
// Single normalized source: accounting.accounts for platform, else public COA.
|
||||||
for (let from = 0; ; from += PAGE_SIZE) {
|
const allAccounts = await fetchChartOfAccounts(associationId, system);
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const filtered = allAccounts.filter((account) => {
|
const filtered = allAccounts.filter((account) => {
|
||||||
const matchesType = accountTypeMatches(account.account_type, accountType);
|
const matchesType = accountTypeMatches(account.account_type, accountType);
|
||||||
|
|||||||
@@ -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}</>;
|
||||||
|
}
|
||||||
@@ -353,6 +353,12 @@ export function AppSidebar() {
|
|||||||
return true;
|
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 () => {
|
const handleSignOut = async () => {
|
||||||
await supabase.auth.signOut();
|
await supabase.auth.signOut();
|
||||||
navigate("/");
|
navigate("/");
|
||||||
@@ -380,7 +386,7 @@ export function AppSidebar() {
|
|||||||
<SidebarGroup className="py-0">
|
<SidebarGroup className="py-0">
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
<SidebarMenu className="gap-0.5 px-1">
|
<SidebarMenu className="gap-0.5 px-1">
|
||||||
{coreItems.map((item) => (
|
{visibleCoreItems.map((item) => (
|
||||||
<NavItem key={item.title} item={item} collapsed={collapsed} />
|
<NavItem key={item.title} item={item} collapsed={collapsed} />
|
||||||
))}
|
))}
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
|
||||||
import { useAssociationAccountingSystem } from './useAssociationAccountingSystem';
|
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.
|
* When omitted, returns the Buildium (default) set.
|
||||||
*/
|
*/
|
||||||
export function useCentralChartOfAccounts(associationId) {
|
export function useCentralChartOfAccounts(associationId) {
|
||||||
@@ -20,24 +22,9 @@ export function useCentralChartOfAccounts(associationId) {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const { data, error: fetchError } = await supabase
|
const rows = await fetchChartOfAccounts(associationId, system);
|
||||||
.from('chart_of_accounts')
|
// Match prior JS behavior: only active accounts surface here.
|
||||||
.select('id, account_number, account_name, account_type, is_active, accounting_system')
|
if (isMounted) setAccounts(rows.filter((a) => a.is_active));
|
||||||
.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);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[useCentralChartOfAccounts] Fetch Error:', err);
|
console.error('[useCentralChartOfAccounts] Fetch Error:', err);
|
||||||
if (isMounted) setError(err.message);
|
if (isMounted) setError(err.message);
|
||||||
@@ -48,7 +35,7 @@ export function useCentralChartOfAccounts(associationId) {
|
|||||||
|
|
||||||
fetchAccounts();
|
fetchAccounts();
|
||||||
return () => { isMounted = false; };
|
return () => { isMounted = false; };
|
||||||
}, [system, systemLoading]);
|
}, [associationId, system, systemLoading]);
|
||||||
|
|
||||||
return { accounts, loading, error, system };
|
return { accounts, loading, error, system };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { supabase } from "@/integrations/supabase/client";
|
|
||||||
import type { Tables } from "@/integrations/supabase/types";
|
|
||||||
import { useAssociationAccountingSystem } from "./useAssociationAccountingSystem";
|
import { useAssociationAccountingSystem } from "./useAssociationAccountingSystem";
|
||||||
|
import { fetchChartOfAccounts, type NormalizedAccount } from "@/lib/chartOfAccountsSource";
|
||||||
type ChartOfAccount = Tables<"chart_of_accounts">;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads the Chart of Accounts scoped to the association's accounting system.
|
* 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.
|
* - `platform` associations read from the Accounting module (`accounting.accounts`).
|
||||||
* - If associationId is omitted/null, returns the Buildium set (default global system).
|
* - `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) {
|
export function useCentralChartOfAccounts(associationId?: string | null) {
|
||||||
const [accounts, setAccounts] = useState<ChartOfAccount[]>([]);
|
const [accounts, setAccounts] = useState<NormalizedAccount[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const { system, loading: systemLoading } = useAssociationAccountingSystem(associationId);
|
const { system, loading: systemLoading } = useAssociationAccountingSystem(associationId);
|
||||||
@@ -21,28 +19,25 @@ export function useCentralChartOfAccounts(associationId?: string | null) {
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const fetchAccounts = async () => {
|
const fetchAccounts = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const { data, error: fetchError } = await supabase
|
try {
|
||||||
.from("chart_of_accounts")
|
const rows = await fetchChartOfAccounts(associationId, system);
|
||||||
.select("*")
|
if (cancelled) return;
|
||||||
.eq("accounting_system", system)
|
setAccounts(rows);
|
||||||
.order("account_number");
|
|
||||||
|
|
||||||
if (cancelled) return;
|
|
||||||
if (fetchError) {
|
|
||||||
console.error("[useCentralChartOfAccounts] Error:", fetchError);
|
|
||||||
setError(fetchError.message);
|
|
||||||
} else {
|
|
||||||
setAccounts(data ?? []);
|
|
||||||
setError(null);
|
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();
|
fetchAccounts();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [system, systemLoading]);
|
}, [associationId, system, systemLoading]);
|
||||||
|
|
||||||
return { accounts, loading, error, system };
|
return { accounts, loading, error, system };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user