diff --git a/src/App.tsx b/src/App.tsx index df8fcf8..cef0324 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -107,7 +107,6 @@ import ReconciliationsPage from "./pages/ReconciliationsPage"; import ImportTransactionsPage from "./pages/ImportTransactionsPage"; import WriteChecksPage from "./pages/WriteChecksPage"; import PrintChecksPage from "./pages/PrintChecksPage"; -import CompanyLedgerPage from "./pages/CompanyLedgerPage"; import CompanyBankAccountsPage from "./pages/CompanyBankAccountsPage"; import CompanyBankAccountsHubPage from "./pages/CompanyBankAccountsHubPage"; import CompanyBankRegisterPage from "./pages/CompanyBankRegisterPage"; @@ -141,7 +140,6 @@ import OwnerLedgerPage from "./pages/OwnerLedgerPage"; import RecordOwnerPaymentPage from "./pages/RecordOwnerPaymentPage"; import DepositBatchesPage from "./pages/DepositBatchesPage"; import TransfersPage from "./pages/TransfersPage"; -import ZohoBooksSettingsPage from "./pages/settings/ZohoBooksSettingsPage"; import BrandingSettingsPage from "./pages/settings/BrandingSettingsPage"; import RolePermissionsPage from "./pages/settings/RolePermissionsPage"; import GeneralSettingsPage from "./pages/settings/GeneralSettingsPage"; @@ -151,7 +149,7 @@ import AdminStripeAccountsPage from "./pages/AdminStripeAccountsPage"; import BuildiumSettingsPage from "./pages/settings/BuildiumSettingsPage"; import BuildiumImportReviewPage from "./pages/settings/BuildiumImportReviewPage"; import RecurringRulesPage from "./pages/settings/RecurringRulesPage"; -import ZohoFinancialReportsPage from "./pages/ZohoFinancialReportsPage"; +import FinancialReportsPage from "./pages/FinancialReportsPage"; import FinancialOverviewPage from "./pages/FinancialOverviewPage"; import RecentLedgerUpdatesPage from "./pages/RecentLedgerUpdatesPage"; import OutstandingBalancesPage from "./pages/OutstandingBalancesPage"; @@ -359,6 +357,7 @@ const App = () => ( } /> } /> } /> + } /> } /> } /> } /> @@ -368,12 +367,11 @@ const App = () => ( } /> } /> } /> - } /> } /> } /> } /> } /> - } /> + } /> } /> }> } /> @@ -437,7 +435,6 @@ const App = () => ( } /> } /> } /> - } /> } /> } /> } /> diff --git a/src/components/ImportZohoBankAccountsDialog.tsx b/src/components/ImportZohoBankAccountsDialog.tsx deleted file mode 100644 index b6dd3cb..0000000 --- a/src/components/ImportZohoBankAccountsDialog.tsx +++ /dev/null @@ -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(""); - const [zohoAccounts, setZohoAccounts] = useState([]); - const [selected, setSelected] = useState>(new Set()); - const [existing, setExisting] = useState>(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(); - (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 ( - - - - - - Import Bank Accounts from Zoho Books - - - Select an association linked to Zoho Books, then choose which bank accounts to import. - - - - {eligible.length === 0 ? ( -
- No associations are linked to a Zoho Books organization yet. Open an association and set - its Zoho Organization ID first. -
- ) : ( -
-
- - -
- -
-
-
- toggleAll(!!v)} - disabled={loading || zohoAccounts.length === 0} - /> - - {loading - ? "Loading…" - : `${selected.size} selected of ${zohoAccounts.length}`} - -
-
- -
- {loading ? ( -
- Fetching from Zoho… -
- ) : zohoAccounts.length === 0 ? ( -
- No bank accounts returned by Zoho for this organization. -
- ) : ( - zohoAccounts.map((a) => { - const dup = - (a.account_number && existing.has(a.account_number)) || - existing.has(`name:${(a.account_name || "").toLowerCase()}`); - return ( - - ); - }) - )} -
-
-
- )} - - - - - -
-
- ); -} diff --git a/src/components/InvoiceMappingDialog.jsx b/src/components/InvoiceMappingDialog.jsx index f855b83..ac5179e 100644 --- a/src/components/InvoiceMappingDialog.jsx +++ b/src/components/InvoiceMappingDialog.jsx @@ -5,7 +5,6 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { useToast } from '@/hooks/use-toast'; import { supabase } from '@/integrations/supabase/client'; -import { pushBillToZohoAfterCreate } from '@/lib/zohoBillSync'; import { useAuth } from '@/contexts/AuthContext'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; 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(); if (billError) throw billError; - if (newBill?.id) pushBillToZohoAfterCreate(newBill.id); if (invoice?.id) { await supabase.from('invoices') diff --git a/src/components/MarkAsPaidDialog.jsx b/src/components/MarkAsPaidDialog.jsx index eda3c8a..fbdf3f5 100644 --- a/src/components/MarkAsPaidDialog.jsx +++ b/src/components/MarkAsPaidDialog.jsx @@ -7,7 +7,6 @@ import { Card, CardContent } from '@/components/ui/card'; import { Loader2, CheckCircle } from 'lucide-react'; import { useToast } from '@/hooks/use-toast'; import { supabase } from '@/integrations/supabase/client'; -import { pushBillPaymentToZohoAfterPay } from '@/lib/zohoBillSync'; import { format } from 'date-fns'; import { useAuth } from '@/contexts/AuthContext'; @@ -38,12 +37,7 @@ export default function MarkAsPaidDialog({ open, onOpenChange, bill, onSuccess } 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", description: sendToPrintQueue ? "Bill marked as paid and check added to print queue." diff --git a/src/components/PaymentFormDialog.jsx b/src/components/PaymentFormDialog.jsx index b96d77c..3944b49 100644 --- a/src/components/PaymentFormDialog.jsx +++ b/src/components/PaymentFormDialog.jsx @@ -7,7 +7,6 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Loader2, DollarSign, AlertCircle, Plus, Trash2 } from 'lucide-react'; import { supabase } from '@/integrations/supabase/client'; -import { syncPaymentToZoho } from '@/lib/zohoFinancialSync'; import { useToast } from '@/hooks/use-toast'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Alert, AlertDescription } from '@/components/ui/alert'; @@ -111,10 +110,6 @@ export default function PaymentFormDialog({ open, onOpenChange, vendorId, client description: data.notes, status: 'completed' }]).select('id').single(); 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) { const { data: userData } = await supabase.auth.getUser(); diff --git a/src/components/SettingsSidebar.tsx b/src/components/SettingsSidebar.tsx index 6b21f7c..84327a7 100644 --- a/src/components/SettingsSidebar.tsx +++ b/src/components/SettingsSidebar.tsx @@ -48,7 +48,6 @@ export const SETTINGS_PAGES = [ { category: "Integrations", items: [ - { path: "/dashboard/settings/zoho-books", title: "Zoho Books", icon: BookOpen }, { path: "/dashboard/settings/buildium", title: "Buildium", icon: Building2 }, { path: "/dashboard/settings/stripe-accounts", title: "Payment Gateways", icon: CreditCard }, { path: "/dashboard/mailchimp", title: "Mailchimp", icon: Mail }, diff --git a/src/components/association/FeesTab.tsx b/src/components/association/FeesTab.tsx new file mode 100644 index 0000000..207b7d4 --- /dev/null +++ b/src/components/association/FeesTab.tsx @@ -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(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 ( +
+ +
+ ); + } + + return ( +
+ {/* ── Interest & Late Fee Rules ── */} + + + + + Interest & Late Fee Rules + + Configure how interest and late fees are calculated for {associationName}. These charges can be auto-applied to owner ledgers. + + + {/* Interest Section */} +
+
+
+

Interest Charges

+

Apply interest on outstanding balances

+
+ setFeeRules({ ...feeRules, interest_enabled: v })} + /> +
+ {feeRules.interest_enabled && ( +
+
+ + setFeeRules({ ...feeRules, interest_rate: Number(e.target.value) })} + className="text-sm" + /> +
+
+ + setFeeRules({ ...feeRules, interest_grace_days: Number(e.target.value) })} + className="text-sm" + /> +
+
+ + +
+
+ + +
+
+ )} +
+ + + + {/* Late Fee Section */} +
+
+
+

