mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
Merge pull request #1 from renee-png/accounting-platform-improvements
Accounting platform: remove Zoho, unify reports, board access, vendor sharing
This commit is contained in:
+3
-6
@@ -107,7 +107,6 @@ import ReconciliationsPage from "./pages/ReconciliationsPage";
|
|||||||
import ImportTransactionsPage from "./pages/ImportTransactionsPage";
|
import ImportTransactionsPage from "./pages/ImportTransactionsPage";
|
||||||
import WriteChecksPage from "./pages/WriteChecksPage";
|
import WriteChecksPage from "./pages/WriteChecksPage";
|
||||||
import PrintChecksPage from "./pages/PrintChecksPage";
|
import PrintChecksPage from "./pages/PrintChecksPage";
|
||||||
import CompanyLedgerPage from "./pages/CompanyLedgerPage";
|
|
||||||
import CompanyBankAccountsPage from "./pages/CompanyBankAccountsPage";
|
import CompanyBankAccountsPage from "./pages/CompanyBankAccountsPage";
|
||||||
import CompanyBankAccountsHubPage from "./pages/CompanyBankAccountsHubPage";
|
import CompanyBankAccountsHubPage from "./pages/CompanyBankAccountsHubPage";
|
||||||
import CompanyBankRegisterPage from "./pages/CompanyBankRegisterPage";
|
import CompanyBankRegisterPage from "./pages/CompanyBankRegisterPage";
|
||||||
@@ -141,7 +140,6 @@ import OwnerLedgerPage from "./pages/OwnerLedgerPage";
|
|||||||
import RecordOwnerPaymentPage from "./pages/RecordOwnerPaymentPage";
|
import RecordOwnerPaymentPage from "./pages/RecordOwnerPaymentPage";
|
||||||
import DepositBatchesPage from "./pages/DepositBatchesPage";
|
import DepositBatchesPage from "./pages/DepositBatchesPage";
|
||||||
import TransfersPage from "./pages/TransfersPage";
|
import TransfersPage from "./pages/TransfersPage";
|
||||||
import ZohoBooksSettingsPage from "./pages/settings/ZohoBooksSettingsPage";
|
|
||||||
import BrandingSettingsPage from "./pages/settings/BrandingSettingsPage";
|
import BrandingSettingsPage from "./pages/settings/BrandingSettingsPage";
|
||||||
import RolePermissionsPage from "./pages/settings/RolePermissionsPage";
|
import RolePermissionsPage from "./pages/settings/RolePermissionsPage";
|
||||||
import GeneralSettingsPage from "./pages/settings/GeneralSettingsPage";
|
import GeneralSettingsPage from "./pages/settings/GeneralSettingsPage";
|
||||||
@@ -151,7 +149,7 @@ import AdminStripeAccountsPage from "./pages/AdminStripeAccountsPage";
|
|||||||
import BuildiumSettingsPage from "./pages/settings/BuildiumSettingsPage";
|
import BuildiumSettingsPage from "./pages/settings/BuildiumSettingsPage";
|
||||||
import BuildiumImportReviewPage from "./pages/settings/BuildiumImportReviewPage";
|
import BuildiumImportReviewPage from "./pages/settings/BuildiumImportReviewPage";
|
||||||
import RecurringRulesPage from "./pages/settings/RecurringRulesPage";
|
import RecurringRulesPage from "./pages/settings/RecurringRulesPage";
|
||||||
import ZohoFinancialReportsPage from "./pages/ZohoFinancialReportsPage";
|
import FinancialReportsPage from "./pages/FinancialReportsPage";
|
||||||
import FinancialOverviewPage from "./pages/FinancialOverviewPage";
|
import FinancialOverviewPage from "./pages/FinancialOverviewPage";
|
||||||
import RecentLedgerUpdatesPage from "./pages/RecentLedgerUpdatesPage";
|
import RecentLedgerUpdatesPage from "./pages/RecentLedgerUpdatesPage";
|
||||||
import OutstandingBalancesPage from "./pages/OutstandingBalancesPage";
|
import OutstandingBalancesPage from "./pages/OutstandingBalancesPage";
|
||||||
@@ -359,6 +357,7 @@ const App = () => (
|
|||||||
<Route path="announcements" element={<AnnouncementsPage />} />
|
<Route path="announcements" element={<AnnouncementsPage />} />
|
||||||
<Route path="media" element={<MediaLibraryPage />} />
|
<Route path="media" element={<MediaLibraryPage />} />
|
||||||
<Route path="budget-management" element={<BudgetManagementPage />} />
|
<Route path="budget-management" element={<BudgetManagementPage />} />
|
||||||
|
<Route path="budget-management/:id" element={<AccountingBudgetDetailPage basePath="/dashboard/budget-management" />} />
|
||||||
<Route path="bank-accounts" element={<BankAccountsHubPage />} />
|
<Route path="bank-accounts" element={<BankAccountsHubPage />} />
|
||||||
<Route path="bank-accounts-list" element={<BankAccountsPage />} />
|
<Route path="bank-accounts-list" element={<BankAccountsPage />} />
|
||||||
<Route path="bank-register" element={<BankRegisterPage />} />
|
<Route path="bank-register" element={<BankRegisterPage />} />
|
||||||
@@ -368,12 +367,11 @@ const App = () => (
|
|||||||
<Route path="import-transactions" element={<ImportTransactionsPage />} />
|
<Route path="import-transactions" element={<ImportTransactionsPage />} />
|
||||||
<Route path="write-checks" element={<WriteChecksPage />} />
|
<Route path="write-checks" element={<WriteChecksPage />} />
|
||||||
<Route path="print-checks" element={<PrintChecksPage />} />
|
<Route path="print-checks" element={<PrintChecksPage />} />
|
||||||
<Route path="company-ledger" element={<CompanyLedgerPage />} />
|
|
||||||
<Route path="company-bank-accounts" element={<CompanyBankAccountsHubPage />} />
|
<Route path="company-bank-accounts" element={<CompanyBankAccountsHubPage />} />
|
||||||
<Route path="company-bank-register" element={<CompanyBankRegisterPage />} />
|
<Route path="company-bank-register" element={<CompanyBankRegisterPage />} />
|
||||||
<Route path="company-checks" element={<CompanyChecksPage />} />
|
<Route path="company-checks" element={<CompanyChecksPage />} />
|
||||||
<Route path="accounting-reports" element={<AccountingReportsPage />} />
|
<Route path="accounting-reports" element={<AccountingReportsPage />} />
|
||||||
<Route path="financial-reports" element={<ZohoFinancialReportsPage />} />
|
<Route path="financial-reports" element={<FinancialReportsPage />} />
|
||||||
<Route path="financial-overview" element={<FinancialOverviewPage />} />
|
<Route path="financial-overview" element={<FinancialOverviewPage />} />
|
||||||
<Route path="accounting" element={<RequireAdmin><AccountingLayout /></RequireAdmin>}>
|
<Route path="accounting" element={<RequireAdmin><AccountingLayout /></RequireAdmin>}>
|
||||||
<Route index element={<AccountingDashboardPage />} />
|
<Route index element={<AccountingDashboardPage />} />
|
||||||
@@ -437,7 +435,6 @@ const App = () => (
|
|||||||
<Route index element={<GeneralSettingsPage />} />
|
<Route index element={<GeneralSettingsPage />} />
|
||||||
<Route path="general" element={<GeneralSettingsPage />} />
|
<Route path="general" element={<GeneralSettingsPage />} />
|
||||||
<Route path="branding" element={<BrandingSettingsPage />} />
|
<Route path="branding" element={<BrandingSettingsPage />} />
|
||||||
<Route path="zoho-books" element={<ZohoBooksSettingsPage />} />
|
|
||||||
<Route path="stripe-accounts" element={<AdminStripeAccountsPage />} />
|
<Route path="stripe-accounts" element={<AdminStripeAccountsPage />} />
|
||||||
<Route path="role-permissions" element={<RolePermissionsPage />} />
|
<Route path="role-permissions" element={<RolePermissionsPage />} />
|
||||||
<Route path="portal-visibility" element={<PortalFunctionVisibilityPage />} />
|
<Route path="portal-visibility" element={<PortalFunctionVisibilityPage />} />
|
||||||
|
|||||||
@@ -1,346 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { supabase } from "@/integrations/supabase/client";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { Loader2, Download, Landmark } from "lucide-react";
|
|
||||||
|
|
||||||
interface Association {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
zoho_organization_id: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ZohoBankAccount {
|
|
||||||
account_id: string;
|
|
||||||
account_name: string;
|
|
||||||
account_type?: string;
|
|
||||||
account_number?: string;
|
|
||||||
routing_number?: string | null;
|
|
||||||
bank_name?: string | null;
|
|
||||||
currency_code?: string;
|
|
||||||
balance?: number;
|
|
||||||
uncategorized_transactions?: number;
|
|
||||||
is_active?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
associations: Association[];
|
|
||||||
defaultAssociationId?: string;
|
|
||||||
onImported: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lets users browse the bank accounts available in an association's linked Zoho
|
|
||||||
* Books organization and import the selected ones into local `bank_accounts`.
|
|
||||||
*/
|
|
||||||
export default function ImportZohoBankAccountsDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
associations,
|
|
||||||
defaultAssociationId,
|
|
||||||
onImported,
|
|
||||||
}: Props) {
|
|
||||||
const { toast } = useToast();
|
|
||||||
const eligible = associations.filter((a) => !!a.zoho_organization_id);
|
|
||||||
|
|
||||||
const [associationId, setAssociationId] = useState<string>("");
|
|
||||||
const [zohoAccounts, setZohoAccounts] = useState<ZohoBankAccount[]>([]);
|
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
||||||
const [existing, setExisting] = useState<Set<string>>(new Set()); // account numbers already imported
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [importing, setImporting] = useState(false);
|
|
||||||
|
|
||||||
// Reset on open
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
setZohoAccounts([]);
|
|
||||||
setSelected(new Set());
|
|
||||||
setExisting(new Set());
|
|
||||||
const initial =
|
|
||||||
(defaultAssociationId && eligible.find((a) => a.id === defaultAssociationId)?.id) ||
|
|
||||||
eligible[0]?.id ||
|
|
||||||
"";
|
|
||||||
setAssociationId(initial);
|
|
||||||
}, [open, defaultAssociationId]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
||||||
|
|
||||||
// Fetch when association changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open || !associationId) return;
|
|
||||||
let cancelled = false;
|
|
||||||
(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const [zohoRes, localRes] = await Promise.all([
|
|
||||||
supabase.functions.invoke("zoho-books", {
|
|
||||||
body: { action: "list_bank_accounts", params: { association_id: associationId } },
|
|
||||||
}),
|
|
||||||
supabase
|
|
||||||
.from("bank_accounts")
|
|
||||||
.select("account_number, account_name")
|
|
||||||
.eq("association_id", associationId),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (cancelled) return;
|
|
||||||
|
|
||||||
if (zohoRes.error) throw zohoRes.error;
|
|
||||||
const list: ZohoBankAccount[] = Array.isArray(zohoRes.data?.data)
|
|
||||||
? zohoRes.data.data
|
|
||||||
: zohoRes.data?.data?.bankaccounts || [];
|
|
||||||
|
|
||||||
setZohoAccounts(list);
|
|
||||||
|
|
||||||
const existingSet = new Set<string>();
|
|
||||||
(localRes.data || []).forEach((a) => {
|
|
||||||
if (a.account_number) existingSet.add(a.account_number);
|
|
||||||
if (a.account_name) existingSet.add(`name:${a.account_name.toLowerCase()}`);
|
|
||||||
});
|
|
||||||
setExisting(existingSet);
|
|
||||||
|
|
||||||
// Pre-select accounts not already imported
|
|
||||||
setSelected(
|
|
||||||
new Set(
|
|
||||||
list
|
|
||||||
.filter(
|
|
||||||
(a) =>
|
|
||||||
!(a.account_number && existingSet.has(a.account_number)) &&
|
|
||||||
!existingSet.has(`name:${(a.account_name || "").toLowerCase()}`)
|
|
||||||
)
|
|
||||||
.map((a) => a.account_id)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} catch (e: any) {
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: "Could not load Zoho bank accounts",
|
|
||||||
description: e?.message || "Check that this association is linked to a Zoho org.",
|
|
||||||
});
|
|
||||||
setZohoAccounts([]);
|
|
||||||
} finally {
|
|
||||||
if (!cancelled) setLoading(false);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [open, associationId, toast]);
|
|
||||||
|
|
||||||
const toggle = (id: string) => {
|
|
||||||
setSelected((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(id)) next.delete(id);
|
|
||||||
else next.add(id);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleAll = (checked: boolean) => {
|
|
||||||
if (!checked) {
|
|
||||||
setSelected(new Set());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSelected(
|
|
||||||
new Set(
|
|
||||||
zohoAccounts
|
|
||||||
.filter(
|
|
||||||
(a) =>
|
|
||||||
!(a.account_number && existing.has(a.account_number)) &&
|
|
||||||
!existing.has(`name:${(a.account_name || "").toLowerCase()}`)
|
|
||||||
)
|
|
||||||
.map((a) => a.account_id)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const mapZohoTypeToLocal = (t?: string): string => {
|
|
||||||
const v = (t || "").toLowerCase();
|
|
||||||
if (v.includes("credit")) return "credit_card";
|
|
||||||
if (v.includes("saving")) return "savings";
|
|
||||||
if (v.includes("paypal") || v.includes("other")) return "other";
|
|
||||||
return "checking";
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleImport = async () => {
|
|
||||||
if (!associationId || selected.size === 0) return;
|
|
||||||
setImporting(true);
|
|
||||||
try {
|
|
||||||
const toImport = zohoAccounts.filter((a) => selected.has(a.account_id));
|
|
||||||
const payload = toImport.map((a) => ({
|
|
||||||
association_id: associationId,
|
|
||||||
account_name: a.account_name || "Zoho Bank Account",
|
|
||||||
account_number: a.account_number || null,
|
|
||||||
routing_number: a.routing_number || null,
|
|
||||||
bank_name: a.bank_name || null,
|
|
||||||
account_type: mapZohoTypeToLocal(a.account_type),
|
|
||||||
account_category: "operating",
|
|
||||||
current_balance: Number(a.balance || 0),
|
|
||||||
status: a.is_active === false ? "inactive" : "active",
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { error } = await supabase.from("bank_accounts").insert(payload);
|
|
||||||
if (error) throw error;
|
|
||||||
|
|
||||||
toast({ title: `Imported ${payload.length} bank account${payload.length === 1 ? "" : "s"} from Zoho` });
|
|
||||||
onImported();
|
|
||||||
onOpenChange(false);
|
|
||||||
} catch (e: any) {
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: "Import failed",
|
|
||||||
description: e?.message || "Could not import bank accounts.",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setImporting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const allSelectableSelected =
|
|
||||||
zohoAccounts.length > 0 &&
|
|
||||||
zohoAccounts
|
|
||||||
.filter(
|
|
||||||
(a) =>
|
|
||||||
!(a.account_number && existing.has(a.account_number)) &&
|
|
||||||
!existing.has(`name:${(a.account_name || "").toLowerCase()}`)
|
|
||||||
)
|
|
||||||
.every((a) => selected.has(a.account_id));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="sm:max-w-2xl max-h-[85vh] overflow-y-auto">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="flex items-center gap-2">
|
|
||||||
<Landmark className="h-5 w-5 text-primary" />
|
|
||||||
Import Bank Accounts from Zoho Books
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Select an association linked to Zoho Books, then choose which bank accounts to import.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{eligible.length === 0 ? (
|
|
||||||
<div className="rounded-md border bg-muted/30 p-4 text-sm text-muted-foreground">
|
|
||||||
No associations are linked to a Zoho Books organization yet. Open an association and set
|
|
||||||
its <span className="font-mono">Zoho Organization ID</span> first.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<Label>Association (Zoho-linked)</Label>
|
|
||||||
<Select value={associationId} onValueChange={setAssociationId}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select association" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{eligible.map((a) => (
|
|
||||||
<SelectItem key={a.id} value={a.id}>
|
|
||||||
{a.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-md border">
|
|
||||||
<div className="flex items-center justify-between px-3 py-2 border-b bg-muted/30">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Checkbox
|
|
||||||
checked={allSelectableSelected}
|
|
||||||
onCheckedChange={(v) => toggleAll(!!v)}
|
|
||||||
disabled={loading || zohoAccounts.length === 0}
|
|
||||||
/>
|
|
||||||
<span className="text-sm font-medium">
|
|
||||||
{loading
|
|
||||||
? "Loading…"
|
|
||||||
: `${selected.size} selected of ${zohoAccounts.length}`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="divide-y max-h-80 overflow-y-auto">
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex items-center justify-center py-10 text-muted-foreground">
|
|
||||||
<Loader2 className="h-5 w-5 animate-spin mr-2" /> Fetching from Zoho…
|
|
||||||
</div>
|
|
||||||
) : zohoAccounts.length === 0 ? (
|
|
||||||
<div className="px-3 py-8 text-center text-sm text-muted-foreground">
|
|
||||||
No bank accounts returned by Zoho for this organization.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
zohoAccounts.map((a) => {
|
|
||||||
const dup =
|
|
||||||
(a.account_number && existing.has(a.account_number)) ||
|
|
||||||
existing.has(`name:${(a.account_name || "").toLowerCase()}`);
|
|
||||||
return (
|
|
||||||
<label
|
|
||||||
key={a.account_id}
|
|
||||||
className={`flex items-center gap-3 px-3 py-2.5 text-sm cursor-pointer hover:bg-muted/50 ${
|
|
||||||
dup ? "opacity-60" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
checked={selected.has(a.account_id)}
|
|
||||||
onCheckedChange={() => toggle(a.account_id)}
|
|
||||||
disabled={dup}
|
|
||||||
/>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="font-medium text-foreground truncate">
|
|
||||||
{a.account_name}
|
|
||||||
{dup && (
|
|
||||||
<span className="ml-2 text-xs text-muted-foreground">
|
|
||||||
(already imported)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{[a.bank_name, a.account_type, a.account_number ? `•••• ${a.account_number.slice(-4)}` : null]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" · ")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right text-sm font-mono">
|
|
||||||
${Number(a.balance || 0).toLocaleString("en-US", { minimumFractionDigits: 2 })}
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={importing}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleImport}
|
|
||||||
disabled={importing || loading || selected.size === 0 || eligible.length === 0}
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
{importing ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />}
|
|
||||||
Import {selected.size > 0 ? `(${selected.size})` : ""}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { pushBillToZohoAfterCreate } from '@/lib/zohoBillSync';
|
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Sparkles, Edit2, Loader2, AlertTriangle, ChevronDown, CheckCircle2, Building2 } from 'lucide-react';
|
import { Sparkles, Edit2, Loader2, AlertTriangle, ChevronDown, CheckCircle2, Building2 } from 'lucide-react';
|
||||||
@@ -260,7 +259,6 @@ export default function InvoiceMappingDialog({ open, onOpenChange, invoice, onSu
|
|||||||
}).select('id').single();
|
}).select('id').single();
|
||||||
|
|
||||||
if (billError) throw billError;
|
if (billError) throw billError;
|
||||||
if (newBill?.id) pushBillToZohoAfterCreate(newBill.id);
|
|
||||||
|
|
||||||
if (invoice?.id) {
|
if (invoice?.id) {
|
||||||
await supabase.from('invoices')
|
await supabase.from('invoices')
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { Card, CardContent } from '@/components/ui/card';
|
|||||||
import { Loader2, CheckCircle } from 'lucide-react';
|
import { Loader2, CheckCircle } from 'lucide-react';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { pushBillPaymentToZohoAfterPay } from '@/lib/zohoBillSync';
|
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
|
||||||
@@ -38,12 +37,7 @@ export default function MarkAsPaidDialog({ open, onOpenChange, bill, onSuccess }
|
|||||||
|
|
||||||
if (billError) throw billError;
|
if (billError) throw billError;
|
||||||
|
|
||||||
// Push payment + check info to Zoho Books (records the vendor payment in the banking journal)
|
toast({
|
||||||
pushBillPaymentToZohoAfterPay(bill.id).then((r) => {
|
|
||||||
if (!r.success) console.warn('Zoho payment sync failed for bill', bill.id, r.error);
|
|
||||||
});
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: "Success",
|
title: "Success",
|
||||||
description: sendToPrintQueue
|
description: sendToPrintQueue
|
||||||
? "Bill marked as paid and check added to print queue."
|
? "Bill marked as paid and check added to print queue."
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Loader2, DollarSign, AlertCircle, Plus, Trash2 } from 'lucide-react';
|
import { Loader2, DollarSign, AlertCircle, Plus, Trash2 } from 'lucide-react';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { syncPaymentToZoho } from '@/lib/zohoFinancialSync';
|
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
@@ -111,10 +110,6 @@ export default function PaymentFormDialog({ open, onOpenChange, vendorId, client
|
|||||||
description: data.notes, status: 'completed'
|
description: data.notes, status: 'completed'
|
||||||
}]).select('id').single();
|
}]).select('id').single();
|
||||||
if (paymentError) throw paymentError;
|
if (paymentError) throw paymentError;
|
||||||
// Best-effort: push to Zoho (records into banking journal)
|
|
||||||
if (insertedPayment?.id) {
|
|
||||||
syncPaymentToZoho(insertedPayment.id).catch((e) => console.warn('Zoho payment sync failed:', e));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (allocations.length > 0) {
|
if (allocations.length > 0) {
|
||||||
const { data: userData } = await supabase.auth.getUser();
|
const { data: userData } = await supabase.auth.getUser();
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ export const SETTINGS_PAGES = [
|
|||||||
{
|
{
|
||||||
category: "Integrations",
|
category: "Integrations",
|
||||||
items: [
|
items: [
|
||||||
{ path: "/dashboard/settings/zoho-books", title: "Zoho Books", icon: BookOpen },
|
|
||||||
{ path: "/dashboard/settings/buildium", title: "Buildium", icon: Building2 },
|
{ path: "/dashboard/settings/buildium", title: "Buildium", icon: Building2 },
|
||||||
{ path: "/dashboard/settings/stripe-accounts", title: "Payment Gateways", icon: CreditCard },
|
{ path: "/dashboard/settings/stripe-accounts", title: "Payment Gateways", icon: CreditCard },
|
||||||
{ path: "/dashboard/mailchimp", title: "Mailchimp", icon: Mail },
|
{ path: "/dashboard/mailchimp", title: "Mailchimp", icon: Mail },
|
||||||
|
|||||||
@@ -0,0 +1,291 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Loader2, Save, DollarSign, Clock } from "lucide-react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
associationId: string;
|
||||||
|
associationName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FeeRules {
|
||||||
|
id?: string;
|
||||||
|
interest_enabled: boolean;
|
||||||
|
interest_rate: number;
|
||||||
|
interest_grace_days: number;
|
||||||
|
interest_compound: string;
|
||||||
|
interest_apply_to: string;
|
||||||
|
late_fee_enabled: boolean;
|
||||||
|
late_fee_type: string;
|
||||||
|
late_fee_amount: number;
|
||||||
|
late_fee_trigger_days: number;
|
||||||
|
late_fee_max: number | null;
|
||||||
|
late_fee_recurring: boolean;
|
||||||
|
auto_apply_enabled: boolean;
|
||||||
|
auto_apply_schedule: string;
|
||||||
|
auto_apply_day: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_FEE_RULES: FeeRules = {
|
||||||
|
interest_enabled: false,
|
||||||
|
interest_rate: 0,
|
||||||
|
interest_grace_days: 30,
|
||||||
|
interest_compound: "monthly",
|
||||||
|
interest_apply_to: "assessments",
|
||||||
|
late_fee_enabled: false,
|
||||||
|
late_fee_type: "flat",
|
||||||
|
late_fee_amount: 0,
|
||||||
|
late_fee_trigger_days: 15,
|
||||||
|
late_fee_max: null,
|
||||||
|
late_fee_recurring: false,
|
||||||
|
auto_apply_enabled: false,
|
||||||
|
auto_apply_schedule: "monthly",
|
||||||
|
auto_apply_day: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FeesTab({ associationId, associationName }: Props) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [feeRules, setFeeRules] = useState<FeeRules>(DEFAULT_FEE_RULES);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchFeeRules();
|
||||||
|
}, [associationId]);
|
||||||
|
|
||||||
|
const fetchFeeRules = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const { data } = await supabase
|
||||||
|
.from("association_fee_rules")
|
||||||
|
.select("*")
|
||||||
|
.eq("association_id", associationId)
|
||||||
|
.maybeSingle();
|
||||||
|
if (data) setFeeRules({ ...DEFAULT_FEE_RULES, ...data });
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveFeeRules = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
const payload = { ...feeRules, association_id: associationId };
|
||||||
|
delete (payload as any).id;
|
||||||
|
|
||||||
|
const { error } = feeRules.id
|
||||||
|
? await supabase.from("association_fee_rules").update(payload).eq("id", feeRules.id)
|
||||||
|
: await supabase.from("association_fee_rules").insert(payload);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
toast({ title: "Error saving fee rules", description: error.message, variant: "destructive" });
|
||||||
|
} else {
|
||||||
|
toast({ title: "Fee rules saved" });
|
||||||
|
fetchFeeRules();
|
||||||
|
}
|
||||||
|
setSaving(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* ── Interest & Late Fee Rules ── */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<DollarSign className="h-5 w-5 text-primary" />
|
||||||
|
Interest & Late Fee Rules
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Configure how interest and late fees are calculated for {associationName}. These charges can be auto-applied to owner ledgers.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* Interest Section */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold">Interest Charges</h4>
|
||||||
|
<p className="text-xs text-muted-foreground">Apply interest on outstanding balances</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={feeRules.interest_enabled}
|
||||||
|
onCheckedChange={(v) => setFeeRules({ ...feeRules, interest_enabled: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{feeRules.interest_enabled && (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 pl-4 border-l-2 border-primary/20">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">APR Rate (%)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={feeRules.interest_rate}
|
||||||
|
onChange={(e) => setFeeRules({ ...feeRules, interest_rate: Number(e.target.value) })}
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Grace Period (days)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={feeRules.interest_grace_days}
|
||||||
|
onChange={(e) => setFeeRules({ ...feeRules, interest_grace_days: Number(e.target.value) })}
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Compounding</Label>
|
||||||
|
<Select value={feeRules.interest_compound} onValueChange={(v) => setFeeRules({ ...feeRules, interest_compound: v })}>
|
||||||
|
<SelectTrigger className="text-sm"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="daily">Daily</SelectItem>
|
||||||
|
<SelectItem value="monthly">Monthly</SelectItem>
|
||||||
|
<SelectItem value="quarterly">Quarterly</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Apply To</Label>
|
||||||
|
<Select value={feeRules.interest_apply_to} onValueChange={(v) => setFeeRules({ ...feeRules, interest_apply_to: v })}>
|
||||||
|
<SelectTrigger className="text-sm"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="assessments">Assessments Only</SelectItem>
|
||||||
|
<SelectItem value="assessments_and_fees">Assessments & Fees</SelectItem>
|
||||||
|
<SelectItem value="all_charges">All Charges</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Late Fee Section */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold">Late Fees</h4>
|
||||||
|
<p className="text-xs text-muted-foreground">Charge late fees on overdue accounts</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={feeRules.late_fee_enabled}
|
||||||
|
onCheckedChange={(v) => setFeeRules({ ...feeRules, late_fee_enabled: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{feeRules.late_fee_enabled && (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 pl-4 border-l-2 border-primary/20">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Fee Type</Label>
|
||||||
|
<Select value={feeRules.late_fee_type} onValueChange={(v) => setFeeRules({ ...feeRules, late_fee_type: v })}>
|
||||||
|
<SelectTrigger className="text-sm"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="flat">Flat Amount ($)</SelectItem>
|
||||||
|
<SelectItem value="percentage">Percentage (%)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">{feeRules.late_fee_type === "flat" ? "Amount ($)" : "Percentage (%)"}</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={feeRules.late_fee_amount}
|
||||||
|
onChange={(e) => setFeeRules({ ...feeRules, late_fee_amount: Number(e.target.value) })}
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Trigger (days past due)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={feeRules.late_fee_trigger_days}
|
||||||
|
onChange={(e) => setFeeRules({ ...feeRules, late_fee_trigger_days: Number(e.target.value) })}
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{feeRules.late_fee_type === "percentage" && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Max Cap ($)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={feeRules.late_fee_max ?? ""}
|
||||||
|
onChange={(e) => setFeeRules({ ...feeRules, late_fee_max: e.target.value ? Number(e.target.value) : null })}
|
||||||
|
className="text-sm"
|
||||||
|
placeholder="No cap"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2 col-span-2">
|
||||||
|
<Switch
|
||||||
|
checked={feeRules.late_fee_recurring}
|
||||||
|
onCheckedChange={(v) => setFeeRules({ ...feeRules, late_fee_recurring: v })}
|
||||||
|
/>
|
||||||
|
<Label className="text-xs">Apply every month while overdue</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Auto-apply Schedule */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold flex items-center gap-2"><Clock className="h-4 w-4" /> Auto-Apply Schedule</h4>
|
||||||
|
<p className="text-xs text-muted-foreground">Automatically generate interest and late fee charges</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={feeRules.auto_apply_enabled}
|
||||||
|
onCheckedChange={(v) => setFeeRules({ ...feeRules, auto_apply_enabled: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{feeRules.auto_apply_enabled && (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 pl-4 border-l-2 border-primary/20">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Frequency</Label>
|
||||||
|
<Select value={feeRules.auto_apply_schedule} onValueChange={(v) => setFeeRules({ ...feeRules, auto_apply_schedule: v })}>
|
||||||
|
<SelectTrigger className="text-sm"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="monthly">Monthly</SelectItem>
|
||||||
|
<SelectItem value="quarterly">Quarterly</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Day of Month</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={28}
|
||||||
|
value={feeRules.auto_apply_day}
|
||||||
|
onChange={(e) => setFeeRules({ ...feeRules, auto_apply_day: Number(e.target.value) })}
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={saveFeeRules} disabled={saving} className="gap-2">
|
||||||
|
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||||
|
Save Fee Rules
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,938 +0,0 @@
|
|||||||
import { useState, useEffect } from "react";
|
|
||||||
import { supabase } from "@/integrations/supabase/client";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
import {
|
|
||||||
Loader2, Save, RefreshCw, DollarSign, Percent, Clock,
|
|
||||||
ArrowUpFromLine, ArrowDownToLine, CheckCircle2, XCircle, AlertTriangle, Tag
|
|
||||||
} from "lucide-react";
|
|
||||||
import { format } from "date-fns";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
associationId: string;
|
|
||||||
associationName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FeeRules {
|
|
||||||
id?: string;
|
|
||||||
interest_enabled: boolean;
|
|
||||||
interest_rate: number;
|
|
||||||
interest_grace_days: number;
|
|
||||||
interest_compound: string;
|
|
||||||
interest_apply_to: string;
|
|
||||||
late_fee_enabled: boolean;
|
|
||||||
late_fee_type: string;
|
|
||||||
late_fee_amount: number;
|
|
||||||
late_fee_trigger_days: number;
|
|
||||||
late_fee_max: number | null;
|
|
||||||
late_fee_recurring: boolean;
|
|
||||||
auto_apply_enabled: boolean;
|
|
||||||
auto_apply_schedule: string;
|
|
||||||
auto_apply_day: number;
|
|
||||||
push_to_zoho: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SyncSettings {
|
|
||||||
id?: string;
|
|
||||||
sync_invoices: boolean;
|
|
||||||
sync_payments: boolean;
|
|
||||||
sync_contacts: boolean;
|
|
||||||
sync_bills: boolean;
|
|
||||||
sync_journal_entries: boolean;
|
|
||||||
auto_sync_enabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AccountMapping {
|
|
||||||
id: string;
|
|
||||||
chart_of_account_id: string;
|
|
||||||
zoho_account_id: string;
|
|
||||||
zoho_account_name: string | null;
|
|
||||||
account_name?: string;
|
|
||||||
account_number?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CustomerMapping {
|
|
||||||
id: string;
|
|
||||||
owner_id: string | null;
|
|
||||||
unit_id: string | null;
|
|
||||||
zoho_customer_id: string;
|
|
||||||
zoho_customer_name: string | null;
|
|
||||||
owner_name?: string;
|
|
||||||
unit_number?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SyncLogEntry {
|
|
||||||
id: string;
|
|
||||||
sync_type: string;
|
|
||||||
direction: string;
|
|
||||||
status: string;
|
|
||||||
record_count: number;
|
|
||||||
error_message: string | null;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_FEE_RULES: FeeRules = {
|
|
||||||
interest_enabled: false,
|
|
||||||
interest_rate: 0,
|
|
||||||
interest_grace_days: 30,
|
|
||||||
interest_compound: "monthly",
|
|
||||||
interest_apply_to: "assessments",
|
|
||||||
late_fee_enabled: false,
|
|
||||||
late_fee_type: "flat",
|
|
||||||
late_fee_amount: 0,
|
|
||||||
late_fee_trigger_days: 15,
|
|
||||||
late_fee_max: null,
|
|
||||||
late_fee_recurring: false,
|
|
||||||
auto_apply_enabled: false,
|
|
||||||
auto_apply_schedule: "monthly",
|
|
||||||
auto_apply_day: 1,
|
|
||||||
push_to_zoho: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_SYNC_SETTINGS: SyncSettings = {
|
|
||||||
sync_invoices: true,
|
|
||||||
sync_payments: true,
|
|
||||||
sync_contacts: true,
|
|
||||||
sync_bills: false,
|
|
||||||
sync_journal_entries: false,
|
|
||||||
auto_sync_enabled: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ZohoFeesTab({ associationId, associationName }: Props) {
|
|
||||||
const { toast } = useToast();
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
|
|
||||||
// Fee rules
|
|
||||||
const [feeRules, setFeeRules] = useState<FeeRules>(DEFAULT_FEE_RULES);
|
|
||||||
|
|
||||||
// Sync settings
|
|
||||||
const [syncSettings, setSyncSettings] = useState<SyncSettings>(DEFAULT_SYNC_SETTINGS);
|
|
||||||
|
|
||||||
// Account mappings
|
|
||||||
const [accountMappings, setAccountMappings] = useState<AccountMapping[]>([]);
|
|
||||||
const [localAccounts, setLocalAccounts] = useState<{ id: string; account_name: string; account_number: string }[]>([]);
|
|
||||||
const [newAccountMapping, setNewAccountMapping] = useState({ chart_of_account_id: "", zoho_account_id: "", zoho_account_name: "" });
|
|
||||||
|
|
||||||
// Customer mappings (unit-based — in Zoho the customer name = property street address)
|
|
||||||
const [customerMappings, setCustomerMappings] = useState<CustomerMapping[]>([]);
|
|
||||||
const [units, setUnits] = useState<{ id: string; unit_number: string; address: string | null }[]>([]);
|
|
||||||
const [newCustomerMapping, setNewCustomerMapping] = useState({ unit_id: "", zoho_customer_id: "", zoho_customer_name: "" });
|
|
||||||
|
|
||||||
// Sync log
|
|
||||||
const [syncLog, setSyncLog] = useState<SyncLogEntry[]>([]);
|
|
||||||
|
|
||||||
// Reporting tags
|
|
||||||
const [reportingTags, setReportingTags] = useState<any[]>([]); // from Zoho
|
|
||||||
const [tagMappings, setTagMappings] = useState<any[]>([]); // saved mappings
|
|
||||||
const [loadingTags, setLoadingTags] = useState(false);
|
|
||||||
const [manualTagMapping, setManualTagMapping] = useState({
|
|
||||||
zoho_tag_id: "",
|
|
||||||
zoho_tag_name: "",
|
|
||||||
zoho_tag_option_id: "",
|
|
||||||
zoho_option_name: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchAll();
|
|
||||||
}, [associationId]);
|
|
||||||
|
|
||||||
const fetchAll = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
const [feeRes, syncRes, acctMapRes, custMapRes, logRes, coaRes, unitsRes] = await Promise.all([
|
|
||||||
supabase.from("association_fee_rules").select("*").eq("association_id", associationId).maybeSingle(),
|
|
||||||
supabase.from("zoho_sync_settings").select("*").eq("association_id", associationId).maybeSingle(),
|
|
||||||
supabase.from("zoho_account_mappings").select("*, chart_of_accounts(account_name, account_number)").eq("association_id", associationId),
|
|
||||||
supabase.from("zoho_customer_mappings").select("*, units(unit_number, address)").eq("association_id", associationId),
|
|
||||||
supabase.from("zoho_sync_log").select("*").eq("association_id", associationId).order("created_at", { ascending: false }).limit(20),
|
|
||||||
supabase.from("chart_of_accounts").select("id, account_name, account_number").eq("association_id", associationId).order("account_number"),
|
|
||||||
supabase.from("units").select("id, unit_number, address").eq("association_id", associationId).order("unit_number"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (feeRes.data) setFeeRules({ ...DEFAULT_FEE_RULES, ...feeRes.data });
|
|
||||||
if (syncRes.data) setSyncSettings({ ...DEFAULT_SYNC_SETTINGS, ...syncRes.data });
|
|
||||||
if (acctMapRes.data) {
|
|
||||||
setAccountMappings(acctMapRes.data.map((m: any) => ({
|
|
||||||
...m,
|
|
||||||
account_name: m.chart_of_accounts?.account_name,
|
|
||||||
account_number: m.chart_of_accounts?.account_number,
|
|
||||||
})));
|
|
||||||
}
|
|
||||||
if (custMapRes.data) {
|
|
||||||
setCustomerMappings(custMapRes.data.map((m: any) => ({
|
|
||||||
...m,
|
|
||||||
unit_number: m.units?.unit_number,
|
|
||||||
unit_address: m.units?.address,
|
|
||||||
})));
|
|
||||||
}
|
|
||||||
if (logRes.data) setSyncLog(logRes.data as SyncLogEntry[]);
|
|
||||||
if (coaRes.data) setLocalAccounts(coaRes.data);
|
|
||||||
if (unitsRes.data) setUnits(unitsRes.data);
|
|
||||||
setLoading(false);
|
|
||||||
|
|
||||||
// Fetch reporting tag mappings
|
|
||||||
fetchReportingTagMappings();
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchReportingTagMappings = async () => {
|
|
||||||
const { data, error } = await supabase.functions.invoke("zoho-books", {
|
|
||||||
body: { action: "get_reporting_tag_mappings", params: { association_id: associationId } },
|
|
||||||
});
|
|
||||||
if (!error && data?.data) setTagMappings(data.data);
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchReportingTags = async () => {
|
|
||||||
setLoadingTags(true);
|
|
||||||
const { data, error } = await supabase.functions.invoke("zoho-books", {
|
|
||||||
body: { action: "list_reporting_tags", params: { association_id: associationId } },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!error && data?.data) {
|
|
||||||
const tags = data.data;
|
|
||||||
setReportingTags(tags);
|
|
||||||
|
|
||||||
const totalOptions = tags.reduce((sum: number, tag: any) => sum + ((tag.options || []).length), 0);
|
|
||||||
if (tags.length === 0) {
|
|
||||||
toast({ title: "No reporting tags found" });
|
|
||||||
} else if (totalOptions === 0) {
|
|
||||||
toast({
|
|
||||||
title: "Tags loaded, but no options were returned",
|
|
||||||
description: "The backend could read your Zoho tags, but Zoho did not return any selectable tag options.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
toast({ title: "Could not fetch reporting tags", variant: "destructive" });
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoadingTags(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveTagMapping = async (tag: any, optionId: string) => {
|
|
||||||
const option = (tag.options || []).find(
|
|
||||||
(o: any) => String(o.tag_option_id ?? o.option_id) === optionId
|
|
||||||
);
|
|
||||||
const { error } = await supabase.functions.invoke("zoho-books", {
|
|
||||||
body: {
|
|
||||||
action: "save_reporting_tag_mapping",
|
|
||||||
params: {
|
|
||||||
association_id: associationId,
|
|
||||||
zoho_tag_id: String(tag.tag_id),
|
|
||||||
zoho_tag_option_id: optionId,
|
|
||||||
zoho_tag_name: tag.tag_name,
|
|
||||||
zoho_option_name: option?.option_name || null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (error) {
|
|
||||||
toast({ title: "Error saving tag mapping", variant: "destructive" });
|
|
||||||
} else {
|
|
||||||
toast({ title: "Reporting tag mapping saved" });
|
|
||||||
fetchReportingTagMappings();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveManualTagMapping = async () => {
|
|
||||||
if (!manualTagMapping.zoho_tag_id.trim() || !manualTagMapping.zoho_tag_option_id.trim()) {
|
|
||||||
toast({
|
|
||||||
title: "Tag ID and option ID are required",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { error } = await supabase.functions.invoke("zoho-books", {
|
|
||||||
body: {
|
|
||||||
action: "save_reporting_tag_mapping",
|
|
||||||
params: {
|
|
||||||
association_id: associationId,
|
|
||||||
zoho_tag_id: manualTagMapping.zoho_tag_id.trim(),
|
|
||||||
zoho_tag_option_id: manualTagMapping.zoho_tag_option_id.trim(),
|
|
||||||
zoho_tag_name: manualTagMapping.zoho_tag_name.trim() || null,
|
|
||||||
zoho_option_name: manualTagMapping.zoho_option_name.trim() || null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
toast({ title: "Error saving manual tag mapping", variant: "destructive" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast({ title: "Manual reporting tag mapping saved" });
|
|
||||||
setManualTagMapping({
|
|
||||||
zoho_tag_id: "",
|
|
||||||
zoho_tag_name: "",
|
|
||||||
zoho_tag_option_id: "",
|
|
||||||
zoho_option_name: "",
|
|
||||||
});
|
|
||||||
fetchReportingTagMappings();
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveFeeRules = async () => {
|
|
||||||
setSaving(true);
|
|
||||||
const payload = { ...feeRules, association_id: associationId };
|
|
||||||
delete (payload as any).id;
|
|
||||||
|
|
||||||
const { error } = feeRules.id
|
|
||||||
? await supabase.from("association_fee_rules").update(payload).eq("id", feeRules.id)
|
|
||||||
: await supabase.from("association_fee_rules").insert(payload);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
toast({ title: "Error saving fee rules", description: error.message, variant: "destructive" });
|
|
||||||
} else {
|
|
||||||
toast({ title: "Fee rules saved" });
|
|
||||||
fetchAll();
|
|
||||||
}
|
|
||||||
setSaving(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveSyncSettings = async () => {
|
|
||||||
setSaving(true);
|
|
||||||
const payload = { ...syncSettings, association_id: associationId };
|
|
||||||
delete (payload as any).id;
|
|
||||||
|
|
||||||
const { error } = syncSettings.id
|
|
||||||
? await supabase.from("zoho_sync_settings").update(payload).eq("id", syncSettings.id)
|
|
||||||
: await supabase.from("zoho_sync_settings").insert(payload);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
toast({ title: "Error saving sync settings", description: error.message, variant: "destructive" });
|
|
||||||
} else {
|
|
||||||
toast({ title: "Sync settings saved" });
|
|
||||||
fetchAll();
|
|
||||||
}
|
|
||||||
setSaving(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const addAccountMapping = async () => {
|
|
||||||
if (!newAccountMapping.chart_of_account_id || !newAccountMapping.zoho_account_id) return;
|
|
||||||
const { error } = await supabase.from("zoho_account_mappings").insert({
|
|
||||||
association_id: associationId,
|
|
||||||
chart_of_account_id: newAccountMapping.chart_of_account_id,
|
|
||||||
zoho_account_id: newAccountMapping.zoho_account_id,
|
|
||||||
zoho_account_name: newAccountMapping.zoho_account_name || null,
|
|
||||||
});
|
|
||||||
if (error) {
|
|
||||||
toast({ title: "Error", description: error.message, variant: "destructive" });
|
|
||||||
} else {
|
|
||||||
setNewAccountMapping({ chart_of_account_id: "", zoho_account_id: "", zoho_account_name: "" });
|
|
||||||
fetchAll();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeAccountMapping = async (id: string) => {
|
|
||||||
await supabase.from("zoho_account_mappings").delete().eq("id", id);
|
|
||||||
fetchAll();
|
|
||||||
};
|
|
||||||
|
|
||||||
const addCustomerMapping = async () => {
|
|
||||||
if (!newCustomerMapping.unit_id || !newCustomerMapping.zoho_customer_id) return;
|
|
||||||
// Auto-fill Zoho customer name from unit address if not provided
|
|
||||||
const selectedUnit = units.find(u => u.id === newCustomerMapping.unit_id);
|
|
||||||
const customerName = newCustomerMapping.zoho_customer_name || selectedUnit?.address || null;
|
|
||||||
const { error } = await supabase.from("zoho_customer_mappings").insert({
|
|
||||||
association_id: associationId,
|
|
||||||
unit_id: newCustomerMapping.unit_id,
|
|
||||||
zoho_customer_id: newCustomerMapping.zoho_customer_id,
|
|
||||||
zoho_customer_name: customerName,
|
|
||||||
});
|
|
||||||
if (error) {
|
|
||||||
toast({ title: "Error", description: error.message, variant: "destructive" });
|
|
||||||
} else {
|
|
||||||
setNewCustomerMapping({ unit_id: "", zoho_customer_id: "", zoho_customer_name: "" });
|
|
||||||
fetchAll();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeCustomerMapping = async (id: string) => {
|
|
||||||
await supabase.from("zoho_customer_mappings").delete().eq("id", id);
|
|
||||||
fetchAll();
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* ── Interest & Late Fee Rules ── */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<DollarSign className="h-5 w-5 text-primary" />
|
|
||||||
Interest & Late Fee Rules
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>Configure how interest and late fees are calculated for {associationName}. These charges can be auto-applied and pushed to Zoho.</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-6">
|
|
||||||
{/* Interest Section */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-semibold">Interest Charges</h4>
|
|
||||||
<p className="text-xs text-muted-foreground">Apply interest on outstanding balances</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={feeRules.interest_enabled}
|
|
||||||
onCheckedChange={(v) => setFeeRules({ ...feeRules, interest_enabled: v })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{feeRules.interest_enabled && (
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 pl-4 border-l-2 border-primary/20">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">APR Rate (%)</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
value={feeRules.interest_rate}
|
|
||||||
onChange={(e) => setFeeRules({ ...feeRules, interest_rate: Number(e.target.value) })}
|
|
||||||
className="text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">Grace Period (days)</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={feeRules.interest_grace_days}
|
|
||||||
onChange={(e) => setFeeRules({ ...feeRules, interest_grace_days: Number(e.target.value) })}
|
|
||||||
className="text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">Compounding</Label>
|
|
||||||
<Select value={feeRules.interest_compound} onValueChange={(v) => setFeeRules({ ...feeRules, interest_compound: v })}>
|
|
||||||
<SelectTrigger className="text-sm"><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="daily">Daily</SelectItem>
|
|
||||||
<SelectItem value="monthly">Monthly</SelectItem>
|
|
||||||
<SelectItem value="quarterly">Quarterly</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">Apply To</Label>
|
|
||||||
<Select value={feeRules.interest_apply_to} onValueChange={(v) => setFeeRules({ ...feeRules, interest_apply_to: v })}>
|
|
||||||
<SelectTrigger className="text-sm"><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="assessments">Assessments Only</SelectItem>
|
|
||||||
<SelectItem value="assessments_and_fees">Assessments & Fees</SelectItem>
|
|
||||||
<SelectItem value="all_charges">All Charges</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
{/* Late Fee Section */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-semibold">Late Fees</h4>
|
|
||||||
<p className="text-xs text-muted-foreground">Charge late fees on overdue accounts</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={feeRules.late_fee_enabled}
|
|
||||||
onCheckedChange={(v) => setFeeRules({ ...feeRules, late_fee_enabled: v })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{feeRules.late_fee_enabled && (
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 pl-4 border-l-2 border-primary/20">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">Fee Type</Label>
|
|
||||||
<Select value={feeRules.late_fee_type} onValueChange={(v) => setFeeRules({ ...feeRules, late_fee_type: v })}>
|
|
||||||
<SelectTrigger className="text-sm"><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="flat">Flat Amount ($)</SelectItem>
|
|
||||||
<SelectItem value="percentage">Percentage (%)</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">{feeRules.late_fee_type === "flat" ? "Amount ($)" : "Percentage (%)"}</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
value={feeRules.late_fee_amount}
|
|
||||||
onChange={(e) => setFeeRules({ ...feeRules, late_fee_amount: Number(e.target.value) })}
|
|
||||||
className="text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">Trigger (days past due)</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={feeRules.late_fee_trigger_days}
|
|
||||||
onChange={(e) => setFeeRules({ ...feeRules, late_fee_trigger_days: Number(e.target.value) })}
|
|
||||||
className="text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{feeRules.late_fee_type === "percentage" && (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">Max Cap ($)</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
value={feeRules.late_fee_max ?? ""}
|
|
||||||
onChange={(e) => setFeeRules({ ...feeRules, late_fee_max: e.target.value ? Number(e.target.value) : null })}
|
|
||||||
className="text-sm"
|
|
||||||
placeholder="No cap"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center gap-2 col-span-2">
|
|
||||||
<Switch
|
|
||||||
checked={feeRules.late_fee_recurring}
|
|
||||||
onCheckedChange={(v) => setFeeRules({ ...feeRules, late_fee_recurring: v })}
|
|
||||||
/>
|
|
||||||
<Label className="text-xs">Apply every month while overdue</Label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
{/* Auto-apply Schedule */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-semibold flex items-center gap-2"><Clock className="h-4 w-4" /> Auto-Apply Schedule</h4>
|
|
||||||
<p className="text-xs text-muted-foreground">Automatically generate interest and late fee charges</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={feeRules.auto_apply_enabled}
|
|
||||||
onCheckedChange={(v) => setFeeRules({ ...feeRules, auto_apply_enabled: v })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{feeRules.auto_apply_enabled && (
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 pl-4 border-l-2 border-primary/20">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">Frequency</Label>
|
|
||||||
<Select value={feeRules.auto_apply_schedule} onValueChange={(v) => setFeeRules({ ...feeRules, auto_apply_schedule: v })}>
|
|
||||||
<SelectTrigger className="text-sm"><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="monthly">Monthly</SelectItem>
|
|
||||||
<SelectItem value="quarterly">Quarterly</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">Day of Month</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
max={28}
|
|
||||||
value={feeRules.auto_apply_day}
|
|
||||||
onChange={(e) => setFeeRules({ ...feeRules, auto_apply_day: Number(e.target.value) })}
|
|
||||||
className="text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Switch
|
|
||||||
checked={feeRules.push_to_zoho}
|
|
||||||
onCheckedChange={(v) => setFeeRules({ ...feeRules, push_to_zoho: v })}
|
|
||||||
/>
|
|
||||||
<Label className="text-xs">Push to Zoho automatically</Label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button onClick={saveFeeRules} disabled={saving} className="gap-2">
|
|
||||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
|
||||||
Save Fee Rules
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* ── Sync Settings ── */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<RefreshCw className="h-5 w-5 text-primary" />
|
|
||||||
Zoho Sync Settings
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>Control which types of data sync between this association and Zoho Books.</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
|
||||||
{([
|
|
||||||
{ key: "sync_invoices", label: "Invoices / Charges" },
|
|
||||||
{ key: "sync_payments", label: "Payments" },
|
|
||||||
{ key: "sync_contacts", label: "Contacts (Owners/Vendors)" },
|
|
||||||
{ key: "sync_bills", label: "Bills / AP" },
|
|
||||||
{ key: "sync_journal_entries", label: "Journal Entries" },
|
|
||||||
{ key: "auto_sync_enabled", label: "Auto-Sync on Save" },
|
|
||||||
] as const).map(({ key, label }) => (
|
|
||||||
<div key={key} className="flex items-center gap-2">
|
|
||||||
<Switch
|
|
||||||
checked={syncSettings[key]}
|
|
||||||
onCheckedChange={(v) => setSyncSettings({ ...syncSettings, [key]: v })}
|
|
||||||
/>
|
|
||||||
<Label className="text-sm">{label}</Label>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button onClick={saveSyncSettings} disabled={saving} variant="outline" className="gap-2">
|
|
||||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
|
||||||
Save Sync Settings
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* ── Account Mappings ── */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Percent className="h-5 w-5 text-primary" />
|
|
||||||
Chart of Accounts Mapping
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>Map your local chart of accounts to their Zoho Books equivalents.</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{accountMappings.length > 0 && (
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Local Account</TableHead>
|
|
||||||
<TableHead>Zoho Account ID</TableHead>
|
|
||||||
<TableHead>Zoho Account Name</TableHead>
|
|
||||||
<TableHead className="w-16"></TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{accountMappings.map((m) => (
|
|
||||||
<TableRow key={m.id}>
|
|
||||||
<TableCell className="text-sm font-medium">{m.account_number} — {m.account_name}</TableCell>
|
|
||||||
<TableCell className="text-sm text-muted-foreground font-mono">{m.zoho_account_id}</TableCell>
|
|
||||||
<TableCell className="text-sm text-muted-foreground">{m.zoho_account_name || "—"}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Button variant="ghost" size="sm" className="text-destructive h-7" onClick={() => removeAccountMapping(m.id)}>
|
|
||||||
<XCircle className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-wrap items-end gap-3 border-t pt-4">
|
|
||||||
<div className="space-y-1.5 flex-1 min-w-[180px]">
|
|
||||||
<Label className="text-xs">Local Account</Label>
|
|
||||||
<Select value={newAccountMapping.chart_of_account_id} onValueChange={(v) => setNewAccountMapping({ ...newAccountMapping, chart_of_account_id: v })}>
|
|
||||||
<SelectTrigger className="text-sm"><SelectValue placeholder="Select account..." /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{localAccounts.map((a) => (
|
|
||||||
<SelectItem key={a.id} value={a.id}>{a.account_number} — {a.account_name}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">Zoho Account ID</Label>
|
|
||||||
<Input
|
|
||||||
value={newAccountMapping.zoho_account_id}
|
|
||||||
onChange={(e) => setNewAccountMapping({ ...newAccountMapping, zoho_account_id: e.target.value })}
|
|
||||||
className="text-sm w-[180px]"
|
|
||||||
placeholder="e.g. 982000..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">Zoho Account Name</Label>
|
|
||||||
<Input
|
|
||||||
value={newAccountMapping.zoho_account_name}
|
|
||||||
onChange={(e) => setNewAccountMapping({ ...newAccountMapping, zoho_account_name: e.target.value })}
|
|
||||||
className="text-sm w-[180px]"
|
|
||||||
placeholder="Optional label"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button onClick={addAccountMapping} size="sm" disabled={!newAccountMapping.chart_of_account_id || !newAccountMapping.zoho_account_id}>
|
|
||||||
Add Mapping
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* ── Customer Mappings (Unit Address = Zoho Customer Name) ── */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<ArrowUpFromLine className="h-5 w-5 text-primary" />
|
|
||||||
Unit → Zoho Customer Mapping
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>In Zoho, the customer name is the property street address. Select a unit to map its address to a Zoho customer.</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{customerMappings.length > 0 && (
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Unit #</TableHead>
|
|
||||||
<TableHead>Property Address</TableHead>
|
|
||||||
<TableHead>Zoho Customer ID</TableHead>
|
|
||||||
<TableHead>Zoho Customer Name</TableHead>
|
|
||||||
<TableHead className="w-16"></TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{customerMappings.map((m: any) => (
|
|
||||||
<TableRow key={m.id}>
|
|
||||||
<TableCell className="text-sm font-medium font-mono">{m.unit_number || "—"}</TableCell>
|
|
||||||
<TableCell className="text-sm text-muted-foreground">{m.unit_address || "—"}</TableCell>
|
|
||||||
<TableCell className="text-sm text-muted-foreground font-mono">{m.zoho_customer_id}</TableCell>
|
|
||||||
<TableCell className="text-sm text-muted-foreground">{m.zoho_customer_name || "—"}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Button variant="ghost" size="sm" className="text-destructive h-7" onClick={() => removeCustomerMapping(m.id)}>
|
|
||||||
<XCircle className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-wrap items-end gap-3 border-t pt-4">
|
|
||||||
<div className="space-y-1.5 flex-1 min-w-[200px]">
|
|
||||||
<Label className="text-xs">Unit (Property Address)</Label>
|
|
||||||
<Select
|
|
||||||
value={newCustomerMapping.unit_id}
|
|
||||||
onValueChange={(v) => {
|
|
||||||
const unit = units.find(u => u.id === v);
|
|
||||||
setNewCustomerMapping({
|
|
||||||
...newCustomerMapping,
|
|
||||||
unit_id: v,
|
|
||||||
zoho_customer_name: unit?.address || "",
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="text-sm"><SelectValue placeholder="Select unit..." /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{units.map((u) => (
|
|
||||||
<SelectItem key={u.id} value={u.id}>
|
|
||||||
{u.unit_number} — {u.address || "No address"}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">Zoho Customer ID</Label>
|
|
||||||
<Input
|
|
||||||
value={newCustomerMapping.zoho_customer_id}
|
|
||||||
onChange={(e) => setNewCustomerMapping({ ...newCustomerMapping, zoho_customer_id: e.target.value })}
|
|
||||||
className="text-sm w-[180px]"
|
|
||||||
placeholder="e.g. 982000..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">Zoho Customer Name (Address)</Label>
|
|
||||||
<Input
|
|
||||||
value={newCustomerMapping.zoho_customer_name}
|
|
||||||
onChange={(e) => setNewCustomerMapping({ ...newCustomerMapping, zoho_customer_name: e.target.value })}
|
|
||||||
className="text-sm w-[220px]"
|
|
||||||
placeholder="Auto-filled from unit address"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button onClick={addCustomerMapping} size="sm" disabled={!newCustomerMapping.unit_id || !newCustomerMapping.zoho_customer_id}>
|
|
||||||
Add Mapping
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* ── Reporting Tags ── */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Tag className="h-5 w-5 text-primary" />
|
|
||||||
Zoho Reporting Tags
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>Map this association to Zoho reporting tags so invoices and payments are tagged correctly.</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{tagMappings.length > 0 && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h4 className="text-sm font-semibold">Current Mappings</h4>
|
|
||||||
{tagMappings.map((m: any) => (
|
|
||||||
<div key={m.id} className="flex items-center gap-3 text-sm">
|
|
||||||
<Badge variant="outline">{m.zoho_tag_name || m.zoho_tag_id}</Badge>
|
|
||||||
<span className="text-muted-foreground">→</span>
|
|
||||||
<Badge>{m.zoho_option_name || m.zoho_tag_option_id}</Badge>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-4 rounded-lg border bg-muted/30 p-4">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<h4 className="text-sm font-semibold">Manual Mapping</h4>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
If Zoho won’t load the dropdown options, enter the tag and option details manually and save them here.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">Zoho Tag ID</Label>
|
|
||||||
<Input
|
|
||||||
value={manualTagMapping.zoho_tag_id}
|
|
||||||
onChange={(e) => setManualTagMapping({ ...manualTagMapping, zoho_tag_id: e.target.value })}
|
|
||||||
placeholder="e.g. 8824065000000000333"
|
|
||||||
className="text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">Zoho Tag Name</Label>
|
|
||||||
<Input
|
|
||||||
value={manualTagMapping.zoho_tag_name}
|
|
||||||
onChange={(e) => setManualTagMapping({ ...manualTagMapping, zoho_tag_name: e.target.value })}
|
|
||||||
placeholder="e.g. Association"
|
|
||||||
className="text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">Zoho Option ID</Label>
|
|
||||||
<Input
|
|
||||||
value={manualTagMapping.zoho_tag_option_id}
|
|
||||||
onChange={(e) => setManualTagMapping({ ...manualTagMapping, zoho_tag_option_id: e.target.value })}
|
|
||||||
placeholder="Enter the option ID"
|
|
||||||
className="text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">Zoho Option Name</Label>
|
|
||||||
<Input
|
|
||||||
value={manualTagMapping.zoho_option_name}
|
|
||||||
onChange={(e) => setManualTagMapping({ ...manualTagMapping, zoho_option_name: e.target.value })}
|
|
||||||
placeholder={associationName}
|
|
||||||
className="text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
className="gap-2"
|
|
||||||
onClick={saveManualTagMapping}
|
|
||||||
disabled={!manualTagMapping.zoho_tag_id.trim() || !manualTagMapping.zoho_tag_option_id.trim()}
|
|
||||||
>
|
|
||||||
<Save className="h-4 w-4" />
|
|
||||||
Save Manual Mapping
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" size="sm" className="gap-2" onClick={fetchReportingTags} disabled={loadingTags}>
|
|
||||||
{loadingTags ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
|
||||||
{reportingTags.length === 0 ? "Load Tags from Zoho" : "Refresh Tags"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{reportingTags.length > 0 && (
|
|
||||||
<div className="space-y-4 border-t pt-4">
|
|
||||||
{reportingTags.map((tag: any) => {
|
|
||||||
const currentMapping = tagMappings.find((m: any) => String(m.zoho_tag_id) === String(tag.tag_id));
|
|
||||||
return (
|
|
||||||
<div key={tag.tag_id} className="space-y-1.5">
|
|
||||||
<Label className="text-xs font-semibold">{tag.tag_name}</Label>
|
|
||||||
<Select
|
|
||||||
value={currentMapping?.zoho_tag_option_id || ""}
|
|
||||||
onValueChange={(v) => saveTagMapping(tag, v)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="text-sm w-full max-w-[300px]">
|
|
||||||
<SelectValue placeholder={`Select ${tag.tag_name} option...`} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{(tag.options || []).map((opt: any) => {
|
|
||||||
const optionId = String(opt.tag_option_id ?? opt.option_id);
|
|
||||||
return (
|
|
||||||
<SelectItem key={optionId} value={optionId}>
|
|
||||||
{opt.option_name}
|
|
||||||
</SelectItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* ── Sync Log ── */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<AlertTriangle className="h-5 w-5 text-primary" />
|
|
||||||
Sync History
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>Recent sync activity for this association.</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{syncLog.length === 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground text-center py-8">No sync activity recorded yet.</p>
|
|
||||||
) : (
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Date</TableHead>
|
|
||||||
<TableHead>Type</TableHead>
|
|
||||||
<TableHead>Direction</TableHead>
|
|
||||||
<TableHead>Records</TableHead>
|
|
||||||
<TableHead>Status</TableHead>
|
|
||||||
<TableHead>Error</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{syncLog.map((entry) => (
|
|
||||||
<TableRow key={entry.id}>
|
|
||||||
<TableCell className="text-xs text-muted-foreground">{format(new Date(entry.created_at), "MMM d, h:mm a")}</TableCell>
|
|
||||||
<TableCell className="text-xs font-medium capitalize">{entry.sync_type}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{entry.direction === "push" ? (
|
|
||||||
<Badge variant="outline" className="text-xs gap-1"><ArrowUpFromLine className="h-3 w-3" /> Push</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge variant="outline" className="text-xs gap-1"><ArrowDownToLine className="h-3 w-3" /> Pull</Badge>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-xs">{entry.record_count}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{entry.status === "success" ? (
|
|
||||||
<Badge className="text-xs bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"><CheckCircle2 className="h-3 w-3 mr-1" />Success</Badge>
|
|
||||||
) : entry.status === "error" ? (
|
|
||||||
<Badge variant="destructive" className="text-xs"><XCircle className="h-3 w-3 mr-1" />Error</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge variant="secondary" className="text-xs">Partial</Badge>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-xs text-destructive max-w-[200px] truncate">{entry.error_message || "—"}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -360,30 +360,6 @@ export function DashboardHeader({ userEmail, fullName, roles = [], userId }: Das
|
|||||||
<TooltipContent>Settings</TooltipContent>
|
<TooltipContent>Settings</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
{/* Zoho Banking */}
|
|
||||||
{associations.filter(a => (a as any).zoho_organization_id).length > 0 && (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground">
|
|
||||||
<Landmark className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="w-56">
|
|
||||||
<div className="px-3 py-1.5 text-[11px] font-medium text-muted-foreground uppercase tracking-wider">Zoho Banking</div>
|
|
||||||
{associations.filter(a => (a as any).zoho_organization_id).map((a: any) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={a.id}
|
|
||||||
className="text-[13px] gap-2 cursor-pointer"
|
|
||||||
onClick={() => window.open(`https://books.zoho.com/app/${a.zoho_organization_id}/banking`, "_blank")}
|
|
||||||
>
|
|
||||||
<Building2 className="h-3.5 w-3.5" />
|
|
||||||
<span className="flex-1 truncate">{a.name}</span>
|
|
||||||
<ExternalLink className="h-3 w-3 text-muted-foreground" />
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* User */}
|
{/* User */}
|
||||||
|
|||||||
@@ -631,26 +631,6 @@ export function DashboardTopNav({ userEmail, fullName, roles = [], userId }: Das
|
|||||||
<TooltipContent>Settings</TooltipContent>
|
<TooltipContent>Settings</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
{/* Zoho Banking */}
|
|
||||||
{associations.filter(a => (a as any).zoho_organization_id).length > 0 && (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground">
|
|
||||||
<Landmark className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="w-56">
|
|
||||||
<div className="px-3 py-1.5 text-[11px] font-medium text-muted-foreground uppercase tracking-wider">Zoho Banking</div>
|
|
||||||
{associations.filter(a => (a as any).zoho_organization_id).map((a: any) => (
|
|
||||||
<DropdownMenuItem key={a.id} className="text-[13px] gap-2 cursor-pointer" onClick={() => window.open(`https://books.zoho.com/app/${a.zoho_organization_id}/banking`, "_blank")}>
|
|
||||||
<Building2 className="h-3.5 w-3.5" />
|
|
||||||
<span className="flex-1 truncate">{a.name}</span>
|
|
||||||
<ExternalLink className="h-3 w-3 text-muted-foreground" />
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* User */}
|
{/* User */}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Loader2, Plus, Trash2, SplitSquareHorizontal, DollarSign, Calendar as CalendarIcon, FileText } from "lucide-react";
|
import { Loader2, Plus, Trash2, SplitSquareHorizontal, DollarSign, Calendar as CalendarIcon, FileText } from "lucide-react";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { syncChargeToZoho, syncPaymentToZoho } from "@/lib/zohoFinancialSync";
|
|
||||||
import { calculateRemainingUnpaidAssessmentBalance, computePaymentWaterfallAllocation, type WaterfallAllocation } from "@/lib/unitLedgerAccountBreakdown";
|
import { calculateRemainingUnpaidAssessmentBalance, computePaymentWaterfallAllocation, type WaterfallAllocation } from "@/lib/unitLedgerAccountBreakdown";
|
||||||
|
|
||||||
interface EditEntry {
|
interface EditEntry {
|
||||||
@@ -476,16 +475,6 @@ export default function UnitLedgerTransactionForm({ open, onOpenChange, unitId:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-sync to Zoho
|
|
||||||
if (data?.id) {
|
|
||||||
try {
|
|
||||||
if (isCharge) await syncChargeToZoho(data.id);
|
|
||||||
else await syncPaymentToZoho(data.id);
|
|
||||||
} catch (syncErr) {
|
|
||||||
console.warn("Zoho sync failed (non-blocking):", syncErr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (onSuccess) onSuccess();
|
if (onSuccess) onSuccess();
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ import { parseLocalDate, todayLocal } from "@/lib/dateUtils";
|
|||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import UnitLedgerTransactionForm from "./UnitLedgerTransactionForm";
|
import UnitLedgerTransactionForm from "./UnitLedgerTransactionForm";
|
||||||
import UnitFeeExclusionsCard from "./UnitFeeExclusionsCard";
|
import UnitFeeExclusionsCard from "./UnitFeeExclusionsCard";
|
||||||
import { syncChargeToZoho, syncPaymentToZoho } from "@/lib/zohoFinancialSync";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
unitId: string;
|
unitId: string;
|
||||||
|
|||||||
@@ -30,6 +30,18 @@ export async function ensureAccountingCompany(associationId: string): Promise<Ac
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
if (error) {
|
if (error) {
|
||||||
|
// Board members (and other non-accounting-staff) can't provision a company,
|
||||||
|
// but may read their association's existing one. Fall back to the read-only
|
||||||
|
// resolver before surfacing an error.
|
||||||
|
try {
|
||||||
|
const { data: roData, error: roErr } = await withTimeout<any>(
|
||||||
|
(supabase as any).schema("accounting").rpc("company_id_for_association", {
|
||||||
|
_association_id: associationId,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
if (!roErr && roData) return { companyId: roData as string, error: null };
|
||||||
|
} catch { /* ignore — fall through to the original error */ }
|
||||||
|
|
||||||
console.error("[accounting] ensure_company_for_association failed", error);
|
console.error("[accounting] ensure_company_for_association failed", error);
|
||||||
const missingRpc = error.code === "PGRST202";
|
const missingRpc = error.code === "PGRST202";
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,21 +1,5 @@
|
|||||||
import { supabase } from "@/integrations/supabase/client";
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
|
|
||||||
/**
|
|
||||||
* Pulls budgets from Zoho Books for a single association.
|
|
||||||
* Uses the Zoho Books `/budgets` endpoint when available, falling back to
|
|
||||||
* deriving actuals from the P&L report.
|
|
||||||
*/
|
|
||||||
export async function pullBudgetsFromZoho(associationId: string, fiscalYear: number) {
|
|
||||||
const { data, error } = await supabase.functions.invoke("zoho-books", {
|
|
||||||
body: {
|
|
||||||
action: "pull_budgets",
|
|
||||||
params: { association_id: associationId, fiscal_year: fiscalYear },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (error) return { success: false, error: error.message };
|
|
||||||
return { success: true, data: data?.data };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pulls budgets from Buildium for one or more associations.
|
* Pulls budgets from Buildium for one or more associations.
|
||||||
* Buildium's `/v1/budgets` endpoint exposes monthly amounts per GL account.
|
* Buildium's `/v1/budgets` endpoint exposes monthly amounts per GL account.
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
import { supabase } from "@/integrations/supabase/client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pushes a newly created bill to Zoho Books.
|
|
||||||
*/
|
|
||||||
export async function pushBillToZohoAfterCreate(billId: string) {
|
|
||||||
try {
|
|
||||||
const { data, error } = await supabase.functions.invoke("zoho-books", {
|
|
||||||
body: { action: "push_bill", params: { bill_id: billId } },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
const message = error.message || "Zoho bill sync failed";
|
|
||||||
console.warn("Zoho bill sync failed:", error);
|
|
||||||
return { success: false, error: message };
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = data?.data;
|
|
||||||
if (result?.status === "created" || result?.status === "already_synced" || result?.status === "duplicate_in_zoho") {
|
|
||||||
console.log("Bill pushed to Zoho:", result);
|
|
||||||
return { success: true, data: result };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: false, error: result?.message || result?.error || "Zoho bill sync failed" };
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("Zoho bill sync error:", e);
|
|
||||||
return { success: false, error: e instanceof Error ? e.message : String(e) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Records a vendor payment in Zoho Books for a paid bill.
|
|
||||||
* Safe to call after marking a bill paid — will push the bill first if needed.
|
|
||||||
*/
|
|
||||||
export async function pushBillPaymentToZohoAfterPay(billId: string) {
|
|
||||||
try {
|
|
||||||
const { data, error } = await supabase.functions.invoke("zoho-books", {
|
|
||||||
body: { action: "push_bill_payment", params: { bill_id: billId } },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
const message = error.message || "Zoho bill payment sync failed";
|
|
||||||
console.warn("Zoho bill payment sync failed:", error);
|
|
||||||
return { success: false, error: message };
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = data?.data;
|
|
||||||
if (result?.status === "created" || result?.status === "already_synced") {
|
|
||||||
console.log("Bill payment pushed to Zoho:", result);
|
|
||||||
return { success: true, data: result };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: false, error: result?.message || result?.error || "Zoho bill payment sync failed" };
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("Zoho bill payment sync error:", e);
|
|
||||||
return { success: false, error: e instanceof Error ? e.message : String(e) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes the recorded vendor payment from Zoho for a bill that has been marked unpaid.
|
|
||||||
* Safe to call even if no payment has been pushed; the edge function is a no-op in that case.
|
|
||||||
*/
|
|
||||||
export async function deleteBillPaymentFromZohoAfterUnpay(billId: string) {
|
|
||||||
try {
|
|
||||||
const { data, error } = await supabase.functions.invoke("zoho-books", {
|
|
||||||
body: { action: "delete_bill_payment", params: { bill_id: billId } },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.warn("Zoho payment delete failed:", error);
|
|
||||||
return { success: false, error: error.message || "Zoho payment delete failed" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = data?.data;
|
|
||||||
if (result?.status === "deleted" || result?.status === "not_synced" || result?.status === "already_deleted") {
|
|
||||||
return { success: true, data: result };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: false, error: result?.message || result?.error || "Zoho payment delete failed" };
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("Zoho payment delete error:", e);
|
|
||||||
return { success: false, error: e instanceof Error ? e.message : String(e) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,483 +0,0 @@
|
|||||||
import jsPDF from "jspdf";
|
|
||||||
import autoTable, { RowInput } from "jspdf-autotable";
|
|
||||||
|
|
||||||
type Association = { id?: string; name: string; logo_url?: string | null };
|
|
||||||
|
|
||||||
const fmt = (n: number | string | undefined | null) => {
|
|
||||||
const v = typeof n === "string" ? parseFloat(n) : n;
|
|
||||||
if (v == null || isNaN(v as number)) return "$0.00";
|
|
||||||
return (v as number).toLocaleString("en-US", { style: "currency", currency: "USD" });
|
|
||||||
};
|
|
||||||
|
|
||||||
async function loadImageAsDataURL(url: string): Promise<{ data: string; format: string } | null> {
|
|
||||||
try {
|
|
||||||
const res = await fetch(url, { mode: "cors" });
|
|
||||||
const blob = await res.blob();
|
|
||||||
const format = blob.type.includes("png")
|
|
||||||
? "PNG"
|
|
||||||
: blob.type.includes("jpeg") || blob.type.includes("jpg")
|
|
||||||
? "JPEG"
|
|
||||||
: "PNG";
|
|
||||||
const data: string = await new Promise((resolve, reject) => {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = () => resolve(reader.result as string);
|
|
||||||
reader.onerror = reject;
|
|
||||||
reader.readAsDataURL(blob);
|
|
||||||
});
|
|
||||||
return { data, format };
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function flattenZohoSections(nodes: any[] = [], depth = 0): any[] {
|
|
||||||
const rows: any[] = [];
|
|
||||||
for (const node of nodes) {
|
|
||||||
const isLeaf = !!node.account_id && !node.account_transactions?.length;
|
|
||||||
const isSection = !isLeaf;
|
|
||||||
rows.push({
|
|
||||||
name: node.name || node.total_label || "—",
|
|
||||||
total: node.total,
|
|
||||||
account_code: node.account_code,
|
|
||||||
_depth: depth,
|
|
||||||
_isSection: isSection,
|
|
||||||
_isTotalRow: !!node.total_label && !node.account_id,
|
|
||||||
});
|
|
||||||
if (node.account_transactions?.length) {
|
|
||||||
rows.push(...flattenZohoSections(node.account_transactions, depth + 1));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function drawHeader(
|
|
||||||
doc: jsPDF,
|
|
||||||
association: Association | null,
|
|
||||||
reportLabel: string,
|
|
||||||
subTitle: string,
|
|
||||||
) {
|
|
||||||
const pageWidth = doc.internal.pageSize.getWidth();
|
|
||||||
const marginX = 40;
|
|
||||||
if (association?.logo_url) {
|
|
||||||
const img = await loadImageAsDataURL(association.logo_url);
|
|
||||||
if (img) {
|
|
||||||
try {
|
|
||||||
doc.addImage(img.data, img.format, marginX, 30, 60, 60, undefined, "FAST");
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
doc.setFont("helvetica", "bold");
|
|
||||||
doc.setFontSize(15);
|
|
||||||
doc.text(reportLabel, pageWidth / 2, 55, { align: "center" });
|
|
||||||
doc.setFont("helvetica", "normal");
|
|
||||||
doc.setFontSize(11);
|
|
||||||
doc.text(association?.name || "All Associations", pageWidth / 2, 73, { align: "center" });
|
|
||||||
doc.setFontSize(9);
|
|
||||||
doc.setTextColor(120);
|
|
||||||
doc.text(subTitle, pageWidth / 2, 88, { align: "center" });
|
|
||||||
doc.setTextColor(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function addFooter(doc: jsPDF) {
|
|
||||||
const pageWidth = doc.internal.pageSize.getWidth();
|
|
||||||
const pageHeight = doc.internal.pageSize.getHeight();
|
|
||||||
const marginX = 40;
|
|
||||||
const pageCount = doc.getNumberOfPages();
|
|
||||||
for (let i = 1; i <= pageCount; i++) {
|
|
||||||
doc.setPage(i);
|
|
||||||
doc.setFontSize(8);
|
|
||||||
doc.setTextColor(120);
|
|
||||||
doc.text(`Generated ${new Date().toLocaleDateString("en-US")}`, marginX, pageHeight - 20);
|
|
||||||
doc.text(`Page ${i} of ${pageCount}`, pageWidth - marginX, pageHeight - 20, { align: "right" });
|
|
||||||
doc.setTextColor(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function save(doc: jsPDF, association: Association | null, reportLabel: string) {
|
|
||||||
const fileName = `${(association?.name || "Report").replace(/[^a-z0-9]+/gi, "_")}_${reportLabel.replace(
|
|
||||||
/[^a-z0-9]+/gi,
|
|
||||||
"_",
|
|
||||||
)}_${new Date().toISOString().slice(0, 10)}.pdf`;
|
|
||||||
doc.save(fileName);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Profit & Loss / Balance Sheet (shared layout) ─── */
|
|
||||||
|
|
||||||
async function generateSectionedReportPdf(opts: {
|
|
||||||
association: Association | null;
|
|
||||||
reportLabel: string;
|
|
||||||
subTitle: string;
|
|
||||||
rows: any[];
|
|
||||||
}) {
|
|
||||||
const { association, reportLabel, subTitle, rows } = opts;
|
|
||||||
const doc = new jsPDF({ unit: "pt", format: "letter" });
|
|
||||||
await drawHeader(doc, association, reportLabel, subTitle);
|
|
||||||
|
|
||||||
const body: RowInput[] = rows.map((r) => {
|
|
||||||
const indent = (r._depth || 0) * 14;
|
|
||||||
const name = `${" ".repeat(Math.floor(indent / 4))}${r.name}${
|
|
||||||
r.account_code ? ` (${r.account_code})` : ""
|
|
||||||
}`;
|
|
||||||
const isBold = r._isTotalRow || r._isSection;
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
content: name,
|
|
||||||
styles: {
|
|
||||||
fontStyle: isBold ? "bold" : "normal",
|
|
||||||
fillColor: r._isTotalRow
|
|
||||||
? [225, 230, 240]
|
|
||||||
: r._isSection
|
|
||||||
? [240, 243, 248]
|
|
||||||
: undefined,
|
|
||||||
cellPadding: { left: 8 + indent, right: 4, top: 4, bottom: 4 },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
content: r.total != null ? fmt(r.total) : "",
|
|
||||||
styles: {
|
|
||||||
halign: "right",
|
|
||||||
fontStyle: isBold ? "bold" : "normal",
|
|
||||||
fillColor: r._isTotalRow
|
|
||||||
? [225, 230, 240]
|
|
||||||
: r._isSection
|
|
||||||
? [240, 243, 248]
|
|
||||||
: undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
autoTable(doc, {
|
|
||||||
startY: 110,
|
|
||||||
head: [
|
|
||||||
[
|
|
||||||
{ content: "Account", styles: { halign: "left" } },
|
|
||||||
{ content: "Amount", styles: { halign: "right" } },
|
|
||||||
],
|
|
||||||
],
|
|
||||||
body,
|
|
||||||
theme: "grid",
|
|
||||||
styles: { font: "helvetica", fontSize: 9, cellPadding: 4, lineColor: [220, 220, 220], lineWidth: 0.3 },
|
|
||||||
headStyles: { fillColor: [40, 60, 90], textColor: [255, 255, 255], fontStyle: "bold" },
|
|
||||||
margin: { left: 40, right: 40 },
|
|
||||||
});
|
|
||||||
|
|
||||||
addFooter(doc);
|
|
||||||
save(doc, association, reportLabel);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateProfitLossPdf(opts: {
|
|
||||||
association: Association | null;
|
|
||||||
fromDate: string;
|
|
||||||
toDate: string;
|
|
||||||
data: any;
|
|
||||||
}) {
|
|
||||||
const top = opts.data?.profit_and_loss || opts.data?.profitandloss || opts.data?.sections || [];
|
|
||||||
const rows = flattenZohoSections(Array.isArray(top) ? top : []);
|
|
||||||
await generateSectionedReportPdf({
|
|
||||||
association: opts.association,
|
|
||||||
reportLabel: "Profit & Loss",
|
|
||||||
subTitle: `${opts.fromDate} to ${opts.toDate}`,
|
|
||||||
rows,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateBalanceSheetPdf(opts: {
|
|
||||||
association: Association | null;
|
|
||||||
asOfDate: string;
|
|
||||||
data: any;
|
|
||||||
}) {
|
|
||||||
const top = opts.data?.balance_sheet || opts.data?.balancesheet || opts.data?.sections || [];
|
|
||||||
const rows = flattenZohoSections(Array.isArray(top) ? top : []);
|
|
||||||
await generateSectionedReportPdf({
|
|
||||||
association: opts.association,
|
|
||||||
reportLabel: "Balance Sheet",
|
|
||||||
subTitle: `As of ${opts.asOfDate}`,
|
|
||||||
rows,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── AR Aging ─── */
|
|
||||||
|
|
||||||
export async function generateARAgingPdf(opts: {
|
|
||||||
association: Association | null;
|
|
||||||
asOfDate: string;
|
|
||||||
data: any;
|
|
||||||
}) {
|
|
||||||
const { association, asOfDate, data } = opts;
|
|
||||||
const report = data?.receivabledetails || data?.receivables || data?.invoice || data;
|
|
||||||
const contacts: any[] =
|
|
||||||
report?.contacts || report?.receivable_details || report?.details || report?.group_list || [];
|
|
||||||
const intervals = Array.isArray(report?.intervals) ? report.intervals : [];
|
|
||||||
|
|
||||||
const getAmount = (row: any, keys: string[]) => {
|
|
||||||
for (const key of keys) {
|
|
||||||
const v = row?.[key];
|
|
||||||
if (v !== undefined && v !== null && v !== "") return Number(v) || 0;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
let rows: any[] = [];
|
|
||||||
if (Array.isArray(contacts) && contacts.length > 0) {
|
|
||||||
rows = contacts;
|
|
||||||
} else {
|
|
||||||
const sections = report?.sections || [];
|
|
||||||
const flat = flattenZohoSections(Array.isArray(sections) ? sections : []).filter(
|
|
||||||
(r) => !r._isSection,
|
|
||||||
);
|
|
||||||
if (flat.length > 0) rows = flat;
|
|
||||||
else if (intervals.length > 0 && Number(report?.total || 0) > 0) {
|
|
||||||
const intervalAmount = (names: string[]) => {
|
|
||||||
const item = intervals.find((i: any) => names.includes(i.interval));
|
|
||||||
return Number(item?.amount || 0);
|
|
||||||
};
|
|
||||||
rows = [
|
|
||||||
{
|
|
||||||
contact_name: "Outstanding receivables",
|
|
||||||
current: intervalAmount(["current", "not_due"]),
|
|
||||||
days_1_30:
|
|
||||||
intervalAmount(["days_1_15"]) + intervalAmount(["days_16_30", "days_1_30"]),
|
|
||||||
days_31_60:
|
|
||||||
intervalAmount(["days_31_45"]) + intervalAmount(["days_46_60", "days_31_60"]),
|
|
||||||
days_61_90: intervalAmount(["days_61_90"]),
|
|
||||||
over_90: intervalAmount(["days_above_45", "days_over_90", "above_45"]),
|
|
||||||
total: report.total,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const doc = new jsPDF({ unit: "pt", format: "letter", orientation: "landscape" });
|
|
||||||
await drawHeader(doc, association, "Accounts Receivable Aging", `As of ${asOfDate}`);
|
|
||||||
|
|
||||||
const totals = { current: 0, d1_30: 0, d31_60: 0, d61_90: 0, over_90: 0, total: 0 };
|
|
||||||
const body: RowInput[] = rows.map((row: any) => {
|
|
||||||
const current = getAmount(row, ["current", "current_amount", "days_0", "days_current"]);
|
|
||||||
const d1_30 = getAmount(row, ["1_to_30", "days_1_30", "days_1_15", "days_16_30"]);
|
|
||||||
const d31_60 = getAmount(row, ["31_to_60", "days_31_60", "days_31_45", "days_46_60"]);
|
|
||||||
const d61_90 = getAmount(row, ["61_to_90", "days_61_90"]);
|
|
||||||
const over_90 = getAmount(row, ["over_90", "days_over_90", "days_above_45", "above_45"]);
|
|
||||||
const total = Number(row.total || row.outstanding || row.amount || 0);
|
|
||||||
totals.current += current;
|
|
||||||
totals.d1_30 += d1_30;
|
|
||||||
totals.d31_60 += d31_60;
|
|
||||||
totals.d61_90 += d61_90;
|
|
||||||
totals.over_90 += over_90;
|
|
||||||
totals.total += total;
|
|
||||||
return [
|
|
||||||
row.contact_name || row.customer_name || row.name || row.customer || "—",
|
|
||||||
{ content: fmt(current), styles: { halign: "right" } },
|
|
||||||
{ content: fmt(d1_30), styles: { halign: "right" } },
|
|
||||||
{ content: fmt(d31_60), styles: { halign: "right" } },
|
|
||||||
{ content: fmt(d61_90), styles: { halign: "right" } },
|
|
||||||
{ content: fmt(over_90), styles: { halign: "right", textColor: over_90 > 0 ? [180, 35, 35] : [40, 40, 40] } },
|
|
||||||
{ content: fmt(total), styles: { halign: "right", fontStyle: "bold" } },
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
if (rows.length === 0) {
|
|
||||||
doc.setFontSize(11);
|
|
||||||
doc.text("No receivable data available for this period.", 40, 130);
|
|
||||||
} else {
|
|
||||||
autoTable(doc, {
|
|
||||||
startY: 110,
|
|
||||||
head: [
|
|
||||||
[
|
|
||||||
"Customer",
|
|
||||||
{ content: "Current", styles: { halign: "right" } },
|
|
||||||
{ content: "1-30", styles: { halign: "right" } },
|
|
||||||
{ content: "31-60", styles: { halign: "right" } },
|
|
||||||
{ content: "61-90", styles: { halign: "right" } },
|
|
||||||
{ content: "90+", styles: { halign: "right" } },
|
|
||||||
{ content: "Total", styles: { halign: "right" } },
|
|
||||||
],
|
|
||||||
],
|
|
||||||
body: [
|
|
||||||
...body,
|
|
||||||
[
|
|
||||||
{ content: "Total", styles: { fontStyle: "bold", fillColor: [225, 230, 240] } },
|
|
||||||
{ content: fmt(totals.current), styles: { halign: "right", fontStyle: "bold", fillColor: [225, 230, 240] } },
|
|
||||||
{ content: fmt(totals.d1_30), styles: { halign: "right", fontStyle: "bold", fillColor: [225, 230, 240] } },
|
|
||||||
{ content: fmt(totals.d31_60), styles: { halign: "right", fontStyle: "bold", fillColor: [225, 230, 240] } },
|
|
||||||
{ content: fmt(totals.d61_90), styles: { halign: "right", fontStyle: "bold", fillColor: [225, 230, 240] } },
|
|
||||||
{ content: fmt(totals.over_90), styles: { halign: "right", fontStyle: "bold", fillColor: [225, 230, 240] } },
|
|
||||||
{ content: fmt(totals.total), styles: { halign: "right", fontStyle: "bold", fillColor: [225, 230, 240] } },
|
|
||||||
],
|
|
||||||
] as RowInput[],
|
|
||||||
theme: "striped",
|
|
||||||
styles: { font: "helvetica", fontSize: 9, cellPadding: 5 },
|
|
||||||
headStyles: { fillColor: [40, 60, 90], textColor: [255, 255, 255], fontStyle: "bold" },
|
|
||||||
margin: { left: 40, right: 40 },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
addFooter(doc);
|
|
||||||
save(doc, association, "AR_Aging");
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Budget vs Actual ─── */
|
|
||||||
|
|
||||||
export async function generateBudgetVsActualPdf(opts: {
|
|
||||||
association: Association | null;
|
|
||||||
fromDate: string;
|
|
||||||
toDate: string;
|
|
||||||
data: any;
|
|
||||||
budgets: any[];
|
|
||||||
comparisonData?: any;
|
|
||||||
comparisonLabel?: string | null;
|
|
||||||
comparisonRange?: { from: string; to: string } | null;
|
|
||||||
}) {
|
|
||||||
const { association, fromDate, toDate, data, budgets, comparisonData, comparisonLabel, comparisonRange } = opts;
|
|
||||||
|
|
||||||
const detectKind = (node: any): "income" | "expense" | "other" => {
|
|
||||||
const label = `${node?.name || ""} ${node?.total_label || ""}`.toLowerCase();
|
|
||||||
if (/(expense|cost of goods|cogs)/.test(label)) return "expense";
|
|
||||||
if (/(income|revenue|sales)/.test(label)) return "income";
|
|
||||||
return "other";
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildActuals = (src: any) => {
|
|
||||||
const income = new Map<string, number>();
|
|
||||||
const expense = new Map<string, number>();
|
|
||||||
const walk = (nodes: any[], kind: "income" | "expense" | "other") => {
|
|
||||||
for (const node of nodes || []) {
|
|
||||||
const childKind = kind === "other" ? detectKind(node) : kind;
|
|
||||||
const target = childKind === "income" ? income : childKind === "expense" ? expense : null;
|
|
||||||
if (target && node.name && !node.total_label) {
|
|
||||||
target.set(node.name.toLowerCase(), parseFloat(node.total || 0));
|
|
||||||
}
|
|
||||||
if (node.account_transactions) walk(node.account_transactions, childKind);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const sections = src?.profit_and_loss || src?.profitandloss || [];
|
|
||||||
walk(Array.isArray(sections) ? sections : [], "other");
|
|
||||||
return { income, expense };
|
|
||||||
};
|
|
||||||
|
|
||||||
const lookupActual = (
|
|
||||||
maps: { income: Map<string, number>; expense: Map<string, number> } | null,
|
|
||||||
category: string,
|
|
||||||
accountType: string,
|
|
||||||
) => {
|
|
||||||
if (!maps) return 0;
|
|
||||||
const key = (category || "").toLowerCase();
|
|
||||||
const isIncome = (accountType || "").toLowerCase() === "income";
|
|
||||||
return (isIncome ? maps.income : maps.expense).get(key) ?? 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const actuals = buildActuals(data);
|
|
||||||
const cmpActuals = comparisonData ? buildActuals(comparisonData) : null;
|
|
||||||
const showCmp = !!cmpActuals && !!comparisonLabel;
|
|
||||||
|
|
||||||
const subTitle = showCmp && comparisonRange
|
|
||||||
? `${fromDate} to ${toDate} vs ${comparisonLabel} (${comparisonRange.from} → ${comparisonRange.to})`
|
|
||||||
: `${fromDate} to ${toDate}`;
|
|
||||||
|
|
||||||
const doc = new jsPDF({ unit: "pt", format: "letter", orientation: "landscape" });
|
|
||||||
await drawHeader(doc, association, "Budget vs Actual", subTitle);
|
|
||||||
|
|
||||||
if (budgets.length === 0) {
|
|
||||||
doc.setFontSize(11);
|
|
||||||
doc.text("No budget categories found for this association.", 40, 130);
|
|
||||||
addFooter(doc);
|
|
||||||
save(doc, association, "Budget_vs_Actual");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const totals = { budgeted: 0, actual: 0, variance: 0, cmp: 0 };
|
|
||||||
const body: RowInput[] = budgets.map((b) => {
|
|
||||||
const lookedUp = lookupActual(actuals, b.category, b.account_type);
|
|
||||||
const actual = lookedUp || b.actual_amount || 0;
|
|
||||||
const budgeted = Number(b.budgeted_amount || 0);
|
|
||||||
const variance = budgeted - actual;
|
|
||||||
const pct = budgeted ? ((actual / budgeted) * 100).toFixed(1) + "%" : "—";
|
|
||||||
const cmp = lookupActual(cmpActuals, b.category, b.account_type);
|
|
||||||
const cmpDelta = cmp !== 0 ? ((actual - cmp) / Math.abs(cmp)) * 100 : NaN;
|
|
||||||
totals.budgeted += budgeted;
|
|
||||||
totals.actual += actual;
|
|
||||||
totals.variance += variance;
|
|
||||||
totals.cmp += cmp;
|
|
||||||
const row: any[] = [
|
|
||||||
b.category,
|
|
||||||
{ content: fmt(budgeted), styles: { halign: "right" } },
|
|
||||||
{ content: fmt(actual), styles: { halign: "right" } },
|
|
||||||
];
|
|
||||||
if (showCmp) {
|
|
||||||
row.push({ content: fmt(cmp), styles: { halign: "right", textColor: [100, 100, 100] } });
|
|
||||||
row.push({
|
|
||||||
content: isFinite(cmpDelta) ? `${cmpDelta >= 0 ? "+" : ""}${cmpDelta.toFixed(1)}%` : "—",
|
|
||||||
styles: {
|
|
||||||
halign: "right",
|
|
||||||
textColor: !isFinite(cmpDelta) ? [120, 120, 120] : cmpDelta >= 0 ? [16, 122, 70] : [180, 35, 35],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
row.push({
|
|
||||||
content: fmt(variance),
|
|
||||||
styles: { halign: "right", textColor: variance < 0 ? [180, 35, 35] : [16, 122, 70] },
|
|
||||||
});
|
|
||||||
row.push({ content: pct, styles: { halign: "right" } });
|
|
||||||
return row;
|
|
||||||
});
|
|
||||||
|
|
||||||
const head: any[] = [
|
|
||||||
"Category",
|
|
||||||
{ content: "Budgeted", styles: { halign: "right" } },
|
|
||||||
{ content: "Actual", styles: { halign: "right" } },
|
|
||||||
];
|
|
||||||
if (showCmp) {
|
|
||||||
head.push({ content: comparisonLabel, styles: { halign: "right" } });
|
|
||||||
head.push({ content: "% Δ", styles: { halign: "right" } });
|
|
||||||
}
|
|
||||||
head.push({ content: "Variance", styles: { halign: "right" } });
|
|
||||||
head.push({ content: "% Used", styles: { halign: "right" } });
|
|
||||||
|
|
||||||
const totalCmpDelta = totals.cmp !== 0 ? ((totals.actual - totals.cmp) / Math.abs(totals.cmp)) * 100 : NaN;
|
|
||||||
const totalRow: any[] = [
|
|
||||||
{ content: "Total", styles: { fontStyle: "bold", fillColor: [225, 230, 240] } },
|
|
||||||
{ content: fmt(totals.budgeted), styles: { halign: "right", fontStyle: "bold", fillColor: [225, 230, 240] } },
|
|
||||||
{ content: fmt(totals.actual), styles: { halign: "right", fontStyle: "bold", fillColor: [225, 230, 240] } },
|
|
||||||
];
|
|
||||||
if (showCmp) {
|
|
||||||
totalRow.push({ content: fmt(totals.cmp), styles: { halign: "right", fontStyle: "bold", fillColor: [225, 230, 240] } });
|
|
||||||
totalRow.push({
|
|
||||||
content: isFinite(totalCmpDelta) ? `${totalCmpDelta >= 0 ? "+" : ""}${totalCmpDelta.toFixed(1)}%` : "—",
|
|
||||||
styles: {
|
|
||||||
halign: "right",
|
|
||||||
fontStyle: "bold",
|
|
||||||
fillColor: [225, 230, 240],
|
|
||||||
textColor: !isFinite(totalCmpDelta) ? [120, 120, 120] : totalCmpDelta >= 0 ? [16, 122, 70] : [180, 35, 35],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
totalRow.push({
|
|
||||||
content: fmt(totals.variance),
|
|
||||||
styles: {
|
|
||||||
halign: "right",
|
|
||||||
fontStyle: "bold",
|
|
||||||
fillColor: [225, 230, 240],
|
|
||||||
textColor: totals.variance < 0 ? [180, 35, 35] : [16, 122, 70],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
totalRow.push({
|
|
||||||
content: totals.budgeted ? ((totals.actual / totals.budgeted) * 100).toFixed(1) + "%" : "—",
|
|
||||||
styles: { halign: "right", fontStyle: "bold", fillColor: [225, 230, 240] },
|
|
||||||
});
|
|
||||||
|
|
||||||
autoTable(doc, {
|
|
||||||
startY: 110,
|
|
||||||
head: [head],
|
|
||||||
body: [...body, totalRow] as RowInput[],
|
|
||||||
theme: "striped",
|
|
||||||
styles: { font: "helvetica", fontSize: 9, cellPadding: 5 },
|
|
||||||
headStyles: { fillColor: [40, 60, 90], textColor: [255, 255, 255], fontStyle: "bold" },
|
|
||||||
margin: { left: 40, right: 40 },
|
|
||||||
});
|
|
||||||
|
|
||||||
addFooter(doc);
|
|
||||||
save(doc, association, "Budget_vs_Actual");
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
import { supabase } from "@/integrations/supabase/client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pushes a single owner ledger charge (debit) to Zoho Books as an invoice.
|
|
||||||
* Call after creating an owner_ledger_entry with debit > 0.
|
|
||||||
*/
|
|
||||||
export async function syncChargeToZoho(ledgerEntryId: string) {
|
|
||||||
try {
|
|
||||||
const { data, error } = await supabase.functions.invoke("zoho-books", {
|
|
||||||
body: { action: "push_invoice", params: { ledger_entry_id: ledgerEntryId } },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error("Zoho invoice sync error:", error);
|
|
||||||
return { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true, data: data?.data };
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Zoho invoice sync exception:", err);
|
|
||||||
return { success: false, error: String(err) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pushes a single admin payment to Zoho Books as a customer payment.
|
|
||||||
* Call after creating an admin_payment record.
|
|
||||||
*/
|
|
||||||
export async function syncPaymentToZoho(paymentId: string) {
|
|
||||||
try {
|
|
||||||
const { data, error } = await supabase.functions.invoke("zoho-books", {
|
|
||||||
body: { action: "push_payment", params: { payment_id: paymentId } },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error("Zoho payment sync error:", error);
|
|
||||||
return { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true, data: data?.data };
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Zoho payment sync exception:", err);
|
|
||||||
return { success: false, error: String(err) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Full bidirectional sync: pulls from Zoho and pushes unsynced local records.
|
|
||||||
*/
|
|
||||||
export async function syncAllFinancials() {
|
|
||||||
try {
|
|
||||||
const { data, error } = await supabase.functions.invoke("zoho-books", {
|
|
||||||
body: { action: "sync_financials", params: {} },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error("Zoho full sync error:", error);
|
|
||||||
return { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true, data: data?.data };
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Zoho full sync exception:", err);
|
|
||||||
return { success: false, error: String(err) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pull invoices from Zoho → local ledger entries
|
|
||||||
*/
|
|
||||||
export async function pullInvoicesFromZoho() {
|
|
||||||
const { data, error } = await supabase.functions.invoke("zoho-books", {
|
|
||||||
body: { action: "pull_invoices", params: {} },
|
|
||||||
});
|
|
||||||
if (error) return { success: false, error: error.message };
|
|
||||||
return { success: true, data: data?.data };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pull payments from Zoho → local admin_payments
|
|
||||||
*/
|
|
||||||
export async function pullPaymentsFromZoho() {
|
|
||||||
const { data, error } = await supabase.functions.invoke("zoho-books", {
|
|
||||||
body: { action: "pull_payments", params: {} },
|
|
||||||
});
|
|
||||||
if (error) return { success: false, error: error.message };
|
|
||||||
return { success: true, data: data?.data };
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useState, useRef, useCallback, useEffect } from "react";
|
import { useState, useRef, useCallback, useEffect } from "react";
|
||||||
import { supabase } from "@/integrations/supabase/client";
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
import { pushBillToZohoAfterCreate } from "@/lib/zohoBillSync";
|
|
||||||
import { saveBillInvoiceToDocuments } from "@/lib/saveBillToDocuments";
|
import { saveBillInvoiceToDocuments } from "@/lib/saveBillToDocuments";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import {
|
import {
|
||||||
@@ -353,7 +352,6 @@ export default function AIInvoiceParserPage() {
|
|||||||
}).select("id").single();
|
}).select("id").single();
|
||||||
|
|
||||||
if (billErr) throw billErr;
|
if (billErr) throw billErr;
|
||||||
if (newBill?.id) pushBillToZohoAfterCreate(newBill.id);
|
|
||||||
|
|
||||||
// File invoice PDF into Documents > Vendor Invoices for this association
|
// File invoice PDF into Documents > Vendor Invoices for this association
|
||||||
saveBillInvoiceToDocuments({
|
saveBillInvoiceToDocuments({
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|||||||
|
|
||||||
type ReportType = "trial_balance" | "income_statement" | "balance_sheet" | "aging" | "delinquency";
|
type ReportType = "trial_balance" | "income_statement" | "balance_sheet" | "aging" | "delinquency";
|
||||||
|
|
||||||
export default function AccountingReportsPage() {
|
export default function AccountingReportsPage({ associationIds }: { associationIds?: string[] } = {}) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [associations, setAssociations] = useState<any[]>([]);
|
const [associations, setAssociations] = useState<any[]>([]);
|
||||||
const [selectedAssocId, setSelectedAssocId] = useState("");
|
const [selectedAssocId, setSelectedAssocId] = useState("");
|
||||||
@@ -28,11 +28,14 @@ export default function AccountingReportsPage() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
supabase.from("associations").select("id, name").eq("status", "active").order("name").then(({ data }) => {
|
let q = supabase.from("associations").select("id, name").eq("status", "active").order("name");
|
||||||
|
// When scoped (e.g. board members), limit the picker to the given associations.
|
||||||
|
if (associationIds?.length) q = q.in("id", associationIds);
|
||||||
|
q.then(({ data }) => {
|
||||||
setAssociations(data || []);
|
setAssociations(data || []);
|
||||||
if (data?.length) setSelectedAssocId(data[0].id);
|
if (data?.length) setSelectedAssocId(data[0].id);
|
||||||
});
|
});
|
||||||
}, []);
|
}, [associationIds?.join(",")]);
|
||||||
|
|
||||||
const generateReport = async () => {
|
const generateReport = async () => {
|
||||||
if (!selectedAssocId) return;
|
if (!selectedAssocId) return;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
|||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import {
|
import {
|
||||||
ArrowLeft, Building2, MapPin, Phone, Mail, Loader2, Users, Home,
|
ArrowLeft, Building2, MapPin, Phone, Mail, Loader2, Users, Home,
|
||||||
AlertTriangle, Gavel, Save, Pencil, Globe, Shield, Landmark,
|
AlertTriangle, Gavel, Save, Pencil, Globe, Shield, Landmark,
|
||||||
@@ -25,7 +26,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger,
|
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import ZohoFeesTab from "@/components/association/ZohoFeesTab";
|
import FeesTab from "@/components/association/FeesTab";
|
||||||
import ViolationTypeManager from "@/components/ViolationTypeManager";
|
import ViolationTypeManager from "@/components/ViolationTypeManager";
|
||||||
import LogoUpload from "@/components/LogoUpload";
|
import LogoUpload from "@/components/LogoUpload";
|
||||||
import AnnualMeetingTab from "@/components/association/AnnualMeetingTab";
|
import AnnualMeetingTab from "@/components/association/AnnualMeetingTab";
|
||||||
@@ -59,6 +60,49 @@ export default function AssociationDetailPage() {
|
|||||||
const [vtForm, setVtForm] = useState({ category: "", article_section: "", citation_text: "", requested_action: "" });
|
const [vtForm, setVtForm] = useState({ category: "", article_section: "", citation_text: "", requested_action: "" });
|
||||||
const [savingVt, setSavingVt] = useState(false);
|
const [savingVt, setSavingVt] = useState(false);
|
||||||
|
|
||||||
|
// ── Board member management (writes board_members; can_upload gates board uploads) ──
|
||||||
|
const [bmDialog, setBmDialog] = useState(false);
|
||||||
|
const [bmEditingId, setBmEditingId] = useState<string | null>(null);
|
||||||
|
const [bmForm, setBmForm] = useState({ member_name: "", member_email: "", phone: "", role: "Member", approval_authority: false, can_upload: false });
|
||||||
|
const [bmSaving, setBmSaving] = useState(false);
|
||||||
|
const [bmDeleteTarget, setBmDeleteTarget] = useState<Tables<"board_members"> | null>(null);
|
||||||
|
|
||||||
|
const openAddBm = () => {
|
||||||
|
setBmEditingId(null);
|
||||||
|
setBmForm({ member_name: "", member_email: "", phone: "", role: "Member", approval_authority: false, can_upload: false });
|
||||||
|
setBmDialog(true);
|
||||||
|
};
|
||||||
|
const openEditBm = (m: any) => {
|
||||||
|
setBmEditingId(m.id);
|
||||||
|
setBmForm({
|
||||||
|
member_name: m.member_name || "", member_email: m.member_email || "", phone: m.phone || "",
|
||||||
|
role: m.role || "Member", approval_authority: !!m.approval_authority, can_upload: !!m.can_upload,
|
||||||
|
});
|
||||||
|
setBmDialog(true);
|
||||||
|
};
|
||||||
|
const saveBm = async () => {
|
||||||
|
if (!bmForm.member_name.trim()) { toast({ variant: "destructive", title: "Name is required" }); return; }
|
||||||
|
setBmSaving(true);
|
||||||
|
const payload = {
|
||||||
|
association_id: id, member_name: bmForm.member_name.trim(),
|
||||||
|
member_email: bmForm.member_email || null, phone: bmForm.phone || null,
|
||||||
|
role: bmForm.role || "Member", approval_authority: bmForm.approval_authority, can_upload: bmForm.can_upload,
|
||||||
|
};
|
||||||
|
const { error } = bmEditingId
|
||||||
|
? await supabase.from("board_members").update(payload as any).eq("id", bmEditingId)
|
||||||
|
: await supabase.from("board_members").insert(payload as any);
|
||||||
|
if (error) toast({ variant: "destructive", title: "Error", description: error.message });
|
||||||
|
else { toast({ title: bmEditingId ? "Board member updated" : "Board member added" }); setBmDialog(false); fetchAll(); }
|
||||||
|
setBmSaving(false);
|
||||||
|
};
|
||||||
|
const deleteBm = async () => {
|
||||||
|
if (!bmDeleteTarget) return;
|
||||||
|
const { error } = await supabase.from("board_members").delete().eq("id", bmDeleteTarget.id);
|
||||||
|
if (error) toast({ variant: "destructive", title: "Error", description: error.message });
|
||||||
|
else { toast({ title: "Board member removed" }); fetchAll(); }
|
||||||
|
setBmDeleteTarget(null);
|
||||||
|
};
|
||||||
|
|
||||||
// Custom fields
|
// Custom fields
|
||||||
const [customFields, setCustomFields] = useState<any[]>([]);
|
const [customFields, setCustomFields] = useState<any[]>([]);
|
||||||
const [cfDialog, setCfDialog] = useState(false);
|
const [cfDialog, setCfDialog] = useState(false);
|
||||||
@@ -112,7 +156,7 @@ export default function AssociationDetailPage() {
|
|||||||
management_fee: assocRes.data.management_fee?.toString() || "",
|
management_fee: assocRes.data.management_fee?.toString() || "",
|
||||||
fiscal_year_start: assocRes.data.fiscal_year_start?.toString() || "1",
|
fiscal_year_start: assocRes.data.fiscal_year_start?.toString() || "1",
|
||||||
zoho_organization_id: assocRes.data.zoho_organization_id || "",
|
zoho_organization_id: assocRes.data.zoho_organization_id || "",
|
||||||
accounting_system: ((assocRes.data as any).accounting_system as string) || ((assocRes.data.zoho_organization_id && String(assocRes.data.zoho_organization_id).trim() !== "") ? "zoho" : "buildium"),
|
accounting_system: ((assocRes.data as any).accounting_system as string) || "buildium",
|
||||||
attorney_name: (assocRes.data as any).attorney_name || "",
|
attorney_name: (assocRes.data as any).attorney_name || "",
|
||||||
attorney_firm: (assocRes.data as any).attorney_firm || "",
|
attorney_firm: (assocRes.data as any).attorney_firm || "",
|
||||||
attorney_email: (assocRes.data as any).attorney_email || "",
|
attorney_email: (assocRes.data as any).attorney_email || "",
|
||||||
@@ -378,6 +422,7 @@ export default function AssociationDetailPage() {
|
|||||||
<TabsTrigger value="amenities">Amenities</TabsTrigger>
|
<TabsTrigger value="amenities">Amenities</TabsTrigger>
|
||||||
<TabsTrigger value="annual-meeting">Annual Meeting</TabsTrigger>
|
<TabsTrigger value="annual-meeting">Annual Meeting</TabsTrigger>
|
||||||
<TabsTrigger value="public-page">Public Page</TabsTrigger>
|
<TabsTrigger value="public-page">Public Page</TabsTrigger>
|
||||||
|
<TabsTrigger value="fees">Fees</TabsTrigger>
|
||||||
<TabsTrigger value="check-layout">Check Layout</TabsTrigger>
|
<TabsTrigger value="check-layout">Check Layout</TabsTrigger>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -827,9 +872,9 @@ export default function AssociationDetailPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* ─── Zoho & Fees Tab ─── */}
|
{/* ─── Fees Tab ─── */}
|
||||||
<TabsContent value="zoho-fees" className="mt-6">
|
<TabsContent value="fees" className="mt-6">
|
||||||
<ZohoFeesTab associationId={id!} associationName={association.name} />
|
<FeesTab associationId={id!} associationName={association.name} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* ─── Association Info Tab ─── */}
|
{/* ─── Association Info Tab ─── */}
|
||||||
@@ -927,38 +972,87 @@ export default function AssociationDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sync IDs */}
|
|
||||||
<div>
|
|
||||||
<h3 className="text-base font-semibold mb-4">Sync Settings</h3>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs font-semibold text-muted-foreground uppercase">Zoho Contact ID</Label>
|
|
||||||
<p className="text-sm font-medium mt-1">{association.zoho_contact_id || "—"}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs font-semibold text-muted-foreground uppercase">Zoho Organization ID</Label>
|
|
||||||
<p className="text-sm font-medium mt-1">{association.zoho_organization_id || "—"}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Board Members */}
|
{/* Board Members */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-base font-semibold mb-4">Board Members</h3>
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-base font-semibold">Board Members</h3>
|
||||||
|
<Button size="sm" variant="outline" className="gap-1" onClick={openAddBm}><Plus className="h-4 w-4" /> Add Member</Button>
|
||||||
|
</div>
|
||||||
{boardMembers.length > 0 ? (
|
{boardMembers.length > 0 ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{boardMembers.map(m => (
|
{boardMembers.map(m => (
|
||||||
<div key={m.id} className="flex items-center justify-between text-sm border rounded-md px-3 py-2">
|
<div key={m.id} className="flex items-center justify-between gap-2 text-sm border rounded-md px-3 py-2">
|
||||||
<span className="font-medium">{m.member_name}</span>
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<span className="font-medium">{m.member_name}</span>
|
||||||
{m.member_email && <span className="text-muted-foreground text-xs">{m.member_email}</span>}
|
{m.member_email && <span className="text-muted-foreground text-xs ml-2">{m.member_email}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
{m.role && <Badge variant="secondary" className="text-xs">{m.role}</Badge>}
|
{m.role && <Badge variant="secondary" className="text-xs">{m.role}</Badge>}
|
||||||
|
{(m as any).approval_authority && <Badge className="text-xs bg-emerald-500/10 text-emerald-700 border-emerald-200 hover:bg-emerald-500/10">Approver</Badge>}
|
||||||
|
{(m as any).can_upload && <Badge className="text-xs bg-blue-500/10 text-blue-700 border-blue-200 hover:bg-blue-500/10">Uploads</Badge>}
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => openEditBm(m)}><Pencil className="h-3.5 w-3.5" /></Button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive hover:text-destructive" onClick={() => setBmDeleteTarget(m)}><Trash2 className="h-3.5 w-3.5" /></Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : <p className="text-sm text-muted-foreground">No board members defined.</p>}
|
) : <p className="text-sm text-muted-foreground">No board members defined.</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Board Member add/edit dialog */}
|
||||||
|
<Dialog open={bmDialog} onOpenChange={setBmDialog}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader><DialogTitle>{bmEditingId ? "Edit" : "Add"} Board Member</DialogTitle></DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2"><Label>Name *</Label><Input value={bmForm.member_name} onChange={e => setBmForm({ ...bmForm, member_name: e.target.value })} /></div>
|
||||||
|
<div className="space-y-2"><Label>Email</Label><Input type="email" value={bmForm.member_email} onChange={e => setBmForm({ ...bmForm, member_email: e.target.value })} placeholder="Matches the member's portal login" /></div>
|
||||||
|
<div className="space-y-2"><Label>Phone</Label><Input value={bmForm.phone} onChange={e => setBmForm({ ...bmForm, phone: e.target.value })} /></div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Role</Label>
|
||||||
|
<Select value={bmForm.role} onValueChange={v => setBmForm({ ...bmForm, role: v })}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="President">President</SelectItem>
|
||||||
|
<SelectItem value="Vice President">Vice President</SelectItem>
|
||||||
|
<SelectItem value="Treasurer">Treasurer</SelectItem>
|
||||||
|
<SelectItem value="Secretary">Secretary</SelectItem>
|
||||||
|
<SelectItem value="Member">Member</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Switch checked={bmForm.approval_authority} onCheckedChange={v => setBmForm({ ...bmForm, approval_authority: v })} />
|
||||||
|
<Label>Approval authority</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Switch checked={bmForm.can_upload} onCheckedChange={v => setBmForm({ ...bmForm, can_upload: v })} />
|
||||||
|
<Label>Allow document & bid/quote uploads</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setBmDialog(false)}>Cancel</Button>
|
||||||
|
<Button onClick={saveBm} disabled={bmSaving} className="gap-2">
|
||||||
|
{bmSaving && <Loader2 className="h-4 w-4 animate-spin" />}{bmEditingId ? "Update" : "Add"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<AlertDialog open={!!bmDeleteTarget} onOpenChange={(o) => { if (!o) setBmDeleteTarget(null); }}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Remove board member?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Remove {bmDeleteTarget?.member_name} from this association's board. This cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={deleteBm} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">Remove</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -1064,9 +1158,9 @@ export default function AssociationDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sync */}
|
{/* Accounting */}
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">Sync Settings</h4>
|
<h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">Accounting</h4>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Accounting System</Label>
|
<Label>Accounting System</Label>
|
||||||
@@ -1077,16 +1171,11 @@ export default function AssociationDetailPage() {
|
|||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="buildium">Buildium</SelectItem>
|
<SelectItem value="buildium">Buildium</SelectItem>
|
||||||
<SelectItem value="zoho">Zoho Books</SelectItem>
|
|
||||||
<SelectItem value="platform">Platform (Accounting) — coming soon</SelectItem>
|
<SelectItem value="platform">Platform (Accounting) — coming soon</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<p className="text-xs text-muted-foreground">Determines Chart of Accounts and where ledger entries route.</p>
|
<p className="text-xs text-muted-foreground">Determines Chart of Accounts and where ledger entries route.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Zoho Organization ID</Label>
|
|
||||||
<Input value={editForm.zoho_organization_id} onChange={(e) => setEditForm({ ...editForm, zoho_organization_id: e.target.value })} placeholder="e.g. 60005XXXXXXX" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,9 +2,8 @@ import { useState, useEffect } from "react";
|
|||||||
import { supabase } from "@/integrations/supabase/client";
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Landmark, Plus, MoreHorizontal, Edit, Trash2, Eye, Cloud, Archive, ArchiveRestore } from "lucide-react";
|
import { Landmark, Plus, MoreHorizontal, Edit, Trash2, Eye, Archive, ArchiveRestore } from "lucide-react";
|
||||||
import RecordImportButton from "@/components/RecordImportButton";
|
import RecordImportButton from "@/components/RecordImportButton";
|
||||||
import ImportZohoBankAccountsDialog from "@/components/ImportZohoBankAccountsDialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -22,7 +21,6 @@ export default function BankAccountsPage() {
|
|||||||
const [associations, setAssociations] = useState<any[]>([]);
|
const [associations, setAssociations] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [zohoImportOpen, setZohoImportOpen] = useState(false);
|
|
||||||
const [editing, setEditing] = useState<any>(null);
|
const [editing, setEditing] = useState<any>(null);
|
||||||
const [selectedAssocId, setSelectedAssocId] = useState("all");
|
const [selectedAssocId, setSelectedAssocId] = useState("all");
|
||||||
const [showArchived, setShowArchived] = useState(false);
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
@@ -163,9 +161,6 @@ export default function BankAccountsPage() {
|
|||||||
}}
|
}}
|
||||||
templateFileName="bank_accounts_template.xlsx"
|
templateFileName="bank_accounts_template.xlsx"
|
||||||
/>
|
/>
|
||||||
<Button variant="outline" className="gap-2" onClick={() => setZohoImportOpen(true)}>
|
|
||||||
<Cloud className="h-4 w-4" /> Import from Zoho
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" className="gap-2" onClick={() => setShowArchived(s => !s)}>
|
<Button variant="outline" className="gap-2" onClick={() => setShowArchived(s => !s)}>
|
||||||
{showArchived ? <><ArchiveRestore className="h-4 w-4" /> Show Active</> : <><Archive className="h-4 w-4" /> Show Archived</>}
|
{showArchived ? <><ArchiveRestore className="h-4 w-4" /> Show Active</> : <><Archive className="h-4 w-4" /> Show Archived</>}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -312,14 +307,6 @@ export default function BankAccountsPage() {
|
|||||||
<DialogFooter><Button onClick={handleSave}>{editing ? "Update" : "Create Account"}</Button></DialogFooter>
|
<DialogFooter><Button onClick={handleSave}>{editing ? "Update" : "Create Account"}</Button></DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<ImportZohoBankAccountsDialog
|
|
||||||
open={zohoImportOpen}
|
|
||||||
onOpenChange={setZohoImportOpen}
|
|
||||||
associations={associations}
|
|
||||||
defaultAssociationId={selectedAssocId !== "all" ? selectedAssocId : undefined}
|
|
||||||
onImported={fetchData}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { supabase } from "@/integrations/supabase/client";
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
import { pushBillToZohoAfterCreate, pushBillPaymentToZohoAfterPay } from "@/lib/zohoBillSync";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { UserCheck, Plus, Search, Eye, Upload, X, ArrowUpDown, Edit, Trash2, MoreHorizontal, AlertTriangle, Loader2, RefreshCw, ArrowUpFromLine, Bell, Printer, Sparkles, Download } from "lucide-react";
|
import { UserCheck, Plus, Search, Eye, Upload, X, ArrowUpDown, Edit, Trash2, MoreHorizontal, AlertTriangle, Loader2, Bell, Printer, Sparkles, Download } from "lucide-react";
|
||||||
import { downloadChecksPdf, type CheckData } from "@/utils/checkPdfGenerator";
|
import { downloadChecksPdf, type CheckData } from "@/utils/checkPdfGenerator";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
@@ -66,9 +65,6 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci
|
|||||||
const [editingBill, setEditingBill] = useState<any>(null);
|
const [editingBill, setEditingBill] = useState<any>(null);
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<any>(null);
|
const [deleteTarget, setDeleteTarget] = useState<any>(null);
|
||||||
const [syncingId, setSyncingId] = useState<string | null>(null);
|
|
||||||
const [syncingAll, setSyncingAll] = useState(false);
|
|
||||||
const [resyncConfirm, setResyncConfirm] = useState<{ type: "single" | "all"; id?: string } | null>(null);
|
|
||||||
|
|
||||||
// Notify Board
|
// Notify Board
|
||||||
const [notifyOpen, setNotifyOpen] = useState(false);
|
const [notifyOpen, setNotifyOpen] = useState(false);
|
||||||
@@ -451,16 +447,6 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci
|
|||||||
}).select("id").single();
|
}).select("id").single();
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
if (newBill?.id) {
|
|
||||||
const syncResult = await pushBillToZohoAfterCreate(newBill.id);
|
|
||||||
if (!syncResult?.success) {
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: "Zoho sync failed",
|
|
||||||
description: syncResult?.error || "Bill was created, but it could not be pushed to Zoho Books.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If board members were selected for approval, create approval requests for each
|
// If board members were selected for approval, create approval requests for each
|
||||||
if (form.approval_member_ids.length > 0) {
|
if (form.approval_member_ids.length > 0) {
|
||||||
@@ -579,15 +565,7 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci
|
|||||||
await supabase.from("invoices").update({ raw_pdf_url: attachmentUrl }).eq("id", editingBill.source_invoice_id);
|
await supabase.from("invoices").update({ raw_pdf_url: attachmentUrl }).eq("id", editingBill.source_invoice_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const syncResult = await pushBillToZohoAfterCreate(editingBill.id);
|
|
||||||
toast({ title: "Bill Updated", description: "Bill changes have been saved." });
|
toast({ title: "Bill Updated", description: "Bill changes have been saved." });
|
||||||
if (!syncResult?.success) {
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: "Zoho sync failed",
|
|
||||||
description: syncResult?.error || "Bill was updated, but it could not be pushed to Zoho Books.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setEditOpen(false);
|
setEditOpen(false);
|
||||||
setEditingBill(null);
|
setEditingBill(null);
|
||||||
setUploadFile(null);
|
setUploadFile(null);
|
||||||
@@ -618,52 +596,6 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci
|
|||||||
const getClientName = (bill: any) => bill.associations?.name || "—";
|
const getClientName = (bill: any) => bill.associations?.name || "—";
|
||||||
const getGlLabel = (bill: any) => bill.chart_of_accounts ? `${bill.chart_of_accounts.account_number} - ${bill.chart_of_accounts.account_name}` : "—";
|
const getGlLabel = (bill: any) => bill.chart_of_accounts ? `${bill.chart_of_accounts.account_number} - ${bill.chart_of_accounts.account_name}` : "—";
|
||||||
|
|
||||||
const handlePushToZoho = async (billId: string) => {
|
|
||||||
setSyncingId(billId);
|
|
||||||
try {
|
|
||||||
const syncResult = await pushBillToZohoAfterCreate(billId);
|
|
||||||
if (syncResult?.success) {
|
|
||||||
toast({ title: "Zoho sync complete", description: "Bill was sent to Zoho Books." });
|
|
||||||
fetchData();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: "Zoho sync failed",
|
|
||||||
description: syncResult?.error || "This bill could not be pushed to Zoho Books.",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setSyncingId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const pushAllToZoho = async (force = false) => {
|
|
||||||
setSyncingAll(true);
|
|
||||||
try {
|
|
||||||
const { data, error } = await supabase.functions.invoke("zoho-books", {
|
|
||||||
body: { action: "push_all_bills", params: { force } },
|
|
||||||
});
|
|
||||||
if (error) throw error;
|
|
||||||
const r = data?.data;
|
|
||||||
toast({ title: "Zoho Sync Complete", description: `${r?.synced || 0} synced, ${r?.errors || 0} errors out of ${r?.total || 0}.` });
|
|
||||||
fetchData();
|
|
||||||
} catch (err: any) {
|
|
||||||
toast({ title: "Zoho sync failed", description: err.message, variant: "destructive" });
|
|
||||||
} finally {
|
|
||||||
setSyncingAll(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleResyncConfirm = () => {
|
|
||||||
if (!resyncConfirm) return;
|
|
||||||
if (resyncConfirm.type === "single") {
|
|
||||||
handlePushToZoho(resyncConfirm.id!);
|
|
||||||
} else {
|
|
||||||
pushAllToZoho(true);
|
|
||||||
}
|
|
||||||
setResyncConfirm(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusDisplay = (bill: any) => {
|
const getStatusDisplay = (bill: any) => {
|
||||||
// Check overdue
|
// Check overdue
|
||||||
if (bill.status === "pending" && bill.due_date && new Date(bill.due_date) < new Date()) {
|
if (bill.status === "pending" && bill.due_date && new Date(bill.due_date) < new Date()) {
|
||||||
@@ -932,10 +864,6 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci
|
|||||||
check_id: u.checkId,
|
check_id: u.checkId,
|
||||||
})
|
})
|
||||||
.eq("id", u.billId);
|
.eq("id", u.billId);
|
||||||
// Push payment to Zoho Books (records vendor payment + check info on the bill)
|
|
||||||
pushBillPaymentToZohoAfterPay(u.billId).then((r) => {
|
|
||||||
if (!r.success) console.warn("Zoho payment sync failed for bill", u.billId, r.error);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (txInserts.length > 0) await supabase.from("bank_transactions").insert(txInserts);
|
if (txInserts.length > 0) await supabase.from("bank_transactions").insert(txInserts);
|
||||||
@@ -985,14 +913,6 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci
|
|||||||
<Button onClick={openNotifyBoard} variant="outline" size="sm" className="gap-2">
|
<Button onClick={openNotifyBoard} variant="outline" size="sm" className="gap-2">
|
||||||
<Bell className="w-4 h-4" /> Notify Board
|
<Bell className="w-4 h-4" /> Notify Board
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => setResyncConfirm({ type: "all" })} disabled={syncingAll} variant="outline" size="sm">
|
|
||||||
{syncingAll ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <RefreshCw className="w-4 h-4 mr-2" />}
|
|
||||||
Resync All to Zoho
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => pushAllToZoho(false)} disabled={syncingAll} variant="outline" size="sm">
|
|
||||||
{syncingAll ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <ArrowUpFromLine className="w-4 h-4 mr-2" />}
|
|
||||||
Sync All to Zoho
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
onClick={openPrintDialog}
|
onClick={openPrintDialog}
|
||||||
disabled={selectedBillIds.size === 0 || printing}
|
disabled={selectedBillIds.size === 0 || printing}
|
||||||
@@ -1196,9 +1116,6 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci
|
|||||||
<DropdownMenuItem onClick={() => openEdit(b)}>
|
<DropdownMenuItem onClick={() => openEdit(b)}>
|
||||||
<Edit className="h-4 w-4 mr-2" /> Edit
|
<Edit className="h-4 w-4 mr-2" /> Edit
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => handlePushToZoho(b.id)}>
|
|
||||||
<Upload className="h-4 w-4 mr-2" /> Push to Zoho Books
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem className="text-destructive" onClick={() => setDeleteTarget(b)}>
|
<DropdownMenuItem className="text-destructive" onClick={() => setDeleteTarget(b)}>
|
||||||
<Trash2 className="h-4 w-4 mr-2" /> Delete
|
<Trash2 className="h-4 w-4 mr-2" /> Delete
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -1433,13 +1350,6 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci
|
|||||||
{detailBill.description && (
|
{detailBill.description && (
|
||||||
<div><p className="text-xs text-muted-foreground uppercase">Description</p><p className="text-sm">{detailBill.description}</p></div>
|
<div><p className="text-xs text-muted-foreground uppercase">Description</p><p className="text-sm">{detailBill.description}</p></div>
|
||||||
)}
|
)}
|
||||||
{!isBoardView && (
|
|
||||||
<div>
|
|
||||||
<Button variant="outline" onClick={() => handlePushToZoho(detailBill.id)} className="gap-2">
|
|
||||||
<Upload className="h-4 w-4" /> Push to Zoho Books
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* Embedded PDF / Attachment Preview */}
|
{/* Embedded PDF / Attachment Preview */}
|
||||||
{detailBill.attachment_url && (
|
{detailBill.attachment_url && (
|
||||||
<div className="border rounded-lg overflow-hidden">
|
<div className="border rounded-lg overflow-hidden">
|
||||||
@@ -1617,24 +1527,6 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci
|
|||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
{/* Resync Confirmation */}
|
|
||||||
<AlertDialog open={!!resyncConfirm} onOpenChange={(open) => { if (!open) setResyncConfirm(null); }}>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Resync to Zoho Books?</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
{resyncConfirm?.type === "all"
|
|
||||||
? "This will re-push all bills to Zoho Books, including ones already synced. Existing Zoho records will be overwritten. This action cannot be undone."
|
|
||||||
: "This will re-push this bill to Zoho Books, overwriting the existing Zoho record. This action cannot be undone."}
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction onClick={handleResyncConfirm}>Confirm Resync</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
|
|
||||||
{/* Notify Board Dialog */}
|
{/* Notify Board Dialog */}
|
||||||
<Dialog open={notifyOpen} onOpenChange={setNotifyOpen}>
|
<Dialog open={notifyOpen} onOpenChange={setNotifyOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
|
|||||||
@@ -237,22 +237,6 @@ export default function BillDetailPage({ boardAssociationIds }: { boardAssociati
|
|||||||
}
|
}
|
||||||
toast({ title: "Updated", description: `Bill marked as ${status}.` });
|
toast({ title: "Updated", description: `Bill marked as ${status}.` });
|
||||||
|
|
||||||
// If marking paid, push the payment + check info to Zoho (best-effort)
|
|
||||||
if (status === "paid" && bill?.zoho_bill_id) {
|
|
||||||
try {
|
|
||||||
const { data, error: invokeErr } = await supabase.functions.invoke("zoho-books", {
|
|
||||||
body: { action: "push_bill_payment", params: { bill_id: id } },
|
|
||||||
});
|
|
||||||
if (invokeErr) throw invokeErr;
|
|
||||||
if (data?.data?.zoho_payment_id) {
|
|
||||||
toast({ title: "Synced to Zoho", description: "Payment & check info recorded in Zoho Books." });
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
console.warn("Zoho payment sync failed:", e);
|
|
||||||
toast({ variant: "destructive", title: "Zoho sync failed", description: e.message || "Could not record payment in Zoho." });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchBill();
|
fetchBill();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+1
-15
@@ -1,6 +1,5 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { supabase } from "@/integrations/supabase/client";
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
import { pushBillToZohoAfterCreate, pushBillPaymentToZohoAfterPay, deleteBillPaymentFromZohoAfterUnpay } from "@/lib/zohoBillSync";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { FileText, Plus, Search, MoreHorizontal, Edit, Trash2, CheckCircle, XCircle, DollarSign, Download, Loader2 } from "lucide-react";
|
import { FileText, Plus, Search, MoreHorizontal, Edit, Trash2, CheckCircle, XCircle, DollarSign, Download, Loader2 } from "lucide-react";
|
||||||
import RecordImportButton from "@/components/RecordImportButton";
|
import RecordImportButton from "@/components/RecordImportButton";
|
||||||
@@ -178,9 +177,8 @@ export default function BillsPage() {
|
|||||||
await supabase.from("bills").update(payload).eq("id", editing.id);
|
await supabase.from("bills").update(payload).eq("id", editing.id);
|
||||||
toast({ title: "Bill updated" });
|
toast({ title: "Bill updated" });
|
||||||
} else {
|
} else {
|
||||||
const { data: newBill } = await supabase.from("bills").insert(payload).select("id").single();
|
await supabase.from("bills").insert(payload).select("id").single();
|
||||||
toast({ title: "Bill created" });
|
toast({ title: "Bill created" });
|
||||||
if (newBill?.id) pushBillToZohoAfterCreate(newBill.id);
|
|
||||||
}
|
}
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
fetchData();
|
fetchData();
|
||||||
@@ -219,18 +217,6 @@ export default function BillsPage() {
|
|||||||
|
|
||||||
await supabase.from("bills").update(updates).eq("id", id);
|
await supabase.from("bills").update(updates).eq("id", id);
|
||||||
toast({ title: `Bill ${status.replace("_", " ")}` });
|
toast({ title: `Bill ${status.replace("_", " ")}` });
|
||||||
|
|
||||||
if (status === "paid") {
|
|
||||||
const result = await pushBillPaymentToZohoAfterPay(id);
|
|
||||||
if (!result.success) {
|
|
||||||
toast({ variant: "destructive", title: "Zoho payment sync failed", description: result.error });
|
|
||||||
}
|
|
||||||
} else if (revertingToUnpaid) {
|
|
||||||
const result = await deleteBillPaymentFromZohoAfterUnpay(id);
|
|
||||||
if (!result.success) {
|
|
||||||
toast({ variant: "destructive", title: "Zoho payment delete failed", description: result.error });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fetchData();
|
fetchData();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+37
-1009
File diff suppressed because it is too large
Load Diff
@@ -38,12 +38,6 @@ const statusColors: Record<string, string> = {
|
|||||||
void: "bg-muted text-muted-foreground line-through",
|
void: "bg-muted text-muted-foreground line-through",
|
||||||
};
|
};
|
||||||
|
|
||||||
const zohoStatusColors: Record<string, string> = {
|
|
||||||
synced: "bg-green-100 text-green-700",
|
|
||||||
error: "bg-red-100 text-red-700",
|
|
||||||
pending: "bg-yellow-100 text-yellow-700",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ClientInvoicesPage() {
|
export default function ClientInvoicesPage() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [invoices, setInvoices] = useState<any[]>([]);
|
const [invoices, setInvoices] = useState<any[]>([]);
|
||||||
@@ -52,9 +46,6 @@ export default function ClientInvoicesPage() {
|
|||||||
const [detailOpen, setDetailOpen] = useState(false);
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
const [selectedInvoice, setSelectedInvoice] = useState<any>(null);
|
const [selectedInvoice, setSelectedInvoice] = useState<any>(null);
|
||||||
const [lineItems, setLineItems] = useState<any[]>([]);
|
const [lineItems, setLineItems] = useState<any[]>([]);
|
||||||
const [syncingId, setSyncingId] = useState<string | null>(null);
|
|
||||||
const [syncingAll, setSyncingAll] = useState(false);
|
|
||||||
const [resyncConfirm, setResyncConfirm] = useState<{ type: "single"; id: string } | { type: "all" } | null>(null);
|
|
||||||
|
|
||||||
// Regenerate (corrected invoice) state
|
// Regenerate (corrected invoice) state
|
||||||
const [regenOpen, setRegenOpen] = useState(false);
|
const [regenOpen, setRegenOpen] = useState(false);
|
||||||
@@ -309,53 +300,6 @@ export default function ClientInvoicesPage() {
|
|||||||
fetchInvoices();
|
fetchInvoices();
|
||||||
};
|
};
|
||||||
|
|
||||||
const pushToZoho = async (invoiceId: string, force = false) => {
|
|
||||||
setSyncingId(invoiceId);
|
|
||||||
try {
|
|
||||||
const { data, error } = await supabase.functions.invoke("zoho-books", {
|
|
||||||
body: { action: "push_client_invoice", params: { invoice_id: invoiceId, force_resync: force } },
|
|
||||||
});
|
|
||||||
if (error) throw error;
|
|
||||||
if (data?.data?.status === "already_synced") {
|
|
||||||
toast({ title: "Already synced", description: "This invoice is already in Zoho Books." });
|
|
||||||
} else {
|
|
||||||
toast({ title: force ? "Resynced to Zoho" : "Synced to Zoho", description: "Invoice pushed successfully." });
|
|
||||||
}
|
|
||||||
fetchInvoices();
|
|
||||||
} catch (err: any) {
|
|
||||||
toast({ title: "Zoho sync failed", description: err.message, variant: "destructive" });
|
|
||||||
} finally {
|
|
||||||
setSyncingId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const pushAllToZoho = async (force = false) => {
|
|
||||||
setSyncingAll(true);
|
|
||||||
try {
|
|
||||||
const { data, error } = await supabase.functions.invoke("zoho-books", {
|
|
||||||
body: { action: "push_all_client_invoices", params: { force_resync: force } },
|
|
||||||
});
|
|
||||||
if (error) throw error;
|
|
||||||
const r = data?.data;
|
|
||||||
toast({ title: "Zoho Sync Complete", description: `${r?.synced || 0} synced, ${r?.errors || 0} errors out of ${r?.total || 0}.` });
|
|
||||||
fetchInvoices();
|
|
||||||
} catch (err: any) {
|
|
||||||
toast({ title: "Zoho sync failed", description: err.message, variant: "destructive" });
|
|
||||||
} finally {
|
|
||||||
setSyncingAll(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleResyncConfirm = () => {
|
|
||||||
if (!resyncConfirm) return;
|
|
||||||
if (resyncConfirm.type === "single") {
|
|
||||||
pushToZoho(resyncConfirm.id, true);
|
|
||||||
} else {
|
|
||||||
pushAllToZoho(true);
|
|
||||||
}
|
|
||||||
setResyncConfirm(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const filtered = invoices.filter(i =>
|
const filtered = invoices.filter(i =>
|
||||||
(i.associations?.name || "").toLowerCase().includes(search.toLowerCase()) ||
|
(i.associations?.name || "").toLowerCase().includes(search.toLowerCase()) ||
|
||||||
i.invoice_number.toLowerCase().includes(search.toLowerCase())
|
i.invoice_number.toLowerCase().includes(search.toLowerCase())
|
||||||
@@ -374,16 +318,6 @@ export default function ClientInvoicesPage() {
|
|||||||
Invoices generated from billable expenses and the invoicing form.
|
Invoices generated from billable expenses and the invoicing form.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button onClick={() => setResyncConfirm({ type: "all" })} disabled={syncingAll} variant="outline" size="sm">
|
|
||||||
{syncingAll ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <RefreshCw className="w-4 h-4 mr-2" />}
|
|
||||||
Resync All to Zoho
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => pushAllToZoho(false)} disabled={syncingAll} variant="outline" size="sm">
|
|
||||||
{syncingAll ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <ArrowUpFromLine className="w-4 h-4 mr-2" />}
|
|
||||||
Sync All to Zoho
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative max-w-md">
|
<div className="relative max-w-md">
|
||||||
@@ -409,7 +343,6 @@ export default function ClientInvoicesPage() {
|
|||||||
<TableHead>Due Date</TableHead>
|
<TableHead>Due Date</TableHead>
|
||||||
<TableHead className="text-right">Amount</TableHead>
|
<TableHead className="text-right">Amount</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
<TableHead>Zoho</TableHead>
|
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -431,15 +364,6 @@ export default function ClientInvoicesPage() {
|
|||||||
{inv.status}
|
{inv.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
|
||||||
{inv.zoho_invoice_id ? (
|
|
||||||
<Badge className={`${zohoStatusColors.synced} border-0 text-xs`}>Synced</Badge>
|
|
||||||
) : inv.zoho_sync_status === "error" ? (
|
|
||||||
<Badge className={`${zohoStatusColors.error} border-0 text-xs`} title={inv.zoho_sync_error || ""}>Error</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge className={`${zohoStatusColors.pending} border-0 text-xs`}>Pending</Badge>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<div className="flex justify-end gap-1">
|
<div className="flex justify-end gap-1">
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => viewDetails(inv)} title="View details">
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => viewDetails(inv)} title="View details">
|
||||||
@@ -448,25 +372,6 @@ export default function ClientInvoicesPage() {
|
|||||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-blue-600" onClick={() => openRegenerate(inv)} title="Regenerate corrected invoice (keeps invoice #, attaches receipts)">
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-blue-600" onClick={() => openRegenerate(inv)} title="Regenerate corrected invoice (keeps invoice #, attaches receipts)">
|
||||||
<FileEdit className="h-4 w-4" />
|
<FileEdit className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
{inv.zoho_invoice_id ? (
|
|
||||||
<Button
|
|
||||||
variant="ghost" size="icon" className="h-8 w-8 text-amber-600"
|
|
||||||
onClick={() => setResyncConfirm({ type: "single", id: inv.id })}
|
|
||||||
disabled={syncingId === inv.id}
|
|
||||||
title="Resync to Zoho"
|
|
||||||
>
|
|
||||||
{syncingId === inv.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="ghost" size="icon" className="h-8 w-8 text-primary"
|
|
||||||
onClick={() => pushToZoho(inv.id)}
|
|
||||||
disabled={syncingId === inv.id}
|
|
||||||
title="Push to Zoho"
|
|
||||||
>
|
|
||||||
{syncingId === inv.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <ArrowUpFromLine className="h-4 w-4" />}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{inv.status !== "paid" && (
|
{inv.status !== "paid" && (
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-emerald-600" onClick={() => updateStatus(inv.id, "paid")} title="Mark paid">
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-emerald-600" onClick={() => updateStatus(inv.id, "paid")} title="Mark paid">
|
||||||
<DollarSign className="h-4 w-4" />
|
<DollarSign className="h-4 w-4" />
|
||||||
@@ -484,26 +389,6 @@ export default function ClientInvoicesPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Resync Confirmation Dialog */}
|
|
||||||
<AlertDialog open={!!resyncConfirm} onOpenChange={(open) => !open && setResyncConfirm(null)}>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Resync to Zoho Books?</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
{resyncConfirm?.type === "all"
|
|
||||||
? "This will re-push all client invoices to Zoho Books, including ones already synced. Existing Zoho invoices will be updated and overwritten with the current data. This action cannot be undone."
|
|
||||||
: "This will re-push this invoice to Zoho Books, overwriting the existing Zoho record with the current data. This action cannot be undone."}
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction onClick={handleResyncConfirm} className="bg-amber-600 hover:bg-amber-700">
|
|
||||||
Yes, Resync
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
|
|
||||||
{/* Detail Dialog */}
|
{/* Detail Dialog */}
|
||||||
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||||
<DialogContent className="sm:max-w-[600px]">
|
<DialogContent className="sm:max-w-[600px]">
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ export default function CompanyBankAccountsHubPage() {
|
|||||||
const [savingSettings, setSavingSettings] = useState(false);
|
const [savingSettings, setSavingSettings] = useState(false);
|
||||||
const [settingsId, setSettingsId] = useState<string | null>(null);
|
const [settingsId, setSettingsId] = useState<string | null>(null);
|
||||||
const [companyName, setCompanyName] = useState("");
|
const [companyName, setCompanyName] = useState("");
|
||||||
const [zohoOrgId, setZohoOrgId] = useState("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -40,7 +39,6 @@ export default function CompanyBankAccountsHubPage() {
|
|||||||
if (data) {
|
if (data) {
|
||||||
setSettingsId(data.id);
|
setSettingsId(data.id);
|
||||||
setCompanyName(data.company_name || "");
|
setCompanyName(data.company_name || "");
|
||||||
setZohoOrgId(data.zoho_organization_id || "");
|
|
||||||
}
|
}
|
||||||
setLoadingSettings(false);
|
setLoadingSettings(false);
|
||||||
})();
|
})();
|
||||||
@@ -53,7 +51,6 @@ export default function CompanyBankAccountsHubPage() {
|
|||||||
const payload = {
|
const payload = {
|
||||||
key: "primary",
|
key: "primary",
|
||||||
company_name: companyName.trim() || null,
|
company_name: companyName.trim() || null,
|
||||||
zoho_organization_id: zohoOrgId.trim() || null,
|
|
||||||
};
|
};
|
||||||
if (settingsId) {
|
if (settingsId) {
|
||||||
const { error } = await supabase.from("company_settings").update(payload).eq("id", settingsId);
|
const { error } = await supabase.from("company_settings").update(payload).eq("id", settingsId);
|
||||||
@@ -80,7 +77,7 @@ export default function CompanyBankAccountsHubPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Company Bank Accounts</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Company Bank Accounts</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Manage your management company's own bank accounts, check printing layout, and Zoho Organization ID — kept separate from client associations.
|
Manage your management company's own bank accounts and check printing layout — kept separate from client associations.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Tabs value={tab} onValueChange={(v) => setParams({ tab: v })}>
|
<Tabs value={tab} onValueChange={(v) => setParams({ tab: v })}>
|
||||||
@@ -109,7 +106,7 @@ export default function CompanyBankAccountsHubPage() {
|
|||||||
<Building2 className="h-4 w-4 text-primary" /> Management Company Info
|
<Building2 className="h-4 w-4 text-primary" /> Management Company Info
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Used when posting your own company transactions to Zoho Books — independent from the Zoho organizations linked to client associations.
|
Your management company's profile information.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
@@ -128,18 +125,6 @@ export default function CompanyBankAccountsHubPage() {
|
|||||||
placeholder="Avria Community Management"
|
placeholder="Avria Community Management"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2 sm:max-w-md">
|
|
||||||
<Label htmlFor="zoho-org-id">Zoho Organization ID</Label>
|
|
||||||
<Input
|
|
||||||
id="zoho-org-id"
|
|
||||||
value={zohoOrgId}
|
|
||||||
onChange={(e) => setZohoOrgId(e.target.value)}
|
|
||||||
placeholder="e.g. 60012345678"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Find this in Zoho Books → Settings → Organization Profile. Numeric ID assigned to your management company's books.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
<Button onClick={handleSaveSettings} disabled={savingSettings} className="gap-2">
|
<Button onClick={handleSaveSettings} disabled={savingSettings} className="gap-2">
|
||||||
{savingSettings ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
{savingSettings ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||||
|
|||||||
@@ -1,605 +0,0 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { supabase } from "@/integrations/supabase/client";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
|
||||||
import { format, startOfMonth, endOfMonth } from "date-fns";
|
|
||||||
import {
|
|
||||||
BookOpen, Loader2, Printer, FileDown, RefreshCw, Filter,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
|
||||||
import {
|
|
||||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import {
|
|
||||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import { downloadChecksPdf, type CheckData } from "@/utils/checkPdfGenerator";
|
|
||||||
import jsPDF from "jspdf";
|
|
||||||
import autoTable from "jspdf-autotable";
|
|
||||||
|
|
||||||
interface Assoc {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
logo_url: string | null;
|
|
||||||
zoho_organization_id: string | null;
|
|
||||||
/** When true this entry represents the management company itself (no association row) */
|
|
||||||
isCompany?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const COMPANY_OPTION_ID = "__company__";
|
|
||||||
|
|
||||||
interface ZohoBank {
|
|
||||||
account_id: string;
|
|
||||||
account_name: string;
|
|
||||||
account_type?: string;
|
|
||||||
bank_name?: string;
|
|
||||||
routing_number?: string | null;
|
|
||||||
account_number?: string | null;
|
|
||||||
currency_code?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ZohoTxn {
|
|
||||||
transaction_id: string;
|
|
||||||
date: string;
|
|
||||||
transaction_type: string;
|
|
||||||
reference_number?: string | null;
|
|
||||||
payee?: string | null;
|
|
||||||
description?: string | null;
|
|
||||||
debit_or_credit?: "debit" | "credit" | string;
|
|
||||||
amount: number;
|
|
||||||
status?: string | null;
|
|
||||||
account_name?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fmt = (n: number) =>
|
|
||||||
n.toLocaleString("en-US", { style: "currency", currency: "USD" });
|
|
||||||
|
|
||||||
// Outflows we can convert into a check
|
|
||||||
const OUTFLOW_TYPES = new Set([
|
|
||||||
"expense", "withdrawal", "transfer_fund", "card_payment", "vendor_payment",
|
|
||||||
"owner_drawings", "payment_refund", "card_refund",
|
|
||||||
]);
|
|
||||||
|
|
||||||
export default function CompanyLedgerPage() {
|
|
||||||
const { toast } = useToast();
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
const [assocs, setAssocs] = useState<Assoc[]>([]);
|
|
||||||
const [assocId, setAssocId] = useState<string>("");
|
|
||||||
const [banks, setBanks] = useState<ZohoBank[]>([]);
|
|
||||||
const [bankId, setBankId] = useState<string>("");
|
|
||||||
const [fromDate, setFromDate] = useState(format(startOfMonth(now), "yyyy-MM-dd"));
|
|
||||||
const [toDate, setToDate] = useState(format(endOfMonth(now), "yyyy-MM-dd"));
|
|
||||||
const [txns, setTxns] = useState<ZohoTxn[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [loadingBanks, setLoadingBanks] = useState(false);
|
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
||||||
const [printing, setPrinting] = useState(false);
|
|
||||||
const [includeMicr, setIncludeMicr] = useState(false);
|
|
||||||
const [filterText, setFilterText] = useState("");
|
|
||||||
const [filterType, setFilterType] = useState<string>("all");
|
|
||||||
|
|
||||||
// ───── load Zoho-linked associations + the management company itself ─────
|
|
||||||
useEffect(() => {
|
|
||||||
(async () => {
|
|
||||||
const [{ data: assocRows }, orgsRes] = await Promise.all([
|
|
||||||
supabase
|
|
||||||
.from("associations")
|
|
||||||
.select("id, name, logo_url, zoho_organization_id")
|
|
||||||
.eq("status", "active")
|
|
||||||
.not("zoho_organization_id", "is", null)
|
|
||||||
.order("name"),
|
|
||||||
supabase.functions.invoke("zoho-books", { body: { action: "list_organizations" } }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const linked: Assoc[] = ((assocRows as any[]) || [])
|
|
||||||
.filter((a) => a.zoho_organization_id && String(a.zoho_organization_id).trim() !== "")
|
|
||||||
.map((a) => ({ ...a, isCompany: false }));
|
|
||||||
|
|
||||||
// Build a "Company" entry for any Zoho org NOT already represented by an association.
|
|
||||||
// This typically surfaces the management company (e.g. Avria Community Management).
|
|
||||||
const usedOrgIds = new Set(linked.map((a) => String(a.zoho_organization_id)));
|
|
||||||
const orgs: any[] = (orgsRes?.data?.data || orgsRes?.data || []) as any[];
|
|
||||||
const companyEntries: Assoc[] = orgs
|
|
||||||
.filter((o) => o?.organization_id && !usedOrgIds.has(String(o.organization_id)))
|
|
||||||
.map((o, idx) => ({
|
|
||||||
id: `${COMPANY_OPTION_ID}:${o.organization_id}`,
|
|
||||||
name: `${o.name || "Management Company"} (Company)`,
|
|
||||||
logo_url: null,
|
|
||||||
zoho_organization_id: String(o.organization_id),
|
|
||||||
isCompany: true,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const all = [...companyEntries, ...linked];
|
|
||||||
setAssocs(all);
|
|
||||||
if (all.length && !assocId) setAssocId(all[0].id);
|
|
||||||
})();
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Helper: build the params used to identify which Zoho org to hit.
|
|
||||||
// Company entries pass organization_id directly; regular associations pass association_id.
|
|
||||||
const buildOrgParams = () => {
|
|
||||||
const a = assocs.find((x) => x.id === assocId);
|
|
||||||
if (a?.isCompany && a.zoho_organization_id) {
|
|
||||||
return { organization_id: a.zoho_organization_id };
|
|
||||||
}
|
|
||||||
return { association_id: assocId };
|
|
||||||
};
|
|
||||||
|
|
||||||
// ───── load Zoho bank accounts whenever assoc changes ─────
|
|
||||||
useEffect(() => {
|
|
||||||
if (!assocId) return;
|
|
||||||
setLoadingBanks(true);
|
|
||||||
setBanks([]);
|
|
||||||
setBankId("");
|
|
||||||
setTxns([]);
|
|
||||||
setSelected(new Set());
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const { data, error } = await supabase.functions.invoke("zoho-books", {
|
|
||||||
body: { action: "list_bank_accounts", params: buildOrgParams() },
|
|
||||||
});
|
|
||||||
if (error) throw error;
|
|
||||||
const list: ZohoBank[] = (data?.data || []).map((b: any) => ({
|
|
||||||
account_id: String(b.account_id),
|
|
||||||
account_name: b.account_name,
|
|
||||||
account_type: b.account_type,
|
|
||||||
bank_name: b.bank_name,
|
|
||||||
routing_number: b.routing_number || null,
|
|
||||||
account_number: b.account_number || null,
|
|
||||||
currency_code: b.currency_code,
|
|
||||||
}));
|
|
||||||
setBanks(list);
|
|
||||||
if (list.length) setBankId(list[0].account_id);
|
|
||||||
} catch (err: any) {
|
|
||||||
toast({ title: "Couldn't load bank accounts", description: err.message, variant: "destructive" });
|
|
||||||
} finally {
|
|
||||||
setLoadingBanks(false);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [assocId, toast]);
|
|
||||||
|
|
||||||
// ───── fetch transactions ─────
|
|
||||||
const fetchTxns = async () => {
|
|
||||||
if (!assocId || !bankId) {
|
|
||||||
toast({ title: "Choose an association & bank account", variant: "destructive" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setLoading(true);
|
|
||||||
setTxns([]);
|
|
||||||
setSelected(new Set());
|
|
||||||
try {
|
|
||||||
const { data, error } = await supabase.functions.invoke("zoho-books", {
|
|
||||||
body: {
|
|
||||||
action: "list_bank_transactions",
|
|
||||||
params: {
|
|
||||||
...buildOrgParams(),
|
|
||||||
query: {
|
|
||||||
account_id: bankId,
|
|
||||||
from_date: fromDate,
|
|
||||||
to_date: toDate,
|
|
||||||
per_page: 200,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (error) throw error;
|
|
||||||
const raw = data?.data?.banktransactions || data?.data?.bank_transactions || [];
|
|
||||||
const list: ZohoTxn[] = raw.map((t: any) => ({
|
|
||||||
transaction_id: String(t.transaction_id),
|
|
||||||
date: t.date,
|
|
||||||
transaction_type: String(t.transaction_type || "").toLowerCase(),
|
|
||||||
reference_number: t.reference_number || null,
|
|
||||||
payee: t.payee_name || t.customer_name || t.vendor_name || t.from_account_name || null,
|
|
||||||
description: t.description || null,
|
|
||||||
debit_or_credit: (t.debit_or_credit || "").toLowerCase(),
|
|
||||||
amount: Number(t.amount) || 0,
|
|
||||||
status: t.status,
|
|
||||||
account_name: t.account_name,
|
|
||||||
}));
|
|
||||||
setTxns(list);
|
|
||||||
if (list.length === 0) {
|
|
||||||
toast({ title: "No transactions found", description: "Try widening the date range." });
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
toast({ title: "Failed to load ledger", description: err.message, variant: "destructive" });
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ───── derived: filtered + running balance ─────
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
return txns.filter((t) => {
|
|
||||||
if (filterType !== "all" && t.transaction_type !== filterType) return false;
|
|
||||||
if (!filterText.trim()) return true;
|
|
||||||
const q = filterText.toLowerCase();
|
|
||||||
return (
|
|
||||||
(t.payee || "").toLowerCase().includes(q) ||
|
|
||||||
(t.description || "").toLowerCase().includes(q) ||
|
|
||||||
(t.reference_number || "").toLowerCase().includes(q)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}, [txns, filterText, filterType]);
|
|
||||||
|
|
||||||
const totals = useMemo(() => {
|
|
||||||
let credits = 0;
|
|
||||||
let debits = 0;
|
|
||||||
for (const t of filtered) {
|
|
||||||
if (t.debit_or_credit === "credit") credits += t.amount;
|
|
||||||
else debits += t.amount;
|
|
||||||
}
|
|
||||||
return { credits, debits, net: credits - debits };
|
|
||||||
}, [filtered]);
|
|
||||||
|
|
||||||
const typeOptions = useMemo(() => {
|
|
||||||
const s = new Set<string>();
|
|
||||||
txns.forEach((t) => t.transaction_type && s.add(t.transaction_type));
|
|
||||||
return Array.from(s).sort();
|
|
||||||
}, [txns]);
|
|
||||||
|
|
||||||
const selectedBank = banks.find((b) => b.account_id === bankId) || null;
|
|
||||||
const selectedAssoc = assocs.find((a) => a.id === assocId) || null;
|
|
||||||
|
|
||||||
// ───── selection helpers (only outflows are check-eligible) ─────
|
|
||||||
const eligibleForCheck = (t: ZohoTxn) =>
|
|
||||||
t.debit_or_credit === "debit" && OUTFLOW_TYPES.has(t.transaction_type);
|
|
||||||
|
|
||||||
const toggle = (id: string) => {
|
|
||||||
const s = new Set(selected);
|
|
||||||
s.has(id) ? s.delete(id) : s.add(id);
|
|
||||||
setSelected(s);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleAllVisible = () => {
|
|
||||||
const eligible = filtered.filter(eligibleForCheck).map((t) => t.transaction_id);
|
|
||||||
if (eligible.every((id) => selected.has(id))) {
|
|
||||||
const s = new Set(selected);
|
|
||||||
eligible.forEach((id) => s.delete(id));
|
|
||||||
setSelected(s);
|
|
||||||
} else {
|
|
||||||
setSelected(new Set([...selected, ...eligible]));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ───── print checks for selected outflows ─────
|
|
||||||
const printChecks = async () => {
|
|
||||||
if (selected.size === 0) {
|
|
||||||
toast({ title: "Select transactions to print", variant: "destructive" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!selectedAssoc) return;
|
|
||||||
setPrinting(true);
|
|
||||||
try {
|
|
||||||
const isCompany = !!selectedAssoc.isCompany;
|
|
||||||
|
|
||||||
// Optional layout for this association (only real associations have layouts/local bank rows)
|
|
||||||
const { data: layoutRow } = isCompany
|
|
||||||
? { data: null as any }
|
|
||||||
: await supabase
|
|
||||||
.from("check_layouts")
|
|
||||||
.select("*")
|
|
||||||
.eq("association_id", selectedAssoc.id)
|
|
||||||
.maybeSingle();
|
|
||||||
|
|
||||||
// Pull next check number from a matching local bank account if available
|
|
||||||
const localMatch = isCompany
|
|
||||||
? { data: null as any }
|
|
||||||
: await supabase
|
|
||||||
.from("bank_accounts")
|
|
||||||
.select("id, account_name, routing_number, account_number, next_check_number")
|
|
||||||
.eq("association_id", selectedAssoc.id)
|
|
||||||
.ilike("account_name", `%${selectedBank?.account_name || ""}%`)
|
|
||||||
.maybeSingle();
|
|
||||||
|
|
||||||
let nextNum = (localMatch.data?.next_check_number as number | undefined) ?? 1001;
|
|
||||||
const routing = selectedBank?.routing_number || localMatch.data?.routing_number || null;
|
|
||||||
const account = selectedBank?.account_number || localMatch.data?.account_number || null;
|
|
||||||
|
|
||||||
if (includeMicr && (!routing || !account)) {
|
|
||||||
toast({
|
|
||||||
title: "Missing routing/account number",
|
|
||||||
description: "Add MICR details to the bank account before printing on blank stock.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
setPrinting(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const today = format(new Date(), "yyyy-MM-dd");
|
|
||||||
const pickedTxns = filtered.filter((t) => selected.has(t.transaction_id));
|
|
||||||
const printable: CheckData[] = pickedTxns.map((t) => ({
|
|
||||||
check_number: t.reference_number || String(nextNum++),
|
|
||||||
check_date: t.date || today,
|
|
||||||
payee: t.payee || t.description || "Vendor",
|
|
||||||
payee_address: null,
|
|
||||||
amount: t.amount,
|
|
||||||
memo: t.description || null,
|
|
||||||
line_items: null,
|
|
||||||
bank_account_name: selectedBank?.account_name || null,
|
|
||||||
bank_routing_number: routing,
|
|
||||||
bank_account_number: account,
|
|
||||||
association_name: selectedAssoc.name,
|
|
||||||
layout: (layoutRow as any) || null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
await downloadChecksPdf(printable, `company-ledger-checks-${today}.pdf`, { includeMicr });
|
|
||||||
|
|
||||||
// If we used local sequence numbers, advance them
|
|
||||||
if (localMatch.data?.id && nextNum > (localMatch.data.next_check_number || 0)) {
|
|
||||||
await supabase.from("bank_accounts")
|
|
||||||
.update({ next_check_number: nextNum })
|
|
||||||
.eq("id", localMatch.data.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
toast({ title: `${printable.length} check(s) generated` });
|
|
||||||
setSelected(new Set());
|
|
||||||
} catch (err: any) {
|
|
||||||
toast({ title: "Print failed", description: err.message, variant: "destructive" });
|
|
||||||
} finally {
|
|
||||||
setPrinting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ───── export the ledger view as a branded PDF ─────
|
|
||||||
const exportLedgerPdf = async () => {
|
|
||||||
if (filtered.length === 0) {
|
|
||||||
toast({ title: "Nothing to export", variant: "destructive" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const doc = new jsPDF({ orientation: "landscape", unit: "pt", format: "letter" });
|
|
||||||
const pageW = doc.internal.pageSize.getWidth();
|
|
||||||
let y = 40;
|
|
||||||
|
|
||||||
// header
|
|
||||||
doc.setFontSize(16);
|
|
||||||
doc.setFont("helvetica", "bold");
|
|
||||||
doc.text(selectedAssoc?.name || "Company Ledger", 40, y);
|
|
||||||
doc.setFontSize(11);
|
|
||||||
doc.setFont("helvetica", "normal");
|
|
||||||
y += 18;
|
|
||||||
doc.text(`Account: ${selectedBank?.account_name || "—"}`, 40, y);
|
|
||||||
y += 14;
|
|
||||||
doc.text(`Period: ${fromDate} to ${toDate}`, 40, y);
|
|
||||||
doc.text(
|
|
||||||
`Net: ${fmt(totals.net)} In: ${fmt(totals.credits)} Out: ${fmt(totals.debits)}`,
|
|
||||||
pageW - 40,
|
|
||||||
y,
|
|
||||||
{ align: "right" },
|
|
||||||
);
|
|
||||||
|
|
||||||
autoTable(doc, {
|
|
||||||
startY: y + 14,
|
|
||||||
head: [["Date", "Type", "Reference", "Payee", "Description", "In", "Out"]],
|
|
||||||
body: filtered.map((t) => [
|
|
||||||
t.date || "",
|
|
||||||
t.transaction_type.replace(/_/g, " "),
|
|
||||||
t.reference_number || "",
|
|
||||||
t.payee || "",
|
|
||||||
t.description || "",
|
|
||||||
t.debit_or_credit === "credit" ? fmt(t.amount) : "",
|
|
||||||
t.debit_or_credit === "debit" ? fmt(t.amount) : "",
|
|
||||||
]),
|
|
||||||
styles: { fontSize: 9, cellPadding: 4 },
|
|
||||||
headStyles: { fillColor: [30, 41, 59], textColor: 255 },
|
|
||||||
columnStyles: {
|
|
||||||
5: { halign: "right" },
|
|
||||||
6: { halign: "right" },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
doc.save(`company-ledger-${fromDate}-to-${toDate}.pdf`);
|
|
||||||
} catch (err: any) {
|
|
||||||
toast({ title: "Export failed", description: err.message, variant: "destructive" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
|
|
||||||
<BookOpen className="h-6 w-6 text-primary" /> Company Ledger
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
|
||||||
Live view of bank-account activity from Zoho Books for the selected association. Pick outflows to print as checks.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Button variant="outline" className="gap-2" onClick={exportLedgerPdf} disabled={filtered.length === 0}>
|
|
||||||
<FileDown className="h-4 w-4" /> Export PDF
|
|
||||||
</Button>
|
|
||||||
<Button className="gap-2" onClick={printChecks} disabled={selected.size === 0 || printing}>
|
|
||||||
<Printer className="h-4 w-4" /> {printing ? "Generating…" : `Print Checks (${selected.size})`}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filters */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="text-base">Source</CardTitle>
|
|
||||||
<CardDescription>Choose the Zoho-linked association and bank account.</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
||||||
<div>
|
|
||||||
<Label>Association</Label>
|
|
||||||
<Select value={assocId} onValueChange={setAssocId}>
|
|
||||||
<SelectTrigger><SelectValue placeholder="Select" /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{assocs.map((a) => (
|
|
||||||
<SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>
|
|
||||||
))}
|
|
||||||
{assocs.length === 0 && (
|
|
||||||
<SelectItem value="__none" disabled>No Zoho-linked associations</SelectItem>
|
|
||||||
)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label>Bank Account</Label>
|
|
||||||
<Select value={bankId} onValueChange={setBankId} disabled={loadingBanks || banks.length === 0}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder={loadingBanks ? "Loading…" : "Select"} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{banks.map((b) => (
|
|
||||||
<SelectItem key={b.account_id} value={b.account_id}>
|
|
||||||
{b.account_name}{b.account_type ? ` — ${b.account_type}` : ""}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label>From</Label>
|
|
||||||
<Input type="date" value={fromDate} onChange={(e) => setFromDate(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label>To</Label>
|
|
||||||
<Input type="date" value={toDate} onChange={(e) => setToDate(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
<div className="md:col-span-4 flex flex-wrap items-center gap-3 pt-1">
|
|
||||||
<Button onClick={fetchTxns} disabled={loading || !bankId} className="gap-2">
|
|
||||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
|
||||||
Load Ledger
|
|
||||||
</Button>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Checkbox id="micr" checked={includeMicr} onCheckedChange={(v) => setIncludeMicr(!!v)} />
|
|
||||||
<Label htmlFor="micr" className="cursor-pointer text-sm font-normal">
|
|
||||||
Print on blank stock (MICR)
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Totals */}
|
|
||||||
{txns.length > 0 && (
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
|
||||||
<Card><CardContent className="pt-6">
|
|
||||||
<p className="text-xs text-muted-foreground uppercase">Money In</p>
|
|
||||||
<p className="text-2xl font-bold text-emerald-600">{fmt(totals.credits)}</p>
|
|
||||||
</CardContent></Card>
|
|
||||||
<Card><CardContent className="pt-6">
|
|
||||||
<p className="text-xs text-muted-foreground uppercase">Money Out</p>
|
|
||||||
<p className="text-2xl font-bold text-red-600">{fmt(totals.debits)}</p>
|
|
||||||
</CardContent></Card>
|
|
||||||
<Card><CardContent className="pt-6">
|
|
||||||
<p className="text-xs text-muted-foreground uppercase">Net</p>
|
|
||||||
<p className={`text-2xl font-bold ${totals.net >= 0 ? "text-emerald-600" : "text-red-600"}`}>
|
|
||||||
{fmt(totals.net)}
|
|
||||||
</p>
|
|
||||||
</CardContent></Card>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Inline filters */}
|
|
||||||
{txns.length > 0 && (
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
<div className="relative flex-1 min-w-[240px]">
|
|
||||||
<Filter className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
className="pl-10"
|
|
||||||
placeholder="Filter by payee, description, or reference…"
|
|
||||||
value={filterText}
|
|
||||||
onChange={(e) => setFilterText(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Select value={filterType} onValueChange={setFilterType}>
|
|
||||||
<SelectTrigger className="w-56"><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All transaction types</SelectItem>
|
|
||||||
{typeOptions.map((t) => (
|
|
||||||
<SelectItem key={t} value={t}>{t.replace(/_/g, " ")}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Ledger table */}
|
|
||||||
<Card>
|
|
||||||
{loading ? (
|
|
||||||
<CardContent className="py-12 flex justify-center">
|
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
|
||||||
</CardContent>
|
|
||||||
) : filtered.length === 0 ? (
|
|
||||||
<CardContent className="py-12 text-center text-muted-foreground">
|
|
||||||
{txns.length === 0
|
|
||||||
? "Load the ledger to view bank-account activity from Zoho."
|
|
||||||
: "No transactions match the current filters."}
|
|
||||||
</CardContent>
|
|
||||||
) : (
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead className="w-12">
|
|
||||||
<Checkbox
|
|
||||||
checked={
|
|
||||||
filtered.filter(eligibleForCheck).length > 0 &&
|
|
||||||
filtered.filter(eligibleForCheck).every((t) => selected.has(t.transaction_id))
|
|
||||||
}
|
|
||||||
onCheckedChange={toggleAllVisible}
|
|
||||||
/>
|
|
||||||
</TableHead>
|
|
||||||
<TableHead>Date</TableHead>
|
|
||||||
<TableHead>Type</TableHead>
|
|
||||||
<TableHead>Reference</TableHead>
|
|
||||||
<TableHead>Payee</TableHead>
|
|
||||||
<TableHead>Description</TableHead>
|
|
||||||
<TableHead className="text-right">In</TableHead>
|
|
||||||
<TableHead className="text-right">Out</TableHead>
|
|
||||||
<TableHead>Status</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{filtered.map((t) => {
|
|
||||||
const eligible = eligibleForCheck(t);
|
|
||||||
const isSel = selected.has(t.transaction_id);
|
|
||||||
return (
|
|
||||||
<TableRow key={t.transaction_id} className={isSel ? "bg-primary/5" : ""}>
|
|
||||||
<TableCell>
|
|
||||||
{eligible ? (
|
|
||||||
<Checkbox checked={isSel} onCheckedChange={() => toggle(t.transaction_id)} />
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-muted-foreground">—</span>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="whitespace-nowrap">{t.date}</TableCell>
|
|
||||||
<TableCell className="capitalize text-xs">
|
|
||||||
{t.transaction_type.replace(/_/g, " ")}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="font-mono text-xs">{t.reference_number || "—"}</TableCell>
|
|
||||||
<TableCell className="font-medium">{t.payee || "—"}</TableCell>
|
|
||||||
<TableCell className="max-w-[280px] truncate" title={t.description || ""}>
|
|
||||||
{t.description || "—"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right text-emerald-600">
|
|
||||||
{t.debit_or_credit === "credit" ? fmt(t.amount) : ""}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right text-red-600">
|
|
||||||
{t.debit_or_credit === "debit" ? fmt(t.amount) : ""}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{t.status ? (
|
|
||||||
<Badge variant="outline" className="text-xs capitalize">{t.status}</Badge>
|
|
||||||
) : "—"}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,915 +1,52 @@
|
|||||||
import { useState, useEffect, useMemo, useCallback, useRef } from "react";
|
import { useMemo } from "react";
|
||||||
import { formatShortMonthDayEST } from "@/lib/timezoneUtils";
|
import { Building2 } from "lucide-react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useAssociation } from "@/contexts/AssociationContext";
|
||||||
import { supabase } from "@/integrations/supabase/client";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
|
||||||
import {
|
|
||||||
DollarSign, TrendingUp, TrendingDown, Wallet, Receipt, RefreshCw,
|
|
||||||
ArrowUpRight, ArrowDownRight, FileText, CreditCard, BarChart3,
|
|
||||||
Download, PieChart, ChevronRight, Calendar, Building2, Printer
|
|
||||||
} from "lucide-react";
|
|
||||||
import { generateFinancialOverviewPdf } from "@/lib/financialOverviewPdf";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import AccountingDashboardPage from "@/pages/accounting/AccountingDashboardPage";
|
||||||
import {
|
|
||||||
BarChart, Bar, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
|
|
||||||
ResponsiveContainer, PieChart as RePieChart, Pie, Cell, Legend, Area, AreaChart
|
|
||||||
} from "recharts";
|
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────
|
/**
|
||||||
const fmt = (n: number) => new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", minimumFractionDigits: 0, maximumFractionDigits: 0 }).format(n);
|
* Financial Overview now renders the same dashboard used inside the Accounting
|
||||||
const fmtFull = (n: number) => new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(n);
|
* module, scoped to a single association. Staff pick the association from the
|
||||||
|
* selector below (synced with the global association picker); board members are
|
||||||
|
* routed through their own scoped wrapper.
|
||||||
|
*/
|
||||||
|
export default function FinancialOverviewPage() {
|
||||||
|
const { associations, selectedAssociation, setSelectedAssociation, loadingAssociations } =
|
||||||
|
useAssociation() as {
|
||||||
|
associations: { id: string; name: string }[];
|
||||||
|
selectedAssociation: { id: string; name: string } | null;
|
||||||
|
setSelectedAssociation: (a: { id: string; name: string } | null) => void;
|
||||||
|
loadingAssociations: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
const sorted = useMemo(
|
||||||
const CHART_COLORS = [
|
() => [...(associations ?? [])].sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
"hsl(224, 76%, 53%)", // primary blue
|
[associations],
|
||||||
"hsl(142, 71%, 45%)", // success green
|
|
||||||
"hsl(38, 92%, 50%)", // warning amber
|
|
||||||
"hsl(0, 72%, 51%)", // destructive red
|
|
||||||
"hsl(262, 52%, 47%)", // purple
|
|
||||||
"hsl(199, 89%, 48%)", // cyan
|
|
||||||
"hsl(25, 95%, 53%)", // orange
|
|
||||||
"hsl(340, 75%, 55%)", // pink
|
|
||||||
];
|
|
||||||
|
|
||||||
// ─── Component ────────────────────────────────────────────────────────
|
|
||||||
export default function FinancialOverviewPage({ boardAssociationIds }: { boardAssociationIds?: string[] } = {}) {
|
|
||||||
const { toast } = useToast();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const isBoardScoped = Array.isArray(boardAssociationIds) && boardAssociationIds.length > 0;
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [associations, setAssociations] = useState<any[]>([]);
|
|
||||||
const [selectedAssocId, setSelectedAssocId] = useState<string>(isBoardScoped && boardAssociationIds!.length === 1 ? boardAssociationIds![0] : "all");
|
|
||||||
|
|
||||||
// Raw data
|
|
||||||
const [bankAccounts, setBankAccounts] = useState<any[]>([]);
|
|
||||||
const [ledgerEntries, setLedgerEntries] = useState<any[]>([]);
|
|
||||||
const [bills, setBills] = useState<any[]>([]);
|
|
||||||
const [transactions, setTransactions] = useState<any[]>([]);
|
|
||||||
const [collections, setCollections] = useState<any[]>([]);
|
|
||||||
const [owners, setOwners] = useState<any[]>([]);
|
|
||||||
|
|
||||||
// ─── Zoho-sourced data (per association) ─────────────────────────
|
|
||||||
// Populated when a specific association is selected AND it has a
|
|
||||||
// zoho_organization_id linked. When present, these override the
|
|
||||||
// locally-derived numbers so the dashboard reflects the books of record.
|
|
||||||
const [zohoLoading, setZohoLoading] = useState(false);
|
|
||||||
const [zohoError, setZohoError] = useState<string | null>(null);
|
|
||||||
const [zohoBanks, setZohoBanks] = useState<any[] | null>(null);
|
|
||||||
const [zohoMonthly, setZohoMonthly] = useState<{ month: string; income: number; expenses: number }[] | null>(null);
|
|
||||||
const [zohoCategories, setZohoCategories] = useState<{ name: string; value: number }[] | null>(null);
|
|
||||||
const [zohoArTotal, setZohoArTotal] = useState<number | null>(null);
|
|
||||||
const [zohoArCount, setZohoArCount] = useState<number | null>(null);
|
|
||||||
|
|
||||||
// ─── Data Loading ────────────────────────────────────────────────
|
|
||||||
const fetchData = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
// Build queries — when board-scoped, restrict everything to the board's associations
|
|
||||||
const assocQ = supabase
|
|
||||||
.from("associations")
|
|
||||||
.select("id, name, logo_url, zoho_organization_id")
|
|
||||||
.eq("status", "active")
|
|
||||||
.order("name");
|
|
||||||
if (isBoardScoped) assocQ.in("id", boardAssociationIds!);
|
|
||||||
|
|
||||||
const bankQ = supabase.from("bank_accounts").select("*").eq("status", "active");
|
|
||||||
if (isBoardScoped) bankQ.in("association_id", boardAssociationIds!);
|
|
||||||
|
|
||||||
const ledgerQ = supabase.from("owner_ledger_entries").select("*").order("date", { ascending: false }).limit(1000);
|
|
||||||
if (isBoardScoped) ledgerQ.in("association_id", boardAssociationIds!);
|
|
||||||
|
|
||||||
const billsQ = supabase.from("bills").select("*").order("bill_date", { ascending: false }).limit(500);
|
|
||||||
if (isBoardScoped) billsQ.in("association_id", boardAssociationIds!);
|
|
||||||
|
|
||||||
const txQ = supabase.from("bank_transactions").select("*").order("date", { ascending: false }).limit(200);
|
|
||||||
if (isBoardScoped) txQ.in("association_id", boardAssociationIds!);
|
|
||||||
|
|
||||||
const colQ = supabase.from("collections").select("id, owner_id, association_id, status, amount_owed, created_at");
|
|
||||||
if (isBoardScoped) colQ.in("association_id", boardAssociationIds!);
|
|
||||||
|
|
||||||
const ownersQ = supabase.from("owners").select("id, first_name, last_name, balance, association_id").eq("status", "active");
|
|
||||||
if (isBoardScoped) ownersQ.in("association_id", boardAssociationIds!);
|
|
||||||
|
|
||||||
const [assocRes, bankRes, ledgerRes, billsRes, txRes, colRes, ownersRes] = await Promise.all([
|
|
||||||
assocQ, bankQ, ledgerQ, billsQ, txQ, colQ, ownersQ,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (assocRes.data) setAssociations(assocRes.data);
|
|
||||||
if (bankRes.data) setBankAccounts(bankRes.data);
|
|
||||||
if (ledgerRes.data) setLedgerEntries(ledgerRes.data);
|
|
||||||
if (billsRes.data) setBills(billsRes.data);
|
|
||||||
if (txRes.data) setTransactions(txRes.data);
|
|
||||||
if (colRes.data) setCollections(colRes.data);
|
|
||||||
if (ownersRes.data) setOwners(ownersRes.data);
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error(err);
|
|
||||||
toast({ variant: "destructive", title: "Error", description: "Failed to load financial data." });
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [toast, isBoardScoped, boardAssociationIds]);
|
|
||||||
|
|
||||||
useEffect(() => { fetchData(); }, [fetchData]);
|
|
||||||
|
|
||||||
// ─── Zoho pull (per-association) ─────────────────────────────────
|
|
||||||
// When a specific association with a Zoho organization is selected, pull
|
|
||||||
// bank balances + monthly P&L + AR aging directly from Zoho Books so the
|
|
||||||
// overview matches the books of record. "All Associations" stays local.
|
|
||||||
const selectedAssoc = useMemo(
|
|
||||||
() => associations.find((a) => a.id === selectedAssocId) || null,
|
|
||||||
[associations, selectedAssocId],
|
|
||||||
);
|
);
|
||||||
const zohoOrgId = selectedAssoc?.zoho_organization_id || null;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Reset whenever the selection changes.
|
|
||||||
setZohoBanks(null);
|
|
||||||
setZohoMonthly(null);
|
|
||||||
setZohoCategories(null);
|
|
||||||
setZohoArTotal(null);
|
|
||||||
setZohoArCount(null);
|
|
||||||
setZohoError(null);
|
|
||||||
if (selectedAssocId === "all" || !zohoOrgId) return;
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
const associationId = selectedAssocId;
|
|
||||||
|
|
||||||
const invoke = async (action: string, params: any) => {
|
|
||||||
const { data, error } = await supabase.functions.invoke("zoho-books", {
|
|
||||||
body: { action, params: { association_id: associationId, ...params } },
|
|
||||||
});
|
|
||||||
if (error) throw error;
|
|
||||||
return (data as any)?.data ?? data;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Recursively walk a Zoho P&L payload, summing leaf account rows by type.
|
|
||||||
const flattenPL = (
|
|
||||||
payload: any,
|
|
||||||
): { income: { name: string; amount: number }[]; expense: { name: string; amount: number }[] } => {
|
|
||||||
const income: { name: string; amount: number }[] = [];
|
|
||||||
const expense: { name: string; amount: number }[] = [];
|
|
||||||
const childKeys = ["account_transactions", "accounts", "sections", "children", "child_accounts", "rows"];
|
|
||||||
const asArr = (x: any) => (Array.isArray(x) ? x : x ? [x] : []);
|
|
||||||
const typeOf = (label: string, fallback: string | null): string | null => {
|
|
||||||
const s = (label || "").toLowerCase();
|
|
||||||
if (/income|revenue/.test(s)) return "income";
|
|
||||||
if (/expense|cost of/.test(s)) return "expense";
|
|
||||||
return fallback;
|
|
||||||
};
|
|
||||||
const walk = (rows: any[], inherited: string | null) => {
|
|
||||||
rows.forEach((row) => {
|
|
||||||
if (!row) return;
|
|
||||||
const label = row.name || row.account_name || row.section_name || "";
|
|
||||||
const t = typeOf(label, inherited);
|
|
||||||
const amt = Math.abs(Number(row.total ?? row.amount ?? row.value ?? 0) || 0);
|
|
||||||
const children = childKeys.flatMap((k) => asArr(row[k]));
|
|
||||||
if (children.length) {
|
|
||||||
walk(children, t);
|
|
||||||
} else if (amt > 0 && t) {
|
|
||||||
(t === "income" ? income : expense).push({ name: label || "Uncategorized", amount: amt });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const entry =
|
|
||||||
payload?.profit_and_loss ??
|
|
||||||
payload?.report?.profit_and_loss ??
|
|
||||||
payload?.sections ??
|
|
||||||
payload?.report?.sections ??
|
|
||||||
payload;
|
|
||||||
walk(asArr(entry), null);
|
|
||||||
return { income, expense };
|
|
||||||
};
|
|
||||||
|
|
||||||
const sumPL = (flat: ReturnType<typeof flattenPL>) => ({
|
|
||||||
income: flat.income.reduce((s, r) => s + r.amount, 0),
|
|
||||||
expenses: flat.expense.reduce((s, r) => s + r.amount, 0),
|
|
||||||
});
|
|
||||||
|
|
||||||
const toIso = (d: Date) => d.toISOString().split("T")[0];
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
setZohoLoading(true);
|
|
||||||
setZohoError(null);
|
|
||||||
try {
|
|
||||||
const now = new Date();
|
|
||||||
const year = now.getFullYear();
|
|
||||||
|
|
||||||
// Kick off everything in parallel.
|
|
||||||
// Zoho rate-limits aggressively (per-minute). Run calls sequentially
|
|
||||||
// with a short stagger to avoid 429 (43) errors that wipe the chart.
|
|
||||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
||||||
const safe = async <T,>(label: string, fn: () => Promise<T>) => {
|
|
||||||
try { return await fn(); } catch (e) {
|
|
||||||
console.warn(`[FinancialOverview] Zoho ${label} failed:`, e);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const banksRes = await safe("banks", () => invoke("list_bank_accounts", {}));
|
|
||||||
if (cancelled) return;
|
|
||||||
await sleep(350);
|
|
||||||
const arRes = await safe("AR aging", () => invoke("get_ar_aging", { date: toIso(now) }));
|
|
||||||
if (cancelled) return;
|
|
||||||
await sleep(350);
|
|
||||||
const annualRes = await safe("annual P&L", () => invoke("get_profit_and_loss", {
|
|
||||||
from_date: `${year}-01-01`,
|
|
||||||
to_date: toIso(now),
|
|
||||||
}));
|
|
||||||
if (cancelled) return;
|
|
||||||
|
|
||||||
const monthlyRes: any[] = [];
|
|
||||||
for (let idx = 0; idx < MONTHS.length; idx++) {
|
|
||||||
const start = new Date(year, idx, 1);
|
|
||||||
const end = new Date(year, idx + 1, 0);
|
|
||||||
if (start > now) { monthlyRes.push(null); continue; }
|
|
||||||
await sleep(350);
|
|
||||||
if (cancelled) return;
|
|
||||||
const r = await safe(`P&L ${MONTHS[idx]}`, () => invoke("get_profit_and_loss", {
|
|
||||||
from_date: toIso(start),
|
|
||||||
to_date: toIso(end),
|
|
||||||
}));
|
|
||||||
monthlyRes.push(r);
|
|
||||||
}
|
|
||||||
if (cancelled) return;
|
|
||||||
|
|
||||||
// Bank accounts
|
|
||||||
if (Array.isArray(banksRes)) {
|
|
||||||
setZohoBanks(banksRes);
|
|
||||||
}
|
|
||||||
|
|
||||||
// AR aging — Zoho returns totals in various shapes; try the common ones.
|
|
||||||
if (arRes) {
|
|
||||||
const tryNumber = (...vals: any[]) => {
|
|
||||||
for (const v of vals) {
|
|
||||||
const n = Number(v);
|
|
||||||
if (isFinite(n) && n !== 0) return n;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
const rows: any[] =
|
|
||||||
arRes?.aging_summary ||
|
|
||||||
arRes?.ar_aging_summary ||
|
|
||||||
arRes?.aragingsummary ||
|
|
||||||
arRes?.rows ||
|
|
||||||
[];
|
|
||||||
const total =
|
|
||||||
tryNumber(arRes?.total, arRes?.total_outstanding, arRes?.summary?.total) ??
|
|
||||||
rows.reduce(
|
|
||||||
(s: number, r: any) =>
|
|
||||||
s + (Number(r?.total) || Number(r?.outstanding) || Number(r?.amount) || 0),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
setZohoArTotal(total || 0);
|
|
||||||
setZohoArCount(Array.isArray(rows) ? rows.length : 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Annual P&L → expense categories
|
|
||||||
if (annualRes) {
|
|
||||||
const flat = flattenPL(annualRes);
|
|
||||||
const cats: Record<string, number> = {};
|
|
||||||
flat.expense.forEach((r) => {
|
|
||||||
cats[r.name] = (cats[r.name] || 0) + r.amount;
|
|
||||||
});
|
|
||||||
const list = Object.entries(cats)
|
|
||||||
.map(([name, value]) => ({ name, value }))
|
|
||||||
.sort((a, b) => b.value - a.value)
|
|
||||||
.slice(0, 8);
|
|
||||||
setZohoCategories(list);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Monthly P&L → chart
|
|
||||||
const chart = MONTHS.map((m, idx) => {
|
|
||||||
const r = monthlyRes?.[idx];
|
|
||||||
if (!r) return { month: m, income: 0, expenses: 0 };
|
|
||||||
const sums = sumPL(flattenPL(r));
|
|
||||||
return { month: m, income: sums.income, expenses: sums.expenses };
|
|
||||||
});
|
|
||||||
setZohoMonthly(chart);
|
|
||||||
} catch (e: any) {
|
|
||||||
if (!cancelled) setZohoError(e?.message || "Zoho fetch failed");
|
|
||||||
} finally {
|
|
||||||
if (!cancelled) setZohoLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
run();
|
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, [selectedAssocId, zohoOrgId]);
|
|
||||||
|
|
||||||
// ─── Filtered Data ───────────────────────────────────────────────
|
|
||||||
const filterByAssoc = useCallback((items: any[]) => {
|
|
||||||
if (selectedAssocId === "all") return items;
|
|
||||||
return items.filter(i => i.association_id === selectedAssocId);
|
|
||||||
}, [selectedAssocId]);
|
|
||||||
|
|
||||||
// ─── Metric Calculations ─────────────────────────────────────────
|
|
||||||
const metrics = useMemo(() => {
|
|
||||||
const filteredBanks = filterByAssoc(bankAccounts);
|
|
||||||
const filteredOwners = filterByAssoc(owners);
|
|
||||||
const filteredBills = filterByAssoc(bills);
|
|
||||||
|
|
||||||
let operatingBalance: number;
|
|
||||||
let reserveBalance: number;
|
|
||||||
if (zohoBanks && zohoBanks.length) {
|
|
||||||
// Zoho returns account_type values like "savings", "checking", "creditcard".
|
|
||||||
// Treat anything with "reserve" in the name/type as a reserve; everything
|
|
||||||
// else (operating cash) rolls into operating balance.
|
|
||||||
const isReserve = (a: any) =>
|
|
||||||
/reserve/i.test(String(a.account_name || a.name || "")) ||
|
|
||||||
/reserve/i.test(String(a.account_type || ""));
|
|
||||||
operatingBalance = zohoBanks
|
|
||||||
.filter((a) => !isReserve(a))
|
|
||||||
.reduce((s, a) => s + (Number(a.balance ?? a.current_balance) || 0), 0);
|
|
||||||
reserveBalance = zohoBanks
|
|
||||||
.filter(isReserve)
|
|
||||||
.reduce((s, a) => s + (Number(a.balance ?? a.current_balance) || 0), 0);
|
|
||||||
} else {
|
|
||||||
operatingBalance = filteredBanks
|
|
||||||
.filter((a) => a.account_category === "operating")
|
|
||||||
.reduce((s, a) => s + (a.current_balance || 0), 0);
|
|
||||||
reserveBalance = filteredBanks
|
|
||||||
.filter((a) => a.account_category === "reserve")
|
|
||||||
.reduce((s, a) => s + (a.current_balance || 0), 0);
|
|
||||||
}
|
|
||||||
const accountsReceivable =
|
|
||||||
zohoArTotal !== null
|
|
||||||
? zohoArTotal
|
|
||||||
: filteredOwners.filter((o) => (o.balance || 0) > 0).reduce((s, o) => s + (o.balance || 0), 0);
|
|
||||||
const accountsPayable = filteredBills.filter(b => b.status === "pending" || b.status === "approved").reduce((s, b) => s + ((b.amount || 0) - (b.amount_paid || 0)), 0);
|
|
||||||
|
|
||||||
return { operatingBalance, reserveBalance, accountsReceivable, accountsPayable };
|
|
||||||
}, [bankAccounts, owners, bills, filterByAssoc, zohoBanks, zohoArTotal]);
|
|
||||||
|
|
||||||
// ─── Monthly Income vs Expenses Chart ────────────────────────────
|
|
||||||
const monthlyChart = useMemo(() => {
|
|
||||||
if (zohoMonthly) return zohoMonthly;
|
|
||||||
const filtered = filterByAssoc(ledgerEntries);
|
|
||||||
const now = new Date();
|
|
||||||
const year = now.getFullYear();
|
|
||||||
|
|
||||||
const data = MONTHS.map((month, idx) => {
|
|
||||||
const monthEntries = filtered.filter(e => {
|
|
||||||
const d = new Date(e.date);
|
|
||||||
return d.getFullYear() === year && d.getMonth() === idx;
|
|
||||||
});
|
|
||||||
const income = monthEntries.reduce((s, e) => s + (e.credit || 0), 0);
|
|
||||||
const expenses = monthEntries.reduce((s, e) => s + (e.debit || 0), 0);
|
|
||||||
return { month, income, expenses };
|
|
||||||
});
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}, [ledgerEntries, filterByAssoc, zohoMonthly]);
|
|
||||||
|
|
||||||
// ─── Delinquency Trend ───────────────────────────────────────────
|
|
||||||
const delinquencyTrend = useMemo(() => {
|
|
||||||
const filtered = filterByAssoc(collections);
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
return Array.from({ length: 6 }, (_, i) => {
|
|
||||||
const d = new Date(now.getFullYear(), now.getMonth() - (5 - i), 1);
|
|
||||||
const monthEnd = new Date(d.getFullYear(), d.getMonth() + 1, 0);
|
|
||||||
const active = filtered.filter(c => {
|
|
||||||
const created = new Date(c.created_at);
|
|
||||||
return created <= monthEnd && c.status !== "resolved";
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
month: MONTHS[d.getMonth()],
|
|
||||||
accounts: active.length,
|
|
||||||
amount: active.reduce((s, c) => s + (c.amount_owed || 0), 0),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}, [collections, filterByAssoc]);
|
|
||||||
|
|
||||||
// ─── Expense Categories Pie ──────────────────────────────────────
|
|
||||||
const expenseCategories = useMemo(() => {
|
|
||||||
if (zohoCategories) return zohoCategories;
|
|
||||||
const filtered = filterByAssoc(ledgerEntries).filter(e => (e.debit || 0) > 0);
|
|
||||||
const cats: Record<string, number> = {};
|
|
||||||
filtered.forEach(e => {
|
|
||||||
const type = e.transaction_type || "Other";
|
|
||||||
const label = type.charAt(0).toUpperCase() + type.slice(1).replace(/_/g, " ");
|
|
||||||
cats[label] = (cats[label] || 0) + (e.debit || 0);
|
|
||||||
});
|
|
||||||
return Object.entries(cats)
|
|
||||||
.map(([name, value]) => ({ name, value }))
|
|
||||||
.sort((a, b) => b.value - a.value)
|
|
||||||
.slice(0, 8);
|
|
||||||
}, [ledgerEntries, filterByAssoc, zohoCategories]);
|
|
||||||
|
|
||||||
// ─── Recent Transactions ─────────────────────────────────────────
|
|
||||||
const recentTransactions = useMemo(() => {
|
|
||||||
return filterByAssoc(transactions).slice(0, 10);
|
|
||||||
}, [transactions, filterByAssoc]);
|
|
||||||
|
|
||||||
// ─── Quick Actions ────────────────────────────────────────────────
|
|
||||||
const quickActions = isBoardScoped
|
|
||||||
? [
|
|
||||||
{ label: "View Reports", icon: BarChart3, path: "/homeowner/board/financial-reports", color: "text-amber-600" },
|
|
||||||
]
|
|
||||||
: [
|
|
||||||
{ label: "Create Invoice", icon: FileText, path: "/dashboard/client-invoices", color: "text-primary" },
|
|
||||||
{ label: "Record Payment", icon: CreditCard, path: "/dashboard/payments", color: "text-emerald-600" },
|
|
||||||
{ label: "Run Report", icon: BarChart3, path: "/dashboard/financial-reports", color: "text-amber-600" },
|
|
||||||
{ label: "Export Financials", icon: Download, path: "/dashboard/reports", color: "text-purple-600" },
|
|
||||||
];
|
|
||||||
|
|
||||||
// ─── Print PDF ────────────────────────────────────────────────────
|
|
||||||
const [printing, setPrinting] = useState(false);
|
|
||||||
const handlePrintPdf = async () => {
|
|
||||||
setPrinting(true);
|
|
||||||
try {
|
|
||||||
const selectedAssoc = associations.find((a) => a.id === selectedAssocId) || null;
|
|
||||||
const scopeLabel =
|
|
||||||
selectedAssocId === "all"
|
|
||||||
? "All Associations"
|
|
||||||
: selectedAssoc?.name || "Selected Association";
|
|
||||||
await generateFinancialOverviewPdf({
|
|
||||||
association: selectedAssoc,
|
|
||||||
scopeLabel,
|
|
||||||
metrics: {
|
|
||||||
...metrics,
|
|
||||||
arAccounts: filterByAssoc(owners).filter((o) => (o.balance || 0) > 0).length,
|
|
||||||
apPending: filterByAssoc(bills).filter((b) => b.status === "pending").length,
|
|
||||||
},
|
|
||||||
monthly: monthlyChart,
|
|
||||||
categories: expenseCategories,
|
|
||||||
delinquency: delinquencyTrend,
|
|
||||||
transactions: recentTransactions,
|
|
||||||
});
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error(err);
|
|
||||||
toast({ variant: "destructive", title: "Export failed", description: err?.message || "Could not generate PDF." });
|
|
||||||
} finally {
|
|
||||||
setPrinting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Print Visual (charts) ────────────────────────────────────────
|
|
||||||
const visualRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const [printingVisual, setPrintingVisual] = useState(false);
|
|
||||||
const handlePrintVisual = async () => {
|
|
||||||
if (!visualRef.current) return;
|
|
||||||
setPrintingVisual(true);
|
|
||||||
try {
|
|
||||||
const [{ default: html2canvas }, { default: jsPDF }] = await Promise.all([
|
|
||||||
import("html2canvas"),
|
|
||||||
import("jspdf"),
|
|
||||||
]);
|
|
||||||
// Allow charts to settle
|
|
||||||
await new Promise((r) => setTimeout(r, 300));
|
|
||||||
const node = visualRef.current;
|
|
||||||
const canvas = await html2canvas(node, {
|
|
||||||
scale: 2,
|
|
||||||
useCORS: true,
|
|
||||||
backgroundColor: "#ffffff",
|
|
||||||
windowWidth: node.scrollWidth,
|
|
||||||
});
|
|
||||||
const pdf = new jsPDF({ unit: "pt", format: "letter", orientation: "landscape" });
|
|
||||||
const pageW = pdf.internal.pageSize.getWidth();
|
|
||||||
const pageH = pdf.internal.pageSize.getHeight();
|
|
||||||
const margin = 24;
|
|
||||||
const usableW = pageW - margin * 2;
|
|
||||||
const ratio = usableW / canvas.width;
|
|
||||||
const fullH = canvas.height * ratio;
|
|
||||||
|
|
||||||
const selectedAssoc = associations.find((a) => a.id === selectedAssocId) || null;
|
|
||||||
const scopeLabel =
|
|
||||||
selectedAssocId === "all" ? "All Associations" : selectedAssoc?.name || "Selected Association";
|
|
||||||
|
|
||||||
const drawHeader = () => {
|
|
||||||
pdf.setFont("helvetica", "bold");
|
|
||||||
pdf.setFontSize(13);
|
|
||||||
pdf.text("Financial Overview", margin, 28);
|
|
||||||
pdf.setFont("helvetica", "normal");
|
|
||||||
pdf.setFontSize(10);
|
|
||||||
pdf.setTextColor(110);
|
|
||||||
pdf.text(scopeLabel, margin, 44);
|
|
||||||
pdf.text(
|
|
||||||
new Date().toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }),
|
|
||||||
pageW - margin,
|
|
||||||
44,
|
|
||||||
{ align: "right" },
|
|
||||||
);
|
|
||||||
pdf.setTextColor(0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const headerH = 56;
|
|
||||||
const contentH = pageH - headerH - margin;
|
|
||||||
// Slice canvas into pages
|
|
||||||
const sliceHpx = contentH / ratio; // px of source per page
|
|
||||||
let y = 0;
|
|
||||||
let page = 0;
|
|
||||||
while (y < canvas.height) {
|
|
||||||
if (page > 0) pdf.addPage();
|
|
||||||
drawHeader();
|
|
||||||
const h = Math.min(sliceHpx, canvas.height - y);
|
|
||||||
const slice = document.createElement("canvas");
|
|
||||||
slice.width = canvas.width;
|
|
||||||
slice.height = h;
|
|
||||||
const ctx = slice.getContext("2d")!;
|
|
||||||
ctx.fillStyle = "#ffffff";
|
|
||||||
ctx.fillRect(0, 0, slice.width, slice.height);
|
|
||||||
ctx.drawImage(canvas, 0, y, canvas.width, h, 0, 0, canvas.width, h);
|
|
||||||
const imgData = slice.toDataURL("image/jpeg", 0.92);
|
|
||||||
pdf.addImage(imgData, "JPEG", margin, headerH, usableW, h * ratio, undefined, "FAST");
|
|
||||||
y += h;
|
|
||||||
page += 1;
|
|
||||||
}
|
|
||||||
const fileName = `${(selectedAssoc?.name || scopeLabel).replace(/[^a-z0-9]+/gi, "_")}_Financial_Overview_Visual_${new Date().toISOString().slice(0, 10)}.pdf`;
|
|
||||||
pdf.save(fileName);
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error(err);
|
|
||||||
toast({ variant: "destructive", title: "Visual export failed", description: err?.message || "Could not generate visual PDF." });
|
|
||||||
} finally {
|
|
||||||
setPrintingVisual(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Custom Tooltip ───────────────────────────────────────────────
|
|
||||||
const ChartTooltip = ({ active, payload, label }: any) => {
|
|
||||||
if (!active || !payload?.length) return null;
|
|
||||||
return (
|
|
||||||
<div className="bg-card border rounded-lg shadow-lg p-3 text-sm">
|
|
||||||
<p className="font-medium text-foreground mb-1">{label}</p>
|
|
||||||
{payload.map((p: any, i: number) => (
|
|
||||||
<div key={i} className="flex items-center gap-2">
|
|
||||||
<div className="h-2 w-2 rounded-full" style={{ backgroundColor: p.color }} />
|
|
||||||
<span className="text-muted-foreground">{p.name}:</span>
|
|
||||||
<span className="font-medium text-foreground">{fmtFull(p.value)}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Render ──────────────────────────────────────────────────────
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center min-h-[60vh]">
|
|
||||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4">
|
||||||
{/* ── Header ─────────────────────────────────────────────────── */}
|
<div className="flex items-center justify-end">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
<Select
|
||||||
<div>
|
value={selectedAssociation?.id ?? ""}
|
||||||
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
|
onValueChange={(id) => {
|
||||||
<Wallet className="h-6 w-6 text-primary" /> Financial Overview
|
const next = sorted.find((a) => a.id === id) ?? null;
|
||||||
</h1>
|
setSelectedAssociation(next);
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
}}
|
||||||
Association financial health at a glance.
|
>
|
||||||
</p>
|
<SelectTrigger className="w-[240px]">
|
||||||
</div>
|
<Building2 className="h-4 w-4 mr-2 text-muted-foreground" />
|
||||||
<div className="flex items-center gap-3">
|
<SelectValue placeholder={loadingAssociations ? "Loading…" : "Select association"} />
|
||||||
<Select value={selectedAssocId} onValueChange={setSelectedAssocId}>
|
</SelectTrigger>
|
||||||
<SelectTrigger className="w-[220px]">
|
<SelectContent>
|
||||||
<Building2 className="h-4 w-4 mr-2 text-muted-foreground" />
|
{sorted.map((a) => (
|
||||||
<SelectValue placeholder={isBoardScoped && associations.length === 1 ? associations[0]?.name : "All Associations"} />
|
<SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{(!isBoardScoped || associations.length > 1) && (
|
|
||||||
<SelectItem value="all">All Associations</SelectItem>
|
|
||||||
)}
|
|
||||||
{associations.map(a => (
|
|
||||||
<SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Button variant="outline" size="sm" onClick={fetchData}>
|
|
||||||
<RefreshCw className="h-4 w-4 mr-2" /> Refresh
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" onClick={handlePrintPdf} disabled={printing}>
|
|
||||||
<Printer className="h-4 w-4 mr-2" /> {printing ? "Generating…" : "Print PDF"}
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="outline" onClick={handlePrintVisual} disabled={printingVisual}>
|
|
||||||
<BarChart3 className="h-4 w-4 mr-2" /> {printingVisual ? "Capturing…" : "Print Visual"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div ref={visualRef} className="space-y-6 bg-background">
|
|
||||||
{selectedAssocId !== "all" && (
|
|
||||||
<div className="flex items-center gap-2 text-xs">
|
|
||||||
{zohoOrgId ? (
|
|
||||||
zohoLoading ? (
|
|
||||||
<Badge variant="outline" className="text-muted-foreground">Loading from Zoho Books…</Badge>
|
|
||||||
) : zohoError ? (
|
|
||||||
<Badge variant="outline" className="text-amber-600 border-amber-500/40">
|
|
||||||
Zoho pull failed — showing local data
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge variant="outline" className="text-emerald-700 border-emerald-500/40">
|
|
||||||
Live from Zoho Books
|
|
||||||
</Badge>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<Badge variant="outline" className="text-muted-foreground">
|
|
||||||
No Zoho organization linked — showing local data
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Top Metrics ────────────────────────────────────────────── */}
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
<MetricCard
|
|
||||||
title="Operating Balance"
|
|
||||||
value={fmt(metrics.operatingBalance)}
|
|
||||||
icon={<Wallet className="h-5 w-5" />}
|
|
||||||
trend={metrics.operatingBalance >= 0 ? "positive" : "negative"}
|
|
||||||
iconBg="bg-primary/10 text-primary"
|
|
||||||
/>
|
|
||||||
<MetricCard
|
|
||||||
title="Reserve Balance"
|
|
||||||
value={fmt(metrics.reserveBalance)}
|
|
||||||
icon={<DollarSign className="h-5 w-5" />}
|
|
||||||
trend="neutral"
|
|
||||||
iconBg="bg-emerald-500/10 text-emerald-600"
|
|
||||||
/>
|
|
||||||
<MetricCard
|
|
||||||
title="Accounts Receivable"
|
|
||||||
value={fmt(metrics.accountsReceivable)}
|
|
||||||
icon={<TrendingUp className="h-5 w-5" />}
|
|
||||||
trend={metrics.accountsReceivable > 0 ? "warning" : "positive"}
|
|
||||||
subtitle={`${
|
|
||||||
zohoArCount !== null
|
|
||||||
? zohoArCount
|
|
||||||
: filterByAssoc(owners).filter((o) => (o.balance || 0) > 0).length
|
|
||||||
} accounts`}
|
|
||||||
iconBg="bg-amber-500/10 text-amber-600"
|
|
||||||
/>
|
|
||||||
<MetricCard
|
|
||||||
title="Accounts Payable"
|
|
||||||
value={fmt(metrics.accountsPayable)}
|
|
||||||
icon={<Receipt className="h-5 w-5" />}
|
|
||||||
trend="neutral"
|
|
||||||
subtitle={`${filterByAssoc(bills).filter(b => b.status === "pending").length} pending`}
|
|
||||||
iconBg="bg-purple-500/10 text-purple-600"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Charts Row ──────────────────────────────────────────────── */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
|
||||||
{/* Income vs Expenses */}
|
|
||||||
<Card className="lg:col-span-2">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<CardTitle className="text-sm font-semibold text-foreground">Income vs Expenses</CardTitle>
|
|
||||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
<span className="h-2 w-2 rounded-full bg-primary" /> Income
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
<span className="h-2 w-2 rounded-full bg-destructive/70" /> Expenses
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="h-[260px]">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<BarChart data={monthlyChart} barGap={2}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(220, 13%, 91%)" vertical={false} />
|
|
||||||
<XAxis dataKey="month" tickLine={false} axisLine={false} tick={{ fill: "hsl(215, 16%, 47%)", fontSize: 12 }} />
|
|
||||||
<YAxis tickLine={false} axisLine={false} tick={{ fill: "hsl(215, 16%, 47%)", fontSize: 12 }} tickFormatter={(v) => `$${(v / 1000).toFixed(0)}k`} />
|
|
||||||
<Tooltip content={<ChartTooltip />} />
|
|
||||||
<Bar dataKey="income" name="Income" fill="hsl(224, 76%, 53%)" radius={[4, 4, 0, 0]} maxBarSize={28} />
|
|
||||||
<Bar dataKey="expenses" name="Expenses" fill="hsl(0, 72%, 51%, 0.7)" radius={[4, 4, 0, 0]} maxBarSize={28} />
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Expense Categories */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-sm font-semibold text-foreground">Expense Categories</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="h-[260px]">
|
|
||||||
{expenseCategories.length > 0 ? (
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<RePieChart>
|
|
||||||
<Pie
|
|
||||||
data={expenseCategories}
|
|
||||||
cx="50%"
|
|
||||||
cy="45%"
|
|
||||||
innerRadius={50}
|
|
||||||
outerRadius={80}
|
|
||||||
paddingAngle={2}
|
|
||||||
dataKey="value"
|
|
||||||
>
|
|
||||||
{expenseCategories.map((_, i) => (
|
|
||||||
<Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} />
|
|
||||||
))}
|
|
||||||
</Pie>
|
|
||||||
<Tooltip formatter={(v: number) => fmtFull(v)} />
|
|
||||||
<Legend
|
|
||||||
layout="horizontal"
|
|
||||||
verticalAlign="bottom"
|
|
||||||
iconType="circle"
|
|
||||||
iconSize={8}
|
|
||||||
formatter={(value: string) => (
|
|
||||||
<span className="text-xs text-muted-foreground">{value}</span>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</RePieChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center justify-center h-full text-sm text-muted-foreground">
|
|
||||||
No expense data available
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Delinquency Trend ───────────────────────────────────────── */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<CardTitle className="text-sm font-semibold text-foreground">Delinquency Trend (6 Months)</CardTitle>
|
|
||||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
<span className="h-2 w-2 rounded-full bg-destructive" /> Amount
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
<span className="h-2 w-2 rounded-full bg-amber-500" /> Accounts
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="h-[200px]">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<AreaChart data={delinquencyTrend}>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="delinqGradient" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="0%" stopColor="hsl(0, 72%, 51%)" stopOpacity={0.2} />
|
|
||||||
<stop offset="100%" stopColor="hsl(0, 72%, 51%)" stopOpacity={0} />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(220, 13%, 91%)" vertical={false} />
|
|
||||||
<XAxis dataKey="month" tickLine={false} axisLine={false} tick={{ fill: "hsl(215, 16%, 47%)", fontSize: 12 }} />
|
|
||||||
<YAxis yAxisId="amount" tickLine={false} axisLine={false} tick={{ fill: "hsl(215, 16%, 47%)", fontSize: 12 }} tickFormatter={(v) => `$${(v / 1000).toFixed(0)}k`} />
|
|
||||||
<YAxis yAxisId="count" orientation="right" tickLine={false} axisLine={false} tick={{ fill: "hsl(215, 16%, 47%)", fontSize: 12 }} />
|
|
||||||
<Tooltip content={<ChartTooltip />} />
|
|
||||||
<Area yAxisId="amount" type="monotone" dataKey="amount" name="Amount" stroke="hsl(0, 72%, 51%)" fill="url(#delinqGradient)" strokeWidth={2} />
|
|
||||||
<Line yAxisId="count" type="monotone" dataKey="accounts" name="Accounts" stroke="hsl(38, 92%, 50%)" strokeWidth={2} dot={{ r: 3, fill: "hsl(38, 92%, 50%)" }} />
|
|
||||||
</AreaChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* ── Bottom Row: Transactions + Quick Actions ─────────────── */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4">
|
|
||||||
{/* Recent Transactions */}
|
|
||||||
<Card className="lg:col-span-3 overflow-hidden">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<CardTitle className="text-sm font-semibold text-foreground">Recent Transactions</CardTitle>
|
|
||||||
<Button variant="ghost" size="sm" className="text-xs text-muted-foreground" onClick={() => navigate("/dashboard/payments")}>
|
|
||||||
View All <ChevronRight className="h-3 w-3 ml-1" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
{recentTransactions.length > 0 ? (
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow className="bg-muted/30">
|
|
||||||
<TableHead className="text-xs">Date</TableHead>
|
|
||||||
<TableHead className="text-xs">Description</TableHead>
|
|
||||||
<TableHead className="text-xs">Type</TableHead>
|
|
||||||
<TableHead className="text-xs text-right">Amount</TableHead>
|
|
||||||
<TableHead className="text-xs text-center">Status</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{recentTransactions.map(tx => {
|
|
||||||
const isCredit = (tx.credit || 0) > 0;
|
|
||||||
const amount = isCredit ? tx.credit : tx.debit;
|
|
||||||
return (
|
|
||||||
<TableRow key={tx.id} className="text-sm">
|
|
||||||
<TableCell className="py-2.5">
|
|
||||||
<span className="text-muted-foreground">{formatShortMonthDayEST(tx.date)}</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="py-2.5">
|
|
||||||
<span className="font-medium text-foreground truncate block max-w-[280px]">
|
|
||||||
{tx.description || "Transaction"}
|
|
||||||
</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="py-2.5">
|
|
||||||
<Badge variant="outline" className="text-[10px] font-medium">
|
|
||||||
{(tx.transaction_type || "other").replace(/_/g, " ")}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="py-2.5 text-right">
|
|
||||||
<span className={`font-semibold ${isCredit ? "text-emerald-600" : "text-foreground"}`}>
|
|
||||||
{isCredit ? "+" : "-"}{fmtFull(amount || 0)}
|
|
||||||
</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="py-2.5 text-center">
|
|
||||||
<Badge
|
|
||||||
className={`text-[10px] border-0 ${
|
|
||||||
tx.is_cleared
|
|
||||||
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400"
|
|
||||||
: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{tx.is_cleared ? "Cleared" : "Pending"}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
) : (
|
|
||||||
<div className="py-12 text-center text-sm text-muted-foreground">
|
|
||||||
No recent transactions found.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Quick Actions */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="text-sm font-semibold text-foreground">Quick Actions</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-2">
|
|
||||||
{quickActions.map(action => (
|
|
||||||
<button
|
|
||||||
key={action.label}
|
|
||||||
onClick={() => navigate(action.path)}
|
|
||||||
className="w-full flex items-center gap-3 p-3 rounded-lg border bg-card hover:bg-muted/50 transition-colors text-left group"
|
|
||||||
>
|
|
||||||
<div className={`h-9 w-9 rounded-lg bg-muted flex items-center justify-center ${action.color} group-hover:scale-105 transition-transform`}>
|
|
||||||
<action.icon className="h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-sm font-medium text-foreground">{action.label}</p>
|
|
||||||
</div>
|
|
||||||
<ChevronRight className="h-4 w-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity" />
|
|
||||||
</button>
|
|
||||||
))}
|
))}
|
||||||
</CardContent>
|
</SelectContent>
|
||||||
</Card>
|
</Select>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AccountingDashboardPage association={selectedAssociation} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Metric Card ──────────────────────────────────────────────────────
|
|
||||||
function MetricCard({ title, value, icon, trend, subtitle, iconBg }: {
|
|
||||||
title: string;
|
|
||||||
value: string;
|
|
||||||
icon: React.ReactNode;
|
|
||||||
trend: "positive" | "negative" | "warning" | "neutral";
|
|
||||||
subtitle?: string;
|
|
||||||
iconBg: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Card className="relative overflow-hidden">
|
|
||||||
<CardContent className="p-5">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{title}</p>
|
|
||||||
<p className={`text-2xl font-bold ${
|
|
||||||
trend === "negative" ? "text-destructive" :
|
|
||||||
trend === "warning" ? "text-amber-600" :
|
|
||||||
trend === "positive" ? "text-emerald-600" :
|
|
||||||
"text-foreground"
|
|
||||||
}`}>
|
|
||||||
{value}
|
|
||||||
</p>
|
|
||||||
{subtitle && <p className="text-xs text-muted-foreground">{subtitle}</p>}
|
|
||||||
</div>
|
|
||||||
<div className={`h-10 w-10 rounded-xl flex items-center justify-center ${iconBg}`}>
|
|
||||||
{icon}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
{/* Subtle accent bar */}
|
|
||||||
<div className={`absolute bottom-0 left-0 right-0 h-0.5 ${
|
|
||||||
trend === "negative" ? "bg-destructive/40" :
|
|
||||||
trend === "warning" ? "bg-amber-500/40" :
|
|
||||||
trend === "positive" ? "bg-emerald-500/40" :
|
|
||||||
"bg-primary/20"
|
|
||||||
}`} />
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { Building2 } from "lucide-react";
|
||||||
|
import { useAssociation } from "@/contexts/AssociationContext";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import AccountingReportsPage from "@/pages/accounting/AccountingReportsPage";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Financial Reports now renders the same reporting suite used inside the
|
||||||
|
* Accounting module, scoped to a single association. Staff pick the association
|
||||||
|
* from the selector below (synced with the global association picker); board
|
||||||
|
* members are routed through their own scoped wrapper.
|
||||||
|
*/
|
||||||
|
export default function FinancialReportsPage() {
|
||||||
|
const { associations, selectedAssociation, setSelectedAssociation, loadingAssociations } =
|
||||||
|
useAssociation() as {
|
||||||
|
associations: { id: string; name: string }[];
|
||||||
|
selectedAssociation: { id: string; name: string } | null;
|
||||||
|
setSelectedAssociation: (a: { id: string; name: string } | null) => void;
|
||||||
|
loadingAssociations: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sorted = useMemo(
|
||||||
|
() => [...(associations ?? [])].sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
|
[associations],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
<Select
|
||||||
|
value={selectedAssociation?.id ?? ""}
|
||||||
|
onValueChange={(id) => {
|
||||||
|
const next = sorted.find((a) => a.id === id) ?? null;
|
||||||
|
setSelectedAssociation(next);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[240px]">
|
||||||
|
<Building2 className="h-4 w-4 mr-2 text-muted-foreground" />
|
||||||
|
<SelectValue placeholder={loadingAssociations ? "Loading…" : "Select association"} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{sorted.map((a) => (
|
||||||
|
<SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AccountingReportsPage association={selectedAssociation} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,7 +15,6 @@ import { Textarea } from "@/components/ui/textarea";
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||||
import { pushBillToZohoAfterCreate } from "@/lib/zohoBillSync";
|
|
||||||
|
|
||||||
const statusColors: Record<string, string> = { pending: "bg-amber-100 text-amber-700", approved: "bg-blue-100 text-blue-700", paid: "bg-emerald-100 text-emerald-700", overdue: "bg-red-100 text-red-700" };
|
const statusColors: Record<string, string> = { pending: "bg-amber-100 text-amber-700", approved: "bg-blue-100 text-blue-700", paid: "bg-emerald-100 text-emerald-700", overdue: "bg-red-100 text-red-700" };
|
||||||
|
|
||||||
@@ -114,8 +113,7 @@ export default function InvoiceTrackingPage() {
|
|||||||
if (editing) {
|
if (editing) {
|
||||||
const { data: updatedInvoice, error } = await supabase.from("invoices").update(payload).eq("id", editing.id).select().single();
|
const { data: updatedInvoice, error } = await supabase.from("invoices").update(payload).eq("id", editing.id).select().single();
|
||||||
if (error) { toast({ title: "Error", description: error.message, variant: "destructive" }); return; }
|
if (error) { toast({ title: "Error", description: error.message, variant: "destructive" }); return; }
|
||||||
const billId = await syncInvoiceBillRecord(updatedInvoice);
|
await syncInvoiceBillRecord(updatedInvoice);
|
||||||
if (billId) pushBillToZohoAfterCreate(billId);
|
|
||||||
toast({ title: "Invoice updated" });
|
toast({ title: "Invoice updated" });
|
||||||
} else {
|
} else {
|
||||||
const { data: assocs } = await supabase.from("associations").select("id").eq("status", "active").limit(1);
|
const { data: assocs } = await supabase.from("associations").select("id").eq("status", "active").limit(1);
|
||||||
@@ -123,8 +121,7 @@ export default function InvoiceTrackingPage() {
|
|||||||
const { data: userData } = await supabase.auth.getUser();
|
const { data: userData } = await supabase.auth.getUser();
|
||||||
const { data: newInvoice, error } = await supabase.from("invoices").insert({ ...payload, association_id: assocs[0].id, created_by: userData?.user?.id || null }).select().single();
|
const { data: newInvoice, error } = await supabase.from("invoices").insert({ ...payload, association_id: assocs[0].id, created_by: userData?.user?.id || null }).select().single();
|
||||||
if (error) { toast({ title: "Error", description: error.message, variant: "destructive" }); return; }
|
if (error) { toast({ title: "Error", description: error.message, variant: "destructive" }); return; }
|
||||||
const billId = await syncInvoiceBillRecord(newInvoice);
|
await syncInvoiceBillRecord(newInvoice);
|
||||||
if (billId) pushBillToZohoAfterCreate(billId);
|
|
||||||
toast({ title: "Invoice created" });
|
toast({ title: "Invoice created" });
|
||||||
}
|
}
|
||||||
setDialogOpen(false); fetch();
|
setDialogOpen(false); fetch();
|
||||||
@@ -229,8 +226,7 @@ export default function InvoiceTrackingPage() {
|
|||||||
const { data: insertedInvoices, error } = await supabase.from("invoices").insert(payload).select();
|
const { data: insertedInvoices, error } = await supabase.from("invoices").insert(payload).select();
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
for (const invoice of insertedInvoices || []) {
|
for (const invoice of insertedInvoices || []) {
|
||||||
const billId = await syncInvoiceBillRecord(invoice);
|
await syncInvoiceBillRecord(invoice);
|
||||||
if (billId) pushBillToZohoAfterCreate(billId);
|
|
||||||
}
|
}
|
||||||
toast({ title: `Imported ${rows.length} invoices` });
|
toast({ title: `Imported ${rows.length} invoices` });
|
||||||
fetch();
|
fetch();
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useState, useEffect, useMemo } from "react";
|
import { useState, useEffect, useMemo } from "react";
|
||||||
import { supabase } from "@/integrations/supabase/client";
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { syncChargeToZoho } from "@/lib/zohoFinancialSync";
|
|
||||||
import { BookOpen, Plus, Search, Download, DollarSign } from "lucide-react";
|
import { BookOpen, Plus, Search, Download, DollarSign } from "lucide-react";
|
||||||
import UnitOwnerSelect from "@/components/UnitOwnerSelect";
|
import UnitOwnerSelect from "@/components/UnitOwnerSelect";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -108,7 +107,7 @@ export default function OwnerLedgerPage() {
|
|||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (!selectedOwnerId) return;
|
if (!selectedOwnerId) return;
|
||||||
const owner = owners.find(o => o.id === selectedOwnerId);
|
const owner = owners.find(o => o.id === selectedOwnerId);
|
||||||
const { data: ledgerEntry } = await supabase.from("owner_ledger_entries").insert({
|
await supabase.from("owner_ledger_entries").insert({
|
||||||
owner_id: selectedOwnerId,
|
owner_id: selectedOwnerId,
|
||||||
association_id: owner?.association_id,
|
association_id: owner?.association_id,
|
||||||
unit_id: form.unit_id || owner?.unit_id || null,
|
unit_id: form.unit_id || owner?.unit_id || null,
|
||||||
@@ -117,7 +116,7 @@ export default function OwnerLedgerPage() {
|
|||||||
description: form.description,
|
description: form.description,
|
||||||
debit: parseFloat(form.debit) || 0,
|
debit: parseFloat(form.debit) || 0,
|
||||||
credit: parseFloat(form.credit) || 0,
|
credit: parseFloat(form.credit) || 0,
|
||||||
}).select("id").single();
|
});
|
||||||
|
|
||||||
toast({ title: "Ledger entry added" });
|
toast({ title: "Ledger entry added" });
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
@@ -126,28 +125,6 @@ export default function OwnerLedgerPage() {
|
|||||||
// Update owner balance
|
// Update owner balance
|
||||||
const newBal = currentBalance + (parseFloat(form.debit) || 0) - (parseFloat(form.credit) || 0);
|
const newBal = currentBalance + (parseFloat(form.debit) || 0) - (parseFloat(form.credit) || 0);
|
||||||
await supabase.from("owners").update({ balance: newBal }).eq("id", selectedOwnerId);
|
await supabase.from("owners").update({ balance: newBal }).eq("id", selectedOwnerId);
|
||||||
|
|
||||||
// Auto-sync to Zoho if association has zoho_organization_id
|
|
||||||
if (ledgerEntry?.id && owner?.association_id) {
|
|
||||||
const { data: assoc } = await supabase.from("associations").select("zoho_organization_id").eq("id", owner.association_id).single();
|
|
||||||
if (assoc?.zoho_organization_id) {
|
|
||||||
const isCharge = (parseFloat(form.debit) || 0) > 0;
|
|
||||||
const isPayment = (parseFloat(form.credit) || 0) > 0;
|
|
||||||
if (isCharge) {
|
|
||||||
syncChargeToZoho(ledgerEntry.id).then(r => {
|
|
||||||
if (r.success) toast({ title: "Synced to Zoho", description: "Charge pushed as invoice." });
|
|
||||||
else console.error("Zoho sync failed:", r.error);
|
|
||||||
});
|
|
||||||
} else if (isPayment) {
|
|
||||||
supabase.functions.invoke("zoho-books", {
|
|
||||||
body: { action: "push_ledger_payment", params: { ledger_entry_id: ledgerEntry.id } },
|
|
||||||
}).then(({ error }) => {
|
|
||||||
if (!error) toast({ title: "Synced to Zoho", description: "Payment pushed." });
|
|
||||||
else console.error("Zoho payment sync failed:", error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const exportCSV = async () => {
|
const exportCSV = async () => {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { supabase } from "@/integrations/supabase/client";
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
import { syncPaymentToZoho } from "@/lib/zohoFinancialSync";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { CreditCard, Plus, Search, MoreHorizontal, Edit, Trash2, Receipt } from "lucide-react";
|
import { CreditCard, Plus, Search, MoreHorizontal, Edit, Trash2, Receipt } from "lucide-react";
|
||||||
import RecordImportButton from "@/components/RecordImportButton";
|
import RecordImportButton from "@/components/RecordImportButton";
|
||||||
@@ -108,15 +107,10 @@ export default function PaymentsPage() {
|
|||||||
const { error } = await supabase.from("admin_payments").update(payload).eq("id", editingPayment.id);
|
const { error } = await supabase.from("admin_payments").update(payload).eq("id", editingPayment.id);
|
||||||
if (error) { toast({ title: "Error", description: error.message, variant: "destructive" }); setSaving(false); return; }
|
if (error) { toast({ title: "Error", description: error.message, variant: "destructive" }); setSaving(false); return; }
|
||||||
toast({ title: "Payment updated" });
|
toast({ title: "Payment updated" });
|
||||||
// Best-effort: push to Zoho (records into banking journal)
|
|
||||||
syncPaymentToZoho(editingPayment.id).catch((e) => console.warn("Zoho payment sync failed:", e));
|
|
||||||
} else {
|
} else {
|
||||||
const { data: inserted, error } = await supabase.from("admin_payments").insert(payload).select("id").single();
|
const { error } = await supabase.from("admin_payments").insert(payload).select("id").single();
|
||||||
if (error) { toast({ title: "Error", description: error.message, variant: "destructive" }); setSaving(false); return; }
|
if (error) { toast({ title: "Error", description: error.message, variant: "destructive" }); setSaving(false); return; }
|
||||||
toast({ title: "Payment recorded" });
|
toast({ title: "Payment recorded" });
|
||||||
if (inserted?.id) {
|
|
||||||
syncPaymentToZoho(inserted.id).catch((e) => console.warn("Zoho payment sync failed:", e));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
|
|||||||
@@ -93,19 +93,6 @@ export default function RecordOwnerPaymentPage() {
|
|||||||
|
|
||||||
toast({ title: "Payment recorded successfully" });
|
toast({ title: "Payment recorded successfully" });
|
||||||
|
|
||||||
// Auto-sync to Zoho if association has zoho_organization_id
|
|
||||||
if (ledgerEntry?.id && owner?.association_id) {
|
|
||||||
const { data: assoc } = await supabase.from("associations").select("zoho_organization_id").eq("id", owner.association_id).single();
|
|
||||||
if (assoc?.zoho_organization_id) {
|
|
||||||
supabase.functions.invoke("zoho-books", {
|
|
||||||
body: { action: "push_ledger_payment", params: { ledger_entry_id: ledgerEntry.id } },
|
|
||||||
}).then(({ error }) => {
|
|
||||||
if (!error) toast({ title: "Synced to Zoho", description: "Payment pushed to Zoho Books." });
|
|
||||||
else console.error("Zoho payment sync failed:", error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setForm({ ...form, owner_id: "", amount: "", reference_number: "", description: "" });
|
setForm({ ...form, owner_id: "", amount: "", reference_number: "", description: "" });
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|||||||
import VendorCoaMappings from "@/components/vendors/VendorCoaMappings";
|
import VendorCoaMappings from "@/components/vendors/VendorCoaMappings";
|
||||||
import { BulkVendorEditDialog } from "@/components/vendors/BulkVendorEditDialog";
|
import { BulkVendorEditDialog } from "@/components/vendors/BulkVendorEditDialog";
|
||||||
|
|
||||||
const emptyForm = { name: "", contact_name: "", email: "", phone: "", address: "", tax_id: "", payment_terms: "30", notes: "", association_id: "", association_ids: [] as string[], is_active: true, is_1099_eligible: false, contract_end_date: "", insurance_carrier: "", insurance_policy_number: "", insurance_expiration_date: "" };
|
const emptyForm = { name: "", contact_name: "", email: "", phone: "", address: "", tax_id: "", payment_terms: "30", notes: "", association_id: "", association_ids: [] as string[], is_active: true, is_1099_eligible: false, share_with_board: false, contract_end_date: "", insurance_carrier: "", insurance_policy_number: "", insurance_expiration_date: "" };
|
||||||
|
|
||||||
// Insurance status: active if expiration date is today or later
|
// Insurance status: active if expiration date is today or later
|
||||||
function getInsuranceStatus(v: any) {
|
function getInsuranceStatus(v: any) {
|
||||||
@@ -110,6 +110,7 @@ export default function VendorsPage() {
|
|||||||
association_ids: ids,
|
association_ids: ids,
|
||||||
is_active: v.is_active,
|
is_active: v.is_active,
|
||||||
is_1099_eligible: v.is_1099_eligible || false,
|
is_1099_eligible: v.is_1099_eligible || false,
|
||||||
|
share_with_board: v.share_with_board || false,
|
||||||
contract_end_date: v.contract_end_date || "",
|
contract_end_date: v.contract_end_date || "",
|
||||||
insurance_carrier: v.insurance_carrier || "",
|
insurance_carrier: v.insurance_carrier || "",
|
||||||
insurance_policy_number: v.insurance_policy_number || "",
|
insurance_policy_number: v.insurance_policy_number || "",
|
||||||
@@ -136,10 +137,10 @@ export default function VendorsPage() {
|
|||||||
const primary = form.association_ids.includes(form.association_id) ? form.association_id : form.association_ids[0];
|
const primary = form.association_ids.includes(form.association_id) ? form.association_id : form.association_ids[0];
|
||||||
const payload = { ...form, association_id: primary, contract_end_date: form.contract_end_date || null, insurance_expiration_date: form.insurance_expiration_date || null, insurance_carrier: form.insurance_carrier || null, insurance_policy_number: form.insurance_policy_number || null };
|
const payload = { ...form, association_id: primary, contract_end_date: form.contract_end_date || null, insurance_expiration_date: form.insurance_expiration_date || null, insurance_carrier: form.insurance_carrier || null, insurance_policy_number: form.insurance_policy_number || null };
|
||||||
if (editing) {
|
if (editing) {
|
||||||
await supabase.from("vendors").update(payload).eq("id", editing.id);
|
await supabase.from("vendors").update(payload as any).eq("id", editing.id);
|
||||||
toast({ title: "Vendor updated" });
|
toast({ title: "Vendor updated" });
|
||||||
} else {
|
} else {
|
||||||
await supabase.from("vendors").insert(payload);
|
await supabase.from("vendors").insert(payload as any);
|
||||||
toast({ title: "Vendor created" });
|
toast({ title: "Vendor created" });
|
||||||
}
|
}
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
@@ -400,9 +401,14 @@ export default function VendorsPage() {
|
|||||||
})()}
|
})()}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant="outline" className={v.is_active ? "bg-emerald-100 text-emerald-700" : "bg-muted text-muted-foreground"}>
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
{v.is_active ? "Active" : "Inactive"}
|
<Badge variant="outline" className={v.is_active ? "bg-emerald-100 text-emerald-700" : "bg-muted text-muted-foreground"}>
|
||||||
</Badge>
|
{v.is_active ? "Active" : "Inactive"}
|
||||||
|
</Badge>
|
||||||
|
{v.share_with_board && (
|
||||||
|
<Badge variant="outline" className="bg-blue-100 text-blue-700 border-blue-200">Shared with board</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@@ -521,6 +527,14 @@ export default function VendorsPage() {
|
|||||||
/>
|
/>
|
||||||
<Label htmlFor="is_1099_eligible">1099 Eligible</Label>
|
<Label htmlFor="is_1099_eligible">1099 Eligible</Label>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="share_with_board"
|
||||||
|
checked={form.share_with_board}
|
||||||
|
onCheckedChange={(checked) => setForm({ ...form, share_with_board: !!checked })}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="share_with_board">Share contact info with board members (shows in the directory)</Label>
|
||||||
|
</div>
|
||||||
<div><Label>Notes</Label><Textarea value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} rows={2} /></div>
|
<div><Label>Notes</Label><Textarea value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} rows={2} /></div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter><Button onClick={handleSave}>{editing ? "Update" : "Create"}</Button></DialogFooter>
|
<DialogFooter><Button onClick={handleSave}>{editing ? "Update" : "Create"}</Button></DialogFooter>
|
||||||
|
|||||||
@@ -1,709 +0,0 @@
|
|||||||
import { useState, useEffect } from "react";
|
|
||||||
import { supabase } from "@/integrations/supabase/client";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
|
||||||
import {
|
|
||||||
BarChart3, Download, Loader2, DollarSign, FileText, TrendingUp,
|
|
||||||
Users, Calendar as CalendarIcon, ChevronRight
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import {
|
|
||||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import {
|
|
||||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { format, startOfMonth, endOfMonth, subMonths } from "date-fns";
|
|
||||||
import {
|
|
||||||
generateProfitLossPdf,
|
|
||||||
generateBalanceSheetPdf,
|
|
||||||
generateARAgingPdf,
|
|
||||||
generateBudgetVsActualPdf,
|
|
||||||
} from "@/lib/zohoFinancialReportPdf";
|
|
||||||
|
|
||||||
type ReportType = "profit_loss" | "balance_sheet" | "ar_aging" | "budget_vs_actual";
|
|
||||||
|
|
||||||
const REPORT_META: Record<ReportType, { label: string; icon: React.ElementType; description: string }> = {
|
|
||||||
profit_loss: { label: "Profit & Loss", icon: TrendingUp, description: "Revenue and expenses for a period" },
|
|
||||||
balance_sheet: { label: "Balance Sheet", icon: FileText, description: "Assets, liabilities, and equity snapshot" },
|
|
||||||
ar_aging: { label: "AR Aging", icon: Users, description: "Outstanding receivable balances by aging" },
|
|
||||||
budget_vs_actual: { label: "Budget vs Actual", icon: BarChart3, description: "Compare budgeted to actual amounts" },
|
|
||||||
};
|
|
||||||
|
|
||||||
const fmt = (n: number | string | undefined | null) => {
|
|
||||||
const v = typeof n === "string" ? parseFloat(n) : n;
|
|
||||||
if (v == null || isNaN(v)) return "$0.00";
|
|
||||||
return v.toLocaleString("en-US", { style: "currency", currency: "USD" });
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ZohoFinancialReportsPage({ boardAssociationIds }: { boardAssociationIds?: string[] } = {}) {
|
|
||||||
const { toast } = useToast();
|
|
||||||
const now = new Date();
|
|
||||||
const [associations, setAssociations] = useState<any[]>([]);
|
|
||||||
const [selectedAssocId, setSelectedAssocId] = useState("");
|
|
||||||
const [reportType, setReportType] = useState<ReportType>("profit_loss");
|
|
||||||
const [fromDate, setFromDate] = useState(format(startOfMonth(now), "yyyy-MM-dd"));
|
|
||||||
const [toDate, setToDate] = useState(format(endOfMonth(now), "yyyy-MM-dd"));
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [reportData, setReportData] = useState<any>(null);
|
|
||||||
|
|
||||||
// Budget data for budget vs actual
|
|
||||||
const [budgets, setBudgets] = useState<any[]>([]);
|
|
||||||
const [comparison, setComparison] = useState<"none" | "prior_year" | "prior_period">("none");
|
|
||||||
const [comparisonData, setComparisonData] = useState<any>(null);
|
|
||||||
const [comparisonRange, setComparisonRange] = useState<{ from: string; to: string } | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let q = supabase.from("associations").select("id, name, logo_url, zoho_organization_id").eq("status", "active").not("zoho_organization_id", "is", null).order("name");
|
|
||||||
if (boardAssociationIds?.length) q = q.in("id", boardAssociationIds);
|
|
||||||
q.then(({ data }) => {
|
|
||||||
const linked = (data || []).filter(a => a.zoho_organization_id && a.zoho_organization_id.trim() !== "");
|
|
||||||
setAssociations(linked);
|
|
||||||
if (linked.length && !selectedAssocId) setSelectedAssocId(linked[0].id);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchReport = async () => {
|
|
||||||
if (!selectedAssocId) {
|
|
||||||
toast({ variant: "destructive", title: "Select an association" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setLoading(true);
|
|
||||||
setReportData(null);
|
|
||||||
setComparisonData(null);
|
|
||||||
setComparisonRange(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (reportType === "budget_vs_actual") {
|
|
||||||
// Compute comparison range
|
|
||||||
let cmpFrom: string | null = null;
|
|
||||||
let cmpTo: string | null = null;
|
|
||||||
if (comparison !== "none") {
|
|
||||||
const fd = new Date(fromDate + "T12:00:00");
|
|
||||||
const td = new Date(toDate + "T12:00:00");
|
|
||||||
if (comparison === "prior_year") {
|
|
||||||
const cf = new Date(fd); cf.setFullYear(cf.getFullYear() - 1);
|
|
||||||
const ct = new Date(td); ct.setFullYear(ct.getFullYear() - 1);
|
|
||||||
cmpFrom = format(cf, "yyyy-MM-dd");
|
|
||||||
cmpTo = format(ct, "yyyy-MM-dd");
|
|
||||||
} else {
|
|
||||||
const days = Math.round((td.getTime() - fd.getTime()) / 86400000) + 1;
|
|
||||||
const ct = new Date(fd); ct.setDate(ct.getDate() - 1);
|
|
||||||
const cf = new Date(ct); cf.setDate(cf.getDate() - days + 1);
|
|
||||||
cmpFrom = format(cf, "yyyy-MM-dd");
|
|
||||||
cmpTo = format(ct, "yyyy-MM-dd");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch budgets from local DB + P&L from Zoho (+ comparison P&L)
|
|
||||||
const calls: Promise<any>[] = [
|
|
||||||
Promise.resolve(supabase.from("budgets").select("*").eq("association_id", selectedAssocId)).then((r) => r),
|
|
||||||
supabase.functions.invoke("zoho-books", {
|
|
||||||
body: {
|
|
||||||
action: "get_profit_and_loss",
|
|
||||||
params: { association_id: selectedAssocId, from_date: fromDate, to_date: toDate },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
if (cmpFrom && cmpTo) {
|
|
||||||
calls.push(
|
|
||||||
supabase.functions.invoke("zoho-books", {
|
|
||||||
body: {
|
|
||||||
action: "get_profit_and_loss",
|
|
||||||
params: { association_id: selectedAssocId, from_date: cmpFrom, to_date: cmpTo },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const [budgetRes, zohoRes, cmpRes] = await Promise.all(calls);
|
|
||||||
setBudgets(budgetRes.data || []);
|
|
||||||
if (zohoRes.error) throw new Error(zohoRes.error.message);
|
|
||||||
setReportData(zohoRes.data?.data);
|
|
||||||
if (cmpRes) {
|
|
||||||
if (cmpRes.error) throw new Error(cmpRes.error.message);
|
|
||||||
setComparisonData(cmpRes.data?.data);
|
|
||||||
setComparisonRange({ from: cmpFrom!, to: cmpTo! });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const actionMap: Record<string, string> = {
|
|
||||||
profit_loss: "get_profit_and_loss",
|
|
||||||
balance_sheet: "get_balance_sheet",
|
|
||||||
ar_aging: "get_ar_aging",
|
|
||||||
};
|
|
||||||
|
|
||||||
const { data, error } = await supabase.functions.invoke("zoho-books", {
|
|
||||||
body: {
|
|
||||||
action: actionMap[reportType],
|
|
||||||
params: {
|
|
||||||
association_id: selectedAssocId,
|
|
||||||
from_date: fromDate,
|
|
||||||
to_date: toDate,
|
|
||||||
date: toDate,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) throw new Error(error.message);
|
|
||||||
setReportData(data?.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
toast({ title: "Report loaded", description: `${REPORT_META[reportType].label} generated successfully.` });
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error("Report fetch error:", err);
|
|
||||||
toast({ variant: "destructive", title: "Report Failed", description: err.message });
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleExportPdf = async () => {
|
|
||||||
if (!reportData) return;
|
|
||||||
const association = associations.find((a) => a.id === selectedAssocId) || null;
|
|
||||||
try {
|
|
||||||
if (reportType === "profit_loss") {
|
|
||||||
await generateProfitLossPdf({ association, fromDate, toDate, data: reportData });
|
|
||||||
} else if (reportType === "balance_sheet") {
|
|
||||||
await generateBalanceSheetPdf({ association, asOfDate: toDate, data: reportData });
|
|
||||||
} else if (reportType === "ar_aging") {
|
|
||||||
await generateARAgingPdf({ association, asOfDate: toDate, data: reportData });
|
|
||||||
} else if (reportType === "budget_vs_actual") {
|
|
||||||
await generateBudgetVsActualPdf({
|
|
||||||
association,
|
|
||||||
fromDate,
|
|
||||||
toDate,
|
|
||||||
data: reportData,
|
|
||||||
budgets,
|
|
||||||
comparisonData,
|
|
||||||
comparisonRange,
|
|
||||||
comparisonLabel:
|
|
||||||
comparison === "prior_year"
|
|
||||||
? "Prior Year"
|
|
||||||
: comparison === "prior_period"
|
|
||||||
? "Prior Period"
|
|
||||||
: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
toast({ variant: "destructive", title: "PDF export failed", description: err.message });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleQuickPeriod = (period: string) => {
|
|
||||||
const n = new Date();
|
|
||||||
if (period === "this_month") {
|
|
||||||
setFromDate(format(startOfMonth(n), "yyyy-MM-dd"));
|
|
||||||
setToDate(format(endOfMonth(n), "yyyy-MM-dd"));
|
|
||||||
} else if (period === "last_month") {
|
|
||||||
const lm = subMonths(n, 1);
|
|
||||||
setFromDate(format(startOfMonth(lm), "yyyy-MM-dd"));
|
|
||||||
setToDate(format(endOfMonth(lm), "yyyy-MM-dd"));
|
|
||||||
} else if (period === "ytd") {
|
|
||||||
setFromDate(format(new Date(n.getFullYear(), 0, 1), "yyyy-MM-dd"));
|
|
||||||
setToDate(format(n, "yyyy-MM-dd"));
|
|
||||||
} else if (period === "last_year") {
|
|
||||||
setFromDate(format(new Date(n.getFullYear() - 1, 0, 1), "yyyy-MM-dd"));
|
|
||||||
setToDate(format(new Date(n.getFullYear() - 1, 11, 31), "yyyy-MM-dd"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (associations.length === 0 && !loading) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6 p-6">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-foreground">Financial Reports</h1>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">Generate financial reports from your accounting data</p>
|
|
||||||
</div>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="py-16 text-center">
|
|
||||||
<BarChart3 className="h-12 w-12 mx-auto text-muted-foreground/50 mb-4" />
|
|
||||||
<h2 className="text-lg font-semibold text-foreground mb-2">No Financial Data Available</h2>
|
|
||||||
<p className="text-muted-foreground max-w-md mx-auto">
|
|
||||||
Financial reports require an active Zoho Books integration. Please link your association to Zoho Books to access live financial reporting.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6 p-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-foreground">Financial Reports</h1>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
|
||||||
Generate financial reports from your accounting data
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Report type cards */}
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
{(Object.entries(REPORT_META) as [ReportType, typeof REPORT_META[ReportType]][]).map(
|
|
||||||
([key, meta]) => {
|
|
||||||
const Icon = meta.icon;
|
|
||||||
const isActive = reportType === key;
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
key={key}
|
|
||||||
className={`cursor-pointer transition-all hover:shadow-md ${
|
|
||||||
isActive ? "ring-2 ring-primary border-primary bg-primary/5" : "hover:border-primary/50"
|
|
||||||
}`}
|
|
||||||
onClick={() => setReportType(key)}
|
|
||||||
>
|
|
||||||
<CardContent className="p-4 flex items-start gap-3">
|
|
||||||
<div className={`p-2 rounded-lg ${isActive ? "bg-primary text-primary-foreground" : "bg-muted"}`}>
|
|
||||||
<Icon className="w-5 h-5" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="font-medium text-sm">{meta.label}</p>
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">{meta.description}</p>
|
|
||||||
</div>
|
|
||||||
{isActive && <ChevronRight className="w-4 h-4 text-primary mt-1" />}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filters */}
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex flex-wrap gap-4 items-end">
|
|
||||||
<div className="min-w-[200px]">
|
|
||||||
<Label className="text-xs mb-1.5 block">Association</Label>
|
|
||||||
<Select value={selectedAssocId} onValueChange={setSelectedAssocId}>
|
|
||||||
<SelectTrigger className="h-9">
|
|
||||||
<SelectValue placeholder="Select Association" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{associations.map((a) => (
|
|
||||||
<SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs mb-1.5 block">From</Label>
|
|
||||||
<Input type="date" value={fromDate} onChange={(e) => setFromDate(e.target.value)} className="h-9 w-[150px]" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs mb-1.5 block">To</Label>
|
|
||||||
<Input type="date" value={toDate} onChange={(e) => setToDate(e.target.value)} className="h-9 w-[150px]" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-1.5">
|
|
||||||
{[
|
|
||||||
{ label: "This Month", value: "this_month" },
|
|
||||||
{ label: "Last Month", value: "last_month" },
|
|
||||||
{ label: "YTD", value: "ytd" },
|
|
||||||
{ label: "Last Year", value: "last_year" },
|
|
||||||
].map((p) => (
|
|
||||||
<Button key={p.value} variant="outline" size="sm" className="h-9 text-xs" onClick={() => handleQuickPeriod(p.value)}>
|
|
||||||
{p.label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{reportType === "budget_vs_actual" && (
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs mb-1.5 block">Compare To</Label>
|
|
||||||
<Select value={comparison} onValueChange={(v) => setComparison(v as any)}>
|
|
||||||
<SelectTrigger className="h-9 w-[160px]">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="none">No comparison</SelectItem>
|
|
||||||
<SelectItem value="prior_year">Prior Year</SelectItem>
|
|
||||||
<SelectItem value="prior_period">Prior Period</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button onClick={fetchReport} disabled={loading} className="h-9 ml-auto">
|
|
||||||
{loading ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <BarChart3 className="w-4 h-4 mr-2" />}
|
|
||||||
{loading ? "Loading..." : "Generate Report"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Report Results */}
|
|
||||||
{reportData && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<CardTitle className="text-lg">{REPORT_META[reportType].label}</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
{reportType === "balance_sheet" || reportType === "ar_aging"
|
|
||||||
? `As of ${toDate}`
|
|
||||||
: `${fromDate} to ${toDate}`}
|
|
||||||
{" · "}
|
|
||||||
{associations.find((a) => a.id === selectedAssocId)?.name}
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" size="sm" onClick={handleExportPdf}>
|
|
||||||
<Download className="w-4 h-4 mr-2" /> Print PDF
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
{reportType === "profit_loss" && <ProfitLossTable data={reportData} />}
|
|
||||||
{reportType === "balance_sheet" && <BalanceSheetTable data={reportData} />}
|
|
||||||
{reportType === "ar_aging" && <ARAgingTable data={reportData} />}
|
|
||||||
{reportType === "budget_vs_actual" && (
|
|
||||||
<BudgetVsActualTable
|
|
||||||
data={reportData}
|
|
||||||
budgets={budgets}
|
|
||||||
comparisonData={comparisonData}
|
|
||||||
comparisonRange={comparisonRange}
|
|
||||||
comparisonLabel={
|
|
||||||
comparison === "prior_year"
|
|
||||||
? "Prior Year"
|
|
||||||
: comparison === "prior_period"
|
|
||||||
? "Prior Period"
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!reportData && !loading && (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-12 text-center">
|
|
||||||
<BarChart3 className="w-12 h-12 mx-auto text-muted-foreground/40 mb-4" />
|
|
||||||
<p className="text-muted-foreground">Select a report type and click Generate to view results</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Sub-Components ─── */
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Flatten Zoho's nested account_transactions structure into renderable rows.
|
|
||||||
* Each node may have: name, total, total_label, account_transactions[], account_id
|
|
||||||
*/
|
|
||||||
function flattenZohoSections(nodes: any[] = [], depth = 0): any[] {
|
|
||||||
const rows: any[] = [];
|
|
||||||
for (const node of nodes) {
|
|
||||||
const isLeaf = !!node.account_id && !node.account_transactions?.length;
|
|
||||||
const isSection = !isLeaf;
|
|
||||||
rows.push({
|
|
||||||
name: node.name || node.total_label || "—",
|
|
||||||
total: node.total,
|
|
||||||
account_code: node.account_code,
|
|
||||||
_depth: depth,
|
|
||||||
_isSection: isSection,
|
|
||||||
_isTotalRow: !!node.total_label && !node.account_id,
|
|
||||||
});
|
|
||||||
if (node.account_transactions?.length) {
|
|
||||||
rows.push(...flattenZohoSections(node.account_transactions, depth + 1));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProfitLossTable({ data }: { data: any }) {
|
|
||||||
// Zoho returns: { profit_and_loss: [...sections...] } or { profitandloss: ... }
|
|
||||||
const topSections = data?.profit_and_loss || data?.profitandloss || data?.sections || [];
|
|
||||||
const rows = flattenZohoSections(Array.isArray(topSections) ? topSections : []);
|
|
||||||
|
|
||||||
if (rows.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="p-8 text-center text-muted-foreground">
|
|
||||||
<p>No profit & loss data available for this period.</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Account</TableHead>
|
|
||||||
<TableHead className="text-right">Amount</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{rows.map((row: any, i: number) => (
|
|
||||||
<TableRow
|
|
||||||
key={i}
|
|
||||||
className={
|
|
||||||
row._isTotalRow
|
|
||||||
? "border-t font-bold bg-muted/50"
|
|
||||||
: row._isSection
|
|
||||||
? "bg-muted/30 font-semibold"
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<TableCell style={{ paddingLeft: `${(row._depth || 0) * 20 + 16}px` }}>
|
|
||||||
{row.name}
|
|
||||||
{row.account_code ? ` (${row.account_code})` : ""}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">{row.total != null ? fmt(row.total) : ""}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BalanceSheetTable({ data }: { data: any }) {
|
|
||||||
const topSections = data?.balance_sheet || data?.balancesheet || data?.sections || [];
|
|
||||||
const rows = flattenZohoSections(Array.isArray(topSections) ? topSections : []);
|
|
||||||
|
|
||||||
if (rows.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="p-8 text-center text-muted-foreground">
|
|
||||||
<p>No balance sheet data available for this period.</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Account</TableHead>
|
|
||||||
<TableHead className="text-right">Amount</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{rows.map((row: any, i: number) => (
|
|
||||||
<TableRow
|
|
||||||
key={i}
|
|
||||||
className={
|
|
||||||
row._isTotalRow
|
|
||||||
? "border-t font-bold bg-muted/50"
|
|
||||||
: row._isSection
|
|
||||||
? "bg-muted/30 font-semibold"
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<TableCell style={{ paddingLeft: `${(row._depth || 0) * 20 + 16}px` }}>
|
|
||||||
{row.name}
|
|
||||||
{row.account_code ? ` (${row.account_code})` : ""}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">{row.total != null ? fmt(row.total) : ""}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ARAgingTable({ data }: { data: any }) {
|
|
||||||
const report = data?.receivabledetails || data?.receivables || data?.invoice || data;
|
|
||||||
const contacts = report?.contacts || report?.receivable_details || report?.details || report?.group_list || [];
|
|
||||||
const intervals = Array.isArray(report?.intervals) ? report.intervals : [];
|
|
||||||
|
|
||||||
const getAmount = (row: any, keys: string[]) => {
|
|
||||||
for (const key of keys) {
|
|
||||||
const value = row?.[key];
|
|
||||||
if (value !== undefined && value !== null && value !== "") return value;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const intervalAmount = (names: string[]) => {
|
|
||||||
const item = intervals.find((i: any) => names.includes(i.interval));
|
|
||||||
return item?.amount || 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderAgingTable = (rows: any[]) => (
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Customer</TableHead>
|
|
||||||
<TableHead className="text-right">Current</TableHead>
|
|
||||||
<TableHead className="text-right">1-30</TableHead>
|
|
||||||
<TableHead className="text-right">31-60</TableHead>
|
|
||||||
<TableHead className="text-right">61-90</TableHead>
|
|
||||||
<TableHead className="text-right">90+</TableHead>
|
|
||||||
<TableHead className="text-right">Total</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{rows.map((row: any, i: number) => (
|
|
||||||
<TableRow key={i}>
|
|
||||||
<TableCell>{row.contact_name || row.customer_name || row.name || row.customer || "—"}</TableCell>
|
|
||||||
<TableCell className="text-right">{fmt(getAmount(row, ["current", "current_amount", "days_0", "days_current"]))}</TableCell>
|
|
||||||
<TableCell className="text-right">{fmt(getAmount(row, ["1_to_30", "days_1_30", "days_1_15", "days_16_30"]))}</TableCell>
|
|
||||||
<TableCell className="text-right">{fmt(getAmount(row, ["31_to_60", "days_31_60", "days_31_45", "days_46_60"]))}</TableCell>
|
|
||||||
<TableCell className="text-right">{fmt(getAmount(row, ["61_to_90", "days_61_90"]))}</TableCell>
|
|
||||||
<TableCell className="text-right">{fmt(getAmount(row, ["over_90", "days_over_90", "days_above_45", "above_45"]))}</TableCell>
|
|
||||||
<TableCell className="text-right font-semibold">{fmt(row.total || row.outstanding || row.amount)}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (Array.isArray(contacts) && contacts.length > 0) {
|
|
||||||
return renderAgingTable(contacts);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sections = report?.sections || [];
|
|
||||||
if (Array.isArray(sections) && sections.length > 0) {
|
|
||||||
const rows = flattenZohoSections(sections).filter((r: any) => !r._isSection);
|
|
||||||
if (rows.length > 0) return renderAgingTable(rows);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (intervals.length > 0 && Number(report?.total || 0) > 0) {
|
|
||||||
return renderAgingTable([{
|
|
||||||
name: "Outstanding receivables",
|
|
||||||
current: intervalAmount(["current", "not_due"]),
|
|
||||||
days_1_30: Number(intervalAmount(["days_1_15"])) + Number(intervalAmount(["days_16_30", "days_1_30"])),
|
|
||||||
days_31_60: Number(intervalAmount(["days_31_45"])) + Number(intervalAmount(["days_46_60", "days_31_60"])),
|
|
||||||
days_61_90: intervalAmount(["days_61_90"]),
|
|
||||||
over_90: intervalAmount(["days_above_45", "days_over_90", "above_45"]),
|
|
||||||
total: report.total,
|
|
||||||
}]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-8 text-center text-muted-foreground">
|
|
||||||
<p>No receivable data available for this period.</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BudgetVsActualTable({
|
|
||||||
data,
|
|
||||||
budgets,
|
|
||||||
comparisonData,
|
|
||||||
comparisonRange,
|
|
||||||
comparisonLabel,
|
|
||||||
}: {
|
|
||||||
data: any;
|
|
||||||
budgets: any[];
|
|
||||||
comparisonData?: any;
|
|
||||||
comparisonRange?: { from: string; to: string } | null;
|
|
||||||
comparisonLabel?: string | null;
|
|
||||||
}) {
|
|
||||||
// Detect whether a top-level Zoho P&L section represents Income or Expense
|
|
||||||
// so a budget row's account_type controls which side of the P&L it pulls from.
|
|
||||||
const detectKind = (node: any): "income" | "expense" | "other" => {
|
|
||||||
const label = `${node?.name || ""} ${node?.total_label || ""}`.toLowerCase();
|
|
||||||
if (/(expense|cost of goods|cogs)/.test(label)) return "expense";
|
|
||||||
if (/(income|revenue|sales)/.test(label)) return "income";
|
|
||||||
return "other";
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildActuals = (src: any) => {
|
|
||||||
const income = new Map<string, number>();
|
|
||||||
const expense = new Map<string, number>();
|
|
||||||
const walk = (nodes: any[], kind: "income" | "expense" | "other") => {
|
|
||||||
for (const node of nodes || []) {
|
|
||||||
const childKind = kind === "other" ? detectKind(node) : kind;
|
|
||||||
const target = childKind === "income" ? income : childKind === "expense" ? expense : null;
|
|
||||||
if (target && node.name && !node.total_label) {
|
|
||||||
target.set(node.name.toLowerCase(), parseFloat(node.total || 0));
|
|
||||||
}
|
|
||||||
if (node.account_transactions) walk(node.account_transactions, childKind);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const sections = src?.profit_and_loss || src?.profitandloss || [];
|
|
||||||
walk(Array.isArray(sections) ? sections : [], "other");
|
|
||||||
return { income, expense };
|
|
||||||
};
|
|
||||||
|
|
||||||
const lookupActual = (
|
|
||||||
maps: { income: Map<string, number>; expense: Map<string, number> },
|
|
||||||
category: string,
|
|
||||||
accountType: string,
|
|
||||||
) => {
|
|
||||||
const key = (category || "").toLowerCase();
|
|
||||||
const isIncome = (accountType || "").toLowerCase() === "income";
|
|
||||||
return (isIncome ? maps.income : maps.expense).get(key) ?? 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const actuals = buildActuals(data);
|
|
||||||
const cmpActuals = comparisonData ? buildActuals(comparisonData) : null;
|
|
||||||
const showCmp = !!cmpActuals && !!comparisonLabel;
|
|
||||||
|
|
||||||
if (budgets.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="p-8 text-center text-muted-foreground">
|
|
||||||
<p>No budget categories found for this association.</p>
|
|
||||||
<p className="text-xs mt-2">Add budget items in the Budget Management page first.</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Category</TableHead>
|
|
||||||
<TableHead className="text-right">Budgeted</TableHead>
|
|
||||||
<TableHead className="text-right">Actual</TableHead>
|
|
||||||
{showCmp && (
|
|
||||||
<TableHead className="text-right">
|
|
||||||
{comparisonLabel}
|
|
||||||
{comparisonRange && (
|
|
||||||
<div className="text-[10px] font-normal text-muted-foreground">
|
|
||||||
{comparisonRange.from} → {comparisonRange.to}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</TableHead>
|
|
||||||
)}
|
|
||||||
{showCmp && <TableHead className="text-right">% Δ</TableHead>}
|
|
||||||
<TableHead className="text-right">Variance</TableHead>
|
|
||||||
<TableHead className="text-right">%</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{budgets.map((b) => {
|
|
||||||
const lookedUp = lookupActual(actuals, b.category, b.account_type);
|
|
||||||
const actual = lookedUp || b.actual_amount || 0;
|
|
||||||
const cmp = cmpActuals ? lookupActual(cmpActuals, b.category, b.account_type) : 0;
|
|
||||||
const cmpDelta = cmp !== 0 ? ((actual - cmp) / Math.abs(cmp)) * 100 : NaN;
|
|
||||||
const variance = b.budgeted_amount - actual;
|
|
||||||
const pct = b.budgeted_amount ? ((actual / b.budgeted_amount) * 100).toFixed(1) : "—";
|
|
||||||
return (
|
|
||||||
<TableRow key={b.id}>
|
|
||||||
<TableCell>{b.category}</TableCell>
|
|
||||||
<TableCell className="text-right">{fmt(b.budgeted_amount)}</TableCell>
|
|
||||||
<TableCell className="text-right">{fmt(actual)}</TableCell>
|
|
||||||
{showCmp && <TableCell className="text-right text-muted-foreground">{fmt(cmp)}</TableCell>}
|
|
||||||
{showCmp && (
|
|
||||||
<TableCell
|
|
||||||
className={`text-right text-xs ${
|
|
||||||
!isFinite(cmpDelta) ? "text-muted-foreground" : cmpDelta >= 0 ? "text-green-600" : "text-destructive"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isFinite(cmpDelta) ? `${cmpDelta >= 0 ? "+" : ""}${cmpDelta.toFixed(1)}%` : "—"}
|
|
||||||
</TableCell>
|
|
||||||
)}
|
|
||||||
<TableCell className={`text-right ${variance < 0 ? "text-destructive" : "text-green-600"}`}>
|
|
||||||
{fmt(variance)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<Badge variant={variance < 0 ? "destructive" : "secondary"} className="text-xs">
|
|
||||||
{pct}%
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from "@/components/ui/dialog";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
|
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
|
||||||
import { Plus, Trash2, Search, Receipt, Upload, Sparkles, FileText, X, AlertCircle, Printer, Loader2 } from "lucide-react";
|
import { Plus, Trash2, Search, Receipt, Upload, Sparkles, FileText, X, AlertCircle, Printer, Loader2, Pencil } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { money, fmtDate } from "./lib/format";
|
import { money, fmtDate } from "./lib/format";
|
||||||
import { StatusBadge } from "./components/StatusBadge";
|
import { StatusBadge } from "./components/StatusBadge";
|
||||||
@@ -45,6 +45,7 @@ export default function AccountingBillsPage() {
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const parseFn = parseBill;
|
const parseFn = parseBill;
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [editId, setEditId] = useState<string | null>(null);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||||
const [autoOnly, setAutoOnly] = useState(false);
|
const [autoOnly, setAutoOnly] = useState(false);
|
||||||
@@ -118,12 +119,32 @@ export default function AccountingBillsPage() {
|
|||||||
const total = +(subtotal + tax).toFixed(2);
|
const total = +(subtotal + tax).toFixed(2);
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
|
setEditId(null);
|
||||||
setVendorId(""); setNumber(""); setDueDate(""); setTaxPct(0); setNotes("");
|
setVendorId(""); setNumber(""); setDueDate(""); setTaxPct(0); setNotes("");
|
||||||
setItems([{ description: "", quantity: 1, rate: 0, account_id: null }]);
|
setItems([{ description: "", quantity: 1, rate: 0, account_id: null }]);
|
||||||
setFile(null); setFilePreview(null); setUploadedUrl(null);
|
setFile(null); setFilePreview(null); setUploadedUrl(null);
|
||||||
setAiFilled(new Set()); setMissingFields(new Set());
|
setAiFilled(new Set()); setMissingFields(new Set());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openEdit = async (b: any) => {
|
||||||
|
setEditId(b.id);
|
||||||
|
setVendorId(b.vendor_id ?? "");
|
||||||
|
setNumber(b.number ?? "");
|
||||||
|
setIssueDate(b.issue_date ?? issueDate);
|
||||||
|
setDueDate(b.due_date ?? "");
|
||||||
|
const sub = Number(b.subtotal || 0);
|
||||||
|
setTaxPct(sub > 0 ? +((Number(b.tax || 0) / sub) * 100).toFixed(4) : 0);
|
||||||
|
setNotes(b.notes ?? "");
|
||||||
|
setUploadedUrl(b.attachment_url ?? null);
|
||||||
|
setFile(null); setFilePreview(null);
|
||||||
|
setAiFilled(new Set()); setMissingFields(new Set());
|
||||||
|
const { data: its } = await accounting.from("bill_items").select("*").eq("bill_id", b.id);
|
||||||
|
setItems(its && its.length
|
||||||
|
? its.map((i: any) => ({ description: i.description ?? "", quantity: Number(i.quantity ?? 1), rate: Number(i.rate ?? 0), account_id: i.account_id ?? null }))
|
||||||
|
: [{ description: "", quantity: 1, rate: 0, account_id: null }]);
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
const handleFile = (f: File) => {
|
const handleFile = (f: File) => {
|
||||||
setFile(f);
|
setFile(f);
|
||||||
setUploadedUrl(null);
|
setUploadedUrl(null);
|
||||||
@@ -224,20 +245,36 @@ export default function AccountingBillsPage() {
|
|||||||
let attachmentUrl = uploadedUrl;
|
let attachmentUrl = uploadedUrl;
|
||||||
if (file && !attachmentUrl) attachmentUrl = await uploadFileObj(file);
|
if (file && !attachmentUrl) attachmentUrl = await uploadFileObj(file);
|
||||||
|
|
||||||
const { data: bill, error } = await accounting.from("bills").insert({
|
const itemRows = (billId: string) => items.map(i => ({
|
||||||
company_id: cid, vendor_id: vendorId || null, number,
|
bill_id: billId, description: i.description, quantity: i.quantity, rate: i.rate,
|
||||||
issue_date: issueDate, due_date: dueDate || null,
|
|
||||||
subtotal, tax, total, status: "open",
|
|
||||||
notes: notes || null,
|
|
||||||
attachment_url: attachmentUrl,
|
|
||||||
}).select().single();
|
|
||||||
if (error || !bill) return toast.error(error?.message ?? "Failed");
|
|
||||||
await accounting.from("bill_items").insert(items.map(i => ({
|
|
||||||
bill_id: bill.id, description: i.description, quantity: i.quantity, rate: i.rate,
|
|
||||||
amount: +(Number(i.quantity) * Number(i.rate)).toFixed(2),
|
amount: +(Number(i.quantity) * Number(i.rate)).toFixed(2),
|
||||||
account_id: i.account_id || null,
|
account_id: i.account_id || null,
|
||||||
})));
|
}));
|
||||||
toast.success("Bill recorded");
|
|
||||||
|
if (editId) {
|
||||||
|
const { error } = await accounting.from("bills").update({
|
||||||
|
vendor_id: vendorId || null, number,
|
||||||
|
issue_date: issueDate, due_date: dueDate || null,
|
||||||
|
subtotal, tax, total,
|
||||||
|
notes: notes || null,
|
||||||
|
attachment_url: attachmentUrl,
|
||||||
|
}).eq("id", editId);
|
||||||
|
if (error) return toast.error(error.message);
|
||||||
|
await accounting.from("bill_items").delete().eq("bill_id", editId);
|
||||||
|
await accounting.from("bill_items").insert(itemRows(editId));
|
||||||
|
toast.success("Bill updated");
|
||||||
|
} else {
|
||||||
|
const { data: bill, error } = await accounting.from("bills").insert({
|
||||||
|
company_id: cid, vendor_id: vendorId || null, number,
|
||||||
|
issue_date: issueDate, due_date: dueDate || null,
|
||||||
|
subtotal, tax, total, status: "open",
|
||||||
|
notes: notes || null,
|
||||||
|
attachment_url: attachmentUrl,
|
||||||
|
}).select().single();
|
||||||
|
if (error || !bill) return toast.error(error?.message ?? "Failed");
|
||||||
|
await accounting.from("bill_items").insert(itemRows(bill.id));
|
||||||
|
toast.success("Bill recorded");
|
||||||
|
}
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
resetForm();
|
resetForm();
|
||||||
qc.invalidateQueries({ queryKey: ["bills", cid] });
|
qc.invalidateQueries({ queryKey: ["bills", cid] });
|
||||||
@@ -448,7 +485,7 @@ export default function AccountingBillsPage() {
|
|||||||
<Dialog open={open} onOpenChange={(o) => { setOpen(o); if (!o) resetForm(); }}>
|
<Dialog open={open} onOpenChange={(o) => { setOpen(o); if (!o) resetForm(); }}>
|
||||||
<DialogTrigger asChild><Button><Plus className="mr-1 h-4 w-4" /> New Bill</Button></DialogTrigger>
|
<DialogTrigger asChild><Button><Plus className="mr-1 h-4 w-4" /> New Bill</Button></DialogTrigger>
|
||||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader><DialogTitle>New bill</DialogTitle></DialogHeader>
|
<DialogHeader><DialogTitle>{editId ? "Edit bill" : "New bill"}</DialogTitle></DialogHeader>
|
||||||
|
|
||||||
{aiFilled.size > 0 && (
|
{aiFilled.size > 0 && (
|
||||||
<div className="rounded-md border border-amber-300 bg-amber-50 text-amber-900 px-3 py-2 text-sm flex items-start gap-2">
|
<div className="rounded-md border border-amber-300 bg-amber-50 text-amber-900 px-3 py-2 text-sm flex items-start gap-2">
|
||||||
@@ -671,6 +708,9 @@ export default function AccountingBillsPage() {
|
|||||||
<Button size="sm" variant="outline" onClick={() => openPayment(b)}>Record payment</Button>
|
<Button size="sm" variant="outline" onClick={() => openPayment(b)}>Record payment</Button>
|
||||||
|
|
||||||
)}
|
)}
|
||||||
|
{b.derivedStatus !== "void" && (
|
||||||
|
<Button size="icon" variant="ghost" onClick={() => openEdit(b)} title="Edit bill"><Pencil className="h-4 w-4" /></Button>
|
||||||
|
)}
|
||||||
<Button size="icon" variant="ghost" onClick={() => remove(b.id)}><Trash2 className="h-4 w-4" /></Button>
|
<Button size="icon" variant="ghost" onClick={() => remove(b.id)}><Trash2 className="h-4 w-4" /></Button>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ function periodLabels(p: string) {
|
|||||||
|
|
||||||
type Grid = Record<string, string[]>;
|
type Grid = Record<string, string[]>;
|
||||||
|
|
||||||
export default function AccountingBudgetDetailPage() {
|
export default function AccountingBudgetDetailPage({ basePath = "/dashboard/accounting/budgets" }: { basePath?: string } = {}) {
|
||||||
const { id = "" } = useParams();
|
const { id = "" } = useParams();
|
||||||
const { companyId } = useCompanyId();
|
const { companyId } = useCompanyId();
|
||||||
const cid = companyId ?? "";
|
const cid = companyId ?? "";
|
||||||
@@ -174,7 +174,7 @@ export default function AccountingBudgetDetailPage() {
|
|||||||
toast.success(activate ? "Budget activated" : "Saved");
|
toast.success(activate ? "Budget activated" : "Saved");
|
||||||
qc.invalidateQueries({ queryKey: ["budget-entries", id] });
|
qc.invalidateQueries({ queryKey: ["budget-entries", id] });
|
||||||
qc.invalidateQueries({ queryKey: ["budgets", cid] });
|
qc.invalidateQueries({ queryKey: ["budgets", cid] });
|
||||||
if (activate) navigate("/dashboard/accounting/budgets");
|
if (activate) navigate(basePath);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (bLoading) {
|
if (bLoading) {
|
||||||
@@ -186,7 +186,7 @@ export default function AccountingBudgetDetailPage() {
|
|||||||
<div className="p-6 space-y-2">
|
<div className="p-6 space-y-2">
|
||||||
<div className="text-muted-foreground">Budget not found (ID: {id})</div>
|
<div className="text-muted-foreground">Budget not found (ID: {id})</div>
|
||||||
<Button variant="outline" size="sm" asChild>
|
<Button variant="outline" size="sm" asChild>
|
||||||
<Link to="/dashboard/accounting/budgets"><ArrowLeft className="mr-1 h-4 w-4" /> Back to Budgets</Link>
|
<Link to={basePath}><ArrowLeft className="mr-1 h-4 w-4" /> Back to Budgets</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -204,7 +204,7 @@ export default function AccountingBudgetDetailPage() {
|
|||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button variant="ghost" size="sm" asChild>
|
<Button variant="ghost" size="sm" asChild>
|
||||||
<Link to="/dashboard/accounting/budgets"><ArrowLeft className="mr-1 h-4 w-4" /> Budgets</Link>
|
<Link to={basePath}><ArrowLeft className="mr-1 h-4 w-4" /> Budgets</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import { accounting } from "@/lib/accountingClient";
|
import { accounting } from "@/lib/accountingClient";
|
||||||
import { useCompanyId } from "./lib/useCompanyId";
|
import { useCompanyId } from "./lib/useCompanyId";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -21,10 +22,12 @@ import { EmptyState } from "./components/EmptyState";
|
|||||||
|
|
||||||
const YEAR_OPTIONS = Array.from({ length: 7 }, (_, i) => new Date().getFullYear() - 2 + i);
|
const YEAR_OPTIONS = Array.from({ length: 7 }, (_, i) => new Date().getFullYear() - 2 + i);
|
||||||
|
|
||||||
export default function AccountingBudgetsPage() {
|
export default function AccountingBudgetsPage({ basePath = "/dashboard/accounting/budgets" }: { basePath?: string } = {}) {
|
||||||
const { companyId, loading: companyLoading, error: companyError, associationId } = useCompanyId();
|
const { companyId, loading: companyLoading, error: companyError, associationId } = useCompanyId();
|
||||||
const cid = companyId ?? "";
|
const cid = companyId ?? "";
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const goToBudget = (id: string) => navigate(`${basePath}/${id}`);
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
@@ -65,7 +68,7 @@ export default function AccountingBudgetsPage() {
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
resetForm();
|
resetForm();
|
||||||
qc.invalidateQueries({ queryKey: ["budgets", cid] });
|
qc.invalidateQueries({ queryKey: ["budgets", cid] });
|
||||||
window.location.href = `/dashboard/accounting/budgets/${data.id}`;
|
goToBudget(data.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const remove = async (id: string) => {
|
const remove = async (id: string) => {
|
||||||
@@ -132,9 +135,9 @@ export default function AccountingBudgetsPage() {
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{(budgets as any[]).map((b: any) => (
|
{(budgets as any[]).map((b: any) => (
|
||||||
<TableRow key={b.id} className="cursor-pointer" onClick={() => window.location.href = `/dashboard/accounting/budgets/${b.id}`}>
|
<TableRow key={b.id} className="cursor-pointer" onClick={() => goToBudget(b.id)}>
|
||||||
<TableCell className="font-medium">
|
<TableCell className="font-medium">
|
||||||
<a href={`/dashboard/accounting/budgets/${b.id}`} className="hover:underline font-medium">{b.name}</a>
|
<span className="hover:underline font-medium">{b.name}</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{b.fiscal_year}</TableCell>
|
<TableCell>{b.fiscal_year}</TableCell>
|
||||||
<TableCell className="text-sm text-muted-foreground capitalize">{b.period_type}</TableCell>
|
<TableCell className="text-sm text-muted-foreground capitalize">{b.period_type}</TableCell>
|
||||||
@@ -150,7 +153,7 @@ export default function AccountingBudgetsPage() {
|
|||||||
<Button size="icon" variant="ghost"><MoreHorizontal className="h-4 w-4" /></Button>
|
<Button size="icon" variant="ghost"><MoreHorizontal className="h-4 w-4" /></Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem onClick={() => { window.location.href = `/dashboard/accounting/budgets/${b.id}`; }}>
|
<DropdownMenuItem onClick={() => goToBudget(b.id)}>
|
||||||
<Pencil className="mr-2 h-4 w-4" /> Open
|
<Pencil className="mr-2 h-4 w-4" /> Open
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => copy(b)}>
|
<DropdownMenuItem onClick={() => copy(b)}>
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export default function AccountingChartOfAccountsPage() {
|
|||||||
const [code, setCode] = useState("");
|
const [code, setCode] = useState("");
|
||||||
const [type, setType] = useState<string>("asset");
|
const [type, setType] = useState<string>("asset");
|
||||||
const [isBank, setIsBank] = useState(false);
|
const [isBank, setIsBank] = useState(false);
|
||||||
|
const [isReserve, setIsReserve] = useState(false);
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [parentId, setParentId] = useState<string>("");
|
const [parentId, setParentId] = useState<string>("");
|
||||||
|
|
||||||
@@ -103,7 +104,7 @@ export default function AccountingChartOfAccountsPage() {
|
|||||||
// ── Accounts actions ──
|
// ── Accounts actions ──
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setEditId(null); setName(""); setCode(""); setType("asset");
|
setEditId(null); setName(""); setCode(""); setType("asset");
|
||||||
setIsBank(false); setDescription(""); setParentId("");
|
setIsBank(false); setIsReserve(false); setDescription(""); setParentId("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEdit = (a: any) => {
|
const openEdit = (a: any) => {
|
||||||
@@ -112,6 +113,7 @@ export default function AccountingChartOfAccountsPage() {
|
|||||||
setCode(a.code ?? "");
|
setCode(a.code ?? "");
|
||||||
setType(a.type ?? "asset");
|
setType(a.type ?? "asset");
|
||||||
setIsBank(a.is_bank ?? false);
|
setIsBank(a.is_bank ?? false);
|
||||||
|
setIsReserve(a.is_reserve ?? false);
|
||||||
setDescription(a.description ?? "");
|
setDescription(a.description ?? "");
|
||||||
setParentId(a.parent_account_id ?? "");
|
setParentId(a.parent_account_id ?? "");
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
@@ -124,6 +126,7 @@ export default function AccountingChartOfAccountsPage() {
|
|||||||
code: code.trim() || null,
|
code: code.trim() || null,
|
||||||
type: type as any,
|
type: type as any,
|
||||||
is_bank: isBank,
|
is_bank: isBank,
|
||||||
|
is_reserve: isReserve,
|
||||||
description: description || null,
|
description: description || null,
|
||||||
parent_account_id: parentId || null,
|
parent_account_id: parentId || null,
|
||||||
};
|
};
|
||||||
@@ -173,7 +176,7 @@ export default function AccountingChartOfAccountsPage() {
|
|||||||
const c = parseFloat(r?.credit || "0") || 0;
|
const c = parseFloat(r?.credit || "0") || 0;
|
||||||
return { company_id: cid, account_id: a.id, debit: d, credit: c };
|
return { company_id: cid, account_id: a.id, debit: d, credit: c };
|
||||||
})
|
})
|
||||||
.filter((r) => r.debit > 0 || r.credit > 0);
|
.filter((r) => r.debit !== 0 || r.credit !== 0);
|
||||||
|
|
||||||
await accounting.from("opening_balances").delete().eq("company_id", cid);
|
await accounting.from("opening_balances").delete().eq("company_id", cid);
|
||||||
if (rowsPayload.length) {
|
if (rowsPayload.length) {
|
||||||
@@ -291,6 +294,10 @@ export default function AccountingChartOfAccountsPage() {
|
|||||||
<input type="checkbox" checked={isBank} onChange={(e) => setIsBank(e.target.checked)} />
|
<input type="checkbox" checked={isBank} onChange={(e) => setIsBank(e.target.checked)} />
|
||||||
This is a bank, cash, or card account
|
This is a bank, cash, or card account
|
||||||
</label>
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input type="checkbox" checked={isReserve} onChange={(e) => setIsReserve(e.target.checked)} />
|
||||||
|
Reserve fund account (appears on the Reserve Fund Schedule)
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => { setOpen(false); resetForm(); }}>Cancel</Button>
|
<Button variant="outline" onClick={() => { setOpen(false); resetForm(); }}>Cancel</Button>
|
||||||
@@ -344,6 +351,7 @@ export default function AccountingChartOfAccountsPage() {
|
|||||||
</div>
|
</div>
|
||||||
{a.description && <div className="text-xs text-muted-foreground">{a.description}</div>}
|
{a.description && <div className="text-xs text-muted-foreground">{a.description}</div>}
|
||||||
{a.is_bank && <div className="text-xs text-muted-foreground">Bank account</div>}
|
{a.is_bank && <div className="text-xs text-muted-foreground">Bank account</div>}
|
||||||
|
{a.is_reserve && <div className="text-xs text-emerald-600 font-medium">Reserve fund</div>}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-xs text-muted-foreground">
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
{parent ? (
|
{parent ? (
|
||||||
@@ -454,14 +462,14 @@ export default function AccountingChartOfAccountsPage() {
|
|||||||
<TableCell className="capitalize text-sm text-muted-foreground">{a.type}</TableCell>
|
<TableCell className="capitalize text-sm text-muted-foreground">{a.type}</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
{isDebit ? (
|
{isDebit ? (
|
||||||
<Input type="number" step="0.01" min="0" inputMode="decimal" value={r.debit}
|
<Input type="number" step="0.01" inputMode="decimal" value={r.debit}
|
||||||
onChange={(e) => updateOb(a.id, "debit", e.target.value)}
|
onChange={(e) => updateOb(a.id, "debit", e.target.value)}
|
||||||
placeholder="0.00" className="text-right ml-auto max-w-[160px]" />
|
placeholder="0.00" className="text-right ml-auto max-w-[160px]" />
|
||||||
) : <span className="text-muted-foreground">—</span>}
|
) : <span className="text-muted-foreground">—</span>}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
{!isDebit ? (
|
{!isDebit ? (
|
||||||
<Input type="number" step="0.01" min="0" inputMode="decimal" value={r.credit}
|
<Input type="number" step="0.01" inputMode="decimal" value={r.credit}
|
||||||
onChange={(e) => updateOb(a.id, "credit", e.target.value)}
|
onChange={(e) => updateOb(a.id, "credit", e.target.value)}
|
||||||
placeholder="0.00" className="text-right ml-auto max-w-[160px]" />
|
placeholder="0.00" className="text-right ml-auto max-w-[160px]" />
|
||||||
) : <span className="text-muted-foreground">—</span>}
|
) : <span className="text-muted-foreground">—</span>}
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import {
|
|||||||
|
|
||||||
const DONUT_COLORS = ["#00897B", "#26A69A", "#F59E0B", "#EF4444", "#6366F1", "#8B5CF6"];
|
const DONUT_COLORS = ["#00897B", "#26A69A", "#F59E0B", "#EF4444", "#6366F1", "#8B5CF6"];
|
||||||
|
|
||||||
export default function AccountingDashboardPage() {
|
export default function AccountingDashboardPage({ association }: { association?: { id: string; name?: string } | null } = {}) {
|
||||||
const { companyId, loading: companyLoading, error: companyError, associationId, associationName } = useCompanyId();
|
const { companyId, loading: companyLoading, error: companyError, associationId, associationName } = useCompanyId(association);
|
||||||
const cid = companyId ?? "";
|
const cid = companyId ?? "";
|
||||||
const c = "USD";
|
const c = "USD";
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Fragment, useEffect, useMemo, useState } from "react";
|
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { CalendarIcon, Lock, Pencil, RotateCcw, Save, Info, Loader2 } from "lucide-react";
|
import { CalendarIcon, Lock, Pencil, RotateCcw, Save, Info, Loader2, Upload, FileDown } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { accounting } from "@/lib/accountingClient";
|
import { accounting } from "@/lib/accountingClient";
|
||||||
import { useCompanyId } from "./lib/useCompanyId";
|
import { useCompanyId } from "./lib/useCompanyId";
|
||||||
|
import { parseCsv, pick } from "./lib/csv";
|
||||||
import { money } from "./lib/format";
|
import { money } from "./lib/format";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -106,7 +107,7 @@ export default function AccountingOpeningBalancesPage() {
|
|||||||
const c = parseFloat(r?.credit || "0") || 0;
|
const c = parseFloat(r?.credit || "0") || 0;
|
||||||
return { company_id: cid, account_id: a.id, debit: d, credit: c };
|
return { company_id: cid, account_id: a.id, debit: d, credit: c };
|
||||||
})
|
})
|
||||||
.filter((r) => r.debit > 0 || r.credit > 0);
|
.filter((r) => r.debit !== 0 || r.credit !== 0);
|
||||||
|
|
||||||
await accounting.from("opening_balances").delete().eq("company_id", cid);
|
await accounting.from("opening_balances").delete().eq("company_id", cid);
|
||||||
if (rowsPayload.length) {
|
if (rowsPayload.length) {
|
||||||
@@ -124,6 +125,78 @@ export default function AccountingOpeningBalancesPage() {
|
|||||||
toast.info("All balances cleared. Click Save to persist.");
|
toast.info("All balances cleared. Click Save to persist.");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── CSV import: a trial balance per account (Account Code/Name, Debit, Credit) ──
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const importCsv = async (file: File) => {
|
||||||
|
let parsed: Record<string, string>[];
|
||||||
|
try {
|
||||||
|
parsed = parseCsv(await file.text());
|
||||||
|
} catch {
|
||||||
|
toast.error("Could not read the CSV file.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!parsed.length) { toast.error("No rows found in the file."); return; }
|
||||||
|
|
||||||
|
const byCode = new Map<string, any>();
|
||||||
|
const byName = new Map<string, any>();
|
||||||
|
for (const a of accounts as any[]) {
|
||||||
|
if (a.code) byCode.set(String(a.code).trim().toLowerCase(), a);
|
||||||
|
if (a.name) byName.set(String(a.name).trim().toLowerCase(), a);
|
||||||
|
}
|
||||||
|
// Signed parse: handles "$", commas, and parentheses/leading-minus as negative,
|
||||||
|
// so a negative balance carries through to reporting (e.g. reserves).
|
||||||
|
const numFrom = (s: string) => {
|
||||||
|
const t = (s || "").trim();
|
||||||
|
const mag = Math.abs(parseFloat(t.replace(/[$,()]/g, "")) || 0);
|
||||||
|
return /^\s*[-(]/.test(t) ? -mag : mag;
|
||||||
|
};
|
||||||
|
|
||||||
|
const next: Record<string, { debit: string; credit: string }> = {};
|
||||||
|
let matched = 0;
|
||||||
|
let unmatched = 0;
|
||||||
|
for (const r of parsed) {
|
||||||
|
const code = pick(r["account code"], r["code"], r["account number"], r["account #"], r["acct"], r["number"]);
|
||||||
|
const name = pick(r["account name"], r["account"], r["name"]);
|
||||||
|
const acct =
|
||||||
|
(code && byCode.get(code.trim().toLowerCase())) ||
|
||||||
|
(name && byName.get(name.trim().toLowerCase())) || null;
|
||||||
|
if (!acct) { if (code || name) unmatched++; continue; }
|
||||||
|
|
||||||
|
let debit = numFrom(pick(r["debit"], r["debits"]));
|
||||||
|
let credit = numFrom(pick(r["credit"], r["credits"]));
|
||||||
|
if (!debit && !credit) {
|
||||||
|
// Single balance column → place on the account's natural side (sign preserved).
|
||||||
|
const signed = numFrom(pick(r["balance"], r["amount"]));
|
||||||
|
const debitNatural = acct.type === "asset" || acct.type === "expense";
|
||||||
|
if (debitNatural) debit = signed; else credit = signed;
|
||||||
|
}
|
||||||
|
if (debit || credit) {
|
||||||
|
next[acct.id] = { debit: debit ? String(debit) : "", credit: credit ? String(credit) : "" };
|
||||||
|
matched++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!matched) {
|
||||||
|
toast.error("No rows matched an account by code or name. Check your CSV headers (Account Code / Account Name, Debit, Credit).");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRows(next);
|
||||||
|
toast.success(
|
||||||
|
`Imported ${matched} account balance${matched === 1 ? "" : "s"}.${unmatched ? ` ${unmatched} row(s) didn't match an account.` : ""} Review and click Save.`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadTemplate = () => {
|
||||||
|
const header = "Account Code,Account Name,Debit,Credit";
|
||||||
|
const sample = (accounts as any[]).slice(0, 5).map((a) => `${a.code ?? ""},"${(a.name ?? "").replace(/"/g, '""')}",,`);
|
||||||
|
const blob = new Blob([[header, ...sample].join("\n")], { type: "text/csv" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url; link.download = "trial-balance-template.csv"; link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
if (!associationId) return <p className="text-sm text-muted-foreground">Select an association.</p>;
|
if (!associationId) return <p className="text-sm text-muted-foreground">Select an association.</p>;
|
||||||
if (companyLoading) return <div className="flex justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>;
|
if (companyLoading) return <div className="flex justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>;
|
||||||
if (companyError || !companyId) return <p className="text-sm text-muted-foreground text-center py-12">{companyError || "Accounting setup is not ready."}</p>;
|
if (companyError || !companyId) return <p className="text-sm text-muted-foreground text-center py-12">{companyError || "Accounting setup is not ready."}</p>;
|
||||||
@@ -136,6 +209,19 @@ export default function AccountingOpeningBalancesPage() {
|
|||||||
<p className="text-sm text-muted-foreground">Set starting balances for each account from your previous accounting system.</p>
|
<p className="text-sm text-muted-foreground">Set starting balances for each account from your previous accounting system.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-end gap-2">
|
<div className="flex items-end gap-2">
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept=".csv,text/csv"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => { const f = e.target.files?.[0]; if (f) importCsv(f); e.target.value = ""; }}
|
||||||
|
/>
|
||||||
|
<Button variant="outline" onClick={downloadTemplate} title="Download a CSV template">
|
||||||
|
<FileDown className="mr-1 h-4 w-4" /> Template
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => fileRef.current?.click()} title="Import a trial balance CSV">
|
||||||
|
<Upload className="mr-1 h-4 w-4" /> Import CSV
|
||||||
|
</Button>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="text-xs text-muted-foreground">As of date</span>
|
<span className="text-xs text-muted-foreground">As of date</span>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -199,14 +285,14 @@ export default function AccountingOpeningBalancesPage() {
|
|||||||
<TableCell className="capitalize text-sm text-muted-foreground">{a.type}</TableCell>
|
<TableCell className="capitalize text-sm text-muted-foreground">{a.type}</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
{isDebit ? (
|
{isDebit ? (
|
||||||
<Input type="number" step="0.01" min="0" inputMode="decimal" value={r.debit}
|
<Input type="number" step="0.01" inputMode="decimal" value={r.debit}
|
||||||
onChange={(e) => update(a.id, "debit", e.target.value)}
|
onChange={(e) => update(a.id, "debit", e.target.value)}
|
||||||
placeholder="0.00" className="text-right ml-auto max-w-[160px]" />
|
placeholder="0.00" className="text-right ml-auto max-w-[160px]" />
|
||||||
) : <span className="text-muted-foreground">—</span>}
|
) : <span className="text-muted-foreground">—</span>}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
{!isDebit ? (
|
{!isDebit ? (
|
||||||
<Input type="number" step="0.01" min="0" inputMode="decimal" value={r.credit}
|
<Input type="number" step="0.01" inputMode="decimal" value={r.credit}
|
||||||
onChange={(e) => update(a.id, "credit", e.target.value)}
|
onChange={(e) => update(a.id, "credit", e.target.value)}
|
||||||
placeholder="0.00" className="text-right ml-auto max-w-[160px]" />
|
placeholder="0.00" className="text-right ml-auto max-w-[160px]" />
|
||||||
) : <span className="text-muted-foreground">—</span>}
|
) : <span className="text-muted-foreground">—</span>}
|
||||||
|
|||||||
@@ -197,20 +197,28 @@ export default function AccountingReconcileDetailPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const deposits = (txs as Tx[]).filter((t) => checked.has(t.id) && t.type === "credit")
|
const mapRow = (t: Tx) => ({ name: t.description ?? "", date: t.date, number: t.reference ?? "", memo: "", amount: Number(t.amount) });
|
||||||
.map((t) => ({ date: t.date, description: t.description, amount: Number(t.amount) }));
|
const clearedDepRows = (txs as Tx[]).filter((t) => checked.has(t.id) && t.type === "credit").map(mapRow);
|
||||||
const withdrawals = (txs as Tx[]).filter((t) => checked.has(t.id) && t.type === "debit")
|
const clearedWdRows = (txs as Tx[]).filter((t) => checked.has(t.id) && t.type === "debit").map(mapRow);
|
||||||
.map((t) => ({ date: t.date, description: t.description, amount: Number(t.amount) }));
|
const unclearedDepRows = (txs as Tx[]).filter((t) => !checked.has(t.id) && t.type === "credit").map(mapRow);
|
||||||
|
const unclearedWdRows = (txs as Tx[]).filter((t) => !checked.has(t.id) && t.type === "debit").map(mapRow);
|
||||||
|
const sumAmt = (rs: { amount: number }[]) => rs.reduce((s, r) => s + r.amount, 0);
|
||||||
|
const begin = Number(active.opening_balance);
|
||||||
|
const ending = begin + sumAmt(clearedDepRows) - sumAmt(clearedWdRows);
|
||||||
|
const book = ending + sumAmt(unclearedDepRows) - sumAmt(unclearedWdRows);
|
||||||
|
const acctLabel = (account as any)?.code ? `${(account as any).code} ${(account as any).name}` : ((account as any)?.name ?? "Account");
|
||||||
|
|
||||||
setSuccessData({
|
setSuccessData({
|
||||||
companyName: associationName ?? "Company",
|
companyName: associationName ?? "Company",
|
||||||
accountName: (account as any)?.name ?? "Account",
|
accountName: acctLabel,
|
||||||
statementEndDate: active.statement_end_date,
|
statementEndDate: active.statement_end_date,
|
||||||
openingBalance: active.opening_balance,
|
beginningBalance: begin,
|
||||||
statementBalance: active.statement_balance,
|
endingBalance: ending,
|
||||||
difference: 0,
|
bookBalance: book,
|
||||||
deposits,
|
clearedDeposits: clearedDepRows,
|
||||||
withdrawals,
|
clearedWithdrawals: clearedWdRows,
|
||||||
|
unclearedDeposits: unclearedDepRows,
|
||||||
|
unclearedWithdrawals: unclearedWdRows,
|
||||||
preparedBy: user?.email ?? "—",
|
preparedBy: user?.email ?? "—",
|
||||||
currency: cur,
|
currency: cur,
|
||||||
completedAt: new Date().toISOString(),
|
completedAt: new Date().toISOString(),
|
||||||
@@ -465,20 +473,23 @@ export default function AccountingReconcileDetailPage() {
|
|||||||
<Button size="sm" variant="ghost" onClick={async () => {
|
<Button size="sm" variant="ghost" onClick={async () => {
|
||||||
const { data: rows } = await accounting
|
const { data: rows } = await accounting
|
||||||
.from("transactions")
|
.from("transactions")
|
||||||
.select("date,description,amount,type")
|
.select("date,description,reference,amount,type")
|
||||||
.eq("reconciliation_id", h.id);
|
.eq("reconciliation_id", h.id);
|
||||||
const deposits = (rows ?? []).filter((r: any) => r.type === "credit")
|
const mapRow = (r: any) => ({ name: r.description ?? "", date: r.date, number: r.reference ?? "", memo: "", amount: Number(r.amount) });
|
||||||
.map((r: any) => ({ date: r.date, description: r.description, amount: Number(r.amount) }));
|
const clearedDeposits = (rows ?? []).filter((r: any) => r.type === "credit").map(mapRow);
|
||||||
const withdrawals = (rows ?? []).filter((r: any) => r.type === "debit")
|
const clearedWithdrawals = (rows ?? []).filter((r: any) => r.type === "debit").map(mapRow);
|
||||||
.map((r: any) => ({ date: r.date, description: r.description, amount: Number(r.amount) }));
|
const sumAmt = (rs: { amount: number }[]) => rs.reduce((s, r) => s + r.amount, 0);
|
||||||
|
const begin = Number(h.opening_balance);
|
||||||
|
const ending = begin + sumAmt(clearedDeposits) - sumAmt(clearedWithdrawals);
|
||||||
|
const acctLabel = (account as any)?.code ? `${(account as any).code} ${(account as any).name}` : ((account as any)?.name ?? "Account");
|
||||||
renderReconciliationPdf({
|
renderReconciliationPdf({
|
||||||
companyName: associationName ?? "Company",
|
companyName: associationName ?? "Company",
|
||||||
accountName: (account as any)?.name ?? "Account",
|
accountName: acctLabel,
|
||||||
statementEndDate: h.statement_end_date,
|
statementEndDate: h.statement_end_date,
|
||||||
openingBalance: Number(h.opening_balance),
|
beginningBalance: begin,
|
||||||
statementBalance: Number(h.statement_balance),
|
endingBalance: ending,
|
||||||
difference: 0,
|
bookBalance: ending,
|
||||||
deposits, withdrawals,
|
clearedDeposits, clearedWithdrawals, unclearedDeposits: [], unclearedWithdrawals: [],
|
||||||
preparedBy: user?.email ?? "—",
|
preparedBy: user?.email ?? "—",
|
||||||
currency: cur,
|
currency: cur,
|
||||||
completedAt: h.completed_at ?? new Date().toISOString(),
|
completedAt: h.completed_at ?? new Date().toISOString(),
|
||||||
@@ -579,8 +590,8 @@ export default function AccountingReconcileDetailPage() {
|
|||||||
{successData && (
|
{successData && (
|
||||||
<div className="space-y-2 text-sm">
|
<div className="space-y-2 text-sm">
|
||||||
<div className="flex justify-between"><span className="text-muted-foreground">Period reconciled</span><span>{fmtDate(successData.statementEndDate)}</span></div>
|
<div className="flex justify-between"><span className="text-muted-foreground">Period reconciled</span><span>{fmtDate(successData.statementEndDate)}</span></div>
|
||||||
<div className="flex justify-between"><span className="text-muted-foreground">Statement balance</span><span>{money(successData.statementBalance, successData.currency)}</span></div>
|
<div className="flex justify-between"><span className="text-muted-foreground">Ending balance</span><span>{money(successData.endingBalance, successData.currency)}</span></div>
|
||||||
<div className="flex justify-between"><span className="text-muted-foreground">Cleared transactions</span><span>{successData.deposits.length + successData.withdrawals.length}</span></div>
|
<div className="flex justify-between"><span className="text-muted-foreground">Cleared transactions</span><span>{successData.clearedDeposits.length + successData.clearedWithdrawals.length}</span></div>
|
||||||
<div className="flex justify-between"><span className="text-muted-foreground">Date completed</span><span>{fmtDate(successData.completedAt)}</span></div>
|
<div className="flex justify-between"><span className="text-muted-foreground">Date completed</span><span>{fmtDate(successData.completedAt)}</span></div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Link } from "react-router-dom";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Fragment, useEffect, useMemo, useState } from "react";
|
import { Fragment, useEffect, useMemo, useState } from "react";
|
||||||
import { accounting } from "@/lib/accountingClient";
|
import { accounting } from "@/lib/accountingClient";
|
||||||
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
import { useCompanyId } from "./lib/useCompanyId";
|
import { useCompanyId } from "./lib/useCompanyId";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -29,12 +30,17 @@ import {
|
|||||||
import { Lock } from "lucide-react";
|
import { Lock } from "lucide-react";
|
||||||
import { TrialBalanceReport } from "./components/TrialBalanceReport";
|
import { TrialBalanceReport } from "./components/TrialBalanceReport";
|
||||||
import { GeneralLedgerReport } from "./components/GeneralLedgerReport";
|
import { GeneralLedgerReport } from "./components/GeneralLedgerReport";
|
||||||
|
import { ReserveFundReport } from "./components/ReserveFundReport";
|
||||||
|
import { ReportSheet } from "./components/ReportSheet";
|
||||||
|
import { loadBrandedLogo, drawBrandedHeader, drawBrandedFooter, type BrandedLogo } from "./lib/reportHeader";
|
||||||
|
import { generateBudgetVsActualPdf } from "@/lib/budgetVsActualPdf";
|
||||||
|
|
||||||
type ReportId =
|
type ReportId =
|
||||||
| "pnl" | "balance-sheet" | "cash-flow" | "movement-of-equity" | "budget-vs-actuals"
|
| "pnl" | "balance-sheet" | "cash-flow" | "movement-of-equity" | "budget-vs-actuals"
|
||||||
| "trial-balance" | "general-ledger"
|
| "trial-balance" | "general-ledger"
|
||||||
| "invoice-summary" | "customer-balances" | "ar-aging" | "homeowner-summary" | "delinquency"
|
| "invoice-summary" | "customer-balances" | "ar-aging" | "homeowner-summary" | "delinquency"
|
||||||
| "expense-summary" | "vendor-balances" | "ap-aging" | "reconciliation";
|
| "expense-summary" | "vendor-balances" | "ap-aging" | "reconciliation"
|
||||||
|
| "reserve-fund";
|
||||||
|
|
||||||
const APP_NAME = "Cozy Books";
|
const APP_NAME = "Cozy Books";
|
||||||
const FINANCIAL: ReportId[] = ["pnl", "balance-sheet", "cash-flow", "movement-of-equity"];
|
const FINANCIAL: ReportId[] = ["pnl", "balance-sheet", "cash-flow", "movement-of-equity"];
|
||||||
@@ -61,6 +67,9 @@ const GROUPS = [
|
|||||||
{ id: "expense-summary" as ReportId, name: "Expense Summary" },
|
{ id: "expense-summary" as ReportId, name: "Expense Summary" },
|
||||||
{ id: "vendor-balances" as ReportId, name: "Vendor Balance Summary" },
|
{ id: "vendor-balances" as ReportId, name: "Vendor Balance Summary" },
|
||||||
]},
|
]},
|
||||||
|
{ name: "Reserves", reports: [
|
||||||
|
{ id: "reserve-fund" as ReportId, name: "Reserve Fund Schedule" },
|
||||||
|
]},
|
||||||
{ name: "Audit", reports: [
|
{ name: "Audit", reports: [
|
||||||
{ id: "reconciliation" as ReportId, name: "Reconciliation Checks" },
|
{ id: "reconciliation" as ReportId, name: "Reconciliation Checks" },
|
||||||
]},
|
]},
|
||||||
@@ -188,8 +197,8 @@ function compareDates(mode: CompareMode, from: string, to: string, customFrom: s
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AccountingReportsPage() {
|
export default function AccountingReportsPage({ association }: { association?: { id: string; name?: string } | null } = {}) {
|
||||||
const { companyId, associationName } = useCompanyId();
|
const { companyId, associationName, associationId } = useCompanyId(association);
|
||||||
const cid = companyId ?? "";
|
const cid = companyId ?? "";
|
||||||
const cur = "USD";
|
const cur = "USD";
|
||||||
const [active, setActive] = useState<ReportId>("pnl");
|
const [active, setActive] = useState<ReportId>("pnl");
|
||||||
@@ -218,7 +227,26 @@ export default function AccountingReportsPage() {
|
|||||||
// Toggles
|
// Toggles
|
||||||
const [showCodes, setShowCodes] = useState(false);
|
const [showCodes, setShowCodes] = useState(false);
|
||||||
const [showZero, setShowZero] = useState(false);
|
const [showZero, setShowZero] = useState(false);
|
||||||
const [preview, setPreview] = useState(false);
|
|
||||||
|
const { data: companyMeta } = useQuery({
|
||||||
|
queryKey: ["company-fy", cid],
|
||||||
|
enabled: !!cid,
|
||||||
|
queryFn: async () => (await accounting.from("companies").select("fiscal_year_start").eq("id", cid).maybeSingle()).data,
|
||||||
|
});
|
||||||
|
const fiscalYearStart = (companyMeta as any)?.fiscal_year_start || "01-01";
|
||||||
|
|
||||||
|
// Association logo for branded reports (ACM fallback handled downstream).
|
||||||
|
const { data: assocMeta } = useQuery({
|
||||||
|
queryKey: ["assoc-logo", associationId],
|
||||||
|
enabled: !!associationId,
|
||||||
|
queryFn: async () => (await supabase.from("associations").select("logo_url").eq("id", associationId!).maybeSingle()).data,
|
||||||
|
});
|
||||||
|
const logoUrl = (assocMeta as any)?.logo_url || null;
|
||||||
|
// Preloaded logo dataURL for synchronous PDF header drawing.
|
||||||
|
const { data: brandedLogo } = useQuery({
|
||||||
|
queryKey: ["branded-logo", logoUrl],
|
||||||
|
queryFn: async () => await loadBrandedLogo(logoUrl),
|
||||||
|
});
|
||||||
|
|
||||||
const { data } = useReportData(cid, from, to);
|
const { data } = useReportData(cid, from, to);
|
||||||
const { data: prevData } = useReportData(
|
const { data: prevData } = useReportData(
|
||||||
@@ -342,45 +370,35 @@ export default function AccountingReportsPage() {
|
|||||||
const hasOwnExport = active === "trial-balance" || active === "general-ledger" || active === "budget-vs-actuals";
|
const hasOwnExport = active === "trial-balance" || active === "general-ledger" || active === "budget-vs-actuals";
|
||||||
const anyExportable = !!(structured || flat || exportFlat);
|
const anyExportable = !!(structured || flat || exportFlat);
|
||||||
|
|
||||||
const doExportPDF = () => {
|
const doExportPDF = async () => {
|
||||||
const fileBase = `${activeMeta.name.replace(/\s+/g, "-").toLowerCase()}-${from}-to-${to}`;
|
const fileBase = `${activeMeta.name.replace(/\s+/g, "-").toLowerCase()}-${from}-to-${to}`;
|
||||||
const src = flat ?? exportFlat;
|
const src = flat ?? exportFlat;
|
||||||
|
const logo = brandedLogo ?? (await loadBrandedLogo(logoUrl));
|
||||||
if (structured) {
|
if (structured) {
|
||||||
const doc = renderReportPdf(
|
const doc = renderReportPdf(
|
||||||
structured,
|
structured,
|
||||||
{ companyName: associationName ?? "Company", appName: APP_NAME, rangeLabel, currency: cur, showCodes, showCompare, showZero },
|
{ companyName: associationName ?? "Company", appName: APP_NAME, rangeLabel, currency: cur, showCodes, showCompare, showZero, logo },
|
||||||
);
|
);
|
||||||
doc.save(`${fileBase}.pdf`);
|
doc.save(`${fileBase}.pdf`);
|
||||||
} else if (src) {
|
} else if (src) {
|
||||||
const doc = new jsPDF({ orientation: src.columns.length > 6 ? "landscape" : "portrait" });
|
const doc = new jsPDF({ unit: "pt", format: "letter", orientation: src.columns.length > 6 ? "landscape" : "portrait" });
|
||||||
doc.setFont("helvetica", "bold"); doc.setFontSize(14); doc.setTextColor(33, 37, 41);
|
const startY = drawBrandedHeader(doc, {
|
||||||
doc.text(src.title, 14, 16);
|
logo, title: src.title,
|
||||||
doc.setFont("helvetica", "bold"); doc.setFontSize(9);
|
metaLines: [{ label: "Properties:", value: associationName ?? "" }, { label: "Period:", value: rangeLabel }],
|
||||||
doc.text("Properties:", 14, 24);
|
});
|
||||||
const lw = doc.getTextWidth("Properties:");
|
|
||||||
doc.setFont("helvetica", "normal");
|
|
||||||
doc.text(` ${associationName ?? ""}`, 14 + lw, 24);
|
|
||||||
doc.setFont("helvetica", "bold"); doc.text("Period:", 14, 30);
|
|
||||||
const lw2 = doc.getTextWidth("Period:");
|
|
||||||
doc.setFont("helvetica", "normal"); doc.text(` ${rangeLabel}`, 14 + lw2, 30);
|
|
||||||
const lastCol = src.columns.length - 1;
|
const lastCol = src.columns.length - 1;
|
||||||
autoTable(doc, {
|
autoTable(doc, {
|
||||||
head: [src.columns],
|
head: [src.columns],
|
||||||
body: src.rows.map(r => r.map(String)),
|
body: src.rows.map(r => r.map(String)),
|
||||||
startY: 36,
|
startY,
|
||||||
|
margin: { left: 40, right: 40 },
|
||||||
styles: { font: "helvetica", fontSize: 8, textColor: [33, 37, 41], lineColor: [222, 226, 230], lineWidth: 0.1 },
|
styles: { font: "helvetica", fontSize: 8, textColor: [33, 37, 41], lineColor: [222, 226, 230], lineWidth: 0.1 },
|
||||||
headStyles: { fillColor: [237, 239, 242], textColor: [33, 37, 41], fontStyle: "bold", lineColor: [196, 200, 205], lineWidth: 0.2 },
|
headStyles: { fillColor: [237, 239, 242], textColor: [33, 37, 41], fontStyle: "bold", lineColor: [196, 200, 205], lineWidth: 0.2 },
|
||||||
alternateRowStyles: { fillColor: [247, 248, 250] },
|
alternateRowStyles: { fillColor: [247, 248, 250] },
|
||||||
columnStyles: { [lastCol]: { halign: "right" } },
|
columnStyles: { [lastCol]: { halign: "right" } },
|
||||||
didParseCell: ({ row, cell }) => { if (src.boldRows?.includes(row.index)) cell.styles.fontStyle = "bold"; },
|
didParseCell: ({ row, cell }) => { if (src.boldRows?.includes(row.index)) cell.styles.fontStyle = "bold"; },
|
||||||
didDrawPage: () => {
|
|
||||||
const pageW = doc.internal.pageSize.getWidth();
|
|
||||||
const pageH = doc.internal.pageSize.getHeight();
|
|
||||||
doc.setFont("helvetica", "normal"); doc.setFontSize(8); doc.setTextColor(110, 116, 122);
|
|
||||||
doc.text(`Created on ${new Date().toLocaleDateString("en-US")}`, 14, pageH - 8);
|
|
||||||
doc.text(`Page ${doc.getNumberOfPages()}`, pageW - 14, pageH - 8, { align: "right" });
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
drawBrandedFooter(doc);
|
||||||
doc.save(`${fileBase}.pdf`);
|
doc.save(`${fileBase}.pdf`);
|
||||||
} else {
|
} else {
|
||||||
toast.error("No data to export for this report");
|
toast.error("No data to export for this report");
|
||||||
@@ -456,7 +474,6 @@ export default function AccountingReportsPage() {
|
|||||||
<span className="text-xs text-muted-foreground self-center">Export available inside the report ↓</span>
|
<span className="text-xs text-muted-foreground self-center">Export available inside the report ↓</span>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{isFinancial && <Button variant="outline" onClick={() => setPreview(true)}><Eye className="mr-1 h-4 w-4" /> Preview</Button>}
|
|
||||||
<Button variant="outline" onClick={exportCSV} disabled={!anyExportable}><Download className="mr-1 h-4 w-4" /> CSV</Button>
|
<Button variant="outline" onClick={exportCSV} disabled={!anyExportable}><Download className="mr-1 h-4 w-4" /> CSV</Button>
|
||||||
<Button onClick={exportPDF} disabled={!anyExportable}><FileDown className="mr-1 h-4 w-4" /> PDF</Button>
|
<Button onClick={exportPDF} disabled={!anyExportable}><FileDown className="mr-1 h-4 w-4" /> PDF</Button>
|
||||||
</>
|
</>
|
||||||
@@ -525,20 +542,35 @@ export default function AccountingReportsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
{active === "budget-vs-actuals" && (
|
{active === "budget-vs-actuals" && (
|
||||||
<BudgetVsActuals companyId={cid} from={from} to={to} currency={cur} companyName={associationName ?? "Company"} rangeLabel={rangeLabel} />
|
<BudgetVsActuals companyId={cid} from={from} to={to} currency={cur} companyName={associationName ?? "Company"} rangeLabel={rangeLabel} logoUrl={logoUrl} />
|
||||||
)}
|
)}
|
||||||
{active === "trial-balance" && (
|
{active === "trial-balance" && (
|
||||||
<TrialBalanceReport companyId={cid} companyName={associationName ?? ""} />
|
<TrialBalanceReport companyId={cid} companyName={associationName ?? ""} logoUrl={logoUrl} />
|
||||||
)}
|
)}
|
||||||
{active === "general-ledger" && (
|
{active === "general-ledger" && (
|
||||||
<GeneralLedgerReport companyId={cid} companyName={associationName ?? ""} />
|
<GeneralLedgerReport companyId={cid} companyName={associationName ?? ""} logoUrl={logoUrl} />
|
||||||
|
)}
|
||||||
|
{active === "reserve-fund" && (
|
||||||
|
<ReserveFundReport companyId={cid} companyName={associationName ?? ""} fiscalYearStart={fiscalYearStart} logoUrl={logoUrl} />
|
||||||
)}
|
)}
|
||||||
{active === "reconciliation" && (
|
{active === "reconciliation" && (
|
||||||
<ReconciliationReport d={data} currency={cur} />
|
<ReportSheet title="Reconciliation Checks" companyName={associationName ?? "Company"} period={rangeLabel} logoUrl={logoUrl}>
|
||||||
|
<ReconciliationReport d={data} currency={cur} />
|
||||||
|
</ReportSheet>
|
||||||
)}
|
)}
|
||||||
{!isFinancial && active !== "budget-vs-actuals" && active !== "trial-balance" && active !== "general-ledger" && active !== "reconciliation" && (
|
{isFinancial && (
|
||||||
<Card>
|
!data ? (
|
||||||
<CardContent>
|
<Card><CardContent className="p-6"><div className="py-8 text-center text-sm text-muted-foreground">Loading…</div></CardContent></Card>
|
||||||
|
) : structured ? (
|
||||||
|
<ReportSheet title={activeMeta.name} subtitle="Accrual basis" companyName={associationName ?? "Company"} period={rangeLabel} logoUrl={logoUrl}>
|
||||||
|
<StructuredTable report={structured} showCodes={showCodes} showCompare={showCompare} showZero={showZero} currency={cur} />
|
||||||
|
</ReportSheet>
|
||||||
|
) : (
|
||||||
|
<Card><CardContent className="p-6"><div className="py-12 text-center text-sm text-muted-foreground">No data for this report in the selected range.</div></CardContent></Card>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{!isFinancial && active !== "budget-vs-actuals" && active !== "trial-balance" && active !== "general-ledger" && active !== "reserve-fund" && active !== "reconciliation" && (
|
||||||
|
<ReportSheet title={activeMeta.name} companyName={associationName ?? "Company"} period={rangeLabel} logoUrl={logoUrl}>
|
||||||
{!data ? (
|
{!data ? (
|
||||||
<div className="text-sm text-muted-foreground">Loading…</div>
|
<div className="text-sm text-muted-foreground">Loading…</div>
|
||||||
) : structured ? (
|
) : structured ? (
|
||||||
@@ -573,38 +605,10 @@ export default function AccountingReportsPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="py-12 text-center text-sm text-muted-foreground">No data for this report in the selected range.</div>
|
<div className="py-12 text-center text-sm text-muted-foreground">No data for this report in the selected range.</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</ReportSheet>
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Print Preview Modal */}
|
|
||||||
<Dialog open={preview} onOpenChange={setPreview}>
|
|
||||||
<DialogContent className="max-w-5xl max-h-[90vh] overflow-hidden flex flex-col">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Print preview · {activeMeta.name}</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="flex flex-wrap items-center gap-5 border-y bg-muted/40 px-4 py-3 text-sm">
|
|
||||||
<Toggle id="t-codes" checked={showCodes} onChange={setShowCodes} label="Show account codes" />
|
|
||||||
<Toggle id="t-compare" checked={showCompare} onChange={(v) => setCompareMode(v ? "prior-year" : "none")} label="Show comparative period" />
|
|
||||||
<Toggle id="t-zero" checked={showZero} onChange={setShowZero} label="Show zero-balance accounts" />
|
|
||||||
</div>
|
|
||||||
<div className="overflow-auto flex-1 bg-muted/30 p-6">
|
|
||||||
<PreviewSheet
|
|
||||||
report={structured ?? (flat ? flatToStructured(flat, activeMeta.name) : null)}
|
|
||||||
companyName={associationName ?? "Company"}
|
|
||||||
rangeLabel={rangeLabel}
|
|
||||||
showCodes={showCodes}
|
|
||||||
showCompare={showCompare && isFinancial}
|
|
||||||
showZero={showZero}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setPreview(false)}>Close</Button>
|
|
||||||
<Button onClick={exportPDF}><FileDown className="mr-1 h-4 w-4" /> Download PDF</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1390,7 +1394,7 @@ function computeBvaActuals(actualsData: any, grouped: Record<string, any[]>, bud
|
|||||||
return m;
|
return m;
|
||||||
}
|
}
|
||||||
|
|
||||||
function BudgetVsActuals({ companyId, from, to, currency, companyName, rangeLabel }: { companyId: string; from: string; to: string; currency: string; companyName: string; rangeLabel: string }) {
|
function BudgetVsActuals({ companyId, from, to, currency, companyName, rangeLabel, logoUrl }: { companyId: string; from: string; to: string; currency: string; companyName: string; rangeLabel: string; logoUrl?: string | null }) {
|
||||||
const { data: budgets = [] } = useQuery({
|
const { data: budgets = [] } = useQuery({
|
||||||
queryKey: ["budgets-active", companyId],
|
queryKey: ["budgets-active", companyId],
|
||||||
enabled: !!companyId,
|
enabled: !!companyId,
|
||||||
@@ -1484,6 +1488,38 @@ function BudgetVsActuals({ companyId, from, to, currency, companyName, rangeLabe
|
|||||||
const actualByAcct = useMemo(() => computeBvaActuals(actualsData, grouped, budgetByAcct), [actualsData, grouped, budgetByAcct]);
|
const actualByAcct = useMemo(() => computeBvaActuals(actualsData, grouped, budgetByAcct), [actualsData, grouped, budgetByAcct]);
|
||||||
const cmpActualByAcct = useMemo(() => computeBvaActuals(cmpActualsData, grouped, budgetByAcct), [cmpActualsData, grouped, budgetByAcct]);
|
const cmpActualByAcct = useMemo(() => computeBvaActuals(cmpActualsData, grouped, budgetByAcct), [cmpActualsData, grouped, budgetByAcct]);
|
||||||
|
|
||||||
|
// Comparison-window budget (pro-rated like budgetByAcct, over [cmpFrom, cmpTo]).
|
||||||
|
const cmpBudgetByAcct = useMemo(() => {
|
||||||
|
const m: Record<string, number> = {};
|
||||||
|
if (!cmpOn || !cmpFrom || !cmpTo) return m;
|
||||||
|
const pt = String(selectedBudget?.period_type ?? "annual");
|
||||||
|
const fy = Number(selectedBudget?.fiscal_year) || new Date(cmpFrom || cmpTo || Date.now()).getFullYear();
|
||||||
|
const fromT = new Date(cmpFrom).getTime();
|
||||||
|
const toT = new Date(cmpTo).getTime();
|
||||||
|
const DAY = 86400000;
|
||||||
|
const span = (idx: number): [number, number] => {
|
||||||
|
if (pt === "monthly") return [new Date(fy, idx, 1).getTime(), new Date(fy, idx + 1, 0).getTime()];
|
||||||
|
if (pt === "quarterly") return [new Date(fy, idx * 3, 1).getTime(), new Date(fy, idx * 3 + 3, 0).getTime()];
|
||||||
|
return [new Date(fy, 0, 1).getTime(), new Date(fy, 11, 31).getTime()];
|
||||||
|
};
|
||||||
|
for (const e of (entries as any[])) {
|
||||||
|
const [s, en] = span(Number(e.period_index) || 0);
|
||||||
|
const overlap = Math.max(0, Math.min(en, toT) - Math.max(s, fromT));
|
||||||
|
const full = en - s;
|
||||||
|
const weight = full > 0 ? Math.min(1, (overlap + DAY) / (full + DAY)) : 1;
|
||||||
|
if (weight <= 0) continue;
|
||||||
|
m[e.account_id] = (m[e.account_id] ?? 0) + Number(e.amount) * weight;
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}, [entries, selectedBudget, cmpOn, cmpFrom, cmpTo]);
|
||||||
|
|
||||||
|
// Full-year budget per account (no pro-ration) for the Annual Budget column.
|
||||||
|
const annualBudgetByAcct = useMemo(() => {
|
||||||
|
const m: Record<string, number> = {};
|
||||||
|
for (const e of (entries as any[])) m[e.account_id] = (m[e.account_id] ?? 0) + Number(e.amount);
|
||||||
|
return m;
|
||||||
|
}, [entries]);
|
||||||
|
|
||||||
const chartData = useMemo(() => {
|
const chartData = useMemo(() => {
|
||||||
const sumGroup = (type: "income" | "expense") => {
|
const sumGroup = (type: "income" | "expense") => {
|
||||||
const accs = grouped[type] ?? [];
|
const accs = grouped[type] ?? [];
|
||||||
@@ -1530,22 +1566,51 @@ function BudgetVsActuals({ companyId, from, to, currency, companyName, rangeLabe
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
const exportPDF = () => {
|
// Export using the shared branded Budget vs. Actuals generator (current period +
|
||||||
const doc = new jsPDF({ unit: "pt", format: "letter" });
|
// optional comparison + Annual Budget columns), matching the main report style.
|
||||||
doc.setFont("helvetica", "bold"); doc.setFontSize(14); doc.setTextColor(33, 37, 41);
|
const exportPDF = async () => {
|
||||||
doc.text("Budget vs Actuals", 40, 50);
|
const nameById = new Map((accounts as any[]).map((a) => [a.id, a.name]));
|
||||||
doc.setFont("helvetica", "normal"); doc.setFontSize(9); doc.setTextColor(110, 116, 122);
|
const parentIds = new Set((accounts as any[]).filter((a) => a.parent_account_id).map((a) => a.parent_account_id));
|
||||||
doc.text(`${companyName} · ${actualsLabel} · Budget pro-rated to period`, 40, 66);
|
const rows: any[] = [];
|
||||||
autoTable(doc, {
|
for (const type of ["income", "expense"] as const) {
|
||||||
startY: 80,
|
for (const a of (grouped[type] ?? [])) {
|
||||||
head: [["Account", "Budget", "Actual", "Variance", "Variance %"]],
|
if (parentIds.has(a.id)) continue; // parent accounts render as group headers
|
||||||
body: exportRows.map((r) => [r.label, money(r.budget, currency), money(r.actual, currency), money(r.variance, currency), r.pct]),
|
const budget = budgetByAcct[a.id] ?? 0;
|
||||||
styles: { font: "helvetica", fontSize: 8, textColor: [33, 37, 41], lineColor: [222, 226, 230], lineWidth: 0.1 },
|
const actual = actualByAcct[a.id] ?? 0;
|
||||||
headStyles: { fillColor: [237, 239, 242], textColor: [33, 37, 41], fontStyle: "bold", lineColor: [196, 200, 205], lineWidth: 0.2 },
|
const cmpA = cmpActualByAcct[a.id] ?? 0;
|
||||||
columnStyles: { 1: { halign: "right" }, 2: { halign: "right" }, 3: { halign: "right" }, 4: { halign: "right" } },
|
const cmpB = cmpBudgetByAcct[a.id] ?? 0;
|
||||||
didParseCell: ({ row, cell, section }) => { if (section === "body" && exportRows[row.index]?.group) cell.styles.fontStyle = "bold"; },
|
const annual = annualBudgetByAcct[a.id] ?? 0;
|
||||||
|
rows.push({
|
||||||
|
id: a.id,
|
||||||
|
category: a.code ? `${a.code} ${a.name}` : a.name,
|
||||||
|
accountType: type,
|
||||||
|
parentId: a.parent_account_id ?? null,
|
||||||
|
parentCategory: a.parent_account_id ? (nameById.get(a.parent_account_id) ?? null) : null,
|
||||||
|
budget, annualBudget: annual, actual, variance: actual - budget,
|
||||||
|
pctOfBudget: budget ? (actual / budget) * 100 : 0,
|
||||||
|
comparisonActual: cmpA, comparisonBudget: cmpB, comparisonVariance: cmpA - cmpB,
|
||||||
|
comparisonPctOfBudget: cmpB ? (cmpA / cmpB) * 100 : 0, cmpDelta: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const inc = rows.filter((r) => r.accountType === "income");
|
||||||
|
const exp = rows.filter((r) => r.accountType !== "income");
|
||||||
|
const sum = (rs: any[], k: string) => rs.reduce((s, r) => s + (Number(r[k]) || 0), 0);
|
||||||
|
await generateBudgetVsActualPdf({
|
||||||
|
association: { name: companyName, logo_url: logoUrl ?? null },
|
||||||
|
fiscalYear: Number(selectedBudget?.fiscal_year) || new Date().getFullYear(),
|
||||||
|
rangeLabel: actualsLabel,
|
||||||
|
comparisonLabel: cmpOn && cmpFrom && cmpTo ? "Comparison" : null,
|
||||||
|
comparisonRangeLabel: cmpOn && cmpFrom && cmpTo ? `${cmpFrom} to ${cmpTo}` : null,
|
||||||
|
rows,
|
||||||
|
totals: {
|
||||||
|
incomeBudget: sum(inc, "budget"), incomeActual: sum(inc, "actual"),
|
||||||
|
incomeCmp: sum(inc, "comparisonActual"), incomeCmpBudget: sum(inc, "comparisonBudget"),
|
||||||
|
expenseBudget: sum(exp, "budget"), expenseActual: sum(exp, "actual"),
|
||||||
|
expenseCmp: sum(exp, "comparisonActual"), expenseCmpBudget: sum(exp, "comparisonBudget"),
|
||||||
|
},
|
||||||
|
comparisonBudgetMonths: null,
|
||||||
});
|
});
|
||||||
doc.save(`${fileBase}.pdf`);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!budgets.length) {
|
if (!budgets.length) {
|
||||||
@@ -1593,25 +1658,7 @@ function BudgetVsActuals({ companyId, from, to, currency, companyName, rangeLabe
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<ReportSheet title="Budget vs. Actuals" subtitle="Accrual basis" companyName={companyName} period={actualsLabel} logoUrl={logoUrl}>
|
||||||
<CardHeader><CardTitle className="text-base">Income vs Expenses</CardTitle></CardHeader>
|
|
||||||
<CardContent className="h-72">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<BarChart data={chartData}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
|
|
||||||
<XAxis dataKey="name" />
|
|
||||||
<YAxis tickFormatter={(v) => money(v, currency)} width={90} />
|
|
||||||
<RTooltip formatter={(v: any) => money(Number(v), currency)} />
|
|
||||||
<Legend />
|
|
||||||
<Bar dataKey="Budget" fill="hsl(174 70% 45%)" />
|
|
||||||
<Bar dataKey="Actual" fill="hsl(214 80% 55%)" />
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
@@ -1673,8 +1720,7 @@ function BudgetVsActuals({ companyId, from, to, currency, companyName, rangeLabe
|
|||||||
})}
|
})}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</CardContent>
|
</ReportSheet>
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import { fmtDate } from "../lib/format";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import jsPDF from "jspdf";
|
import jsPDF from "jspdf";
|
||||||
import autoTable from "jspdf-autotable";
|
import autoTable from "jspdf-autotable";
|
||||||
|
import { ReportSheet } from "./ReportSheet";
|
||||||
|
import { loadBrandedLogo, drawBrandedHeader, drawBrandedFooter } from "../lib/reportHeader";
|
||||||
|
|
||||||
const TEAL: [number, number, number] = [0, 137, 123];
|
const TEAL: [number, number, number] = [0, 137, 123];
|
||||||
const DEBIT_NATURAL = ["asset", "expense"];
|
const DEBIT_NATURAL = ["asset", "expense"];
|
||||||
@@ -35,7 +37,7 @@ type Txn = {
|
|||||||
debit: number; credit: number; balance: number; abnormal?: boolean;
|
debit: number; credit: number; balance: number; abnormal?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function GeneralLedgerReport({ companyId, companyName }: { companyId: string; companyName: string }) {
|
export function GeneralLedgerReport({ companyId, companyName, logoUrl }: { companyId: string; companyName: string; logoUrl?: string | null }) {
|
||||||
const [from, setFrom] = useState(startOfYear());
|
const [from, setFrom] = useState(startOfYear());
|
||||||
const [to, setTo] = useState(today());
|
const [to, setTo] = useState(today());
|
||||||
const [selectedAccounts, setSelectedAccounts] = useState<string[]>([]);
|
const [selectedAccounts, setSelectedAccounts] = useState<string[]>([]);
|
||||||
@@ -144,28 +146,18 @@ export function GeneralLedgerReport({ companyId, companyName }: { companyId: str
|
|||||||
|
|
||||||
const toggleAccount = (id: string) => setSelectedAccounts((s) => s.includes(id) ? s.filter((x) => x !== id) : [...s, id]);
|
const toggleAccount = (id: string) => setSelectedAccounts((s) => s.includes(id) ? s.filter((x) => x !== id) : [...s, id]);
|
||||||
|
|
||||||
const exportPDF = () => {
|
const exportPDF = async () => {
|
||||||
const doc = new jsPDF({ unit: "pt", format: "letter" });
|
const doc = new jsPDF({ unit: "pt", format: "letter" });
|
||||||
const W = doc.internal.pageSize.getWidth();
|
const W = doc.internal.pageSize.getWidth();
|
||||||
const H = doc.internal.pageSize.getHeight();
|
const H = doc.internal.pageSize.getHeight();
|
||||||
const ML = 54;
|
const ML = 54;
|
||||||
|
|
||||||
// Header
|
const logo = await loadBrandedLogo(logoUrl);
|
||||||
doc.setFillColor(...TEAL); doc.rect(ML, 40, 44, 44, "F");
|
let cursorY = drawBrandedHeader(doc, {
|
||||||
doc.setFont("times", "bold"); doc.setFontSize(20); doc.setTextColor(255);
|
logo, title: "General Ledger",
|
||||||
doc.text((companyName[0] ?? "?").toUpperCase(), ML + 22, 70, { align: "center" });
|
subtitle: `${fmtDate(from)} – ${fmtDate(to)} · ${basis === "accrual" ? "Accrual" : "Cash"} Basis`,
|
||||||
doc.setTextColor(20); doc.setFontSize(16);
|
metaLines: [{ label: "Properties:", value: companyName }],
|
||||||
doc.text(companyName, ML + 56, 60);
|
});
|
||||||
doc.setFont("times", "normal"); doc.setFontSize(11); doc.setTextColor(100);
|
|
||||||
doc.text("Accounting", ML + 56, 76);
|
|
||||||
doc.setFont("times", "bold"); doc.setFontSize(22); doc.setTextColor(20);
|
|
||||||
doc.text("General Ledger", W / 2, 120, { align: "center" });
|
|
||||||
doc.setFont("times", "normal"); doc.setFontSize(11); doc.setTextColor(100);
|
|
||||||
doc.text(`${fmtDate(from)} – ${fmtDate(to)} · ${basis === "accrual" ? "Accrual" : "Cash"} Basis`, W / 2, 138, { align: "center" });
|
|
||||||
doc.setDrawColor(...TEAL); doc.setLineWidth(1.5);
|
|
||||||
doc.line(ML, 150, W - ML, 150);
|
|
||||||
|
|
||||||
let cursorY = 165;
|
|
||||||
|
|
||||||
// Table of contents (placeholder — page numbers filled after layout)
|
// Table of contents (placeholder — page numbers filled after layout)
|
||||||
const tocEntries: { name: string; page: number }[] = [];
|
const tocEntries: { name: string; page: number }[] = [];
|
||||||
@@ -252,12 +244,7 @@ export function GeneralLedgerReport({ companyId, companyName }: { companyId: str
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const pageCount = doc.getNumberOfPages();
|
drawBrandedFooter(doc);
|
||||||
for (let i = 1; i <= pageCount; i++) {
|
|
||||||
doc.setPage(i);
|
|
||||||
doc.setFontSize(8); doc.setTextColor(120); doc.setFont("times", "normal");
|
|
||||||
doc.text(`Generated ${new Date().toLocaleDateString()} · Page ${i} of ${pageCount}`, W / 2, H - 24, { align: "center" });
|
|
||||||
}
|
|
||||||
doc.save(`general-ledger-${from}-to-${to}.pdf`);
|
doc.save(`general-ledger-${from}-to-${to}.pdf`);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -359,10 +346,17 @@ export function GeneralLedgerReport({ companyId, companyName }: { companyId: str
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<ReportSheet
|
||||||
|
title="General Ledger"
|
||||||
|
subtitle={`${fmtDate(from)} – ${fmtDate(to)} · ${basis === "accrual" ? "Accrual" : "Cash"} Basis`}
|
||||||
|
companyName={companyName}
|
||||||
|
logoUrl={logoUrl}
|
||||||
|
>
|
||||||
{accountList.length === 0 ? (
|
{accountList.length === 0 ? (
|
||||||
<Card><CardContent className="py-12 text-center text-sm text-muted-foreground">No transactions in this period.</CardContent></Card>
|
<div className="py-12 text-center text-sm text-muted-foreground">No transactions in this period.</div>
|
||||||
) : (
|
) : (
|
||||||
accountList.map((g) => {
|
<div className="space-y-4">
|
||||||
|
{accountList.map((g) => {
|
||||||
const collapsedHere = isCollapsed(g.account.id);
|
const collapsedHere = isCollapsed(g.account.id);
|
||||||
const page = pageByAcct[g.account.id] ?? 1;
|
const page = pageByAcct[g.account.id] ?? 1;
|
||||||
const totalRows = g.entries.length;
|
const totalRows = g.entries.length;
|
||||||
@@ -465,8 +459,10 @@ export function GeneralLedgerReport({ companyId, companyName }: { companyId: str
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
})
|
})}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</ReportSheet>
|
||||||
|
|
||||||
<Sheet open={!!drillTxn} onOpenChange={(o) => !o && setDrillTxn(null)}>
|
<Sheet open={!!drillTxn} onOpenChange={(o) => !o && setDrillTxn(null)}>
|
||||||
<SheetContent className="sm:max-w-md">
|
<SheetContent className="sm:max-w-md">
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import acmLogoFull from "@/assets/acm-logo-full.png";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Branded on-screen "sheet" for every accounting report, mirroring the printed
|
||||||
|
* report look: logo top-left, centered title + optional subtitle, centered
|
||||||
|
* company name + period line, then the report table (children).
|
||||||
|
*
|
||||||
|
* Falls back to the management-company (ACM) logo when no association logo is set.
|
||||||
|
*/
|
||||||
|
export function ReportSheet({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
companyName,
|
||||||
|
period,
|
||||||
|
logoUrl,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
companyName?: string;
|
||||||
|
period?: string;
|
||||||
|
logoUrl?: string | null;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="relative mb-5">
|
||||||
|
<img
|
||||||
|
src={logoUrl || (acmLogoFull as unknown as string)}
|
||||||
|
alt=""
|
||||||
|
className="absolute left-0 top-0 h-10 w-auto object-contain"
|
||||||
|
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}
|
||||||
|
/>
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-xl font-semibold tracking-tight">{title}</h2>
|
||||||
|
{subtitle && <p className="text-sm text-muted-foreground">{subtitle}</p>}
|
||||||
|
{companyName && <p className="text-sm font-medium mt-1">{companyName}</p>}
|
||||||
|
{period && <p className="text-xs text-muted-foreground">{period}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { accounting } from "@/lib/accountingClient";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { FileDown, Download } from "lucide-react";
|
||||||
|
import jsPDF from "jspdf";
|
||||||
|
import autoTable from "jspdf-autotable";
|
||||||
|
import { ReportSheet } from "./ReportSheet";
|
||||||
|
import { loadBrandedLogo, drawBrandedHeader, drawBrandedFooter } from "../lib/reportHeader";
|
||||||
|
|
||||||
|
type AcctType = "asset" | "liability" | "equity" | "income" | "expense";
|
||||||
|
const DEBIT_NATURAL: AcctType[] = ["asset", "expense"];
|
||||||
|
|
||||||
|
const TEAL: [number, number, number] = [0, 137, 123];
|
||||||
|
|
||||||
|
type ReserveAccount = { id: string; name: string; code: string | null; type: AcctType };
|
||||||
|
|
||||||
|
type Row = {
|
||||||
|
code: string | null;
|
||||||
|
name: string;
|
||||||
|
priorBalance: number;
|
||||||
|
currMonthAdditions: number;
|
||||||
|
additionsThisYear: number;
|
||||||
|
currMonthExpenses: number;
|
||||||
|
prevExpenseThisYear: number;
|
||||||
|
totExpenseThisYear: number;
|
||||||
|
currentBalance: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Always two decimals, thousands-separated, negatives in parentheses. */
|
||||||
|
function num(n: number): string {
|
||||||
|
const v = Math.round((n + Number.EPSILON) * 100) / 100 || 0;
|
||||||
|
const abs = Math.abs(v).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
return v < 0 ? `(${abs})` : abs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Last day of a YYYY-MM month, as YYYY-MM-DD (no timezone drift). */
|
||||||
|
function monthEnd(ym: string): string {
|
||||||
|
const [y, m] = ym.split("-").map(Number);
|
||||||
|
const d = new Date(y, m, 0).getDate();
|
||||||
|
return `${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function monthStartOf(ym: string): string {
|
||||||
|
return `${ym}-01`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fiscal-year start (YYYY-MM-DD) for the FY that contains `asOf`. */
|
||||||
|
function fiscalYearStartFor(asOf: string, fiscalYearStart: string): string {
|
||||||
|
const year = Number(asOf.slice(0, 4));
|
||||||
|
const md = (fiscalYearStart || "01-01").trim(); // "MM-DD"
|
||||||
|
const candidate = `${year}-${md}`;
|
||||||
|
return asOf >= candidate ? candidate : `${year - 1}-${md}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReserveFundReport({
|
||||||
|
companyId,
|
||||||
|
companyName,
|
||||||
|
fiscalYearStart = "01-01",
|
||||||
|
logoUrl,
|
||||||
|
}: {
|
||||||
|
companyId: string;
|
||||||
|
companyName: string;
|
||||||
|
fiscalYearStart?: string;
|
||||||
|
logoUrl?: string | null;
|
||||||
|
}) {
|
||||||
|
const [asOfMonth, setAsOfMonth] = useState(() => new Date().toISOString().slice(0, 7));
|
||||||
|
|
||||||
|
const asOf = monthEnd(asOfMonth);
|
||||||
|
const monthStart = monthStartOf(asOfMonth);
|
||||||
|
const fyStart = fiscalYearStartFor(asOf, fiscalYearStart);
|
||||||
|
|
||||||
|
const { data: reserveAccounts = [], isLoading: acctLoading } = useQuery({
|
||||||
|
queryKey: ["reserve-accounts", companyId],
|
||||||
|
enabled: !!companyId,
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await accounting
|
||||||
|
.from("accounts")
|
||||||
|
.select("id,name,code,type,is_reserve")
|
||||||
|
.eq("company_id", companyId)
|
||||||
|
.eq("is_reserve", true)
|
||||||
|
.order("code", { ascending: true });
|
||||||
|
return (data ?? []) as ReserveAccount[];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const reserveIds = useMemo(() => reserveAccounts.map((a) => a.id), [reserveAccounts]);
|
||||||
|
|
||||||
|
// Only pull ledger lines for the (few) reserve accounts — never the whole
|
||||||
|
// company ledger, which can run into millions of rows.
|
||||||
|
const { data: glLines = [], isLoading: glLoading } = useQuery({
|
||||||
|
queryKey: ["reserve-gl", companyId, asOf, reserveIds.join(",")],
|
||||||
|
enabled: !!companyId && reserveIds.length > 0,
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await accounting
|
||||||
|
.from("journal_entry_lines")
|
||||||
|
.select("debit,credit,account_id,journal_entries!inner(company_id,date)")
|
||||||
|
.eq("journal_entries.company_id", companyId)
|
||||||
|
.in("account_id", reserveIds)
|
||||||
|
.lte("journal_entries.date", asOf)
|
||||||
|
.limit(50000);
|
||||||
|
return (data ?? []) as any[];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = useMemo<Row[]>(() => {
|
||||||
|
const byAcct = new Map<string, ReserveAccount>();
|
||||||
|
for (const a of reserveAccounts) byAcct.set(a.id, a);
|
||||||
|
|
||||||
|
const acc = new Map<string, Row>();
|
||||||
|
for (const a of reserveAccounts) {
|
||||||
|
acc.set(a.id, {
|
||||||
|
code: a.code,
|
||||||
|
name: a.name,
|
||||||
|
priorBalance: 0,
|
||||||
|
currMonthAdditions: 0,
|
||||||
|
additionsThisYear: 0,
|
||||||
|
currMonthExpenses: 0,
|
||||||
|
prevExpenseThisYear: 0,
|
||||||
|
totExpenseThisYear: 0,
|
||||||
|
currentBalance: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const l of glLines) {
|
||||||
|
const a = byAcct.get(l.account_id);
|
||||||
|
if (!a) continue; // only reserve accounts
|
||||||
|
const r = acc.get(l.account_id)!;
|
||||||
|
const d: string = l.journal_entries?.date ?? "";
|
||||||
|
if (!d) continue;
|
||||||
|
const debit = Number(l.debit || 0);
|
||||||
|
const credit = Number(l.credit || 0);
|
||||||
|
const naturalDebit = DEBIT_NATURAL.includes(a.type);
|
||||||
|
// Additions move the balance up (natural side); expenses move it down.
|
||||||
|
const addition = naturalDebit ? debit : credit;
|
||||||
|
const expense = naturalDebit ? credit : debit;
|
||||||
|
const naturalDelta = naturalDebit ? debit - credit : credit - debit;
|
||||||
|
|
||||||
|
if (d < fyStart) {
|
||||||
|
r.priorBalance += naturalDelta;
|
||||||
|
} else if (d <= asOf) {
|
||||||
|
r.additionsThisYear += addition;
|
||||||
|
r.totExpenseThisYear += expense;
|
||||||
|
if (d >= monthStart) {
|
||||||
|
r.currMonthAdditions += addition;
|
||||||
|
r.currMonthExpenses += expense;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const out: Row[] = [];
|
||||||
|
for (const a of reserveAccounts) {
|
||||||
|
const r = acc.get(a.id)!;
|
||||||
|
r.prevExpenseThisYear = r.totExpenseThisYear - r.currMonthExpenses;
|
||||||
|
r.currentBalance = r.priorBalance + r.additionsThisYear - r.totExpenseThisYear;
|
||||||
|
out.push(r);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, [reserveAccounts, glLines, fyStart, monthStart, asOf]);
|
||||||
|
|
||||||
|
const totals = useMemo(() => {
|
||||||
|
return rows.reduce(
|
||||||
|
(t, r) => ({
|
||||||
|
priorBalance: t.priorBalance + r.priorBalance,
|
||||||
|
currMonthAdditions: t.currMonthAdditions + r.currMonthAdditions,
|
||||||
|
additionsThisYear: t.additionsThisYear + r.additionsThisYear,
|
||||||
|
currMonthExpenses: t.currMonthExpenses + r.currMonthExpenses,
|
||||||
|
prevExpenseThisYear: t.prevExpenseThisYear + r.prevExpenseThisYear,
|
||||||
|
totExpenseThisYear: t.totExpenseThisYear + r.totExpenseThisYear,
|
||||||
|
currentBalance: t.currentBalance + r.currentBalance,
|
||||||
|
}),
|
||||||
|
{ priorBalance: 0, currMonthAdditions: 0, additionsThisYear: 0, currMonthExpenses: 0, prevExpenseThisYear: 0, totExpenseThisYear: 0, currentBalance: 0 },
|
||||||
|
);
|
||||||
|
}, [rows]);
|
||||||
|
|
||||||
|
const asOfLabel = new Date(asOf + "T00:00:00").toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" });
|
||||||
|
const isLoading = acctLoading || glLoading;
|
||||||
|
|
||||||
|
// Expenses display as negatives (draws against the reserve), matching the schedule convention.
|
||||||
|
const exp = (n: number) => num(n === 0 ? 0 : -n);
|
||||||
|
|
||||||
|
const exportPDF = async () => {
|
||||||
|
const doc = new jsPDF({ unit: "pt", format: "letter", orientation: "landscape" });
|
||||||
|
const ML = 40;
|
||||||
|
const logo = await loadBrandedLogo(logoUrl);
|
||||||
|
const startY = drawBrandedHeader(doc, {
|
||||||
|
logo, title: "Allocated Reserve Fund Schedule", subtitle: asOfLabel,
|
||||||
|
metaLines: [{ label: "Properties:", value: companyName || "" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = rows.map((r) => [
|
||||||
|
`${r.code ? r.code + " " : ""}${r.name}`,
|
||||||
|
num(r.priorBalance),
|
||||||
|
num(r.currMonthAdditions),
|
||||||
|
num(r.additionsThisYear),
|
||||||
|
exp(r.currMonthExpenses),
|
||||||
|
exp(r.prevExpenseThisYear),
|
||||||
|
exp(r.totExpenseThisYear),
|
||||||
|
num(r.currentBalance),
|
||||||
|
]);
|
||||||
|
body.push([
|
||||||
|
{ content: "TOTAL RESERVE FUNDS", styles: { fontStyle: "bold" } } as any,
|
||||||
|
{ content: num(totals.priorBalance), styles: { fontStyle: "bold", halign: "right" } } as any,
|
||||||
|
{ content: num(totals.currMonthAdditions), styles: { fontStyle: "bold", halign: "right" } } as any,
|
||||||
|
{ content: num(totals.additionsThisYear), styles: { fontStyle: "bold", halign: "right" } } as any,
|
||||||
|
{ content: exp(totals.currMonthExpenses), styles: { fontStyle: "bold", halign: "right" } } as any,
|
||||||
|
{ content: exp(totals.prevExpenseThisYear), styles: { fontStyle: "bold", halign: "right" } } as any,
|
||||||
|
{ content: exp(totals.totExpenseThisYear), styles: { fontStyle: "bold", halign: "right" } } as any,
|
||||||
|
{ content: num(totals.currentBalance), styles: { fontStyle: "bold", halign: "right" } } as any,
|
||||||
|
]);
|
||||||
|
|
||||||
|
autoTable(doc, {
|
||||||
|
startY,
|
||||||
|
head: [[
|
||||||
|
"Account",
|
||||||
|
"Prior Year\nBalance Forward",
|
||||||
|
"Curr Month\nAdditions",
|
||||||
|
"Additions\nThis Year",
|
||||||
|
"Curr Month\nExpenses",
|
||||||
|
"Prev Expense\nThis Year",
|
||||||
|
"Tot Expense\nThis Year",
|
||||||
|
"Current\nBalance",
|
||||||
|
]],
|
||||||
|
body,
|
||||||
|
styles: { fontSize: 8, cellPadding: 3 },
|
||||||
|
headStyles: { fillColor: TEAL, textColor: 255, halign: "right", fontSize: 8 },
|
||||||
|
columnStyles: {
|
||||||
|
0: { halign: "left", cellWidth: 150 },
|
||||||
|
1: { halign: "right" }, 2: { halign: "right" }, 3: { halign: "right" },
|
||||||
|
4: { halign: "right" }, 5: { halign: "right" }, 6: { halign: "right" }, 7: { halign: "right" },
|
||||||
|
},
|
||||||
|
margin: { left: ML, right: ML },
|
||||||
|
});
|
||||||
|
|
||||||
|
drawBrandedFooter(doc);
|
||||||
|
doc.save(`reserve-fund-schedule-${asOf}.pdf`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportCSV = () => {
|
||||||
|
const header = ["Code", "Account", "Prior Year Balance Forward", "Curr Month Additions", "Additions This Year", "Curr Month Expenses", "Prev Expense This Year", "Tot Expense This Year", "Current Balance"];
|
||||||
|
const lines = [header.join(",")];
|
||||||
|
const f = (n: number) => (Math.round((n + Number.EPSILON) * 100) / 100 || 0).toFixed(2);
|
||||||
|
for (const r of rows) {
|
||||||
|
lines.push([
|
||||||
|
r.code ?? "", `"${r.name.replace(/"/g, '""')}"`,
|
||||||
|
f(r.priorBalance), f(r.currMonthAdditions), f(r.additionsThisYear),
|
||||||
|
f(-r.currMonthExpenses), f(-r.prevExpenseThisYear), f(-r.totExpenseThisYear), f(r.currentBalance),
|
||||||
|
].join(","));
|
||||||
|
}
|
||||||
|
lines.push([
|
||||||
|
"", '"TOTAL RESERVE FUNDS"',
|
||||||
|
f(totals.priorBalance), f(totals.currMonthAdditions), f(totals.additionsThisYear),
|
||||||
|
f(-totals.currMonthExpenses), f(-totals.prevExpenseThisYear), f(-totals.totExpenseThisYear), f(totals.currentBalance),
|
||||||
|
].join(","));
|
||||||
|
const blob = new Blob([lines.join("\n")], { type: "text/csv" });
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = URL.createObjectURL(blob);
|
||||||
|
a.download = `reserve-fund-schedule-${asOf}.csv`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(a.href);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-wrap items-end gap-4 py-4">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground">As of Month</Label>
|
||||||
|
<Input type="month" value={asOfMonth} onChange={(e) => setAsOfMonth(e.target.value || asOfMonth)} className="w-44 mt-1" />
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground pb-1">
|
||||||
|
Fiscal year from {new Date(fyStart + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
|
||||||
|
</div>
|
||||||
|
{rows.length > 0 && (
|
||||||
|
<div className="ml-auto flex gap-2">
|
||||||
|
<Button variant="outline" onClick={exportCSV}><Download className="mr-1 h-4 w-4" /> CSV</Button>
|
||||||
|
<Button onClick={exportPDF}><FileDown className="mr-1 h-4 w-4" /> PDF</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Card><CardContent className="p-8 text-center text-sm text-muted-foreground">Loading…</CardContent></Card>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<Card><CardContent className="p-8 text-center text-sm text-muted-foreground">
|
||||||
|
No reserve accounts yet. Mark accounts as a <span className="font-medium">Reserve fund account</span> in the
|
||||||
|
Chart of Accounts to include them on this schedule.
|
||||||
|
</CardContent></Card>
|
||||||
|
) : (
|
||||||
|
<ReportSheet title="Allocated Reserve Fund Schedule" subtitle={asOfLabel} companyName={companyName} logoUrl={logoUrl}>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-y text-[11px] uppercase tracking-wide text-muted-foreground">
|
||||||
|
<th className="px-3 py-2 text-left font-semibold">Account</th>
|
||||||
|
<th className="px-3 py-2 text-right font-semibold">Prior Year<br />Balance Forward</th>
|
||||||
|
<th className="px-3 py-2 text-right font-semibold">Curr Month<br />Additions</th>
|
||||||
|
<th className="px-3 py-2 text-right font-semibold">Additions<br />This Year</th>
|
||||||
|
<th className="px-3 py-2 text-right font-semibold">Curr Month<br />Expenses</th>
|
||||||
|
<th className="px-3 py-2 text-right font-semibold">Prev Expense<br />This Year</th>
|
||||||
|
<th className="px-3 py-2 text-right font-semibold">Tot Expense<br />This Year</th>
|
||||||
|
<th className="px-3 py-2 text-right font-semibold">Current<br />Balance</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((r, i) => (
|
||||||
|
<tr key={i} className="border-b">
|
||||||
|
<td className="px-3 py-1.5">
|
||||||
|
{r.code && <span className="font-mono text-xs text-muted-foreground mr-2">{r.code}</span>}
|
||||||
|
{r.name}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 text-right tabular-nums">{num(r.priorBalance)}</td>
|
||||||
|
<td className="px-3 py-1.5 text-right tabular-nums">{num(r.currMonthAdditions)}</td>
|
||||||
|
<td className="px-3 py-1.5 text-right tabular-nums">{num(r.additionsThisYear)}</td>
|
||||||
|
<td className="px-3 py-1.5 text-right tabular-nums">{exp(r.currMonthExpenses)}</td>
|
||||||
|
<td className="px-3 py-1.5 text-right tabular-nums">{exp(r.prevExpenseThisYear)}</td>
|
||||||
|
<td className="px-3 py-1.5 text-right tabular-nums">{exp(r.totExpenseThisYear)}</td>
|
||||||
|
<td className="px-3 py-1.5 text-right tabular-nums font-medium">{num(r.currentBalance)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr className="border-t-2 border-b-2 border-primary font-bold">
|
||||||
|
<td className="px-3 py-3">TOTAL RESERVE FUNDS</td>
|
||||||
|
<td className="px-3 py-3 text-right tabular-nums">{num(totals.priorBalance)}</td>
|
||||||
|
<td className="px-3 py-3 text-right tabular-nums">{num(totals.currMonthAdditions)}</td>
|
||||||
|
<td className="px-3 py-3 text-right tabular-nums">{num(totals.additionsThisYear)}</td>
|
||||||
|
<td className="px-3 py-3 text-right tabular-nums">{exp(totals.currMonthExpenses)}</td>
|
||||||
|
<td className="px-3 py-3 text-right tabular-nums">{exp(totals.prevExpenseThisYear)}</td>
|
||||||
|
<td className="px-3 py-3 text-right tabular-nums">{exp(totals.totExpenseThisYear)}</td>
|
||||||
|
<td className="px-3 py-3 text-right tabular-nums">{num(totals.currentBalance)}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</ReportSheet>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ import { CheckCircle2, AlertTriangle, FileDown, Download } from "lucide-react";
|
|||||||
import { fmtDate } from "../lib/format";
|
import { fmtDate } from "../lib/format";
|
||||||
import jsPDF from "jspdf";
|
import jsPDF from "jspdf";
|
||||||
import autoTable from "jspdf-autotable";
|
import autoTable from "jspdf-autotable";
|
||||||
|
import { ReportSheet } from "./ReportSheet";
|
||||||
|
import { loadBrandedLogo, drawBrandedHeader, drawBrandedFooter } from "../lib/reportHeader";
|
||||||
|
|
||||||
type Account = {
|
type Account = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -50,7 +52,7 @@ function splitDebitCredit(a: Account): { debit: number; credit: number } {
|
|||||||
return bal >= 0 ? { debit: 0, credit: bal } : { debit: -bal, credit: 0 };
|
return bal >= 0 ? { debit: 0, credit: bal } : { debit: -bal, credit: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TrialBalanceReport({ companyId, companyName }: { companyId: string; companyName: string }) {
|
export function TrialBalanceReport({ companyId, companyName, logoUrl }: { companyId: string; companyName: string; logoUrl?: string | null }) {
|
||||||
const [asOf, setAsOf] = useState(() => new Date().toISOString().slice(0, 10));
|
const [asOf, setAsOf] = useState(() => new Date().toISOString().slice(0, 10));
|
||||||
const [basis, setBasis] = useState<"accrual" | "cash">("accrual");
|
const [basis, setBasis] = useState<"accrual" | "cash">("accrual");
|
||||||
const [showZero, setShowZero] = useState(false);
|
const [showZero, setShowZero] = useState(false);
|
||||||
@@ -119,26 +121,16 @@ export function TrialBalanceReport({ companyId, companyName }: { companyId: stri
|
|||||||
const diff = totals.debit - totals.credit;
|
const diff = totals.debit - totals.credit;
|
||||||
const inBalance = Math.abs(diff) < 0.005;
|
const inBalance = Math.abs(diff) < 0.005;
|
||||||
|
|
||||||
const exportPDF = () => {
|
const exportPDF = async () => {
|
||||||
const doc = new jsPDF({ unit: "pt", format: "letter" });
|
const doc = new jsPDF({ unit: "pt", format: "letter" });
|
||||||
const W = doc.internal.pageSize.getWidth();
|
const W = doc.internal.pageSize.getWidth();
|
||||||
const ML = 54;
|
const ML = 40;
|
||||||
|
const logo = await loadBrandedLogo(logoUrl);
|
||||||
// Header
|
const startY = drawBrandedHeader(doc, {
|
||||||
doc.setFillColor(...TEAL); doc.rect(ML, 40, 44, 44, "F");
|
logo, title: "Trial Balance",
|
||||||
doc.setFont("times", "bold"); doc.setFontSize(20); doc.setTextColor(255);
|
subtitle: `As of ${fmtDate(asOf)} · ${basis === "cash" ? "Cash" : "Accrual"} basis`,
|
||||||
doc.text((companyName[0] ?? "?").toUpperCase(), ML + 22, 70, { align: "center" });
|
metaLines: [{ label: "Properties:", value: companyName }],
|
||||||
doc.setTextColor(20); doc.setFontSize(16);
|
});
|
||||||
doc.text(companyName, ML + 56, 60);
|
|
||||||
doc.setFont("times", "normal"); doc.setFontSize(11); doc.setTextColor(100);
|
|
||||||
doc.text("Accounting", ML + 56, 76);
|
|
||||||
|
|
||||||
doc.setFont("times", "bold"); doc.setFontSize(22); doc.setTextColor(20);
|
|
||||||
doc.text("Trial Balance", W / 2, 120, { align: "center" });
|
|
||||||
doc.setFont("times", "normal"); doc.setFontSize(11); doc.setTextColor(100);
|
|
||||||
doc.text(`As of ${fmtDate(asOf)} · ${basis === "cash" ? "Cash" : "Accrual"} basis`, W / 2, 138, { align: "center" });
|
|
||||||
doc.setDrawColor(...TEAL); doc.setLineWidth(1.5);
|
|
||||||
doc.line(ML, 150, W - ML, 150);
|
|
||||||
|
|
||||||
// Body rows
|
// Body rows
|
||||||
const body: any[] = [];
|
const body: any[] = [];
|
||||||
@@ -167,7 +159,7 @@ export function TrialBalanceReport({ companyId, companyName }: { companyId: stri
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
autoTable(doc, {
|
autoTable(doc, {
|
||||||
startY: 165,
|
startY,
|
||||||
head: [["Code", "Account", "Debit", "Credit"]],
|
head: [["Code", "Account", "Debit", "Credit"]],
|
||||||
body,
|
body,
|
||||||
styles: { fontSize: 9, cellPadding: 4 },
|
styles: { fontSize: 9, cellPadding: 4 },
|
||||||
@@ -186,13 +178,7 @@ export function TrialBalanceReport({ companyId, companyName }: { companyId: stri
|
|||||||
doc.text(`Out of balance by ${fmt(Math.abs(diff))} — check for unposted journal entries or missing opening balances`, W / 2, finalY, { align: "center" });
|
doc.text(`Out of balance by ${fmt(Math.abs(diff))} — check for unposted journal entries or missing opening balances`, W / 2, finalY, { align: "center" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Footer page numbers
|
drawBrandedFooter(doc);
|
||||||
const pageCount = doc.getNumberOfPages();
|
|
||||||
for (let i = 1; i <= pageCount; i++) {
|
|
||||||
doc.setPage(i);
|
|
||||||
doc.setFontSize(8); doc.setTextColor(120); doc.setFont("times", "normal");
|
|
||||||
doc.text(`Generated ${new Date().toLocaleDateString()} · Page ${i} of ${pageCount}`, W / 2, doc.internal.pageSize.getHeight() - 24, { align: "center" });
|
|
||||||
}
|
|
||||||
doc.save(`trial-balance-${asOf}.pdf`);
|
doc.save(`trial-balance-${asOf}.pdf`);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -260,11 +246,15 @@ export function TrialBalanceReport({ companyId, companyName }: { companyId: stri
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
{isLoading ? (
|
||||||
<CardContent className="p-0">
|
<Card><CardContent className="p-8 text-center text-sm text-muted-foreground">Loading…</CardContent></Card>
|
||||||
{isLoading ? (
|
) : (
|
||||||
<div className="p-8 text-center text-sm text-muted-foreground">Loading…</div>
|
<ReportSheet
|
||||||
) : (
|
title="Trial Balance"
|
||||||
|
subtitle={`As of ${fmtDate(asOf)} · ${basis === "cash" ? "Cash" : "Accrual"} basis`}
|
||||||
|
companyName={companyName}
|
||||||
|
logoUrl={logoUrl}
|
||||||
|
>
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b text-xs uppercase tracking-wide text-muted-foreground">
|
<tr className="border-b text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
@@ -312,9 +302,8 @@ export function TrialBalanceReport({ companyId, companyName }: { companyId: stri
|
|||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
)}
|
</ReportSheet>
|
||||||
</CardContent>
|
)}
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="py-4 flex items-center justify-center">
|
<CardContent className="py-4 flex items-center justify-center">
|
||||||
|
|||||||
@@ -1,142 +1,135 @@
|
|||||||
import jsPDF from "jspdf";
|
import jsPDF from "jspdf";
|
||||||
import autoTable from "jspdf-autotable";
|
import autoTable, { RowInput } from "jspdf-autotable";
|
||||||
import { money, fmtDate } from "./format";
|
import { fmtDate } from "./format";
|
||||||
|
import { loadBrandedLogo, drawBrandedHeader, drawBrandedFooter } from "./reportHeader";
|
||||||
|
|
||||||
export type ReconRow = { date: string; description: string; amount: number };
|
export type ReconRow = { name: string; date: string; number: string; memo: string; amount: number };
|
||||||
|
|
||||||
export type ReconReportData = {
|
export type ReconReportData = {
|
||||||
companyName: string;
|
companyName: string;
|
||||||
|
/** e.g. "1003 BBC - Operating" */
|
||||||
accountName: string;
|
accountName: string;
|
||||||
statementEndDate: string;
|
statementEndDate: string;
|
||||||
openingBalance: number;
|
beginningBalance: number;
|
||||||
statementBalance: number;
|
endingBalance: number;
|
||||||
difference: number;
|
bookBalance: number;
|
||||||
deposits: ReconRow[];
|
clearedDeposits: ReconRow[];
|
||||||
withdrawals: ReconRow[];
|
clearedWithdrawals: ReconRow[];
|
||||||
|
unclearedDeposits: ReconRow[];
|
||||||
|
unclearedWithdrawals: ReconRow[];
|
||||||
preparedBy: string;
|
preparedBy: string;
|
||||||
currency: string;
|
currency: string;
|
||||||
completedAt: string;
|
completedAt: string;
|
||||||
|
logoUrl?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const TEAL = [0, 137, 123] as [number, number, number];
|
const TEXT: [number, number, number] = [33, 37, 41];
|
||||||
|
const HEADER_FILL: [number, number, number] = [222, 226, 230];
|
||||||
|
|
||||||
export function renderReconciliationPdf(d: ReconReportData) {
|
/** Two decimals, thousands separators, negatives in parentheses. */
|
||||||
|
function num(n: number): string {
|
||||||
|
const v = Math.round((n + Number.EPSILON) * 100) / 100 || 0;
|
||||||
|
const abs = Math.abs(v).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
return v < 0 ? `(${abs})` : abs;
|
||||||
|
}
|
||||||
|
const sum = (rows: ReconRow[]) => rows.reduce((s, r) => s + Number(r.amount || 0), 0);
|
||||||
|
|
||||||
|
export async function renderReconciliationPdf(d: ReconReportData) {
|
||||||
const doc = new jsPDF({ unit: "pt", format: "letter" });
|
const doc = new jsPDF({ unit: "pt", format: "letter" });
|
||||||
const w = doc.internal.pageSize.getWidth();
|
const W = doc.internal.pageSize.getWidth();
|
||||||
const left = 48;
|
const ML = 40;
|
||||||
|
|
||||||
// Header
|
const logo = await loadBrandedLogo(d.logoUrl);
|
||||||
doc.setFont("times", "bold");
|
let y = drawBrandedHeader(doc, {
|
||||||
doc.setFontSize(18);
|
logo,
|
||||||
doc.text(d.companyName, left, 64);
|
title: "Bank Reconciliation",
|
||||||
doc.setFont("times", "normal");
|
metaLines: [
|
||||||
doc.setFontSize(13);
|
{ label: "Bank account", value: d.accountName },
|
||||||
doc.text("Bank Reconciliation Report", left, 84);
|
{ label: "Statement ending date", value: fmtDate(d.statementEndDate) },
|
||||||
doc.setFontSize(10);
|
|
||||||
doc.setTextColor(110);
|
|
||||||
doc.text(`${d.accountName} · As of ${fmtDate(d.statementEndDate)}`, left, 100);
|
|
||||||
doc.setTextColor(0);
|
|
||||||
doc.setDrawColor(...TEAL);
|
|
||||||
doc.setLineWidth(1);
|
|
||||||
doc.line(left, 112, w - left, 112);
|
|
||||||
|
|
||||||
// Summary box
|
|
||||||
let y = 132;
|
|
||||||
doc.setFillColor(245, 250, 249);
|
|
||||||
doc.rect(left, y, w - left * 2, 78, "F");
|
|
||||||
doc.setFont("times", "bold");
|
|
||||||
doc.setFontSize(11);
|
|
||||||
doc.text("Summary", left + 12, y + 18);
|
|
||||||
doc.setFont("times", "normal");
|
|
||||||
doc.setFontSize(10);
|
|
||||||
const rows: [string, string][] = [
|
|
||||||
["Opening Balance", money(d.openingBalance, d.currency)],
|
|
||||||
["Statement Balance", money(d.statementBalance, d.currency)],
|
|
||||||
[
|
|
||||||
"Difference",
|
|
||||||
`${money(d.difference, d.currency)}${
|
|
||||||
Math.abs(d.difference) < 0.005 ? " ✓ Reconciled" : ""
|
|
||||||
}`,
|
|
||||||
],
|
],
|
||||||
];
|
}) + 8;
|
||||||
rows.forEach((r, i) => {
|
|
||||||
doc.text(r[0], left + 12, y + 36 + i * 14);
|
const clearedDep = sum(d.clearedDeposits);
|
||||||
doc.text(r[1], w - left - 12, y + 36 + i * 14, { align: "right" });
|
const clearedWd = sum(d.clearedWithdrawals);
|
||||||
});
|
const unclearedDep = sum(d.unclearedDeposits);
|
||||||
y += 96;
|
const unclearedWd = sum(d.unclearedWithdrawals);
|
||||||
|
|
||||||
|
// ── Summary block ──
|
||||||
|
const lineH = 18;
|
||||||
|
const summary: { label: string; value: number; bold?: boolean }[] = [
|
||||||
|
{ label: "Beginning balance", value: d.beginningBalance, bold: true },
|
||||||
|
{ label: " + Cleared deposits", value: clearedDep },
|
||||||
|
{ label: " - Cleared withdrawals", value: -clearedWd },
|
||||||
|
{ label: "Ending balance", value: d.endingBalance, bold: true },
|
||||||
|
{ label: " + Uncleared deposits", value: unclearedDep },
|
||||||
|
{ label: " - Uncleared withdrawals", value: -unclearedWd },
|
||||||
|
{ label: "Book balance", value: d.bookBalance, bold: true },
|
||||||
|
];
|
||||||
|
for (const r of summary) {
|
||||||
|
doc.setFont("helvetica", r.bold ? "bold" : "normal");
|
||||||
|
doc.setFontSize(r.bold ? 11 : 10);
|
||||||
|
doc.setTextColor(...TEXT);
|
||||||
|
doc.text(r.label, ML, y);
|
||||||
|
doc.text(num(r.value), W - ML, y, { align: "right" });
|
||||||
|
doc.setDrawColor(225); doc.setLineWidth(0.5);
|
||||||
|
doc.line(ML, y + 5, W - ML, y + 5);
|
||||||
|
y += lineH;
|
||||||
|
}
|
||||||
|
y += 10;
|
||||||
|
|
||||||
|
// ── Register ──
|
||||||
|
const COLS = 5;
|
||||||
|
const body: RowInput[] = [];
|
||||||
|
const sectionRow = (label: string, fill?: boolean) =>
|
||||||
|
body.push([{ content: label, colSpan: COLS, styles: { fontStyle: "bold", fillColor: fill ? [240, 242, 245] : [255, 255, 255] } }]);
|
||||||
|
const subHeaderRow = (label: string) =>
|
||||||
|
body.push([{ content: label, colSpan: COLS, styles: { fontStyle: "bold", cellPadding: { top: 3, bottom: 2, left: 14, right: 4 } } }]);
|
||||||
|
const dataRow = (r: ReconRow, withdrawal: boolean) =>
|
||||||
|
body.push([
|
||||||
|
{ content: r.name || "", styles: { cellPadding: { top: 2, bottom: 2, left: 22, right: 4 } } },
|
||||||
|
r.date ? fmtDate(r.date) : "",
|
||||||
|
r.number || "",
|
||||||
|
r.memo || "",
|
||||||
|
{ content: num(withdrawal ? -Math.abs(r.amount) : r.amount), styles: { halign: "right" } },
|
||||||
|
]);
|
||||||
|
const totalRow = (label: string, value: number) =>
|
||||||
|
body.push([
|
||||||
|
{ content: label, colSpan: 4, styles: { halign: "right", fontStyle: "bold", cellPadding: { top: 2, bottom: 2, left: 4, right: 8 } } },
|
||||||
|
{ content: num(value), styles: { halign: "right", fontStyle: "bold" } },
|
||||||
|
]);
|
||||||
|
|
||||||
|
body.push([
|
||||||
|
{ content: "Beginning balance", colSpan: 4, styles: { fontStyle: "bold" } },
|
||||||
|
{ content: num(d.beginningBalance), styles: { halign: "right", fontStyle: "bold" } },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const group = (title: string, deposits: ReconRow[], withdrawals: ReconRow[]) => {
|
||||||
|
sectionRow(title, true);
|
||||||
|
subHeaderRow("+ Deposits");
|
||||||
|
deposits.forEach((r) => dataRow(r, false));
|
||||||
|
totalRow(`Total for ${title} deposits`, sum(deposits));
|
||||||
|
subHeaderRow("- Withdrawals");
|
||||||
|
withdrawals.forEach((r) => dataRow(r, true));
|
||||||
|
totalRow(`Total for ${title} withdrawals`, -sum(withdrawals));
|
||||||
|
};
|
||||||
|
|
||||||
|
group("Cleared", d.clearedDeposits, d.clearedWithdrawals);
|
||||||
|
group("Uncleared", d.unclearedDeposits, d.unclearedWithdrawals);
|
||||||
|
|
||||||
// Deposits table
|
|
||||||
autoTable(doc, {
|
autoTable(doc, {
|
||||||
startY: y,
|
startY: y,
|
||||||
head: [["Date", "Description", "Amount"]],
|
head: [["Name", "Date", "Number", "Memo", "Amount"]],
|
||||||
body: d.deposits.map((r) => [
|
body,
|
||||||
fmtDate(r.date),
|
styles: { font: "helvetica", fontSize: 8.5, textColor: TEXT, lineColor: [225, 228, 232], lineWidth: 0.1, cellPadding: 2 },
|
||||||
r.description,
|
headStyles: { fillColor: HEADER_FILL, textColor: TEXT, fontStyle: "bold" },
|
||||||
money(r.amount, d.currency),
|
columnStyles: {
|
||||||
]),
|
1: { halign: "left", cellWidth: 64 },
|
||||||
foot: [
|
2: { halign: "left", cellWidth: 60 },
|
||||||
[
|
4: { halign: "right", cellWidth: 80 },
|
||||||
"",
|
|
||||||
"Total Cleared Deposits",
|
|
||||||
money(
|
|
||||||
d.deposits.reduce((s, r) => s + Number(r.amount), 0),
|
|
||||||
d.currency,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
headStyles: { fillColor: TEAL, textColor: 255 },
|
|
||||||
footStyles: { fillColor: [235, 245, 244], textColor: 0, fontStyle: "bold" },
|
|
||||||
columnStyles: { 2: { halign: "right" } },
|
|
||||||
margin: { left, right: left },
|
|
||||||
styles: { font: "times", fontSize: 9 },
|
|
||||||
didDrawPage: () => {
|
|
||||||
doc.setFont("times", "bold");
|
|
||||||
doc.setFontSize(11);
|
|
||||||
doc.text("Cleared Deposits", left, (doc as any).lastAutoTable?.startY ? y - 6 : y - 6);
|
|
||||||
},
|
},
|
||||||
|
margin: { left: ML, right: ML },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Withdrawals
|
drawBrandedFooter(doc);
|
||||||
const afterDep = (doc as any).lastAutoTable.finalY + 24;
|
doc.save(`reconciliation-${d.accountName.replace(/\s+/g, "-").toLowerCase()}-${d.statementEndDate}.pdf`);
|
||||||
doc.setFont("times", "bold");
|
|
||||||
doc.setFontSize(11);
|
|
||||||
doc.text("Cleared Withdrawals", left, afterDep - 6);
|
|
||||||
autoTable(doc, {
|
|
||||||
startY: afterDep,
|
|
||||||
head: [["Date", "Description", "Amount"]],
|
|
||||||
body: d.withdrawals.map((r) => [
|
|
||||||
fmtDate(r.date),
|
|
||||||
r.description,
|
|
||||||
money(r.amount, d.currency),
|
|
||||||
]),
|
|
||||||
foot: [
|
|
||||||
[
|
|
||||||
"",
|
|
||||||
"Total Cleared Withdrawals",
|
|
||||||
money(
|
|
||||||
d.withdrawals.reduce((s, r) => s + Number(r.amount), 0),
|
|
||||||
d.currency,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
headStyles: { fillColor: TEAL, textColor: 255 },
|
|
||||||
footStyles: { fillColor: [235, 245, 244], textColor: 0, fontStyle: "bold" },
|
|
||||||
columnStyles: { 2: { halign: "right" } },
|
|
||||||
margin: { left, right: left },
|
|
||||||
styles: { font: "times", fontSize: 9 },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Footer
|
|
||||||
const pageH = doc.internal.pageSize.getHeight();
|
|
||||||
doc.setFontSize(9);
|
|
||||||
doc.setTextColor(110);
|
|
||||||
doc.text(
|
|
||||||
`Reconciled ${fmtDate(d.completedAt)} · Prepared by ${d.preparedBy}`,
|
|
||||||
left,
|
|
||||||
pageH - 32,
|
|
||||||
);
|
|
||||||
|
|
||||||
doc.save(
|
|
||||||
`reconciliation-${d.accountName.replace(/\s+/g, "-").toLowerCase()}-${d.statementEndDate}.pdf`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import jsPDF from "jspdf";
|
||||||
|
import { loadImageAsDataURL } from "@/lib/reportCover";
|
||||||
|
import acmLogoFull from "@/assets/acm-logo-full.png";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared branded header/footer for every accounting report PDF, so all reports
|
||||||
|
* match the "main report" look: logo top-left, centered title + optional
|
||||||
|
* subtitle, optional left-aligned meta lines, and a "Generated … / Page X of Y"
|
||||||
|
* footer on every page.
|
||||||
|
*
|
||||||
|
* The logo load is async (canvas → dataURL), so callers preload it once via
|
||||||
|
* `loadBrandedLogo()` and pass the result into the sync `drawBrandedHeader()`.
|
||||||
|
* Falls back to the management-company (ACM) logo when no association logo is set.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MUTED: [number, number, number] = [110, 116, 122];
|
||||||
|
|
||||||
|
export type BrandedLogo = { dataURL: string; w: number; h: number };
|
||||||
|
|
||||||
|
/** Preload the association logo (ACM fallback) as a dataURL for PDF embedding. */
|
||||||
|
export async function loadBrandedLogo(logoUrl?: string | null): Promise<BrandedLogo | null> {
|
||||||
|
const url = logoUrl || (acmLogoFull as unknown as string);
|
||||||
|
const img = await loadImageAsDataURL(url, "png");
|
||||||
|
if (img) return { dataURL: img.dataURL, w: img.w, h: img.h };
|
||||||
|
// Fall back to the bundled ACM logo if the association logo failed to load.
|
||||||
|
if (logoUrl) {
|
||||||
|
const fallback = await loadImageAsDataURL(acmLogoFull as unknown as string, "png");
|
||||||
|
if (fallback) return { dataURL: fallback.dataURL, w: fallback.w, h: fallback.h };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BrandedHeaderOpts = {
|
||||||
|
logo?: BrandedLogo | null;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
/** Optional bold-label / value lines under the title (e.g. account, period). */
|
||||||
|
metaLines?: { label: string; value: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Draws the branded header on the current page; returns the y to start the body. */
|
||||||
|
export function drawBrandedHeader(doc: jsPDF, opts: BrandedHeaderOpts): number {
|
||||||
|
const W = doc.internal.pageSize.getWidth();
|
||||||
|
const ML = 40;
|
||||||
|
|
||||||
|
if (opts.logo) {
|
||||||
|
try {
|
||||||
|
const h = 46;
|
||||||
|
const w = Math.min(170, h * (opts.logo.w / opts.logo.h));
|
||||||
|
doc.addImage(opts.logo.dataURL, "PNG", ML, 28, w, h, undefined, "FAST");
|
||||||
|
} catch { /* ignore logo failures */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.setFont("helvetica", "bold"); doc.setFontSize(18); doc.setTextColor(20);
|
||||||
|
doc.text(opts.title, W / 2, 52, { align: "center" });
|
||||||
|
let headerBottom = 60;
|
||||||
|
if (opts.subtitle) {
|
||||||
|
doc.setFont("helvetica", "normal"); doc.setFontSize(11); doc.setTextColor(90);
|
||||||
|
doc.text(opts.subtitle, W / 2, 70, { align: "center" });
|
||||||
|
headerBottom = 74;
|
||||||
|
}
|
||||||
|
|
||||||
|
let y = Math.max(headerBottom, 88) + 6;
|
||||||
|
if (opts.metaLines?.length) {
|
||||||
|
doc.setFontSize(10);
|
||||||
|
for (const m of opts.metaLines) {
|
||||||
|
doc.setFont("helvetica", "bold"); doc.setTextColor(20);
|
||||||
|
doc.text(m.label, ML, y);
|
||||||
|
const lw = doc.getTextWidth(m.label);
|
||||||
|
doc.setFont("helvetica", "normal");
|
||||||
|
doc.text(` ${m.value}`, ML + lw, y);
|
||||||
|
y += 14;
|
||||||
|
}
|
||||||
|
y += 4;
|
||||||
|
}
|
||||||
|
return y;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Draws "Generated … / Page X of Y" on every page. Call once after the body. */
|
||||||
|
export function drawBrandedFooter(doc: jsPDF): void {
|
||||||
|
const W = doc.internal.pageSize.getWidth();
|
||||||
|
const H = doc.internal.pageSize.getHeight();
|
||||||
|
const ML = 40;
|
||||||
|
const total = doc.getNumberOfPages();
|
||||||
|
const gen = new Date().toLocaleString("en-US");
|
||||||
|
for (let p = 1; p <= total; p++) {
|
||||||
|
doc.setPage(p);
|
||||||
|
doc.setFont("helvetica", "normal"); doc.setFontSize(8); doc.setTextColor(...MUTED);
|
||||||
|
doc.text(`Generated ${gen}`, ML, H - 24);
|
||||||
|
doc.text(`Page ${p} of ${total}`, W - ML, H - 24, { align: "right" });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,8 @@ export type RenderOpts = {
|
|||||||
showZero: boolean;
|
showZero: boolean;
|
||||||
/** Optional metadata lines for the header block (bold label + value). */
|
/** Optional metadata lines for the header block (bold label + value). */
|
||||||
meta?: { label: string; value: string }[];
|
meta?: { label: string; value: string }[];
|
||||||
|
/** Preloaded logo (dataURL + dimensions) for the branded header. */
|
||||||
|
logo?: { dataURL: string; w: number; h: number } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type RGB = [number, number, number];
|
type RGB = [number, number, number];
|
||||||
@@ -91,10 +93,16 @@ function buildReport(doc: jsPDF, report: StructuredReport, opts: RenderOpts): js
|
|||||||
|
|
||||||
// Full header (page 1): title + metadata block + column header
|
// Full header (page 1): title + metadata block + column header
|
||||||
const drawFullHeader = () => {
|
const drawFullHeader = () => {
|
||||||
y = 50;
|
// Branded logo (top-left) + centered title — matches the main reports.
|
||||||
doc.setFont("helvetica", "bold"); doc.setFontSize(14); doc.setTextColor(...TEXT);
|
if (opts.logo) {
|
||||||
doc.text(report.title, ML, y);
|
try {
|
||||||
y += 20;
|
const lh = 40; const lw = Math.min(150, lh * (opts.logo.w / opts.logo.h));
|
||||||
|
doc.addImage(opts.logo.dataURL, "PNG", ML, 26, lw, lh, undefined, "FAST");
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
doc.setFont("helvetica", "bold"); doc.setFontSize(16); doc.setTextColor(...TEXT);
|
||||||
|
doc.text(report.title, W / 2, 48, { align: "center" });
|
||||||
|
y = 82;
|
||||||
|
|
||||||
const meta = opts.meta ?? [
|
const meta = opts.meta ?? [
|
||||||
{ label: "Properties:", value: opts.companyName || "—" },
|
{ label: "Properties:", value: opts.companyName || "—" },
|
||||||
@@ -114,24 +122,24 @@ function buildReport(doc: jsPDF, report: StructuredReport, opts: RenderOpts): js
|
|||||||
drawColHeader();
|
drawColHeader();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Continuation header (page 2+): title + column header only
|
// Continuation header (page 2+): centered title + column header only
|
||||||
const drawContHeader = () => {
|
const drawContHeader = () => {
|
||||||
y = 50;
|
|
||||||
doc.setFont("helvetica", "bold"); doc.setFontSize(12); doc.setTextColor(...TEXT);
|
doc.setFont("helvetica", "bold"); doc.setFontSize(12); doc.setTextColor(...TEXT);
|
||||||
doc.text(report.title, ML, y);
|
doc.text(report.title, W / 2, 40, { align: "center" });
|
||||||
y += 16;
|
y = 56;
|
||||||
drawColHeader();
|
drawColHeader();
|
||||||
};
|
};
|
||||||
|
|
||||||
const drawFooter = () => {
|
const drawFooter = () => {
|
||||||
const total = doc.getNumberOfPages();
|
const total = doc.getNumberOfPages();
|
||||||
const created = new Date().toLocaleDateString("en-US");
|
const gen = new Date().toLocaleString("en-US");
|
||||||
|
const pages = total - firstPage + 1;
|
||||||
// Footer only on content pages (skip any preceding cover page).
|
// Footer only on content pages (skip any preceding cover page).
|
||||||
for (let p = firstPage; p <= total; p++) {
|
for (let p = firstPage; p <= total; p++) {
|
||||||
doc.setPage(p);
|
doc.setPage(p);
|
||||||
doc.setFont("helvetica", "normal"); doc.setFontSize(8); doc.setTextColor(...MUTED);
|
doc.setFont("helvetica", "normal"); doc.setFontSize(8); doc.setTextColor(...MUTED);
|
||||||
doc.text(`Created on ${created}`, ML, H - 28);
|
doc.text(`Generated ${gen}`, ML, H - 28);
|
||||||
doc.text(`Page ${p - firstPage + 1}`, contentR, H - 28, { align: "right" });
|
doc.text(`Page ${p - firstPage + 1} of ${pages}`, contentR, H - 28, { align: "right" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -11,15 +11,19 @@ import { useAccountingCompany } from "@/hooks/useAccountingCompany";
|
|||||||
*
|
*
|
||||||
* Every ported accounting page uses this instead of the old `useCompany`.
|
* Every ported accounting page uses this instead of the old `useCompany`.
|
||||||
*/
|
*/
|
||||||
export function useCompanyId() {
|
export function useCompanyId(override?: { id: string; name?: string } | null) {
|
||||||
const { selectedAssociation } = useAssociation() as {
|
const { selectedAssociation } = useAssociation() as {
|
||||||
selectedAssociation: { id: string; name?: string } | null;
|
selectedAssociation: { id: string; name?: string } | null;
|
||||||
};
|
};
|
||||||
const associationId = selectedAssociation?.id ?? null;
|
// When an explicit association is supplied (e.g. the Financial Overview /
|
||||||
|
// Reports wrappers, or a board scope), it takes precedence over the global
|
||||||
|
// association picker.
|
||||||
|
const active = override ?? selectedAssociation;
|
||||||
|
const associationId = active?.id ?? null;
|
||||||
const { companyId, loading, error } = useAccountingCompany(associationId);
|
const { companyId, loading, error } = useAccountingCompany(associationId);
|
||||||
return {
|
return {
|
||||||
associationId,
|
associationId,
|
||||||
associationName: selectedAssociation?.name ?? null,
|
associationName: active?.name ?? null,
|
||||||
companyId,
|
companyId,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useBoardAssociations } from "@/contexts/BoardAssociationContext";
|
import { useBoardAssociations } from "@/contexts/BoardAssociationContext";
|
||||||
import FinancialOverviewPage from "@/pages/FinancialOverviewPage";
|
import BoardFinancialOverviewView from "@/pages/board/BoardFinancialOverviewView";
|
||||||
|
|
||||||
export default function BoardFinancialOverviewPage() {
|
export default function BoardFinancialOverviewPage() {
|
||||||
const { associationIds, loading } = useBoardAssociations();
|
const { associationIds, loading } = useBoardAssociations();
|
||||||
@@ -10,5 +10,5 @@ export default function BoardFinancialOverviewPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <FinancialOverviewPage boardAssociationIds={associationIds} />;
|
return <BoardFinancialOverviewView boardAssociationIds={associationIds} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,674 @@
|
|||||||
|
import { useState, useEffect, useMemo, useCallback, useRef } from "react";
|
||||||
|
import { formatShortMonthDayEST } from "@/lib/timezoneUtils";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import {
|
||||||
|
DollarSign, TrendingUp, TrendingDown, Wallet, Receipt, RefreshCw,
|
||||||
|
ArrowUpRight, ArrowDownRight, FileText, CreditCard, BarChart3,
|
||||||
|
Download, PieChart, ChevronRight, Calendar, Building2, Printer
|
||||||
|
} from "lucide-react";
|
||||||
|
import { generateFinancialOverviewPdf } from "@/lib/financialOverviewPdf";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import {
|
||||||
|
BarChart, Bar, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||||
|
ResponsiveContainer, PieChart as RePieChart, Pie, Cell, Legend, Area, AreaChart
|
||||||
|
} from "recharts";
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────
|
||||||
|
const fmt = (n: number) => new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", minimumFractionDigits: 0, maximumFractionDigits: 0 }).format(n);
|
||||||
|
const fmtFull = (n: number) => new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(n);
|
||||||
|
|
||||||
|
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||||
|
const CHART_COLORS = [
|
||||||
|
"hsl(224, 76%, 53%)", // primary blue
|
||||||
|
"hsl(142, 71%, 45%)", // success green
|
||||||
|
"hsl(38, 92%, 50%)", // warning amber
|
||||||
|
"hsl(0, 72%, 51%)", // destructive red
|
||||||
|
"hsl(262, 52%, 47%)", // purple
|
||||||
|
"hsl(199, 89%, 48%)", // cyan
|
||||||
|
"hsl(25, 95%, 53%)", // orange
|
||||||
|
"hsl(340, 75%, 55%)", // pink
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Component ────────────────────────────────────────────────────────
|
||||||
|
export default function BoardFinancialOverviewView({ boardAssociationIds }: { boardAssociationIds?: string[] } = {}) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isBoardScoped = Array.isArray(boardAssociationIds) && boardAssociationIds.length > 0;
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [associations, setAssociations] = useState<any[]>([]);
|
||||||
|
const [selectedAssocId, setSelectedAssocId] = useState<string>(isBoardScoped && boardAssociationIds!.length === 1 ? boardAssociationIds![0] : "all");
|
||||||
|
|
||||||
|
// Raw data
|
||||||
|
const [bankAccounts, setBankAccounts] = useState<any[]>([]);
|
||||||
|
const [ledgerEntries, setLedgerEntries] = useState<any[]>([]);
|
||||||
|
const [bills, setBills] = useState<any[]>([]);
|
||||||
|
const [transactions, setTransactions] = useState<any[]>([]);
|
||||||
|
const [collections, setCollections] = useState<any[]>([]);
|
||||||
|
const [owners, setOwners] = useState<any[]>([]);
|
||||||
|
|
||||||
|
|
||||||
|
// ─── Data Loading ────────────────────────────────────────────────
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// Build queries — when board-scoped, restrict everything to the board's associations
|
||||||
|
const assocQ = supabase
|
||||||
|
.from("associations")
|
||||||
|
.select("id, name, logo_url")
|
||||||
|
.eq("status", "active")
|
||||||
|
.order("name");
|
||||||
|
if (isBoardScoped) assocQ.in("id", boardAssociationIds!);
|
||||||
|
|
||||||
|
const bankQ = supabase.from("bank_accounts").select("*").eq("status", "active");
|
||||||
|
if (isBoardScoped) bankQ.in("association_id", boardAssociationIds!);
|
||||||
|
|
||||||
|
const ledgerQ = supabase.from("owner_ledger_entries").select("*").order("date", { ascending: false }).limit(1000);
|
||||||
|
if (isBoardScoped) ledgerQ.in("association_id", boardAssociationIds!);
|
||||||
|
|
||||||
|
const billsQ = supabase.from("bills").select("*").order("bill_date", { ascending: false }).limit(500);
|
||||||
|
if (isBoardScoped) billsQ.in("association_id", boardAssociationIds!);
|
||||||
|
|
||||||
|
const txQ = supabase.from("bank_transactions").select("*").order("date", { ascending: false }).limit(200);
|
||||||
|
if (isBoardScoped) txQ.in("association_id", boardAssociationIds!);
|
||||||
|
|
||||||
|
const colQ = supabase.from("collections").select("id, owner_id, association_id, status, amount_owed, created_at");
|
||||||
|
if (isBoardScoped) colQ.in("association_id", boardAssociationIds!);
|
||||||
|
|
||||||
|
const ownersQ = supabase.from("owners").select("id, first_name, last_name, balance, association_id").eq("status", "active");
|
||||||
|
if (isBoardScoped) ownersQ.in("association_id", boardAssociationIds!);
|
||||||
|
|
||||||
|
const [assocRes, bankRes, ledgerRes, billsRes, txRes, colRes, ownersRes] = await Promise.all([
|
||||||
|
assocQ, bankQ, ledgerQ, billsQ, txQ, colQ, ownersQ,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (assocRes.data) setAssociations(assocRes.data);
|
||||||
|
if (bankRes.data) setBankAccounts(bankRes.data);
|
||||||
|
if (ledgerRes.data) setLedgerEntries(ledgerRes.data);
|
||||||
|
if (billsRes.data) setBills(billsRes.data);
|
||||||
|
if (txRes.data) setTransactions(txRes.data);
|
||||||
|
if (colRes.data) setCollections(colRes.data);
|
||||||
|
if (ownersRes.data) setOwners(ownersRes.data);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(err);
|
||||||
|
toast({ variant: "destructive", title: "Error", description: "Failed to load financial data." });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [toast, isBoardScoped, boardAssociationIds]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchData(); }, [fetchData]);
|
||||||
|
|
||||||
|
|
||||||
|
// ─── Filtered Data ───────────────────────────────────────────────
|
||||||
|
const filterByAssoc = useCallback((items: any[]) => {
|
||||||
|
if (selectedAssocId === "all") return items;
|
||||||
|
return items.filter(i => i.association_id === selectedAssocId);
|
||||||
|
}, [selectedAssocId]);
|
||||||
|
|
||||||
|
// ─── Metric Calculations ─────────────────────────────────────────
|
||||||
|
const metrics = useMemo(() => {
|
||||||
|
const filteredBanks = filterByAssoc(bankAccounts);
|
||||||
|
const filteredOwners = filterByAssoc(owners);
|
||||||
|
const filteredBills = filterByAssoc(bills);
|
||||||
|
|
||||||
|
const operatingBalance = filteredBanks
|
||||||
|
.filter((a) => a.account_category === "operating")
|
||||||
|
.reduce((s, a) => s + (a.current_balance || 0), 0);
|
||||||
|
const reserveBalance = filteredBanks
|
||||||
|
.filter((a) => a.account_category === "reserve")
|
||||||
|
.reduce((s, a) => s + (a.current_balance || 0), 0);
|
||||||
|
const accountsReceivable = filteredOwners
|
||||||
|
.filter((o) => (o.balance || 0) > 0)
|
||||||
|
.reduce((s, o) => s + (o.balance || 0), 0);
|
||||||
|
const accountsPayable = filteredBills.filter(b => b.status === "pending" || b.status === "approved").reduce((s, b) => s + ((b.amount || 0) - (b.amount_paid || 0)), 0);
|
||||||
|
|
||||||
|
return { operatingBalance, reserveBalance, accountsReceivable, accountsPayable };
|
||||||
|
}, [bankAccounts, owners, bills, filterByAssoc]);
|
||||||
|
|
||||||
|
// ─── Monthly Income vs Expenses Chart ────────────────────────────
|
||||||
|
const monthlyChart = useMemo(() => {
|
||||||
|
const filtered = filterByAssoc(ledgerEntries);
|
||||||
|
const now = new Date();
|
||||||
|
const year = now.getFullYear();
|
||||||
|
|
||||||
|
const data = MONTHS.map((month, idx) => {
|
||||||
|
const monthEntries = filtered.filter(e => {
|
||||||
|
const d = new Date(e.date);
|
||||||
|
return d.getFullYear() === year && d.getMonth() === idx;
|
||||||
|
});
|
||||||
|
const income = monthEntries.reduce((s, e) => s + (e.credit || 0), 0);
|
||||||
|
const expenses = monthEntries.reduce((s, e) => s + (e.debit || 0), 0);
|
||||||
|
return { month, income, expenses };
|
||||||
|
});
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}, [ledgerEntries, filterByAssoc]);
|
||||||
|
|
||||||
|
// ─── Delinquency Trend ───────────────────────────────────────────
|
||||||
|
const delinquencyTrend = useMemo(() => {
|
||||||
|
const filtered = filterByAssoc(collections);
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
return Array.from({ length: 6 }, (_, i) => {
|
||||||
|
const d = new Date(now.getFullYear(), now.getMonth() - (5 - i), 1);
|
||||||
|
const monthEnd = new Date(d.getFullYear(), d.getMonth() + 1, 0);
|
||||||
|
const active = filtered.filter(c => {
|
||||||
|
const created = new Date(c.created_at);
|
||||||
|
return created <= monthEnd && c.status !== "resolved";
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
month: MONTHS[d.getMonth()],
|
||||||
|
accounts: active.length,
|
||||||
|
amount: active.reduce((s, c) => s + (c.amount_owed || 0), 0),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [collections, filterByAssoc]);
|
||||||
|
|
||||||
|
// ─── Expense Categories Pie ──────────────────────────────────────
|
||||||
|
const expenseCategories = useMemo(() => {
|
||||||
|
const filtered = filterByAssoc(ledgerEntries).filter(e => (e.debit || 0) > 0);
|
||||||
|
const cats: Record<string, number> = {};
|
||||||
|
filtered.forEach(e => {
|
||||||
|
const type = e.transaction_type || "Other";
|
||||||
|
const label = type.charAt(0).toUpperCase() + type.slice(1).replace(/_/g, " ");
|
||||||
|
cats[label] = (cats[label] || 0) + (e.debit || 0);
|
||||||
|
});
|
||||||
|
return Object.entries(cats)
|
||||||
|
.map(([name, value]) => ({ name, value }))
|
||||||
|
.sort((a, b) => b.value - a.value)
|
||||||
|
.slice(0, 8);
|
||||||
|
}, [ledgerEntries, filterByAssoc]);
|
||||||
|
|
||||||
|
// ─── Recent Transactions ─────────────────────────────────────────
|
||||||
|
const recentTransactions = useMemo(() => {
|
||||||
|
return filterByAssoc(transactions).slice(0, 10);
|
||||||
|
}, [transactions, filterByAssoc]);
|
||||||
|
|
||||||
|
// ─── Quick Actions ────────────────────────────────────────────────
|
||||||
|
const quickActions = isBoardScoped
|
||||||
|
? [
|
||||||
|
{ label: "View Reports", icon: BarChart3, path: "/homeowner/board/financial-reports", color: "text-amber-600" },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ label: "Create Invoice", icon: FileText, path: "/dashboard/client-invoices", color: "text-primary" },
|
||||||
|
{ label: "Record Payment", icon: CreditCard, path: "/dashboard/payments", color: "text-emerald-600" },
|
||||||
|
{ label: "Run Report", icon: BarChart3, path: "/dashboard/financial-reports", color: "text-amber-600" },
|
||||||
|
{ label: "Export Financials", icon: Download, path: "/dashboard/reports", color: "text-purple-600" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Print PDF ────────────────────────────────────────────────────
|
||||||
|
const [printing, setPrinting] = useState(false);
|
||||||
|
const handlePrintPdf = async () => {
|
||||||
|
setPrinting(true);
|
||||||
|
try {
|
||||||
|
const selectedAssoc = associations.find((a) => a.id === selectedAssocId) || null;
|
||||||
|
const scopeLabel =
|
||||||
|
selectedAssocId === "all"
|
||||||
|
? "All Associations"
|
||||||
|
: selectedAssoc?.name || "Selected Association";
|
||||||
|
await generateFinancialOverviewPdf({
|
||||||
|
association: selectedAssoc,
|
||||||
|
scopeLabel,
|
||||||
|
metrics: {
|
||||||
|
...metrics,
|
||||||
|
arAccounts: filterByAssoc(owners).filter((o) => (o.balance || 0) > 0).length,
|
||||||
|
apPending: filterByAssoc(bills).filter((b) => b.status === "pending").length,
|
||||||
|
},
|
||||||
|
monthly: monthlyChart,
|
||||||
|
categories: expenseCategories,
|
||||||
|
delinquency: delinquencyTrend,
|
||||||
|
transactions: recentTransactions,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(err);
|
||||||
|
toast({ variant: "destructive", title: "Export failed", description: err?.message || "Could not generate PDF." });
|
||||||
|
} finally {
|
||||||
|
setPrinting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Print Visual (charts) ────────────────────────────────────────
|
||||||
|
const visualRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [printingVisual, setPrintingVisual] = useState(false);
|
||||||
|
const handlePrintVisual = async () => {
|
||||||
|
if (!visualRef.current) return;
|
||||||
|
setPrintingVisual(true);
|
||||||
|
try {
|
||||||
|
const [{ default: html2canvas }, { default: jsPDF }] = await Promise.all([
|
||||||
|
import("html2canvas"),
|
||||||
|
import("jspdf"),
|
||||||
|
]);
|
||||||
|
// Allow charts to settle
|
||||||
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
|
const node = visualRef.current;
|
||||||
|
const canvas = await html2canvas(node, {
|
||||||
|
scale: 2,
|
||||||
|
useCORS: true,
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
windowWidth: node.scrollWidth,
|
||||||
|
});
|
||||||
|
const pdf = new jsPDF({ unit: "pt", format: "letter", orientation: "landscape" });
|
||||||
|
const pageW = pdf.internal.pageSize.getWidth();
|
||||||
|
const pageH = pdf.internal.pageSize.getHeight();
|
||||||
|
const margin = 24;
|
||||||
|
const usableW = pageW - margin * 2;
|
||||||
|
const ratio = usableW / canvas.width;
|
||||||
|
const fullH = canvas.height * ratio;
|
||||||
|
|
||||||
|
const selectedAssoc = associations.find((a) => a.id === selectedAssocId) || null;
|
||||||
|
const scopeLabel =
|
||||||
|
selectedAssocId === "all" ? "All Associations" : selectedAssoc?.name || "Selected Association";
|
||||||
|
|
||||||
|
const drawHeader = () => {
|
||||||
|
pdf.setFont("helvetica", "bold");
|
||||||
|
pdf.setFontSize(13);
|
||||||
|
pdf.text("Financial Overview", margin, 28);
|
||||||
|
pdf.setFont("helvetica", "normal");
|
||||||
|
pdf.setFontSize(10);
|
||||||
|
pdf.setTextColor(110);
|
||||||
|
pdf.text(scopeLabel, margin, 44);
|
||||||
|
pdf.text(
|
||||||
|
new Date().toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }),
|
||||||
|
pageW - margin,
|
||||||
|
44,
|
||||||
|
{ align: "right" },
|
||||||
|
);
|
||||||
|
pdf.setTextColor(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const headerH = 56;
|
||||||
|
const contentH = pageH - headerH - margin;
|
||||||
|
// Slice canvas into pages
|
||||||
|
const sliceHpx = contentH / ratio; // px of source per page
|
||||||
|
let y = 0;
|
||||||
|
let page = 0;
|
||||||
|
while (y < canvas.height) {
|
||||||
|
if (page > 0) pdf.addPage();
|
||||||
|
drawHeader();
|
||||||
|
const h = Math.min(sliceHpx, canvas.height - y);
|
||||||
|
const slice = document.createElement("canvas");
|
||||||
|
slice.width = canvas.width;
|
||||||
|
slice.height = h;
|
||||||
|
const ctx = slice.getContext("2d")!;
|
||||||
|
ctx.fillStyle = "#ffffff";
|
||||||
|
ctx.fillRect(0, 0, slice.width, slice.height);
|
||||||
|
ctx.drawImage(canvas, 0, y, canvas.width, h, 0, 0, canvas.width, h);
|
||||||
|
const imgData = slice.toDataURL("image/jpeg", 0.92);
|
||||||
|
pdf.addImage(imgData, "JPEG", margin, headerH, usableW, h * ratio, undefined, "FAST");
|
||||||
|
y += h;
|
||||||
|
page += 1;
|
||||||
|
}
|
||||||
|
const fileName = `${(selectedAssoc?.name || scopeLabel).replace(/[^a-z0-9]+/gi, "_")}_Financial_Overview_Visual_${new Date().toISOString().slice(0, 10)}.pdf`;
|
||||||
|
pdf.save(fileName);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(err);
|
||||||
|
toast({ variant: "destructive", title: "Visual export failed", description: err?.message || "Could not generate visual PDF." });
|
||||||
|
} finally {
|
||||||
|
setPrintingVisual(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Custom Tooltip ───────────────────────────────────────────────
|
||||||
|
const ChartTooltip = ({ active, payload, label }: any) => {
|
||||||
|
if (!active || !payload?.length) return null;
|
||||||
|
return (
|
||||||
|
<div className="bg-card border rounded-lg shadow-lg p-3 text-sm">
|
||||||
|
<p className="font-medium text-foreground mb-1">{label}</p>
|
||||||
|
{payload.map((p: any, i: number) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<div className="h-2 w-2 rounded-full" style={{ backgroundColor: p.color }} />
|
||||||
|
<span className="text-muted-foreground">{p.name}:</span>
|
||||||
|
<span className="font-medium text-foreground">{fmtFull(p.value)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Render ──────────────────────────────────────────────────────
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* ── Header ─────────────────────────────────────────────────── */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
|
||||||
|
<Wallet className="h-6 w-6 text-primary" /> Financial Overview
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Association financial health at a glance.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Select value={selectedAssocId} onValueChange={setSelectedAssocId}>
|
||||||
|
<SelectTrigger className="w-[220px]">
|
||||||
|
<Building2 className="h-4 w-4 mr-2 text-muted-foreground" />
|
||||||
|
<SelectValue placeholder={isBoardScoped && associations.length === 1 ? associations[0]?.name : "All Associations"} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(!isBoardScoped || associations.length > 1) && (
|
||||||
|
<SelectItem value="all">All Associations</SelectItem>
|
||||||
|
)}
|
||||||
|
{associations.map(a => (
|
||||||
|
<SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button variant="outline" size="sm" onClick={fetchData}>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" /> Refresh
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={handlePrintPdf} disabled={printing}>
|
||||||
|
<Printer className="h-4 w-4 mr-2" /> {printing ? "Generating…" : "Print PDF"}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={handlePrintVisual} disabled={printingVisual}>
|
||||||
|
<BarChart3 className="h-4 w-4 mr-2" /> {printingVisual ? "Capturing…" : "Print Visual"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref={visualRef} className="space-y-6 bg-background">
|
||||||
|
{/* ── Top Metrics ────────────────────────────────────────────── */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<MetricCard
|
||||||
|
title="Operating Balance"
|
||||||
|
value={fmt(metrics.operatingBalance)}
|
||||||
|
icon={<Wallet className="h-5 w-5" />}
|
||||||
|
trend={metrics.operatingBalance >= 0 ? "positive" : "negative"}
|
||||||
|
iconBg="bg-primary/10 text-primary"
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
title="Reserve Balance"
|
||||||
|
value={fmt(metrics.reserveBalance)}
|
||||||
|
icon={<DollarSign className="h-5 w-5" />}
|
||||||
|
trend="neutral"
|
||||||
|
iconBg="bg-emerald-500/10 text-emerald-600"
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
title="Accounts Receivable"
|
||||||
|
value={fmt(metrics.accountsReceivable)}
|
||||||
|
icon={<TrendingUp className="h-5 w-5" />}
|
||||||
|
trend={metrics.accountsReceivable > 0 ? "warning" : "positive"}
|
||||||
|
subtitle={`${filterByAssoc(owners).filter((o) => (o.balance || 0) > 0).length} accounts`}
|
||||||
|
iconBg="bg-amber-500/10 text-amber-600"
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
title="Accounts Payable"
|
||||||
|
value={fmt(metrics.accountsPayable)}
|
||||||
|
icon={<Receipt className="h-5 w-5" />}
|
||||||
|
trend="neutral"
|
||||||
|
subtitle={`${filterByAssoc(bills).filter(b => b.status === "pending").length} pending`}
|
||||||
|
iconBg="bg-purple-500/10 text-purple-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Charts Row ──────────────────────────────────────────────── */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||||
|
{/* Income vs Expenses */}
|
||||||
|
<Card className="lg:col-span-2">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-sm font-semibold text-foreground">Income vs Expenses</CardTitle>
|
||||||
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-primary" /> Income
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-destructive/70" /> Expenses
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[260px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={monthlyChart} barGap={2}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="hsl(220, 13%, 91%)" vertical={false} />
|
||||||
|
<XAxis dataKey="month" tickLine={false} axisLine={false} tick={{ fill: "hsl(215, 16%, 47%)", fontSize: 12 }} />
|
||||||
|
<YAxis tickLine={false} axisLine={false} tick={{ fill: "hsl(215, 16%, 47%)", fontSize: 12 }} tickFormatter={(v) => `$${(v / 1000).toFixed(0)}k`} />
|
||||||
|
<Tooltip content={<ChartTooltip />} />
|
||||||
|
<Bar dataKey="income" name="Income" fill="hsl(224, 76%, 53%)" radius={[4, 4, 0, 0]} maxBarSize={28} />
|
||||||
|
<Bar dataKey="expenses" name="Expenses" fill="hsl(0, 72%, 51%, 0.7)" radius={[4, 4, 0, 0]} maxBarSize={28} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Expense Categories */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-semibold text-foreground">Expense Categories</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[260px]">
|
||||||
|
{expenseCategories.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<RePieChart>
|
||||||
|
<Pie
|
||||||
|
data={expenseCategories}
|
||||||
|
cx="50%"
|
||||||
|
cy="45%"
|
||||||
|
innerRadius={50}
|
||||||
|
outerRadius={80}
|
||||||
|
paddingAngle={2}
|
||||||
|
dataKey="value"
|
||||||
|
>
|
||||||
|
{expenseCategories.map((_, i) => (
|
||||||
|
<Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip formatter={(v: number) => fmtFull(v)} />
|
||||||
|
<Legend
|
||||||
|
layout="horizontal"
|
||||||
|
verticalAlign="bottom"
|
||||||
|
iconType="circle"
|
||||||
|
iconSize={8}
|
||||||
|
formatter={(value: string) => (
|
||||||
|
<span className="text-xs text-muted-foreground">{value}</span>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</RePieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-full text-sm text-muted-foreground">
|
||||||
|
No expense data available
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Delinquency Trend ───────────────────────────────────────── */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-sm font-semibold text-foreground">Delinquency Trend (6 Months)</CardTitle>
|
||||||
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-destructive" /> Amount
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-amber-500" /> Accounts
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[200px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={delinquencyTrend}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="delinqGradient" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="hsl(0, 72%, 51%)" stopOpacity={0.2} />
|
||||||
|
<stop offset="100%" stopColor="hsl(0, 72%, 51%)" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="hsl(220, 13%, 91%)" vertical={false} />
|
||||||
|
<XAxis dataKey="month" tickLine={false} axisLine={false} tick={{ fill: "hsl(215, 16%, 47%)", fontSize: 12 }} />
|
||||||
|
<YAxis yAxisId="amount" tickLine={false} axisLine={false} tick={{ fill: "hsl(215, 16%, 47%)", fontSize: 12 }} tickFormatter={(v) => `$${(v / 1000).toFixed(0)}k`} />
|
||||||
|
<YAxis yAxisId="count" orientation="right" tickLine={false} axisLine={false} tick={{ fill: "hsl(215, 16%, 47%)", fontSize: 12 }} />
|
||||||
|
<Tooltip content={<ChartTooltip />} />
|
||||||
|
<Area yAxisId="amount" type="monotone" dataKey="amount" name="Amount" stroke="hsl(0, 72%, 51%)" fill="url(#delinqGradient)" strokeWidth={2} />
|
||||||
|
<Line yAxisId="count" type="monotone" dataKey="accounts" name="Accounts" stroke="hsl(38, 92%, 50%)" strokeWidth={2} dot={{ r: 3, fill: "hsl(38, 92%, 50%)" }} />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ── Bottom Row: Transactions + Quick Actions ─────────────── */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4">
|
||||||
|
{/* Recent Transactions */}
|
||||||
|
<Card className="lg:col-span-3 overflow-hidden">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-sm font-semibold text-foreground">Recent Transactions</CardTitle>
|
||||||
|
<Button variant="ghost" size="sm" className="text-xs text-muted-foreground" onClick={() => navigate("/dashboard/payments")}>
|
||||||
|
View All <ChevronRight className="h-3 w-3 ml-1" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{recentTransactions.length > 0 ? (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="bg-muted/30">
|
||||||
|
<TableHead className="text-xs">Date</TableHead>
|
||||||
|
<TableHead className="text-xs">Description</TableHead>
|
||||||
|
<TableHead className="text-xs">Type</TableHead>
|
||||||
|
<TableHead className="text-xs text-right">Amount</TableHead>
|
||||||
|
<TableHead className="text-xs text-center">Status</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{recentTransactions.map(tx => {
|
||||||
|
const isCredit = (tx.credit || 0) > 0;
|
||||||
|
const amount = isCredit ? tx.credit : tx.debit;
|
||||||
|
return (
|
||||||
|
<TableRow key={tx.id} className="text-sm">
|
||||||
|
<TableCell className="py-2.5">
|
||||||
|
<span className="text-muted-foreground">{formatShortMonthDayEST(tx.date)}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="py-2.5">
|
||||||
|
<span className="font-medium text-foreground truncate block max-w-[280px]">
|
||||||
|
{tx.description || "Transaction"}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="py-2.5">
|
||||||
|
<Badge variant="outline" className="text-[10px] font-medium">
|
||||||
|
{(tx.transaction_type || "other").replace(/_/g, " ")}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="py-2.5 text-right">
|
||||||
|
<span className={`font-semibold ${isCredit ? "text-emerald-600" : "text-foreground"}`}>
|
||||||
|
{isCredit ? "+" : "-"}{fmtFull(amount || 0)}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="py-2.5 text-center">
|
||||||
|
<Badge
|
||||||
|
className={`text-[10px] border-0 ${
|
||||||
|
tx.is_cleared
|
||||||
|
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400"
|
||||||
|
: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tx.is_cleared ? "Cleared" : "Pending"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
) : (
|
||||||
|
<div className="py-12 text-center text-sm text-muted-foreground">
|
||||||
|
No recent transactions found.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm font-semibold text-foreground">Quick Actions</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{quickActions.map(action => (
|
||||||
|
<button
|
||||||
|
key={action.label}
|
||||||
|
onClick={() => navigate(action.path)}
|
||||||
|
className="w-full flex items-center gap-3 p-3 rounded-lg border bg-card hover:bg-muted/50 transition-colors text-left group"
|
||||||
|
>
|
||||||
|
<div className={`h-9 w-9 rounded-lg bg-muted flex items-center justify-center ${action.color} group-hover:scale-105 transition-transform`}>
|
||||||
|
<action.icon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-foreground">{action.label}</p>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Metric Card ──────────────────────────────────────────────────────
|
||||||
|
function MetricCard({ title, value, icon, trend, subtitle, iconBg }: {
|
||||||
|
title: string;
|
||||||
|
value: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
trend: "positive" | "negative" | "warning" | "neutral";
|
||||||
|
subtitle?: string;
|
||||||
|
iconBg: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card className="relative overflow-hidden">
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{title}</p>
|
||||||
|
<p className={`text-2xl font-bold ${
|
||||||
|
trend === "negative" ? "text-destructive" :
|
||||||
|
trend === "warning" ? "text-amber-600" :
|
||||||
|
trend === "positive" ? "text-emerald-600" :
|
||||||
|
"text-foreground"
|
||||||
|
}`}>
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
{subtitle && <p className="text-xs text-muted-foreground">{subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
<div className={`h-10 w-10 rounded-xl flex items-center justify-center ${iconBg}`}>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
{/* Subtle accent bar */}
|
||||||
|
<div className={`absolute bottom-0 left-0 right-0 h-0.5 ${
|
||||||
|
trend === "negative" ? "bg-destructive/40" :
|
||||||
|
trend === "warning" ? "bg-amber-500/40" :
|
||||||
|
trend === "positive" ? "bg-emerald-500/40" :
|
||||||
|
"bg-primary/20"
|
||||||
|
}`} />
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,46 @@
|
|||||||
|
import { Building2 } from "lucide-react";
|
||||||
import { useBoardAssociations } from "@/contexts/BoardAssociationContext";
|
import { useBoardAssociations } from "@/contexts/BoardAssociationContext";
|
||||||
import ZohoFinancialReportsPage from "@/pages/ZohoFinancialReportsPage";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import AccountingReportsPage from "@/pages/accounting/AccountingReportsPage";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Board Financial Reports mirror the staff Accounting Reports (P&L, Balance Sheet,
|
||||||
|
* Cash Flow, Movement of Equity, Trial Balance, General Ledger, Reserve Fund,
|
||||||
|
* AR/AP Aging, Budget vs Actuals, Reconciliation), scoped to the board's selected
|
||||||
|
* association. Board members have read-only access to the accounting ledger via RLS.
|
||||||
|
*/
|
||||||
export default function BoardFinancialReportsPage() {
|
export default function BoardFinancialReportsPage() {
|
||||||
const { associationIds, loading } = useBoardAssociations();
|
const { associations, selectedAssociationId, setSelectedAssociationId, loading } = useBoardAssociations();
|
||||||
if (loading) return <div className="flex justify-center py-12"><div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" /></div>;
|
|
||||||
return <ZohoFinancialReportsPage boardAssociationIds={associationIds} />;
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selected = associations.find((a) => a.id === selectedAssociationId) ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{associations.length > 1 && (
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
<Select value={selectedAssociationId ?? ""} onValueChange={setSelectedAssociationId}>
|
||||||
|
<SelectTrigger className="w-[240px]">
|
||||||
|
<Building2 className="h-4 w-4 mr-2 text-muted-foreground" />
|
||||||
|
<SelectValue placeholder="Select association" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{associations.map((a) => (
|
||||||
|
<SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AccountingReportsPage association={selected} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,5 +4,5 @@ import AccountingReportsPage from "@/pages/AccountingReportsPage";
|
|||||||
export default function BoardReportsPage() {
|
export default function BoardReportsPage() {
|
||||||
const { associationIds, loading } = useBoardAssociations();
|
const { associationIds, loading } = useBoardAssociations();
|
||||||
if (loading) return <div className="flex justify-center py-12"><div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" /></div>;
|
if (loading) return <div className="flex justify-center py-12"><div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" /></div>;
|
||||||
return <AccountingReportsPage />;
|
return <AccountingReportsPage associationIds={associationIds} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,10 +83,6 @@ export default function BudgetVsActualReport({ associationId, fiscalYear }: Prop
|
|||||||
const [association, setAssociation] = useState<
|
const [association, setAssociation] = useState<
|
||||||
{ id: string; name: string; logo_url: string | null; zoho_organization_id: string | null } | null
|
{ id: string; name: string; logo_url: string | null; zoho_organization_id: string | null } | null
|
||||||
>(null);
|
>(null);
|
||||||
const [zohoCur, setZohoCur] = useState<Aggregated | null>(null);
|
|
||||||
const [zohoCmp, setZohoCmp] = useState<Aggregated | null>(null);
|
|
||||||
const [zohoLoading, setZohoLoading] = useState(false);
|
|
||||||
const [zohoError, setZohoError] = useState<string | null>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [printing, setPrinting] = useState(false);
|
const [printing, setPrinting] = useState(false);
|
||||||
const [timeframe, setTimeframe] = useState<Timeframe>("ytd");
|
const [timeframe, setTimeframe] = useState<Timeframe>("ytd");
|
||||||
@@ -265,118 +261,8 @@ export default function BudgetVsActualReport({ associationId, fiscalYear }: Prop
|
|||||||
[ranges, actuals],
|
[ranges, actuals],
|
||||||
);
|
);
|
||||||
|
|
||||||
// If the association is linked to a Zoho organization, fetch the P&L for the
|
const curActuals = localCurActuals;
|
||||||
// current (and comparison) ranges and use those totals as the actuals source.
|
const cmpActuals = localCmpActuals;
|
||||||
const isZohoLinked = !!association?.zoho_organization_id;
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isZohoLinked || !associationId) {
|
|
||||||
setZohoCur(null);
|
|
||||||
setZohoCmp(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let cancelled = false;
|
|
||||||
// Debounce so rapid filter changes don't fire overlapping (rate-limited) requests
|
|
||||||
const debounce = setTimeout(() => { run(); }, 350);
|
|
||||||
const toIso = (d: Date) =>
|
|
||||||
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
||||||
const flatten = (pl: any): { name: string; amount: number; type: "income" | "expense" }[] => {
|
|
||||||
const out: { name: string; amount: number; type: "income" | "expense" }[] = [];
|
|
||||||
const sections = pl?.profit_and_loss || pl?.profitandloss || pl?.sections || pl?.reports || pl?.rows || [];
|
|
||||||
const walk = (nodes: any[], inheritedType: "income" | "expense" | null) => {
|
|
||||||
for (const n of nodes || []) {
|
|
||||||
const label = String(n?.section_name || n?.name || n?.account_name || n?.account_type || n?.total_label || "").toLowerCase();
|
|
||||||
let t = inheritedType;
|
|
||||||
if (/income|revenue|operating_income|other_income/.test(label)) t = "income";
|
|
||||||
else if (/expense|cost|cogs|cost_of_goods/.test(label)) t = "expense";
|
|
||||||
const accountName = n?.account_name || n?.name || n?.account || null;
|
|
||||||
const hasChildren = zohoChildKeys.some((key) => asArray(n?.[key]).length > 0);
|
|
||||||
const isTotal = !!n?.total_label || /^total\b/.test(String(accountName || "").trim().toLowerCase());
|
|
||||||
const raw = parseMoney(n?.total ?? n?.amount ?? n?.amount_in_base_currency ?? n?.balance ?? n?.value);
|
|
||||||
if (accountName && !hasChildren && !isTotal && raw !== 0) {
|
|
||||||
out.push({ name: String(accountName), amount: Math.abs(raw), type: t || "expense" });
|
|
||||||
}
|
|
||||||
zohoChildKeys.forEach((key) => {
|
|
||||||
const children = asArray(n?.[key]);
|
|
||||||
if (children.length) walk(children, t);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
walk(asArray(sections), null);
|
|
||||||
return out;
|
|
||||||
};
|
|
||||||
const buildAgg = (items: { name: string; amount: number; type: "income" | "expense" }[]): Aggregated => {
|
|
||||||
const byGl: Record<string, Bucket> = {};
|
|
||||||
const byName: Record<string, Bucket> = {};
|
|
||||||
const make = (): Bucket => ({ income: 0, expense: 0, glIds: new Set(), names: new Set() });
|
|
||||||
items.forEach((it) => {
|
|
||||||
const key = normalizeName(it.name);
|
|
||||||
const nb = (byName[key] ||= make());
|
|
||||||
if (!nb.display) nb.display = it.name;
|
|
||||||
nb.names.add(key);
|
|
||||||
const glId = coaIdByName[key];
|
|
||||||
if (glId) nb.glIds.add(glId);
|
|
||||||
if (it.type === "income") nb.income += it.amount; else nb.expense += it.amount;
|
|
||||||
if (glId) {
|
|
||||||
const gb = (byGl[glId] ||= make());
|
|
||||||
gb.glIds.add(glId);
|
|
||||||
gb.names.add(key);
|
|
||||||
if (it.type === "income") gb.income += it.amount; else gb.expense += it.amount;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return { byGl, byName };
|
|
||||||
};
|
|
||||||
const fetchOne = async (from: Date, to: Date) => {
|
|
||||||
// Retry once on transient errors (rate limit / cold start)
|
|
||||||
let lastErr: any = null;
|
|
||||||
for (let attempt = 0; attempt < 2; attempt++) {
|
|
||||||
const { data, error } = await supabase.functions.invoke("zoho-books", {
|
|
||||||
body: {
|
|
||||||
action: "get_profit_and_loss",
|
|
||||||
params: { association_id: associationId, from_date: toIso(from), to_date: toIso(to) },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!error) return buildAgg(flatten((data as any)?.data ?? data));
|
|
||||||
lastErr = error;
|
|
||||||
await new Promise((r) => setTimeout(r, 600));
|
|
||||||
}
|
|
||||||
throw new Error(lastErr?.message || "Zoho fetch failed");
|
|
||||||
};
|
|
||||||
const hasAnyData = (agg: Aggregated | null) => {
|
|
||||||
if (!agg) return false;
|
|
||||||
const sumBuckets = (rec: Record<string, Bucket>) =>
|
|
||||||
Object.values(rec).reduce((s, b) => s + Math.abs(b.income) + Math.abs(b.expense), 0);
|
|
||||||
return sumBuckets(agg.byName) + sumBuckets(agg.byGl) > 0;
|
|
||||||
};
|
|
||||||
async function run() {
|
|
||||||
setZohoLoading(true);
|
|
||||||
setZohoError(null);
|
|
||||||
try {
|
|
||||||
const cur = await fetchOne(ranges.curStart, ranges.curEnd);
|
|
||||||
if (cancelled) return;
|
|
||||||
// If Zoho returned nothing parseable, fall back to local actuals rather
|
|
||||||
// than rendering an empty (but truthy) aggregate that zeros every row.
|
|
||||||
setZohoCur(hasAnyData(cur) ? cur : null);
|
|
||||||
if (ranges.cmpStart && ranges.cmpEnd) {
|
|
||||||
const cmp = await fetchOne(ranges.cmpStart, ranges.cmpEnd);
|
|
||||||
if (cancelled) return;
|
|
||||||
setZohoCmp(hasAnyData(cmp) ? cmp : null);
|
|
||||||
} else {
|
|
||||||
setZohoCmp(null);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("[BudgetVsActualReport] Zoho P&L fetch failed, falling back to local:", e);
|
|
||||||
// Do NOT clear previous successful results — that's what caused the
|
|
||||||
// table to flicker / show wrong data when filters change quickly.
|
|
||||||
if (!cancelled) setZohoError((e as Error)?.message || "Zoho fetch failed");
|
|
||||||
} finally {
|
|
||||||
if (!cancelled) setZohoLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return () => { cancelled = true; clearTimeout(debounce); };
|
|
||||||
}, [isZohoLinked, associationId, ranges, coaIdByName]);
|
|
||||||
|
|
||||||
const curActuals = zohoCur || localCurActuals;
|
|
||||||
const cmpActuals = zohoCmp || localCmpActuals;
|
|
||||||
|
|
||||||
// Build report rows: one per leaf budget line, with matched actuals
|
// Build report rows: one per leaf budget line, with matched actuals
|
||||||
const reportRows = useMemo(() => {
|
const reportRows = useMemo(() => {
|
||||||
@@ -471,10 +357,7 @@ export default function BudgetVsActualReport({ associationId, fiscalYear }: Prop
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
// Walk byName so each unbudgeted line shows under its human-readable name.
|
// Walk byName so each unbudgeted line shows under its human-readable name.
|
||||||
// When Zoho is the actuals source, always surface unbudgeted lines so
|
const showUnbudgeted = !hideUnbudgeted;
|
||||||
// expenses pulled from Zoho aren't silently hidden when they don't match
|
|
||||||
// a local budget category.
|
|
||||||
const showUnbudgeted = !hideUnbudgeted || !!zohoCur;
|
|
||||||
if (showUnbudgeted) {
|
if (showUnbudgeted) {
|
||||||
Object.entries(curActuals.byName).forEach(([key, v]) => {
|
Object.entries(curActuals.byName).forEach(([key, v]) => {
|
||||||
const isMatchedById = Array.from(v.glIds).some((id) => matchedGlIds.has(id));
|
const isMatchedById = Array.from(v.glIds).some((id) => matchedGlIds.has(id));
|
||||||
@@ -526,7 +409,7 @@ export default function BudgetVsActualReport({ associationId, fiscalYear }: Prop
|
|||||||
if (ac !== bc) return ac.localeCompare(bc, undefined, { numeric: true });
|
if (ac !== bc) return ac.localeCompare(bc, undefined, { numeric: true });
|
||||||
return a.category.localeCompare(b.category);
|
return a.category.localeCompare(b.category);
|
||||||
});
|
});
|
||||||
}, [budgets, fiscalYear, curActuals, cmpActuals, budgetMonths, comparisonBudgetMonths, accountParents, accountNumbers, hideUnbudgeted, zohoCur]);
|
}, [budgets, fiscalYear, curActuals, cmpActuals, budgetMonths, comparisonBudgetMonths, accountParents, accountNumbers, hideUnbudgeted]);
|
||||||
|
|
||||||
const totals = useMemo(() => {
|
const totals = useMemo(() => {
|
||||||
const income = reportRows.filter((r) => r.accountType === "income");
|
const income = reportRows.filter((r) => r.accountType === "income");
|
||||||
@@ -742,16 +625,6 @@ export default function BudgetVsActualReport({ associationId, fiscalYear }: Prop
|
|||||||
<Badge variant="secondary">Period: {rangeLabel}</Badge>
|
<Badge variant="secondary">Period: {rangeLabel}</Badge>
|
||||||
{cmpRangeLabel && <Badge variant="outline">Compare: {cmpRangeLabel}</Badge>}
|
{cmpRangeLabel && <Badge variant="outline">Compare: {cmpRangeLabel}</Badge>}
|
||||||
{budgetMonths < 12 && <Badge variant="outline">Budget pro-rated to {budgetMonths} of 12 months</Badge>}
|
{budgetMonths < 12 && <Badge variant="outline">Budget pro-rated to {budgetMonths} of 12 months</Badge>}
|
||||||
{isZohoLinked && (
|
|
||||||
<Badge variant="outline" className="border-emerald-500/40 text-emerald-700">
|
|
||||||
{zohoLoading ? "Loading Zoho P&L…" : "Actuals: Zoho Books P&L"}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{zohoError && (
|
|
||||||
<Badge variant="outline" className="border-amber-500/50 text-amber-700">
|
|
||||||
Zoho refresh failed — showing previous data. {zohoError}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* KPI cards */}
|
{/* KPI cards */}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import AssociationDirectoryManager from "@/components/association/AssociationDirectoryManager";
|
import AssociationDirectoryManager from "@/components/association/AssociationDirectoryManager";
|
||||||
import { useHomeowner } from "@/contexts/HomeownerContext";
|
import { useHomeowner } from "@/contexts/HomeownerContext";
|
||||||
|
import { useBoardAssociations } from "@/contexts/BoardAssociationContext";
|
||||||
import { supabase } from "@/integrations/supabase/client";
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
@@ -10,7 +11,7 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { AlertCircle, Loader2, Save, Users } from "lucide-react";
|
import { AlertCircle, Loader2, Save, Users, Briefcase } from "lucide-react";
|
||||||
|
|
||||||
const optInSchema = z.object({
|
const optInSchema = z.object({
|
||||||
display_name: z.string().trim().min(1, "Name is required").max(150),
|
display_name: z.string().trim().min(1, "Name is required").max(150),
|
||||||
@@ -34,6 +35,7 @@ type DirectoryEntry = {
|
|||||||
|
|
||||||
export default function HomeownerDirectoryPage() {
|
export default function HomeownerDirectoryPage() {
|
||||||
const { currentOwner, association, loading } = useHomeowner();
|
const { currentOwner, association, loading } = useHomeowner();
|
||||||
|
const board = useBoardAssociations();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [entry, setEntry] = useState<DirectoryEntry | null>(null);
|
const [entry, setEntry] = useState<DirectoryEntry | null>(null);
|
||||||
const [form, setForm] = useState<OptInForm>({ display_name: "", title: "", email: "", phone: "", notes: "", optedIn: false });
|
const [form, setForm] = useState<OptInForm>({ display_name: "", title: "", email: "", phone: "", notes: "", optedIn: false });
|
||||||
@@ -131,8 +133,25 @@ export default function HomeownerDirectoryPage() {
|
|||||||
toast({ title: "Directory opt-in saved" });
|
toast({ title: "Directory opt-in saved" });
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading || entryLoading) return <div className="flex justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>;
|
if (loading || entryLoading || board.loading) return <div className="flex justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>;
|
||||||
if (!currentOwner || !association) return <div className="text-center py-20 text-muted-foreground"><AlertCircle className="h-10 w-10 mx-auto mb-3 opacity-40" />No association is linked to your account.</div>;
|
|
||||||
|
const isBoard = (board.associationIds?.length ?? 0) > 0;
|
||||||
|
|
||||||
|
// Board members who aren't owners still get the shared-vendor directory.
|
||||||
|
if (!currentOwner || !association) {
|
||||||
|
if (isBoard) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2"><Users className="h-6 w-6" /> Directory</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">Vendor contacts shared with the board.</p>
|
||||||
|
</div>
|
||||||
|
<BoardVendorDirectory associationIds={board.associationIds} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <div className="text-center py-20 text-muted-foreground"><AlertCircle className="h-10 w-10 mx-auto mb-3 opacity-40" />No association is linked to your account.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -165,10 +184,55 @@ export default function HomeownerDirectoryPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<AssociationDirectoryManager associationId={association.id} mode="readonly" title={`${association.name} Directory`} description="Published contacts for your association." />
|
<AssociationDirectoryManager associationId={association.id} mode="readonly" title={`${association.name} Directory`} description="Published contacts for your association." />
|
||||||
|
|
||||||
|
{isBoard && <BoardVendorDirectory associationIds={board.associationIds} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Vendor contacts a manager has shared with the board. Board members only; RLS
|
||||||
|
* ensures only shared vendors for the member's association(s) are returned. */
|
||||||
|
function BoardVendorDirectory({ associationIds }: { associationIds: string[] }) {
|
||||||
|
const [vendors, setVendors] = useState<any[]>([]);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!associationIds?.length) { setLoaded(true); return; }
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
const { data } = await (supabase as any)
|
||||||
|
.from("vendors")
|
||||||
|
.select("id, name, contact_name, email, phone, address")
|
||||||
|
.eq("share_with_board", true)
|
||||||
|
.order("name");
|
||||||
|
if (!cancelled) { setVendors(data ?? []); setLoaded(true); }
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [associationIds?.join(",")]);
|
||||||
|
|
||||||
|
if (loaded && vendors.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2"><Briefcase className="h-5 w-5" /> Vendors</CardTitle>
|
||||||
|
<CardDescription>Vendor contacts shared with the board.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{vendors.map((v) => (
|
||||||
|
<div key={v.id} className="rounded-md border bg-background p-3">
|
||||||
|
<div className="font-medium text-foreground">{v.name}</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{[v.contact_name, v.phone, v.email].filter(Boolean).join(" · ") || "—"}
|
||||||
|
</div>
|
||||||
|
{v.address && <div className="text-xs text-muted-foreground mt-0.5">{v.address}</div>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Field({ label, error, className, children }: { label: string; error?: string; className?: string; children: React.ReactNode }) {
|
function Field({ label, error, className, children }: { label: string; error?: string; className?: string; children: React.ReactNode }) {
|
||||||
return <div className={`space-y-1.5 ${className || ""}`}><Label>{label}</Label>{children}{error && <p className="text-xs text-destructive">{error}</p>}</div>;
|
return <div className={`space-y-1.5 ${className || ""}`}><Label>{label}</Label>{children}{error && <p className="text-xs text-destructive">{error}</p>}</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import { Settings, Save, Loader2, RefreshCw, CheckCircle2, XCircle, ArrowDownToL
|
|||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { useToast } from "@/components/ui/use-toast";
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
import { syncAllFinancials, pullInvoicesFromZoho, pullPaymentsFromZoho } from "@/lib/zohoFinancialSync";
|
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
||||||
|
|
||||||
const SETTINGS_FIELDS = [
|
const SETTINGS_FIELDS = [
|
||||||
{ key: "company_name", label: "Company Name", placeholder: "Your management company name", type: "text" },
|
{ key: "company_name", label: "Company Name", placeholder: "Your management company name", type: "text" },
|
||||||
@@ -28,33 +26,14 @@ export default function GeneralSettingsPage() {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [dirty, setDirty] = useState(false);
|
const [dirty, setDirty] = useState(false);
|
||||||
|
|
||||||
// Zoho state
|
|
||||||
const [zohoTesting, setZohoTesting] = useState(false);
|
|
||||||
const [zohoConnected, setZohoConnected] = useState<boolean | null>(null);
|
|
||||||
const [zohoOrgName, setZohoOrgName] = useState<string | null>(null);
|
|
||||||
const [zohoSyncing, setZohoSyncing] = useState(false);
|
|
||||||
const [zohoPulling, setZohoPulling] = useState(false);
|
|
||||||
const [zohoConfig, setZohoConfig] = useState<Record<string, { set: boolean; masked: string }>>({});
|
|
||||||
const [zohoConfigLoading, setZohoConfigLoading] = useState(false);
|
|
||||||
const [companyContactSyncing, setCompanyContactSyncing] = useState(false);
|
|
||||||
|
|
||||||
// Zoho Org IDs per association
|
|
||||||
const [associations, setAssociations] = useState<{ id: string; name: string; zoho_organization_id: string | null }[]>([]);
|
|
||||||
const [assocOrgIds, setAssocOrgIds] = useState<Record<string, string>>({});
|
|
||||||
const [assocDirty, setAssocDirty] = useState(false);
|
|
||||||
const [assocSaving, setAssocSaving] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchSettings();
|
fetchSettings();
|
||||||
fetchZohoConfig();
|
|
||||||
fetchAssociations();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchSettings = async () => {
|
const fetchSettings = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const allKeys = [
|
const allKeys = [
|
||||||
...SETTINGS_FIELDS.map(f => f.key),
|
...SETTINGS_FIELDS.map(f => f.key),
|
||||||
"zoho_company_org_id",
|
|
||||||
"maintenance_mode",
|
"maintenance_mode",
|
||||||
"maintenance_message",
|
"maintenance_message",
|
||||||
];
|
];
|
||||||
@@ -72,20 +51,6 @@ export default function GeneralSettingsPage() {
|
|||||||
setDirty(false);
|
setDirty(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchAssociations = async () => {
|
|
||||||
const { data } = await supabase
|
|
||||||
.from("associations")
|
|
||||||
.select("id, name, zoho_organization_id")
|
|
||||||
.eq("status", "active")
|
|
||||||
.order("name");
|
|
||||||
if (data) {
|
|
||||||
setAssociations(data);
|
|
||||||
const map: Record<string, string> = {};
|
|
||||||
data.forEach(a => { map[a.id] = a.zoho_organization_id || ""; });
|
|
||||||
setAssocOrgIds(map);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChange = (key: string, value: string) => {
|
const handleChange = (key: string, value: string) => {
|
||||||
setSettings(prev => ({ ...prev, [key]: value }));
|
setSettings(prev => ({ ...prev, [key]: value }));
|
||||||
setDirty(true);
|
setDirty(true);
|
||||||
@@ -104,99 +69,6 @@ export default function GeneralSettingsPage() {
|
|||||||
setSaving(false);
|
setSaving(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAssocOrgChange = (id: string, value: string) => {
|
|
||||||
setAssocOrgIds(prev => ({ ...prev, [id]: value }));
|
|
||||||
setAssocDirty(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveAssocOrgIds = async () => {
|
|
||||||
setAssocSaving(true);
|
|
||||||
const updates = associations.map(a =>
|
|
||||||
supabase
|
|
||||||
.from("associations")
|
|
||||||
.update({ zoho_organization_id: assocOrgIds[a.id] || null })
|
|
||||||
.eq("id", a.id)
|
|
||||||
);
|
|
||||||
const results = await Promise.all(updates);
|
|
||||||
const failed = results.find(r => r.error);
|
|
||||||
if (failed?.error) {
|
|
||||||
toast({ title: "Error saving", description: failed.error.message, variant: "destructive" });
|
|
||||||
} else {
|
|
||||||
toast({ title: "Zoho Org IDs saved", description: "Association organization IDs updated." });
|
|
||||||
setAssocDirty(false);
|
|
||||||
}
|
|
||||||
setAssocSaving(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchZohoConfig = async () => {
|
|
||||||
setZohoConfigLoading(true);
|
|
||||||
try {
|
|
||||||
const { data, error } = await supabase.functions.invoke("zoho-config", {
|
|
||||||
body: { action: "get_config" },
|
|
||||||
});
|
|
||||||
if (!error && data?.data) setZohoConfig(data.data);
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
setZohoConfigLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const testZohoConnection = async () => {
|
|
||||||
setZohoTesting(true);
|
|
||||||
setZohoConnected(null);
|
|
||||||
try {
|
|
||||||
const { data, error } = await supabase.functions.invoke("zoho-books", {
|
|
||||||
body: { action: "test_connection" },
|
|
||||||
});
|
|
||||||
if (error) throw error;
|
|
||||||
const org = data?.data?.organization;
|
|
||||||
const orgs = data?.data?.organizations || [];
|
|
||||||
if (org) {
|
|
||||||
setZohoConnected(true);
|
|
||||||
setZohoOrgName(org.name);
|
|
||||||
toast({ title: "Connected!", description: `Linked to ${org.name}` });
|
|
||||||
} else if (orgs.length > 0) {
|
|
||||||
setZohoConnected(true);
|
|
||||||
setZohoOrgName(orgs[0].name);
|
|
||||||
toast({ title: "Connected!", description: `${orgs.length} organization(s) available.` });
|
|
||||||
} else {
|
|
||||||
setZohoConnected(true);
|
|
||||||
setZohoOrgName("Unknown");
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
setZohoConnected(false);
|
|
||||||
toast({ title: "Connection failed", description: err.message, variant: "destructive" });
|
|
||||||
} finally {
|
|
||||||
setZohoTesting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleZohoFullSync = async () => {
|
|
||||||
setZohoSyncing(true);
|
|
||||||
try {
|
|
||||||
const result = await syncAllFinancials();
|
|
||||||
if (result.success) {
|
|
||||||
toast({ title: "Zoho Sync Complete", description: "All financial data has been synced." });
|
|
||||||
} else {
|
|
||||||
toast({ title: "Sync failed", description: result.error, variant: "destructive" });
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setZohoSyncing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleZohoPull = async () => {
|
|
||||||
setZohoPulling(true);
|
|
||||||
try {
|
|
||||||
const [inv, pay] = await Promise.all([pullInvoicesFromZoho(), pullPaymentsFromZoho()]);
|
|
||||||
if (inv.success && pay.success) {
|
|
||||||
toast({ title: "Pull Complete", description: "Invoices and payments pulled from Zoho." });
|
|
||||||
} else {
|
|
||||||
toast({ title: "Pull had errors", description: inv.error || pay.error || "Unknown error", variant: "destructive" });
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setZohoPulling(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-3xl">
|
<div className="space-y-6 max-w-3xl">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -278,202 +150,6 @@ export default function GeneralSettingsPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Zoho Books Credentials */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-lg flex items-center gap-2">
|
|
||||||
<KeyRound className="w-5 h-5 text-primary" />
|
|
||||||
Zoho Books Credentials
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Your Zoho Books API credentials are stored securely as backend secrets.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
{zohoConfigLoading ? (
|
|
||||||
<div className="flex justify-center py-4"><Loader2 className="h-5 w-5 animate-spin text-muted-foreground" /></div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{[
|
|
||||||
{ key: "ZOHO_CLIENT_ID", label: "Client ID" },
|
|
||||||
{ key: "ZOHO_CLIENT_SECRET", label: "Client Secret" },
|
|
||||||
{ key: "ZOHO_REFRESH_TOKEN", label: "Refresh Token" },
|
|
||||||
].map(({ key, label }) => {
|
|
||||||
const info = zohoConfig[key];
|
|
||||||
return (
|
|
||||||
<div key={key} className="flex items-center justify-between py-2 border-b border-border last:border-0">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-foreground">{label}</p>
|
|
||||||
<p className="text-xs text-muted-foreground font-mono">{key}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{info?.set ? (
|
|
||||||
<>
|
|
||||||
<span className="text-sm font-mono text-muted-foreground">{info.masked}</span>
|
|
||||||
<Badge variant="outline" className="text-xs bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-300">Set</Badge>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Badge variant="secondary" className="text-xs">Not configured</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Zoho Organization IDs */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-lg flex items-center gap-2">
|
|
||||||
<Building2 className="w-5 h-5 text-primary" />
|
|
||||||
Zoho Organization IDs
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Set the Zoho Books Organization ID for your management company and for each client association.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-5">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="zoho_company_org_id" className="font-semibold">Company Organization ID</Label>
|
|
||||||
<Input
|
|
||||||
id="zoho_company_org_id"
|
|
||||||
value={settings["zoho_company_org_id"] || ""}
|
|
||||||
onChange={e => handleChange("zoho_company_org_id", e.target.value)}
|
|
||||||
placeholder="e.g. 918131027"
|
|
||||||
className="max-w-sm font-mono"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">Used when syncing company-level client invoices to Zoho.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="font-semibold">Association Organization IDs</Label>
|
|
||||||
<div className="border rounded-md">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Association</TableHead>
|
|
||||||
<TableHead>Zoho Organization ID</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{associations.map(a => (
|
|
||||||
<TableRow key={a.id}>
|
|
||||||
<TableCell className="text-sm font-medium">{a.name}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Input
|
|
||||||
value={assocOrgIds[a.id] || ""}
|
|
||||||
onChange={e => handleAssocOrgChange(a.id, e.target.value)}
|
|
||||||
placeholder="e.g. 918352869"
|
|
||||||
className="max-w-[200px] font-mono h-8 text-sm"
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={handleSaveAssocOrgIds}
|
|
||||||
disabled={assocSaving || !assocDirty}
|
|
||||||
size="sm"
|
|
||||||
className="mt-2"
|
|
||||||
>
|
|
||||||
{assocSaving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Save className="mr-2 h-4 w-4" />}
|
|
||||||
Save Organization IDs
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Zoho Books Integration */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-lg flex items-center gap-2">
|
|
||||||
<BookOpen className="w-5 h-5 text-primary" />
|
|
||||||
Zoho Books Integration
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Connect and sync your financial data with Zoho Books.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{zohoConnected === null && <Badge variant="secondary">Not tested</Badge>}
|
|
||||||
{zohoConnected === true && (
|
|
||||||
<Badge className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
|
||||||
<CheckCircle2 className="w-3 h-3 mr-1" /> Connected
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{zohoConnected === false && (
|
|
||||||
<Badge variant="destructive"><XCircle className="w-3 h-3 mr-1" /> Failed</Badge>
|
|
||||||
)}
|
|
||||||
{zohoOrgName && (
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
Organization: <strong className="text-foreground">{zohoOrgName}</strong>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-3">
|
|
||||||
<Button onClick={testZohoConnection} disabled={zohoTesting} variant="outline" size="sm">
|
|
||||||
{zohoTesting ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <RefreshCw className="w-4 h-4 mr-2" />}
|
|
||||||
Test Connection
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleZohoFullSync} disabled={zohoSyncing || zohoConnected !== true} variant="outline" size="sm">
|
|
||||||
{zohoSyncing ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <ArrowUpFromLine className="w-4 h-4 mr-2" />}
|
|
||||||
Full Sync
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleZohoPull} disabled={zohoPulling || zohoConnected !== true} variant="outline" size="sm">
|
|
||||||
{zohoPulling ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <ArrowDownToLine className="w-4 h-4 mr-2" />}
|
|
||||||
Pull Invoices & Payments
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Sync Clients to Company Zoho */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-lg flex items-center gap-2">
|
|
||||||
<Users className="w-5 h-5 text-primary" />
|
|
||||||
Sync Clients to Company Zoho
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Push all active associations as customer contacts to your company's Zoho Books organization (uses the Company Organization ID above).
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Button
|
|
||||||
onClick={async () => {
|
|
||||||
setCompanyContactSyncing(true);
|
|
||||||
try {
|
|
||||||
const { data, error } = await supabase.functions.invoke("zoho-books", {
|
|
||||||
body: { action: "push_contacts_company", params: {} },
|
|
||||||
});
|
|
||||||
if (error) throw error;
|
|
||||||
const r = data?.data;
|
|
||||||
toast({
|
|
||||||
title: "Client Sync Complete",
|
|
||||||
description: `Created: ${r?.created ?? 0}, Updated: ${r?.updated ?? 0}, Errors: ${r?.errors ?? 0}`,
|
|
||||||
});
|
|
||||||
} catch (err: any) {
|
|
||||||
toast({ title: "Sync failed", description: err.message, variant: "destructive" });
|
|
||||||
} finally {
|
|
||||||
setCompanyContactSyncing(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={companyContactSyncing}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{companyContactSyncing ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Users className="w-4 h-4 mr-2" />}
|
|
||||||
Sync Clients Now
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,280 +0,0 @@
|
|||||||
import { useState, useEffect } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
|
||||||
import { supabase } from "@/integrations/supabase/client";
|
|
||||||
import { CheckCircle2, XCircle, Loader2, RefreshCw, BookOpen, ArrowDownToLine, ArrowUpFromLine, Building2 } from "lucide-react";
|
|
||||||
|
|
||||||
interface ZohoOrg {
|
|
||||||
organization_id: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Association {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
zoho_organization_id: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ZohoBooksSettingsPage() {
|
|
||||||
const { toast } = useToast();
|
|
||||||
const [testing, setTesting] = useState(false);
|
|
||||||
const [connected, setConnected] = useState<boolean | null>(null);
|
|
||||||
const [orgName, setOrgName] = useState<string | null>(null);
|
|
||||||
const [zohoOrgs, setZohoOrgs] = useState<ZohoOrg[]>([]);
|
|
||||||
const [associations, setAssociations] = useState<Association[]>([]);
|
|
||||||
const [pulling, setPulling] = useState(false);
|
|
||||||
const [pushing, setPushing] = useState(false);
|
|
||||||
const [savingOrg, setSavingOrg] = useState<string | null>(null);
|
|
||||||
const [syncResult, setSyncResult] = useState<{ type: string; data: any } | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
supabase.from("associations").select("id, name, zoho_organization_id").eq("status", "active").order("name")
|
|
||||||
.then(({ data }) => { if (data) setAssociations(data as Association[]); });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const testConnection = async () => {
|
|
||||||
setTesting(true);
|
|
||||||
setConnected(null);
|
|
||||||
try {
|
|
||||||
const { data, error } = await supabase.functions.invoke("zoho-books", {
|
|
||||||
body: { action: "test_connection" },
|
|
||||||
});
|
|
||||||
if (error) throw error;
|
|
||||||
|
|
||||||
const matchedOrg = data?.data?.organization;
|
|
||||||
const orgs = data?.data?.organizations || [];
|
|
||||||
setZohoOrgs(orgs.map((o: any) => ({ organization_id: String(o.organization_id), name: o.name })));
|
|
||||||
|
|
||||||
if (matchedOrg) {
|
|
||||||
setConnected(true);
|
|
||||||
setOrgName(matchedOrg.name);
|
|
||||||
toast({ title: "Connected!", description: `Linked to ${matchedOrg.name}` });
|
|
||||||
} else if (orgs.length > 0) {
|
|
||||||
setConnected(true);
|
|
||||||
setOrgName(orgs[0].name);
|
|
||||||
toast({ title: "Connected!", description: `${orgs.length} organization(s) available.` });
|
|
||||||
} else {
|
|
||||||
setConnected(true);
|
|
||||||
setOrgName("Unknown");
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
setConnected(false);
|
|
||||||
toast({ title: "Connection failed", description: err.message, variant: "destructive" });
|
|
||||||
} finally {
|
|
||||||
setTesting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAssignOrg = async (associationId: string, zohoOrgId: string) => {
|
|
||||||
setSavingOrg(associationId);
|
|
||||||
const value = zohoOrgId === "__none__" ? null : zohoOrgId;
|
|
||||||
const { error } = await supabase.from("associations").update({ zoho_organization_id: value }).eq("id", associationId);
|
|
||||||
if (error) {
|
|
||||||
toast({ title: "Error", description: error.message, variant: "destructive" });
|
|
||||||
} else {
|
|
||||||
setAssociations(prev => prev.map(a => a.id === associationId ? { ...a, zoho_organization_id: value } : a));
|
|
||||||
toast({ title: "Saved", description: "Zoho organization assigned." });
|
|
||||||
}
|
|
||||||
setSavingOrg(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const pullContacts = async () => {
|
|
||||||
setPulling(true);
|
|
||||||
setSyncResult(null);
|
|
||||||
try {
|
|
||||||
const { data, error } = await supabase.functions.invoke("zoho-books", { body: { action: "pull_contacts" } });
|
|
||||||
if (error) throw error;
|
|
||||||
setSyncResult({ type: "pull", data: data?.data });
|
|
||||||
toast({ title: "Pull complete", description: `${data?.data?.total || 0} contacts processed.` });
|
|
||||||
} catch (err: any) {
|
|
||||||
toast({ title: "Pull failed", description: err.message, variant: "destructive" });
|
|
||||||
} finally { setPulling(false); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const pushContacts = async () => {
|
|
||||||
setPushing(true);
|
|
||||||
setSyncResult(null);
|
|
||||||
try {
|
|
||||||
const { data, error } = await supabase.functions.invoke("zoho-books", { body: { action: "push_contacts" } });
|
|
||||||
if (error) throw error;
|
|
||||||
setSyncResult({ type: "push", data: data?.data });
|
|
||||||
toast({ title: "Push complete", description: `${data?.data?.created || 0} created, ${data?.data?.updated || 0} updated.` });
|
|
||||||
} catch (err: any) {
|
|
||||||
toast({ title: "Push failed", description: err.message, variant: "destructive" });
|
|
||||||
} finally { setPushing(false); }
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6 max-w-3xl">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
|
|
||||||
<BookOpen className="w-6 h-6 text-primary" />
|
|
||||||
Zoho Books Integration
|
|
||||||
</h1>
|
|
||||||
<p className="text-muted-foreground mt-1">
|
|
||||||
Connect your Zoho Books account and map each association to its own Zoho organization.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Connection Status */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-lg">Connection Status</CardTitle>
|
|
||||||
<CardDescription>Test your Zoho Books connection to verify credentials.</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{connected === null && <Badge variant="secondary">Not tested</Badge>}
|
|
||||||
{connected === true && (
|
|
||||||
<Badge className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
|
||||||
<CheckCircle2 className="w-3 h-3 mr-1" />Connected
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{connected === false && <Badge variant="destructive"><XCircle className="w-3 h-3 mr-1" />Failed</Badge>}
|
|
||||||
{orgName && <span className="text-sm text-muted-foreground">Primary: <strong className="text-foreground">{orgName}</strong></span>}
|
|
||||||
{zohoOrgs.length > 1 && <span className="text-xs text-muted-foreground">({zohoOrgs.length} orgs available)</span>}
|
|
||||||
</div>
|
|
||||||
<Button onClick={testConnection} disabled={testing} variant="outline">
|
|
||||||
{testing ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <RefreshCw className="w-4 h-4 mr-2" />}
|
|
||||||
Test Connection
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Organization Mapping */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-lg flex items-center gap-2">
|
|
||||||
<Building2 className="w-5 h-5" />
|
|
||||||
Organization Mapping
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Assign each association to a Zoho Books organization. All synced data (contacts, invoices, payments) will use the assigned org.
|
|
||||||
{zohoOrgs.length === 0 && " Test connection first to load available organizations."}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{associations.length === 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground py-4 text-center">No associations found.</p>
|
|
||||||
) : (
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Association</TableHead>
|
|
||||||
<TableHead>Zoho Organization</TableHead>
|
|
||||||
<TableHead className="w-20">Status</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{associations.map(assoc => (
|
|
||||||
<TableRow key={assoc.id}>
|
|
||||||
<TableCell className="font-medium">{assoc.name}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{zohoOrgs.length > 0 ? (
|
|
||||||
<Select
|
|
||||||
value={assoc.zoho_organization_id || "__none__"}
|
|
||||||
onValueChange={v => handleAssignOrg(assoc.id, v)}
|
|
||||||
disabled={savingOrg === assoc.id}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-64">
|
|
||||||
<SelectValue placeholder="Select Zoho org..." />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="__none__">— Not assigned —</SelectItem>
|
|
||||||
{zohoOrgs.map(org => (
|
|
||||||
<SelectItem key={org.organization_id} value={org.organization_id}>
|
|
||||||
{org.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
) : (
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{assoc.zoho_organization_id || "Not assigned"}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{assoc.zoho_organization_id ? (
|
|
||||||
<Badge variant="outline" className="text-xs bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-300">Mapped</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge variant="secondary" className="text-xs">Unmapped</Badge>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Contact Sync */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-lg">Contact Sync</CardTitle>
|
|
||||||
<CardDescription>Sync associations, owners, and vendors bidirectionally. Each association syncs to its mapped Zoho org.</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="flex flex-wrap gap-3">
|
|
||||||
<Button onClick={pullContacts} disabled={pulling || pushing} variant="outline">
|
|
||||||
{pulling ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <ArrowDownToLine className="w-4 h-4 mr-2" />}
|
|
||||||
Pull from Zoho
|
|
||||||
</Button>
|
|
||||||
<Button onClick={pushContacts} disabled={pulling || pushing} variant="outline">
|
|
||||||
{pushing ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <ArrowUpFromLine className="w-4 h-4 mr-2" />}
|
|
||||||
Push to Zoho
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{syncResult && (
|
|
||||||
<div className="rounded-md border p-3 text-sm space-y-1 bg-muted/50">
|
|
||||||
<p className="font-medium text-foreground">{syncResult.type === "pull" ? "Pull" : "Push"} Results</p>
|
|
||||||
{syncResult.type === "pull" ? (
|
|
||||||
<>
|
|
||||||
<p className="text-muted-foreground">Total Zoho contacts: {syncResult.data?.total || 0}</p>
|
|
||||||
<p className="text-muted-foreground">Matched & updated: {syncResult.data?.updated || 0}</p>
|
|
||||||
<p className="text-muted-foreground">Skipped (no match): {syncResult.data?.skipped || 0}</p>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<p className="text-muted-foreground">Created in Zoho: {syncResult.data?.created || 0}</p>
|
|
||||||
<p className="text-muted-foreground">Updated in Zoho: {syncResult.data?.updated || 0}</p>
|
|
||||||
<p className="text-muted-foreground">Errors: {syncResult.data?.errors || 0}</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Available Features */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-lg">Available Sync Actions</CardTitle>
|
|
||||||
<CardDescription>Once connected, these features are available per-association.</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
|
||||||
{["Contacts — Bidirectional sync with associations, owners & vendors",
|
|
||||||
"Invoices — Push charges and pull invoices per org",
|
|
||||||
"Payments — Sync owner payments bidirectionally",
|
|
||||||
"Bills — Sync vendor bills for payment tracking",
|
|
||||||
"Chart of Accounts — Pull your Zoho chart of accounts",
|
|
||||||
"Bank Transactions — Fetch bank transaction records",
|
|
||||||
].map((item, i) => (
|
|
||||||
<li key={i} className="flex items-start gap-2">
|
|
||||||
<CheckCircle2 className="w-4 h-4 text-primary mt-0.5 shrink-0" />
|
|
||||||
<span><strong className="text-foreground">{item.split("—")[0].trim()}</strong> —{item.split("—")[1]}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,77 +0,0 @@
|
|||||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
|
||||||
|
|
||||||
const corsHeaders = {
|
|
||||||
"Access-Control-Allow-Origin": "*",
|
|
||||||
"Access-Control-Allow-Headers":
|
|
||||||
"authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
|
|
||||||
};
|
|
||||||
|
|
||||||
function getServiceClient() {
|
|
||||||
return createClient(
|
|
||||||
Deno.env.get("SUPABASE_URL")!,
|
|
||||||
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function verifyAdmin(req: Request) {
|
|
||||||
const authHeader = req.headers.get("Authorization");
|
|
||||||
if (!authHeader) throw new Error("Not authenticated");
|
|
||||||
|
|
||||||
const token = authHeader.replace("Bearer ", "");
|
|
||||||
const supabase = createClient(
|
|
||||||
Deno.env.get("SUPABASE_URL")!,
|
|
||||||
Deno.env.get("SUPABASE_ANON_KEY")!
|
|
||||||
);
|
|
||||||
const { data: { user }, error } = await supabase.auth.getUser(token);
|
|
||||||
if (error || !user) throw new Error("Invalid session");
|
|
||||||
|
|
||||||
const db = getServiceClient();
|
|
||||||
const { data: roles } = await db
|
|
||||||
.from("user_roles")
|
|
||||||
.select("role")
|
|
||||||
.eq("user_id", user.id);
|
|
||||||
|
|
||||||
const isAdmin = (roles || []).some((r: any) => r.role === "admin" || r.role === "manager");
|
|
||||||
if (!isAdmin) throw new Error("Insufficient permissions");
|
|
||||||
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
|
|
||||||
Deno.serve(async (req) => {
|
|
||||||
if (req.method === "OPTIONS") {
|
|
||||||
return new Response(null, { headers: corsHeaders });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await verifyAdmin(req);
|
|
||||||
const { action } = await req.json();
|
|
||||||
|
|
||||||
if (action === "get_config") {
|
|
||||||
// Return masked values so the UI can show what's configured
|
|
||||||
const keys = ["ZOHO_CLIENT_ID", "ZOHO_CLIENT_SECRET", "ZOHO_REFRESH_TOKEN", "ZOHO_ORGANIZATION_ID"];
|
|
||||||
const config: Record<string, { set: boolean; masked: string }> = {};
|
|
||||||
|
|
||||||
for (const key of keys) {
|
|
||||||
const val = Deno.env.get(key) || "";
|
|
||||||
config[key] = {
|
|
||||||
set: val.length > 0,
|
|
||||||
masked: val.length > 4 ? "••••" + val.slice(-4) : val.length > 0 ? "••••" : "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(JSON.stringify({ data: config }), {
|
|
||||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(JSON.stringify({ error: "Unknown action" }), {
|
|
||||||
status: 400,
|
|
||||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
||||||
});
|
|
||||||
} catch (e: any) {
|
|
||||||
return new Response(JSON.stringify({ error: e.message }), {
|
|
||||||
status: 401,
|
|
||||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"rewrites": [
|
||||||
|
{ "source": "/(.*)", "destination": "/index.html" }
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user