diff --git a/src/components/BillApprovalRequestDialog.jsx b/src/components/BillApprovalRequestDialog.jsx index 62473d1..a591a3c 100644 --- a/src/components/BillApprovalRequestDialog.jsx +++ b/src/components/BillApprovalRequestDialog.jsx @@ -65,7 +65,7 @@ export default function BillApprovalRequestDialog({ open, onOpenChange, billId, return { association_id: clientId, bill_id: billId, - vendor_name: bm?.member_name || 'Approver', + approver_name: bm?.member_name || 'Approver', status: 'pending', notes: comment || null, }; diff --git a/src/hooks/useBillApprovals.js b/src/hooks/useBillApprovals.js index bb55007..cad8697 100644 --- a/src/hooks/useBillApprovals.js +++ b/src/hooks/useBillApprovals.js @@ -64,7 +64,7 @@ export function useBillApprovals() { approved_by: userData?.user?.id, status: 'approved', notes: comment, - vendor_name: voterName || 'Unknown', + approver_name: voterName || 'Unknown', association_id: (bills.find(b => b.id === billId))?.association_id }) .select() @@ -76,7 +76,7 @@ export function useBillApprovals() { approved_by: userData?.user?.id, status: 'denied', notes: comment, - vendor_name: voterName || 'Unknown', + approver_name: voterName || 'Unknown', association_id: (bills.find(b => b.id === billId))?.association_id }); if (approvalError) throw approvalError; diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index afa650a..bfda03c 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -1781,7 +1781,7 @@ export type Database = { notes: string | null status: string updated_at: string - vendor_name: string + approver_name: string } Insert: { amount?: number @@ -1796,7 +1796,7 @@ export type Database = { notes?: string | null status?: string updated_at?: string - vendor_name: string + approver_name: string } Update: { amount?: number @@ -1811,7 +1811,7 @@ export type Database = { notes?: string | null status?: string updated_at?: string - vendor_name?: string + approver_name?: string } Relationships: [ { diff --git a/src/pages/AIInvoiceParserPage.tsx b/src/pages/AIInvoiceParserPage.tsx index 9f30133..fc2ad81 100644 --- a/src/pages/AIInvoiceParserPage.tsx +++ b/src/pages/AIInvoiceParserPage.tsx @@ -370,7 +370,7 @@ export default function AIInvoiceParserPage() { return { association_id: reviewForm.association_id, bill_id: newBill?.id || null, - vendor_name: bm?.member_name || reviewData.vendor_name, + approver_name: bm?.member_name || reviewData.vendor_name, amount: reviewData.total_amount || 0, invoice_id: invoice.id, status: "pending", diff --git a/src/pages/BillApprovalsPage.tsx b/src/pages/BillApprovalsPage.tsx index 80120a6..356d11b 100644 --- a/src/pages/BillApprovalsPage.tsx +++ b/src/pages/BillApprovalsPage.tsx @@ -23,6 +23,7 @@ const statusColors: Record = { pending: "bg-amber-100 text-amber-700", approved: "bg-emerald-100 text-emerald-700", rejected: "bg-red-100 text-red-700", + denied: "bg-red-100 text-red-700", paid: "bg-emerald-100 text-emerald-700", overdue: "bg-red-100 text-red-700", draft: "bg-muted text-muted-foreground", @@ -164,7 +165,7 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci .from("bill_approvals") .select("bill_id") .in("association_id", boardAssociationIds!) - .in("vendor_name", names) + .in("approver_name", names) .not("bill_id", "is", null); assignedBillIds = Array.from(new Set((myApprovals || []).map((a: any) => a.bill_id).filter(Boolean))); } @@ -218,7 +219,7 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci const billIds = billsList.map((b: any) => b.id); const { data: approvals } = await supabase .from("bill_approvals") - .select("id, bill_id, vendor_name, status") + .select("id, bill_id, approver_name, status") .in("bill_id", billIds); const grouped: Record = {}; (approvals || []).forEach((a: any) => { @@ -456,7 +457,7 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci return { association_id: form.association_id, bill_id: newBill?.id || null, - vendor_name: bm?.member_name || vendorDisplayName, + approver_name: bm?.member_name || vendorDisplayName, amount: parseFloat(form.amount) || 0, status: "pending", notes: form.description || null, @@ -1081,7 +1082,7 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci {a.status === 'approved' ? '✓' : a.status === 'denied' || a.status === 'rejected' ? '✗' : '⏳'} - {a.vendor_name} + {a.approver_name} ))} diff --git a/src/pages/BillDetailPage.tsx b/src/pages/BillDetailPage.tsx index 119d313..88a07b4 100644 --- a/src/pages/BillDetailPage.tsx +++ b/src/pages/BillDetailPage.tsx @@ -169,8 +169,8 @@ export default function BillDetailPage({ boardAssociationIds }: { boardAssociati // Notify staff (admins/managers) that a board member has voted try { - const approvalRecord = (bill?.bill_approvals || []).find((a: any) => a.id === approvalId); - const voterName = approvalRecord?.vendor_name || "A board member"; + const approvalRecord = approvals.find((a: any) => a.id === approvalId); + const voterName = approvalRecord?.approver_name || "A board member"; const billLabel = bill?.invoice_number ? `Bill #${bill.invoice_number}` : `Bill ${(id || "").slice(0, 8)}`; const associationName = bill?.associations?.name || "an association"; const verb = action === "approved" ? "approved" : "denied"; @@ -462,7 +462,7 @@ export default function BillDetailPage({ boardAssociationIds }: { boardAssociati {approvals.map((a) => (
-

{a.vendor_name}

+

{a.approver_name}

${Number(a.amount).toFixed(2)} · {new Date(a.created_at).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}

@@ -472,7 +472,7 @@ export default function BillDetailPage({ boardAssociationIds }: { boardAssociati {a.status.charAt(0).toUpperCase() + a.status.slice(1)} - {a.status === "pending" && (boardAssociationIds ? userBoardMemberNames.includes(a.vendor_name) : true) && ( + {a.status === "pending" && (boardAssociationIds ? userBoardMemberNames.includes(a.approver_name) : true) && ( <>
- { setOpen(v); if (!v) resetForm(); }}> - - - +
+ + { setOpen(v); if (!v) resetForm(); }}> + + + {editId ? "Edit account" : "New account"}
@@ -304,9 +310,18 @@ export default function AccountingChartOfAccountsPage() { -
+
+
+ qc.invalidateQueries({ queryKey: ["accounts", cid] })} + /> + Accounts diff --git a/src/pages/accounting/components/ChartOfAccountsImportDialog.tsx b/src/pages/accounting/components/ChartOfAccountsImportDialog.tsx new file mode 100644 index 0000000..fafb2f0 --- /dev/null +++ b/src/pages/accounting/components/ChartOfAccountsImportDialog.tsx @@ -0,0 +1,430 @@ +import { useEffect, useMemo, useState } from "react"; +import * as XLSX from "xlsx"; +import { toast } from "sonner"; +import { accounting } from "@/lib/accountingClient"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, +} from "@/components/ui/dialog"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { AlertCircle, CheckCircle2, AlertTriangle, ArrowRight, Loader2 } from "lucide-react"; + +const ACCOUNT_TYPES = ["asset", "liability", "equity", "income", "expense"] as const; +type AccountType = (typeof ACCOUNT_TYPES)[number]; + +// Importable target fields, in the order shown in the mapping step. +const FIELDS: { key: string; label: string; required: boolean; hint: string }[] = [ + { key: "code", label: "Account code", required: false, hint: "e.g. 1100" }, + { key: "name", label: "Account name", required: true, hint: "e.g. Operating Cash" }, + { key: "type", label: "Type", required: true, hint: "Asset, Liability, Equity, Income, or Expense" }, + { key: "description", label: "Description", required: false, hint: "Optional notes" }, + { key: "is_bank", label: "Bank account?", required: false, hint: "Yes / No" }, + { key: "is_reserve", label: "Reserve fund?", required: false, hint: "Yes / No" }, +]; + +// Map the many ways accounting systems label account types onto our 5 enum values. +const TYPE_ALIASES: Record = { + asset: "asset", assets: "asset", bank: "asset", cash: "asset", + "accounts receivable": "asset", "a/r": "asset", ar: "asset", receivable: "asset", + "fixed asset": "asset", "fixed assets": "asset", "other asset": "asset", + "other current asset": "asset", "current asset": "asset", + liability: "liability", liabilities: "liability", + "accounts payable": "liability", "a/p": "liability", ap: "liability", payable: "liability", + "credit card": "liability", "current liability": "liability", + "other current liability": "liability", "long term liability": "liability", + "long-term liability": "liability", + equity: "equity", equities: "equity", "retained earnings": "equity", capital: "equity", + income: "income", revenue: "income", revenues: "income", sales: "income", + "other income": "income", + expense: "expense", expenses: "expense", "cost of goods sold": "expense", + cogs: "expense", "other expense": "expense", +}; + +function normalizeType(raw: unknown): AccountType | null { + const v = String(raw ?? "").trim().toLowerCase(); + if (!v) return null; + if ((ACCOUNT_TYPES as readonly string[]).includes(v)) return v as AccountType; + return TYPE_ALIASES[v] ?? null; +} + +function parseBool(raw: unknown): boolean { + const v = String(raw ?? "").trim().toLowerCase(); + return ["yes", "y", "true", "1", "x", "✓"].includes(v); +} + +const norm = (s: string) => s.toLowerCase().replace(/[\s_-]/g, ""); + +type PreviewRow = { + _id: number; + code: string; + name: string; + type: AccountType | null; + rawType: string; + description: string; + is_bank: boolean; + is_reserve: boolean; + errors: string[]; + duplicate: boolean; +}; + +export default function ChartOfAccountsImportDialog({ + open, onOpenChange, companyId, existingAccounts, onSuccess, +}: { + open: boolean; + onOpenChange: (v: boolean) => void; + companyId: string; + existingAccounts: { code?: string | null }[]; + onSuccess?: () => void; +}) { + const [step, setStep] = useState(1); + const [busy, setBusy] = useState(false); + const [file, setFile] = useState(null); + const [headers, setHeaders] = useState([]); + const [rows, setRows] = useState[]>([]); + const [mapping, setMapping] = useState>({}); + const [preview, setPreview] = useState([]); + const [summary, setSummary] = useState<{ total: number; imported: number; skipped: number } | null>(null); + + useEffect(() => { + if (!open) { + setStep(1); setBusy(false); setFile(null); setHeaders([]); setRows([]); + setMapping({}); setPreview([]); setSummary(null); + } + }, [open]); + + const existingCodes = useMemo(() => { + const s = new Set(); + for (const a of existingAccounts) { + const c = String(a.code ?? "").trim().toLowerCase(); + if (c) s.add(c); + } + return s; + }, [existingAccounts]); + + const handleFile = (e: React.ChangeEvent) => { + const f = e.target.files?.[0]; + if (!f) return; + const ok = /\.(csv|xlsx|xls)$/i.test(f.name) || f.type === "text/csv"; + if (!ok) { + toast.error("Please upload a CSV or Excel file."); + return; + } + setFile(f); + }; + + const parseFile = () => { + if (!file) return; + setBusy(true); + const reader = new FileReader(); + reader.onload = (e) => { + try { + const data = new Uint8Array(e.target!.result as ArrayBuffer); + const wb = XLSX.read(data, { type: "array" }); + const ws = wb.Sheets[wb.SheetNames[0]]; + const json = XLSX.utils.sheet_to_json(ws, { header: 1, blankrows: false }); + if (json.length < 2) { + toast.error("The file appears to be empty or has only a header row."); + setBusy(false); + return; + } + const hdrs = (json[0] as any[]).map((h) => String(h ?? "").trim()).filter(Boolean); + const dataRows = json.slice(1).map((r) => { + const obj: Record = {}; + hdrs.forEach((h, i) => { obj[h] = (r as any[])[i]; }); + return obj; + }); + setHeaders(hdrs); + setRows(dataRows); + + // Auto-map by header name. + const auto: Record = {}; + for (const field of FIELDS) { + const match = hdrs.find((h) => norm(h) === norm(field.key) || norm(h) === norm(field.label)); + if (match) auto[field.key] = match; + } + setMapping(auto); + setStep(2); + } catch { + toast.error("Could not read the file."); + } finally { + setBusy(false); + } + }; + reader.readAsArrayBuffer(file); + }; + + const buildPreview = () => { + const missingRequired = FIELDS.filter((f) => f.required && !mapping[f.key]); + if (missingRequired.length) { + toast.error(`Map required fields: ${missingRequired.map((f) => f.label).join(", ")}`); + return; + } + const seenCodes = new Set(); + const out: PreviewRow[] = rows.map((row, i) => { + const get = (key: string) => (mapping[key] ? row[mapping[key]] : undefined); + const code = String(get("code") ?? "").trim(); + const name = String(get("name") ?? "").trim(); + const rawType = String(get("type") ?? "").trim(); + const type = normalizeType(rawType); + const description = String(get("description") ?? "").trim(); + const is_bank = mapping.is_bank ? parseBool(get("is_bank")) : false; + const is_reserve = mapping.is_reserve ? parseBool(get("is_reserve")) : false; + + const errors: string[] = []; + if (!name) errors.push("Name is required"); + if (!type) errors.push(rawType ? `Unrecognized type "${rawType}"` : "Type is required"); + + const codeKey = code.toLowerCase(); + let duplicate = false; + if (codeKey) { + if (existingCodes.has(codeKey)) duplicate = true; + else if (seenCodes.has(codeKey)) duplicate = true; + seenCodes.add(codeKey); + } + + return { _id: i, code, name, type, rawType, description, is_bank, is_reserve, errors, duplicate }; + }); + setPreview(out); + setStep(3); + }; + + const importable = useMemo( + () => preview.filter((r) => r.errors.length === 0 && !r.duplicate), + [preview] + ); + + const runImport = async () => { + if (importable.length === 0) { + toast.error("No valid new accounts to import."); + return; + } + setBusy(true); + try { + const payload = importable.map((r) => ({ + company_id: companyId, + name: r.name, + code: r.code || null, + type: r.type, + description: r.description || null, + is_bank: r.is_bank, + is_reserve: r.is_reserve, + })); + + const CHUNK = 200; + for (let i = 0; i < payload.length; i += CHUNK) { + const { error } = await accounting.from("accounts").insert(payload.slice(i, i + CHUNK)); + if (error) throw error; + } + + setSummary({ + total: preview.length, + imported: importable.length, + skipped: preview.length - importable.length, + }); + setStep(4); + onSuccess?.(); + } catch (err: any) { + toast.error(err?.message || "Import failed."); + } finally { + setBusy(false); + } + }; + + const errorCount = preview.filter((r) => r.errors.length > 0).length; + const dupCount = preview.filter((r) => r.errors.length === 0 && r.duplicate).length; + + return ( + { if (!busy) onOpenChange(v); }}> + + + + {step === 1 && "Import Chart of Accounts"} + {step === 2 && "Map Columns"} + {step === 3 && "Preview Accounts"} + {step === 4 && "Import Complete"} + + + {step === 1 && "Upload a CSV or Excel file containing your account list."} + {step === 2 && "Match your file's columns to account fields."} + {step === 3 && "Review accounts before importing. Existing codes are skipped."} + {step === 4 && "Review the results of the import."} + + + +
+ {step === 1 && ( +
+ + + Expected columns + + At minimum include an Account name and a Type{" "} + (Asset, Liability, Equity, Income, or Expense). Optional columns: Account code, + Description, Bank account?, Reserve fund?. You'll map them on the next step. + + +
+ + + {file &&

Selected: {file.name}

} +
+
+ )} + + {step === 2 && ( +
+ {FIELDS.map((field) => ( +
+ +

{field.hint}

+ +
+ ))} +
+ )} + + {step === 3 && ( +
+ 0 ? "destructive" : "default"}> + {errorCount > 0 + ? + : } + {errorCount > 0 ? "Some rows have errors" : "Ready to import"} + + {importable.length} new account(s) will be imported + {dupCount > 0 ? `, ${dupCount} duplicate(s) skipped` : ""} + {errorCount > 0 ? `, ${errorCount} row(s) with errors skipped` : ""}. + + +
+ + + + Code + Name + Type + Bank + Reserve + + + + + {preview.map((r) => { + const bad = r.errors.length > 0; + return ( + + {r.code || "—"} + {r.name || Missing} + + {r.type ?? {r.rawType || "Missing"}} + + {r.is_bank ? "Yes" : "—"} + {r.is_reserve ? "Yes" : "—"} + + {bad ? ( + + ) : r.duplicate ? ( + + ) : ( + + )} + + + ); + })} + +
+
+
+ )} + + {step === 4 && summary && ( +
+
+ +
+
+

Import Complete

+

Your chart of accounts has been updated.

+
+
+
+

Total

+

{summary.total}

+
+
+

Imported

+

{summary.imported}

+
+
+

Skipped

+

{summary.skipped}

+
+
+
+ )} +
+ + + {step < 4 ? ( + <> + + {step === 1 && ( + + )} + {step === 2 && ( + <> + + + + )} + {step === 3 && ( + <> + + + + )} + + ) : ( + + )} + +
+
+ ); +} diff --git a/supabase/.temp/cli-latest b/supabase/.temp/cli-latest index da95817..f98a4ce 100644 --- a/supabase/.temp/cli-latest +++ b/supabase/.temp/cli-latest @@ -1 +1 @@ -v2.104.0 \ No newline at end of file +v2.105.0 \ No newline at end of file diff --git a/supabase/functions/buildium-sync/index.ts b/supabase/functions/buildium-sync/index.ts index b7afce3..2d360cf 100644 --- a/supabase/functions/buildium-sync/index.ts +++ b/supabase/functions/buildium-sync/index.ts @@ -2225,7 +2225,7 @@ Deno.serve(async (req) => { const { error: aErr } = await supabase.from("bill_approvals").insert({ bill_id: finalBillId, association_id: assocLocalId, - vendor_name: buildiumVendor?.Name || buildiumVendor?.CompanyName || "Buildium Vendor", + approver_name: buildiumVendor?.Name || buildiumVendor?.CompanyName || "Buildium Vendor", amount, status: "pending", });