Late Fees

+

Charge late fees on overdue accounts

+
+ setFeeRules({ ...feeRules, late_fee_enabled: v })} + /> +
+ {feeRules.late_fee_enabled && ( +
+
+ + +
+
+ + setFeeRules({ ...feeRules, late_fee_amount: Number(e.target.value) })} + className="text-sm" + /> +
+
+ + setFeeRules({ ...feeRules, late_fee_trigger_days: Number(e.target.value) })} + className="text-sm" + /> +
+ {feeRules.late_fee_type === "percentage" && ( +
+ + setFeeRules({ ...feeRules, late_fee_max: e.target.value ? Number(e.target.value) : null })} + className="text-sm" + placeholder="No cap" + /> +
+ )} +
+ setFeeRules({ ...feeRules, late_fee_recurring: v })} + /> + +
+
+ )} +
+ + + + {/* Auto-apply Schedule */} +
+
+
+

Auto-Apply Schedule

+

Automatically generate interest and late fee charges

+
+ setFeeRules({ ...feeRules, auto_apply_enabled: v })} + /> +
+ {feeRules.auto_apply_enabled && ( +
+
+ + +
+
+ + setFeeRules({ ...feeRules, auto_apply_day: Number(e.target.value) })} + className="text-sm" + /> +
+
+ )} +
+ +
+ +
+
+
+
+ ); +} diff --git a/src/components/association/ZohoFeesTab.tsx b/src/components/association/ZohoFeesTab.tsx deleted file mode 100644 index 818a2ad..0000000 --- a/src/components/association/ZohoFeesTab.tsx +++ /dev/null @@ -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(DEFAULT_FEE_RULES); - - // Sync settings - const [syncSettings, setSyncSettings] = useState(DEFAULT_SYNC_SETTINGS); - - // Account mappings - const [accountMappings, setAccountMappings] = useState([]); - 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([]); - 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([]); - - // Reporting tags - const [reportingTags, setReportingTags] = useState([]); // from Zoho - const [tagMappings, setTagMappings] = useState([]); // 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 ( -
- -
- ); - } - - return ( -
- {/* ── Interest & Late Fee Rules ── */} - - - - - Interest & Late Fee Rules - - Configure how interest and late fees are calculated for {associationName}. These charges can be auto-applied and pushed to Zoho. - - - {/* Interest Section */} -
-
-
-

Interest Charges

-

Apply interest on outstanding balances

-
- setFeeRules({ ...feeRules, interest_enabled: v })} - /> -
- {feeRules.interest_enabled && ( -
-
- - setFeeRules({ ...feeRules, interest_rate: Number(e.target.value) })} - className="text-sm" - /> -
-
- - setFeeRules({ ...feeRules, interest_grace_days: Number(e.target.value) })} - className="text-sm" - /> -
-
- - -
-
- - -
-
- )} -
- - - - {/* Late Fee Section */} -
-
-
-

Late Fees

-

Charge late fees on overdue accounts

-
- setFeeRules({ ...feeRules, late_fee_enabled: v })} - /> -
- {feeRules.late_fee_enabled && ( -
-
- - -
-
- - setFeeRules({ ...feeRules, late_fee_amount: Number(e.target.value) })} - className="text-sm" - /> -
-
- - setFeeRules({ ...feeRules, late_fee_trigger_days: Number(e.target.value) })} - className="text-sm" - /> -
- {feeRules.late_fee_type === "percentage" && ( -
- - setFeeRules({ ...feeRules, late_fee_max: e.target.value ? Number(e.target.value) : null })} - className="text-sm" - placeholder="No cap" - /> -
- )} -
- setFeeRules({ ...feeRules, late_fee_recurring: v })} - /> - -
-
- )} -
- - - - {/* Auto-apply Schedule */} -
-
-
-

Auto-Apply Schedule

-

Automatically generate interest and late fee charges

-
- setFeeRules({ ...feeRules, auto_apply_enabled: v })} - /> -
- {feeRules.auto_apply_enabled && ( -
-
- - -
-
- - setFeeRules({ ...feeRules, auto_apply_day: Number(e.target.value) })} - className="text-sm" - /> -
-
- setFeeRules({ ...feeRules, push_to_zoho: v })} - /> - -
-
- )} -
- -
- -
-
-
- - {/* ── Sync Settings ── */} - - - - - Zoho Sync Settings - - Control which types of data sync between this association and Zoho Books. - - -
- {([ - { 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 }) => ( -
- setSyncSettings({ ...syncSettings, [key]: v })} - /> - -
- ))} -
-
- -
-
-
- - {/* ── Account Mappings ── */} - - - - - Chart of Accounts Mapping - - Map your local chart of accounts to their Zoho Books equivalents. - - - {accountMappings.length > 0 && ( - - - - Local Account - Zoho Account ID - Zoho Account Name - - - - - {accountMappings.map((m) => ( - - {m.account_number} — {m.account_name} - {m.zoho_account_id} - {m.zoho_account_name || "—"} - - - - - ))} - -
- )} -
-
- - -
-
- - setNewAccountMapping({ ...newAccountMapping, zoho_account_id: e.target.value })} - className="text-sm w-[180px]" - placeholder="e.g. 982000..." - /> -
-
- - setNewAccountMapping({ ...newAccountMapping, zoho_account_name: e.target.value })} - className="text-sm w-[180px]" - placeholder="Optional label" - /> -
- -
-
-
- - {/* ── Customer Mappings (Unit Address = Zoho Customer Name) ── */} - - - - - Unit → Zoho Customer Mapping - - In Zoho, the customer name is the property street address. Select a unit to map its address to a Zoho customer. - - - {customerMappings.length > 0 && ( - - - - Unit # - Property Address - Zoho Customer ID - Zoho Customer Name - - - - - {customerMappings.map((m: any) => ( - - {m.unit_number || "—"} - {m.unit_address || "—"} - {m.zoho_customer_id} - {m.zoho_customer_name || "—"} - - - - - ))} - -
- )} -
-
- - -
-
- - setNewCustomerMapping({ ...newCustomerMapping, zoho_customer_id: e.target.value })} - className="text-sm w-[180px]" - placeholder="e.g. 982000..." - /> -
-
- - setNewCustomerMapping({ ...newCustomerMapping, zoho_customer_name: e.target.value })} - className="text-sm w-[220px]" - placeholder="Auto-filled from unit address" - /> -
- -
-
-
- - {/* ── Reporting Tags ── */} - - - - - Zoho Reporting Tags - - Map this association to Zoho reporting tags so invoices and payments are tagged correctly. - - - {tagMappings.length > 0 && ( -
-

Current Mappings

- {tagMappings.map((m: any) => ( -
- {m.zoho_tag_name || m.zoho_tag_id} - - {m.zoho_option_name || m.zoho_tag_option_id} -
- ))} -
- )} - -
-
-

