Accounting platform: remove Zoho, unify reports, board access, vendor sharing

- Remove the Zoho Books integration (edge functions, sync libs, settings,
  reports/overview, banking links, fees tab, import dialog); preserve fee
  rules as a standalone FeesTab and the COA accounting_system classification.
- Financial Overview/Reports (staff + board) render the Accounting dashboard
  and reports; board reports mirror the rich Accounting Reports.
- New Reserve Fund Schedule report + an is_reserve flag on accounts.
- Unify all report exports to a branded format (logo + centered header +
  footer): shared ReportSheet (on-screen) and reportHeader (PDF). Budget vs
  Actuals and Bank Reconciliation PDFs now match the reference layout.
- Render financial reports inline (no preview pop-up).
- Budget Management mirrors Accounting Budgeting (staff-accessible) with SPA
  navigation; editable bills in the Accounting Bills page.
- Negative opening balances flow through to the GL and reports (allow negative
  input; keep non-zero on save; signed CSV import).
- Upload a per-account trial balance via CSV on Opening Balances.
- Board members: read-only RLS access to their association's accounting ledger;
  editable board-members panel on the association page; share vendor contacts
  with the board (toggle + directory section).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 18:29:31 -04:00
parent db20226d62
commit e302fb91f0
63 changed files with 2406 additions and 9514 deletions
+3 -6
View File
@@ -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>
);
}
-2
View File
@@ -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')
-6
View File
@@ -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,11 +37,6 @@ 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)
pushBillPaymentToZohoAfterPay(bill.id).then((r) => {
if (!r.success) console.warn('Zoho payment sync failed for bill', bill.id, r.error);
});
toast({ toast({
title: "Success", title: "Success",
description: sendToPrintQueue description: sendToPrintQueue
-5
View File
@@ -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();
-1
View File
@@ -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 },
+291
View File
@@ -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>
);
}
-938
View File
@@ -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 wont 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;
+12
View File
@@ -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 {
-16
View File
@@ -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.
-85
View File
@@ -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) };
}
}
-483
View File
@@ -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");
}
-88
View File
@@ -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 };
}
-2
View File
@@ -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({
+6 -3
View File
@@ -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;
+119 -30
View File
@@ -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">
<div className="min-w-0">
<span className="font-medium">{m.member_name}</span> <span className="font-medium">{m.member_name}</span>
<div className="flex items-center gap-2"> {m.member_email && <span className="text-muted-foreground text-xs ml-2">{m.member_email}</span>}
{m.member_email && <span className="text-muted-foreground text-xs">{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 &amp; 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>
+1 -14
View File
@@ -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 -109
View File
@@ -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>
-16
View File
@@ -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
View File
@@ -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();
}; };
File diff suppressed because it is too large Load Diff
-115
View File
@@ -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]">
+2 -17
View File
@@ -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" />}
-605
View File
@@ -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>
);
}
+34 -897
View File
@@ -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>
<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" /> <Building2 className="h-4 w-4 mr-2 text-muted-foreground" />
<SelectValue placeholder={isBoardScoped && associations.length === 1 ? associations[0]?.name : "All Associations"} /> <SelectValue placeholder={loadingAssociations ? "Loading…" : "Select association"} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{(!isBoardScoped || associations.length > 1) && ( {sorted.map((a) => (
<SelectItem value="all">All Associations</SelectItem>
)}
{associations.map(a => (
<SelectItem key={a.id} value={a.id}>{a.name}</SelectItem> <SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </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>
<div ref={visualRef} className="space-y-6 bg-background"> <AccountingDashboardPage association={selectedAssociation} />
{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>
</Card>
</div>
</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>
);
}
+52
View File
@@ -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>
);
}
+3 -7
View File
@@ -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();
+2 -25
View File
@@ -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 -7
View File
@@ -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);
-13
View File
@@ -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);
+17 -3
View File
@@ -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>
<div className="flex flex-wrap items-center gap-1">
<Badge variant="outline" className={v.is_active ? "bg-emerald-100 text-emerald-700" : "bg-muted text-muted-foreground"}> <Badge variant="outline" className={v.is_active ? "bg-emerald-100 text-emerald-700" : "bg-muted text-muted-foreground"}>
{v.is_active ? "Active" : "Inactive"} {v.is_active ? "Active" : "Inactive"}
</Badge> </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>
-709
View File
@@ -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>
);
}
+47 -7
View File
@@ -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,6 +245,25 @@ 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 itemRows = (billId: string) => items.map(i => ({
bill_id: billId, description: i.description, quantity: i.quantity, rate: i.rate,
amount: +(Number(i.quantity) * Number(i.rate)).toFixed(2),
account_id: i.account_id || null,
}));
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({ const { data: bill, error } = await accounting.from("bills").insert({
company_id: cid, vendor_id: vendorId || null, number, company_id: cid, vendor_id: vendorId || null, number,
issue_date: issueDate, due_date: dueDate || null, issue_date: issueDate, due_date: dueDate || null,
@@ -232,12 +272,9 @@ export default function AccountingBillsPage() {
attachment_url: attachmentUrl, attachment_url: attachmentUrl,
}).select().single(); }).select().single();
if (error || !bill) return toast.error(error?.message ?? "Failed"); if (error || !bill) return toast.error(error?.message ?? "Failed");
await accounting.from("bill_items").insert(items.map(i => ({ await accounting.from("bill_items").insert(itemRows(bill.id));
bill_id: bill.id, description: i.description, quantity: i.quantity, rate: i.rate,
amount: +(Number(i.quantity) * Number(i.rate)).toFixed(2),
account_id: i.account_id || null,
})));
toast.success("Bill recorded"); 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>
)} )}
+144 -98
View File
@@ -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" && (
<ReportSheet title="Reconciliation Checks" companyName={associationName ?? "Company"} period={rangeLabel} logoUrl={logoUrl}>
<ReconciliationReport d={data} currency={cur} /> <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>
<CardContent className="p-0">
{isLoading ? ( {isLoading ? (
<div className="p-8 text-center text-sm text-muted-foreground">Loading</div> <Card><CardContent className="p-8 text-center text-sm text-muted-foreground">Loading</CardContent></Card>
) : ( ) : (
<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">
+112 -119
View File
@@ -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`,
);
} }
+92
View File
@@ -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" });
}
}
+19 -11
View File
@@ -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" });
} }
}; };
+7 -3
View File
@@ -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>
);
}
+42 -4
View File
@@ -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>
);
} }
+1 -1
View File
@@ -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} />;
} }
+4 -131
View File
@@ -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 */}
+67 -3
View File
@@ -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>;
} }
-324
View File
@@ -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
-77
View File
@@ -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" },
});
}
});
+5
View File
@@ -0,0 +1,5 @@
{
"rewrites": [
{ "source": "/(.*)", "destination": "/index.html" }
]
}