Manual Mapping

-

- If Zoho won’t load the dropdown options, enter the tag and option details manually and save them here. -

-
- -
-
- - setManualTagMapping({ ...manualTagMapping, zoho_tag_id: e.target.value })} - placeholder="e.g. 8824065000000000333" - className="text-sm" - /> -
-
- - setManualTagMapping({ ...manualTagMapping, zoho_tag_name: e.target.value })} - placeholder="e.g. Association" - className="text-sm" - /> -
-
- - setManualTagMapping({ ...manualTagMapping, zoho_tag_option_id: e.target.value })} - placeholder="Enter the option ID" - className="text-sm" - /> -
-
- - setManualTagMapping({ ...manualTagMapping, zoho_option_name: e.target.value })} - placeholder={associationName} - className="text-sm" - /> -
-
- -
- - -
-
- - {reportingTags.length > 0 && ( -
- {reportingTags.map((tag: any) => { - const currentMapping = tagMappings.find((m: any) => String(m.zoho_tag_id) === String(tag.tag_id)); - return ( -
- - -
- ); - })} -
- )} -
-
- - {/* ── Sync Log ── */} - - - - - Sync History - - Recent sync activity for this association. - - - {syncLog.length === 0 ? ( -

No sync activity recorded yet.

- ) : ( - - - - Date - Type - Direction - Records - Status - Error - - - - {syncLog.map((entry) => ( - - {format(new Date(entry.created_at), "MMM d, h:mm a")} - {entry.sync_type} - - {entry.direction === "push" ? ( - Push - ) : ( - Pull - )} - - {entry.record_count} - - {entry.status === "success" ? ( - Success - ) : entry.status === "error" ? ( - Error - ) : ( - Partial - )} - - {entry.error_message || "—"} - - ))} - -
- )} -
-
-
- ); -} diff --git a/src/components/dashboard/DashboardHeader.tsx b/src/components/dashboard/DashboardHeader.tsx index 0034ec5..015d167 100644 --- a/src/components/dashboard/DashboardHeader.tsx +++ b/src/components/dashboard/DashboardHeader.tsx @@ -360,30 +360,6 @@ export function DashboardHeader({ userEmail, fullName, roles = [], userId }: Das Settings - {/* Zoho Banking */} - {associations.filter(a => (a as any).zoho_organization_id).length > 0 && ( - - - - - -
Zoho Banking
- {associations.filter(a => (a as any).zoho_organization_id).map((a: any) => ( - window.open(`https://books.zoho.com/app/${a.zoho_organization_id}/banking`, "_blank")} - > - - {a.name} - - - ))} -
-
- )} {/* User */} diff --git a/src/components/dashboard/DashboardTopNav.tsx b/src/components/dashboard/DashboardTopNav.tsx index 7bfa40e..346e48f 100644 --- a/src/components/dashboard/DashboardTopNav.tsx +++ b/src/components/dashboard/DashboardTopNav.tsx @@ -631,26 +631,6 @@ export function DashboardTopNav({ userEmail, fullName, roles = [], userId }: Das Settings - {/* Zoho Banking */} - {associations.filter(a => (a as any).zoho_organization_id).length > 0 && ( - - - - - -
Zoho Banking
- {associations.filter(a => (a as any).zoho_organization_id).map((a: any) => ( - window.open(`https://books.zoho.com/app/${a.zoho_organization_id}/banking`, "_blank")}> - - {a.name} - - - ))} -
-
- )} {/* User */} diff --git a/src/components/unit-profile/UnitLedgerTransactionForm.tsx b/src/components/unit-profile/UnitLedgerTransactionForm.tsx index 714cedc..fe8d72f 100644 --- a/src/components/unit-profile/UnitLedgerTransactionForm.tsx +++ b/src/components/unit-profile/UnitLedgerTransactionForm.tsx @@ -12,7 +12,6 @@ import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Loader2, Plus, Trash2, SplitSquareHorizontal, DollarSign, Calendar as CalendarIcon, FileText } from "lucide-react"; import { Checkbox } from "@/components/ui/checkbox"; -import { syncChargeToZoho, syncPaymentToZoho } from "@/lib/zohoFinancialSync"; import { calculateRemainingUnpaidAssessmentBalance, computePaymentWaterfallAllocation, type WaterfallAllocation } from "@/lib/unitLedgerAccountBreakdown"; 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(); onOpenChange(false); } catch (err: any) { diff --git a/src/components/unit-profile/UnitLedgerView.tsx b/src/components/unit-profile/UnitLedgerView.tsx index 23be7af..2789e40 100644 --- a/src/components/unit-profile/UnitLedgerView.tsx +++ b/src/components/unit-profile/UnitLedgerView.tsx @@ -29,7 +29,6 @@ import { parseLocalDate, todayLocal } from "@/lib/dateUtils"; import { useAuth } from "@/contexts/AuthContext"; import UnitLedgerTransactionForm from "./UnitLedgerTransactionForm"; import UnitFeeExclusionsCard from "./UnitFeeExclusionsCard"; -import { syncChargeToZoho, syncPaymentToZoho } from "@/lib/zohoFinancialSync"; interface Props { unitId: string; diff --git a/src/lib/accountingClient.ts b/src/lib/accountingClient.ts index e45ba1e..b45747f 100644 --- a/src/lib/accountingClient.ts +++ b/src/lib/accountingClient.ts @@ -30,6 +30,18 @@ export async function ensureAccountingCompany(associationId: string): Promise( + (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); const missingRpc = error.code === "PGRST202"; return { diff --git a/src/lib/budgetSync.ts b/src/lib/budgetSync.ts index 3b59b91..f09aef7 100644 --- a/src/lib/budgetSync.ts +++ b/src/lib/budgetSync.ts @@ -1,21 +1,5 @@ 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. * Buildium's `/v1/budgets` endpoint exposes monthly amounts per GL account. diff --git a/src/lib/zohoBillSync.ts b/src/lib/zohoBillSync.ts deleted file mode 100644 index af6d310..0000000 --- a/src/lib/zohoBillSync.ts +++ /dev/null @@ -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) }; - } -} diff --git a/src/lib/zohoFinancialReportPdf.ts b/src/lib/zohoFinancialReportPdf.ts deleted file mode 100644 index cafc2ab..0000000 --- a/src/lib/zohoFinancialReportPdf.ts +++ /dev/null @@ -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(); - const expense = new Map(); - 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; expense: Map } | 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"); -} - diff --git a/src/lib/zohoFinancialSync.ts b/src/lib/zohoFinancialSync.ts deleted file mode 100644 index a07068b..0000000 --- a/src/lib/zohoFinancialSync.ts +++ /dev/null @@ -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 }; -} diff --git a/src/pages/AIInvoiceParserPage.tsx b/src/pages/AIInvoiceParserPage.tsx index 126bf0a..9f30133 100644 --- a/src/pages/AIInvoiceParserPage.tsx +++ b/src/pages/AIInvoiceParserPage.tsx @@ -1,6 +1,5 @@ import { useState, useRef, useCallback, useEffect } from "react"; import { supabase } from "@/integrations/supabase/client"; -import { pushBillToZohoAfterCreate } from "@/lib/zohoBillSync"; import { saveBillInvoiceToDocuments } from "@/lib/saveBillToDocuments"; import { useToast } from "@/hooks/use-toast"; import { @@ -353,7 +352,6 @@ export default function AIInvoiceParserPage() { }).select("id").single(); if (billErr) throw billErr; - if (newBill?.id) pushBillToZohoAfterCreate(newBill.id); // File invoice PDF into Documents > Vendor Invoices for this association saveBillInvoiceToDocuments({ diff --git a/src/pages/AccountingReportsPage.tsx b/src/pages/AccountingReportsPage.tsx index 8582e8b..2d746de 100644 --- a/src/pages/AccountingReportsPage.tsx +++ b/src/pages/AccountingReportsPage.tsx @@ -12,7 +12,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 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 [associations, setAssociations] = useState([]); const [selectedAssocId, setSelectedAssocId] = useState(""); @@ -28,11 +28,14 @@ export default function AccountingReportsPage() { const [loading, setLoading] = useState(false); 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 || []); if (data?.length) setSelectedAssocId(data[0].id); }); - }, []); + }, [associationIds?.join(",")]); const generateReport = async () => { if (!selectedAssocId) return; diff --git a/src/pages/AssociationDetailPage.tsx b/src/pages/AssociationDetailPage.tsx index afa1426..e96b43b 100644 --- a/src/pages/AssociationDetailPage.tsx +++ b/src/pages/AssociationDetailPage.tsx @@ -12,6 +12,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@ import { Textarea } from "@/components/ui/textarea"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; import { ArrowLeft, Building2, MapPin, Phone, Mail, Loader2, Users, Home, AlertTriangle, Gavel, Save, Pencil, Globe, Shield, Landmark, @@ -25,7 +26,7 @@ import { import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import ZohoFeesTab from "@/components/association/ZohoFeesTab"; +import FeesTab from "@/components/association/FeesTab"; import ViolationTypeManager from "@/components/ViolationTypeManager"; import LogoUpload from "@/components/LogoUpload"; 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 [savingVt, setSavingVt] = useState(false); + // ── Board member management (writes board_members; can_upload gates board uploads) ── + const [bmDialog, setBmDialog] = useState(false); + const [bmEditingId, setBmEditingId] = useState(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 | 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 const [customFields, setCustomFields] = useState([]); const [cfDialog, setCfDialog] = useState(false); @@ -112,7 +156,7 @@ export default function AssociationDetailPage() { management_fee: assocRes.data.management_fee?.toString() || "", fiscal_year_start: assocRes.data.fiscal_year_start?.toString() || "1", 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_firm: (assocRes.data as any).attorney_firm || "", attorney_email: (assocRes.data as any).attorney_email || "", @@ -378,6 +422,7 @@ export default function AssociationDetailPage() { Amenities Annual Meeting Public Page + Fees Check Layout + {boardMembers.length > 0 ? (
{boardMembers.map(m => ( -
- {m.member_name} -
- {m.member_email && {m.member_email}} +
+
+ {m.member_name} + {m.member_email && {m.member_email}} +
+
{m.role && {m.role}} + {(m as any).approval_authority && Approver} + {(m as any).can_upload && Uploads} + +
))}
) :

No board members defined.

}
+ + {/* Board Member add/edit dialog */} + + + {bmEditingId ? "Edit" : "Add"} Board Member +
+
setBmForm({ ...bmForm, member_name: e.target.value })} />
+
setBmForm({ ...bmForm, member_email: e.target.value })} placeholder="Matches the member's portal login" />
+
setBmForm({ ...bmForm, phone: e.target.value })} />
+
+ + +
+
+ setBmForm({ ...bmForm, approval_authority: v })} /> + +
+
+ setBmForm({ ...bmForm, can_upload: v })} /> + +
+
+ + + + +
+
+ + { if (!o) setBmDeleteTarget(null); }}> + + + Remove board member? + + Remove {bmDeleteTarget?.member_name} from this association's board. This cannot be undone. + + + + Cancel + Remove + + + @@ -1064,9 +1158,9 @@ export default function AssociationDetailPage() {
- {/* Sync */} + {/* Accounting */}
-

Sync Settings

+

Accounting

@@ -1077,16 +1171,11 @@ export default function AssociationDetailPage() { Buildium - Zoho Books Platform (Accounting) — coming soon

Determines Chart of Accounts and where ledger entries route.

-
- - setEditForm({ ...editForm, zoho_organization_id: e.target.value })} placeholder="e.g. 60005XXXXXXX" /> -
diff --git a/src/pages/BankAccountsPage.tsx b/src/pages/BankAccountsPage.tsx index f0e7564..c241575 100644 --- a/src/pages/BankAccountsPage.tsx +++ b/src/pages/BankAccountsPage.tsx @@ -2,9 +2,8 @@ import { useState, useEffect } from "react"; import { supabase } from "@/integrations/supabase/client"; import { useToast } from "@/hooks/use-toast"; 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 ImportZohoBankAccountsDialog from "@/components/ImportZohoBankAccountsDialog"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; @@ -22,7 +21,6 @@ export default function BankAccountsPage() { const [associations, setAssociations] = useState([]); const [loading, setLoading] = useState(true); const [dialogOpen, setDialogOpen] = useState(false); - const [zohoImportOpen, setZohoImportOpen] = useState(false); const [editing, setEditing] = useState(null); const [selectedAssocId, setSelectedAssocId] = useState("all"); const [showArchived, setShowArchived] = useState(false); @@ -163,9 +161,6 @@ export default function BankAccountsPage() { }} templateFileName="bank_accounts_template.xlsx" /> - @@ -312,14 +307,6 @@ export default function BankAccountsPage() { - - ); } diff --git a/src/pages/BillApprovalsPage.tsx b/src/pages/BillApprovalsPage.tsx index 1ade49e..e47270a 100644 --- a/src/pages/BillApprovalsPage.tsx +++ b/src/pages/BillApprovalsPage.tsx @@ -1,9 +1,8 @@ import { useState, useEffect, useRef, useCallback } from "react"; import { useNavigate } from "react-router-dom"; import { supabase } from "@/integrations/supabase/client"; -import { pushBillToZohoAfterCreate, pushBillPaymentToZohoAfterPay } from "@/lib/zohoBillSync"; 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 { Alert, AlertDescription } from "@/components/ui/alert"; import { Checkbox } from "@/components/ui/checkbox"; @@ -66,9 +65,6 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci const [editingBill, setEditingBill] = useState(null); const [editOpen, setEditOpen] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); - const [syncingId, setSyncingId] = useState(null); - const [syncingAll, setSyncingAll] = useState(false); - const [resyncConfirm, setResyncConfirm] = useState<{ type: "single" | "all"; id?: string } | null>(null); // Notify Board const [notifyOpen, setNotifyOpen] = useState(false); @@ -451,16 +447,6 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci }).select("id").single(); 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 (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); } - const syncResult = await pushBillToZohoAfterCreate(editingBill.id); 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); setEditingBill(null); setUploadFile(null); @@ -618,52 +596,6 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci 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 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) => { // Check overdue 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, }) .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); @@ -985,14 +913,6 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci - - - - )} {/* Embedded PDF / Attachment Preview */} {detailBill.attachment_url && (
@@ -1617,24 +1527,6 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci - {/* Resync Confirmation */} - { if (!open) setResyncConfirm(null); }}> - - - Resync to Zoho Books? - - {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."} - - - - Cancel - Confirm Resync - - - - {/* Notify Board Dialog */} diff --git a/src/pages/BillDetailPage.tsx b/src/pages/BillDetailPage.tsx index 7e0c709..119d313 100644 --- a/src/pages/BillDetailPage.tsx +++ b/src/pages/BillDetailPage.tsx @@ -237,22 +237,6 @@ export default function BillDetailPage({ boardAssociationIds }: { boardAssociati } 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(); }; diff --git a/src/pages/BillsPage.tsx b/src/pages/BillsPage.tsx index 89f49d9..9b49a5a 100644 --- a/src/pages/BillsPage.tsx +++ b/src/pages/BillsPage.tsx @@ -1,6 +1,5 @@ import { useState, useEffect } from "react"; import { supabase } from "@/integrations/supabase/client"; -import { pushBillToZohoAfterCreate, pushBillPaymentToZohoAfterPay, deleteBillPaymentFromZohoAfterUnpay } from "@/lib/zohoBillSync"; import { useToast } from "@/hooks/use-toast"; import { FileText, Plus, Search, MoreHorizontal, Edit, Trash2, CheckCircle, XCircle, DollarSign, Download, Loader2 } from "lucide-react"; import RecordImportButton from "@/components/RecordImportButton"; @@ -178,9 +177,8 @@ export default function BillsPage() { await supabase.from("bills").update(payload).eq("id", editing.id); toast({ title: "Bill updated" }); } 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" }); - if (newBill?.id) pushBillToZohoAfterCreate(newBill.id); } setDialogOpen(false); fetchData(); @@ -219,18 +217,6 @@ export default function BillsPage() { await supabase.from("bills").update(updates).eq("id", id); 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(); }; diff --git a/src/pages/BudgetManagementPage.tsx b/src/pages/BudgetManagementPage.tsx index eab1280..8076c39 100644 --- a/src/pages/BudgetManagementPage.tsx +++ b/src/pages/BudgetManagementPage.tsx @@ -1,1021 +1,49 @@ -import { useState, useEffect, useCallback, useMemo } from "react"; -import { supabase } from "@/integrations/supabase/client"; -import { useToast } from "@/hooks/use-toast"; -import { - PieChart, Plus, Search, MoreHorizontal, Edit, Trash2, DollarSign, - TrendingUp, TrendingDown, Download, Loader2, ChevronRight, ChevronDown, FolderTree, -} from "lucide-react"; -import RecordImportButton from "@/components/RecordImportButton"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Card, CardContent } from "@/components/ui/card"; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog"; -import { Label } from "@/components/ui/label"; -import { Textarea } from "@/components/ui/textarea"; +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 { Switch } from "@/components/ui/switch"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; -import { Progress } from "@/components/ui/progress"; -import { Badge } from "@/components/ui/badge"; -import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; -import { pullBudgetsFromZoho, pullBudgetsFromBuildium } from "@/lib/budgetSync"; -import BudgetVsActualReport from "./budget/BudgetVsActualReport"; -import { generateBudgetReportPdf } from "@/lib/budgetReportPdf"; -import { FileText, FileSpreadsheet } from "lucide-react"; -import * as XLSX from "xlsx"; - -type BudgetRow = { - id: string; - association_id: string; - fiscal_year: number; - category: string; - budgeted_amount: number | null; - actual_amount: number | null; - notes: string | null; - parent_id: string | null; - account_type: string; - is_parent: boolean; - gl_account_id: string | null; - associations?: { name: string } | null; -}; - -type GLAccount = { id: string; account_name: string; account_number: string; account_type: string }; - -const MONTH_NAMES = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]; - -function exportBudgetMonthlyXlsx({ - assocName, fiscalYear, budgets, liveActuals, selectedAssociation, -}: { - assocName: string; - fiscalYear: number; - budgets: BudgetRow[]; - liveActuals: Array<{ association_id: string; period_month: string; account_type: string; gl_account_id: string | null; category_name: string | null; amount: number }>; - selectedAssociation: string; -}) { - // Build per-line monthly actuals by gl_account_id and category name - const byGl: Record = {}; - const byName: Record = {}; - liveActuals.forEach((a) => { - if (selectedAssociation && a.association_id !== selectedAssociation) return; - const d = new Date(a.period_month + "T12:00:00"); - if (d.getFullYear() !== fiscalYear) return; - const m = d.getMonth(); - const amt = Number(a.amount) || 0; - if (a.gl_account_id) { - const arr = (byGl[a.gl_account_id] ||= Array(12).fill(0)); - arr[m] += amt; - } - const key = (a.category_name || "").trim().toLowerCase(); - if (key) { - const arr = (byName[key] ||= Array(12).fill(0)); - arr[m] += amt; - } - }); - - const leafRows = budgets.filter((b) => !b.is_parent); - const header = [ - "Category", "Type", "Budgeted (Annual)", "Monthly Budget", - ...MONTH_NAMES.map((m) => `${m} Actual`), - "YTD Actual", "Variance", - ]; - - const sheetData: any[][] = [header]; - let totalsBudget = 0, totalsYtd = 0; - const monthTotals = Array(12).fill(0); - - leafRows.forEach((b) => { - const monthly = b.gl_account_id && byGl[b.gl_account_id] - ? byGl[b.gl_account_id] - : byName[b.category.trim().toLowerCase()] || Array(12).fill(0); - const annualBudget = Number(b.budgeted_amount || 0); - const ytd = monthly.reduce((s, v) => s + v, 0); - totalsBudget += annualBudget; - totalsYtd += ytd; - monthly.forEach((v, i) => monthTotals[i] += v); - sheetData.push([ - b.category, - b.account_type === "income" ? "Income" : "Expense", - annualBudget, - annualBudget / 12, - ...monthly, - ytd, - annualBudget - ytd, - ]); - }); - - sheetData.push([ - "TOTAL", "", totalsBudget, totalsBudget / 12, - ...monthTotals, totalsYtd, totalsBudget - totalsYtd, - ]); - - const ws = XLSX.utils.aoa_to_sheet(sheetData); - ws["!cols"] = [{ wch: 32 }, { wch: 10 }, { wch: 16 }, { wch: 14 }, ...Array(12).fill({ wch: 11 }), { wch: 14 }, { wch: 14 }]; - - const wb = XLSX.utils.book_new(); - XLSX.utils.book_append_sheet(wb, ws, `Budget ${fiscalYear}`); - const safeName = assocName.replace(/[^a-z0-9]+/gi, "_"); - XLSX.writeFile(wb, `${safeName}_Budget_${fiscalYear}_Monthly.xlsx`); -} +import AccountingBudgetsPage from "@/pages/accounting/AccountingBudgetsPage"; +/** + * Budget Management mirrors the Accounting → Budgeting experience, available to + * dashboard staff outside the admin-only Accounting area. The association + * selector drives the global selection that AccountingBudgetsPage scopes to; + * opening a budget routes to /dashboard/budget-management/:id (full editor). + */ export default function BudgetManagementPage() { - const { toast } = useToast(); - const [budgets, setBudgets] = useState([]); - const [associations, setAssociations] = useState([]); - const [liveActuals, setLiveActuals] = useState< - Array<{ association_id: string; period_month: string; account_type: string; gl_account_id: string | null; category_name: string | null; amount: number }> - >([]); - const [loading, setLoading] = useState(true); - const [search, setSearch] = useState(""); - const [selectedAssociation, setSelectedAssociation] = useState(""); - const [yearFilter, setYearFilter] = useState(new Date().getFullYear().toString()); - const [dialogOpen, setDialogOpen] = useState(false); - const [editing, setEditing] = useState(null); - const [pullDialogOpen, setPullDialogOpen] = useState(false); - const [pulling, setPulling] = useState(false); - const [pullAssociationId, setPullAssociationId] = useState(""); - const [pullYear, setPullYear] = useState(new Date().getFullYear().toString()); - const [collapsed, setCollapsed] = useState>({}); - const [glAccounts, setGlAccounts] = useState([]); - const [activeTab, setActiveTab] = useState("budget"); - const [form, setForm] = useState({ - association_id: "", - fiscal_year: new Date().getFullYear().toString(), - category: "", - budgeted_amount: "", - actual_amount: "0", - notes: "", - account_type: "expense", - is_parent: false, - parent_id: "" as string, - gl_account_id: "" as string, - }); - - const fetchData = useCallback(async () => { - setLoading(true); - const [budgetRes, assocRes, coaRes, actualsRes] = await Promise.all([ - supabase.from("budgets").select("*, associations(name)").order("category"), - supabase - .from("associations") - .select("id, name, zoho_organization_id, logo_url") - .eq("status", "active") - .order("name"), - supabase - .from("chart_of_accounts") - .select("id, account_name, account_number, account_type") - .eq("is_active", true) - .in("account_type", ["expense", "income", "revenue"]) - .order("account_number"), - supabase - .from("budget_actuals_monthly" as any) - .select("association_id, period_month, account_type, gl_account_id, category_name, amount"), - ]); - setBudgets((budgetRes.data || []) as any); - const assocs = assocRes.data || []; - setAssociations(assocs); - setGlAccounts((coaRes.data as any) || []); - setLiveActuals(((actualsRes.data as any) || []) as any); - setSelectedAssociation((prev) => prev || (assocs[0]?.id ?? "")); - setLoading(false); - }, []); - - useEffect(() => { - fetchData(); - }, [fetchData]); - - const openNew = (parent?: BudgetRow) => { - setEditing(null); - setForm({ - association_id: parent?.association_id || selectedAssociation || associations[0]?.id || "", - fiscal_year: parent?.fiscal_year?.toString() || yearFilter || new Date().getFullYear().toString(), - category: "", - budgeted_amount: "", - actual_amount: "0", - notes: "", - account_type: parent?.account_type || "expense", - is_parent: false, - parent_id: parent?.id || "", - gl_account_id: "", - }); - setDialogOpen(true); - }; - - const openEdit = (b: BudgetRow) => { - setEditing(b); - setForm({ - association_id: b.association_id, - fiscal_year: b.fiscal_year.toString(), - category: b.category, - budgeted_amount: b.budgeted_amount?.toString() || "", - actual_amount: b.actual_amount?.toString() || "0", - notes: b.notes || "", - account_type: b.account_type || "expense", - is_parent: !!b.is_parent, - parent_id: b.parent_id || "", - gl_account_id: b.gl_account_id || "", - }); - setDialogOpen(true); - }; - - const handleSave = async () => { - if (!form.association_id || !form.category) { - toast({ variant: "destructive", title: "Missing fields", description: "Association and category are required." }); - return; - } - if (!form.is_parent && !form.budgeted_amount) { - toast({ variant: "destructive", title: "Missing amount", description: "Budgeted amount is required for non-parent lines." }); - return; - } - const payload = { - association_id: form.association_id, - fiscal_year: parseInt(form.fiscal_year), - category: form.category, - budgeted_amount: form.is_parent ? 0 : parseFloat(form.budgeted_amount) || 0, - actual_amount: form.is_parent ? 0 : parseFloat(form.actual_amount) || 0, - notes: form.notes || null, - account_type: form.account_type, - is_parent: form.is_parent, - parent_id: form.parent_id || null, - gl_account_id: form.gl_account_id || null, + 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; }; - if (editing) { - const { error } = await supabase.from("budgets").update(payload).eq("id", editing.id); - if (error) { toast({ variant: "destructive", title: "Error", description: error.message }); return; } - toast({ title: "Updated" }); - } else { - const { error } = await supabase.from("budgets").insert(payload); - if (error) { toast({ variant: "destructive", title: "Error", description: error.message }); return; } - toast({ title: "Budget line created" }); - } - setDialogOpen(false); - fetchData(); - }; - const handleDelete = async (id: string) => { - await supabase.from("budgets").delete().eq("id", id); - toast({ title: "Deleted" }); - fetchData(); - }; - - const openPullDialog = () => { - const defaultAssocId = selectedAssociation || associations[0]?.id || ""; - setPullAssociationId(defaultAssocId); - setPullYear(yearFilter || new Date().getFullYear().toString()); - setPullDialogOpen(true); - }; - - const handlePull = async () => { - if (!pullAssociationId) { - toast({ variant: "destructive", title: "Select an association" }); - return; - } - const assoc = associations.find((a) => a.id === pullAssociationId); - if (!assoc) { - toast({ variant: "destructive", title: "Association not found" }); - return; - } - - const fiscalYear = parseInt(pullYear) || new Date().getFullYear(); - const usesZoho = - assoc.zoho_organization_id && String(assoc.zoho_organization_id).trim() !== ""; - - setPulling(true); - try { - const result = usesZoho - ? await pullBudgetsFromZoho(pullAssociationId, fiscalYear) - : await pullBudgetsFromBuildium([pullAssociationId], fiscalYear); - - if (!result.success) { - toast({ - variant: "destructive", - title: `Failed to pull from ${usesZoho ? "Zoho Books" : "Buildium"}`, - description: result.error, - }); - return; - } - - const r: any = result.data || {}; - toast({ - title: `Pulled budgets from ${usesZoho ? "Zoho Books" : "Buildium"}`, - description: `${r.imported ?? 0} added, ${r.updated ?? 0} updated, ${r.fetched ?? 0} fetched`, - }); - setPullDialogOpen(false); - setYearFilter(String(fiscalYear)); - setSelectedAssociation(pullAssociationId); - fetchData(); - } finally { - setPulling(false); - } - }; - - // Filter by association + year + search, then overlay LIVE actuals (from - // bills/invoices/billable expenses) so newly entered items show immediately - // without needing to manually update the stored actual_amount. - const filtered = useMemo(() => { - const fy = parseInt(yearFilter); - // Aggregate live actuals for the active association/year by gl_account_id and lowercase category name. - const liveByGl: Record = {}; - const liveByName: Record = {}; - liveActuals.forEach((a) => { - if (selectedAssociation && a.association_id !== selectedAssociation) return; - const periodYear = new Date(a.period_month + "T12:00:00").getFullYear(); - if (!isNaN(fy) && periodYear !== fy) return; - const amt = Number(a.amount) || 0; - const isIncome = a.account_type === "income"; - if (a.gl_account_id) { - const b = (liveByGl[a.gl_account_id] ||= { income: 0, expense: 0 }); - if (isIncome) b.income += amt; else b.expense += amt; - } - const nameKey = (a.category_name || "").trim().toLowerCase(); - if (nameKey) { - const b = (liveByName[nameKey] ||= { income: 0, expense: 0 }); - if (isIncome) b.income += amt; else b.expense += amt; - } - }); - - return budgets - .filter((b) => { - if (selectedAssociation && b.association_id !== selectedAssociation) return false; - if (yearFilter && b.fiscal_year.toString() !== yearFilter) return false; - if (search && !b.category.toLowerCase().includes(search.toLowerCase())) return false; - return true; - }) - .map((b) => { - if (b.is_parent) return b; - const isIncome = b.account_type === "income"; - let live: number | null = null; - if (b.gl_account_id && liveByGl[b.gl_account_id]) { - live = isIncome ? liveByGl[b.gl_account_id].income : liveByGl[b.gl_account_id].expense; - } else { - const key = b.category.trim().toLowerCase(); - if (liveByName[key]) { - live = isIncome ? liveByName[key].income : liveByName[key].expense; - } - } - // Use the live value when available; otherwise fall back to stored actual_amount. - return live !== null ? { ...b, actual_amount: live } : b; - }); - }, [budgets, liveActuals, selectedAssociation, yearFilter, search]); - - // Build hierarchy: group by parent_id, compute parent rollups from children - const { tree, rollupBudgeted, rollupActual } = useMemo(() => { - const childrenByParent: Record = {}; - const roots: BudgetRow[] = []; - filtered.forEach((b) => { - if (b.parent_id) { - (childrenByParent[b.parent_id] ||= []).push(b); - } else { - roots.push(b); - } - }); - // If a parent isn't in filtered (search excluded it) but its children are, still show child as root. - const filteredIds = new Set(filtered.map((b) => b.id)); - Object.keys(childrenByParent).forEach((pid) => { - if (!filteredIds.has(pid)) { - childrenByParent[pid].forEach((c) => roots.push(c)); - delete childrenByParent[pid]; - } - }); - - const rollupBudgeted: Record = {}; - const rollupActual: Record = {}; - const computeRollup = (id: string): { b: number; a: number } => { - const kids = childrenByParent[id] || []; - let b = 0, a = 0; - kids.forEach((k) => { - if (k.is_parent) { - const r = computeRollup(k.id); - b += r.b; - a += r.a; - } else { - b += Number(k.budgeted_amount) || 0; - a += Number(k.actual_amount) || 0; - } - }); - rollupBudgeted[id] = b; - rollupActual[id] = a; - return { b, a }; - }; - roots.forEach((r) => { if (r.is_parent) computeRollup(r.id); }); - - return { tree: { roots, childrenByParent }, rollupBudgeted, rollupActual }; - }, [filtered]); - - // Totals: only count leaf rows (non-parents) to avoid double counting - const leafRows = filtered.filter((b) => !b.is_parent); - const incomeRows = leafRows.filter((b) => b.account_type === "income"); - const expenseRows = leafRows.filter((b) => b.account_type !== "income"); - const totalIncomeBudgeted = incomeRows.reduce((s, b) => s + Number(b.budgeted_amount || 0), 0); - const totalExpenseBudgeted = expenseRows.reduce((s, b) => s + Number(b.budgeted_amount || 0), 0); - const totalIncomeActual = incomeRows.reduce((s, b) => s + Number(b.actual_amount || 0), 0); - const totalExpenseActual = expenseRows.reduce((s, b) => s + Number(b.actual_amount || 0), 0); - const netBudgeted = totalIncomeBudgeted - totalExpenseBudgeted; - const netActual = totalIncomeActual - totalExpenseActual; - - const years = [...new Set(budgets.map((b) => b.fiscal_year.toString()))].sort().reverse(); - if (!years.includes(yearFilter)) years.unshift(yearFilter); - - const fmt = (n: number) => "$" + n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); - - // Possible parent options for the form (same association + year, parents only, - // exclude self and any descendants of the editing row to prevent cycles). - const parentOptions = useMemo(() => { - const sameScope = budgets.filter((b) => - b.is_parent && - b.association_id === form.association_id && - b.fiscal_year.toString() === form.fiscal_year && - (!editing || b.id !== editing.id) - ); - if (!editing) return sameScope; - - // Build descendant set of the editing row to forbid as parents. - const childrenByParent: Record = {}; - budgets.forEach((b) => { - if (b.parent_id) (childrenByParent[b.parent_id] ||= []).push(b.id); - }); - const descendants = new Set(); - const stack = [editing.id]; - while (stack.length) { - const id = stack.pop()!; - (childrenByParent[id] || []).forEach((cid) => { - if (!descendants.has(cid)) { - descendants.add(cid); - stack.push(cid); - } - }); - } - return sameScope.filter((b) => !descendants.has(b.id)); - }, [budgets, form.association_id, form.fiscal_year, editing]); - - // Build a label that shows the parent's full path so nesting is clear in the dropdown. - const parentPathLabel = (id: string): string => { - const map = new Map(budgets.map((b) => [b.id, b])); - const parts: string[] = []; - let cur = map.get(id); - let safety = 0; - while (cur && safety++ < 20) { - parts.unshift(cur.category); - cur = cur.parent_id ? map.get(cur.parent_id) : undefined; - } - return parts.join(" › "); - }; - - const renderRow = (b: BudgetRow, depth: number): JSX.Element[] => { - const kids = tree.childrenByParent[b.id] || []; - const isCollapsed = collapsed[b.id]; - const budgeted = b.is_parent ? (rollupBudgeted[b.id] || 0) : Number(b.budgeted_amount || 0); - const actual = b.is_parent ? (rollupActual[b.id] || 0) : Number(b.actual_amount || 0); - const pct = budgeted > 0 ? Math.min((actual / budgeted) * 100, 100) : 0; - const v = budgeted - actual; - - // Gradiated background for parent/header rows by indent depth. - // Leaf accounts stay white (default). Deeper headers get progressively lighter shading. - const parentShades = ["hsl(var(--muted))", "hsl(var(--muted)/0.7)", "hsl(var(--muted)/0.5)", "hsl(var(--muted)/0.35)", "hsl(var(--muted)/0.22)"]; - const headerBg = b.is_parent ? (parentShades[Math.min(depth, parentShades.length - 1)]) : undefined; - - const rows: JSX.Element[] = [ - - -
- {b.is_parent && kids.length > 0 ? ( - - ) : ( - - )} - {b.is_parent && } - {b.category} - {b.is_parent && Parent} -
-
- - - {b.account_type === "income" ? "Income" : "Expense"} - - - {b.fiscal_year} - {b.is_parent && kids.length === 0 ? "—" : fmt(budgeted)} - {b.is_parent && kids.length === 0 ? "—" : fmt(actual)} - - {budgeted > 0 ? ( -
- - {pct.toFixed(0)}% -
- ) : } -
- - {budgeted === 0 && b.is_parent && kids.length === 0 ? "—" : fmt(v)} - - - - - - - - {b.is_parent && ( - openNew(b)}> - Add Subaccount - - )} - openEdit(b)}> - Edit - - handleDelete(b.id)}> - Delete - - - - -
, - ]; - - if (!isCollapsed) { - kids - .slice() - .sort((a, c) => a.category.localeCompare(c.category)) - .forEach((k) => rows.push(...renderRow(k, depth + 1))); - } - - return rows; - }; - - const selectedAssocName = associations.find((a) => a.id === selectedAssociation)?.name || ""; + const sorted = useMemo( + () => [...(associations ?? [])].sort((a, b) => a.name.localeCompare(b.name)), + [associations], + ); return ( -
- {/* Header */} -
-
-

- Budget Management -

-

- Build hierarchical budgets per association with parent categories, nested subaccounts, and income tracking. -

-
-
- { - const assocMap = new Map(associations.map((a) => [a.name.toLowerCase().trim(), a.id])); - const payload = rows.map((r) => { - const assocId = assocMap.get(r.association_name?.toLowerCase().trim()) || associations[0]?.id; - if (!assocId) throw new Error("No association found"); - return { - category: r.category || "General", - fiscal_year: parseInt(r.fiscal_year) || new Date().getFullYear(), - budgeted_amount: parseFloat(r.budgeted_amount) || 0, - actual_amount: parseFloat(r.actual_amount) || 0, - account_type: (r.account_type || "expense").toLowerCase() === "income" ? "income" : "expense", - notes: r.notes || null, - association_id: assocId, - }; - }); - const { error } = await supabase.from("budgets").insert(payload); - if (error) throw error; - toast({ title: `Imported ${rows.length} budget lines` }); - fetchData(); - }} - templateFileName="budgets_template.xlsx" - /> - - - - -
+
+
+
- {/* Association selector — prominent */} - - -
- - -
-
- - -
-
-
- - - - Budget Setup - Budget vs Actual - - - - {/* KPI Cards: Income, Expenses, Net */} -
- - -
- Income (Budget / Actual) -
-

{fmt(totalIncomeBudgeted)}

-

Actual: {fmt(totalIncomeActual)}

-
-
- - -
- Expenses (Budget / Actual) -
-

{fmt(totalExpenseBudgeted)}

-

Actual: {fmt(totalExpenseActual)}

-
-
- - -
- Net (Budget / Actual) -
-

= 0 ? "text-emerald-600" : "text-destructive"}`}> - {fmt(netBudgeted)} -

-

- Actual:{" "} - = 0 ? "text-emerald-600" : "text-destructive"}>{fmt(netActual)} -

-
-
-
- - {/* Search */} -
-
- - setSearch(e.target.value)} /> -
- {filtered.length} items - {selectedAssocName && ( - {selectedAssocName} • FY {yearFilter} - )} -
- - {/* Table */} - {loading ? ( -
-
-
- ) : !selectedAssociation ? ( - - - Select an association to view its budget. - - - ) : filtered.length === 0 ? ( - - - No budget lines found. Click "New Budget Line" to get started. - - - ) : ( - - - - - Category - Type - Year - Budgeted - Actual - Usage - Variance - - - - - {tree.roots - .slice() - .sort((a, b) => { - if (a.account_type !== b.account_type) return a.account_type === "income" ? -1 : 1; - if (a.is_parent !== b.is_parent) return a.is_parent ? -1 : 1; - return a.category.localeCompare(b.category); - }) - .flatMap((r) => renderRow(r, 0))} - -
-
- )} - - - - {selectedAssociation ? ( - - ) : ( - - - Select an association to view its Budget vs Actual report. - - - )} - - - - - {/* Create/Edit Dialog */} - - - - {editing ? "Edit" : "New"} Budget Line - - Create parent categories to group related lines. Parent categories don't need an amount — totals roll up from their subaccounts. - - -
-
- - -
- -
-
- - -
-
-
-
- -

Header — no amount needed

-
- setForm({ ...form, is_parent: v })} - /> -
-
-
- -
- - - {form.is_parent && ( -

- Nest this parent category under another parent to create grouped sub-headers. -

- )} -
- - {!form.is_parent && ( -
- - -

- Linking to a GL account ensures every transaction posted to that account flows automatically into this budget's actuals. -

-
- )} - -
-
- - setForm({ ...form, category: e.target.value })} - placeholder="e.g. Landscaping" - /> -
-
- - setForm({ ...form, fiscal_year: e.target.value })} - /> -
-
- - {!form.is_parent && ( -
-
- - setForm({ ...form, budgeted_amount: e.target.value })} - placeholder="0.00" - /> -
-
- - setForm({ ...form, actual_amount: e.target.value })} - placeholder="0.00" - /> -
-
- )} - -
- -