mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
7464d55b6c
Under accrual, a vendor expense is recognized once — when the bill is entered (Dr Expense / Cr A/P). Paying a bill must only relieve the liability (Dr A/P / Cr Bank) and never re-hit the expense account. This hardens the existing Pay Bills flow against re-recognition and double counting. - Strict bill matching: extract a pure, dependency-free matcher into lib/billMatch.ts (debitMatchesBill/matchOpenBills) — same vendor, status open/overdue/partially_paid, amount within $0.01 of remaining (or <= remaining for partials), debit date within ±30 days of due/issue date. Unit-tested in billMatch.test.ts (covers the identical-recurring-charge regression). - AccountingBankingPage.saveTx uses the strict rule (was "any open bill"), so a thrice-paid identical charge only clears the in-window bill. - Bank-feed categorizer (bulkSetCategory) matches open bills before assigning an expense COA: single match clears A/P + links the bill; multi-match is skipped with a prompt to resolve in Pay Bills; no match categorizes as a direct expense. - DB guard: add accounting.transactions.bill_id (FK -> bills) and CHECK chk_bill_payment_no_coa (bill_id IS NULL OR coa_account_id IS NULL) so a bill-linked payment can never carry an expense category. Both writers set bill_id on single-bill payments; partial payments now write partially_paid. - One-time cleanup: clear coa_account_id on Ashley Manor's 8 double-counted bill payments ($2,198.98) so the GL reposts them as Dr A/P / Cr Bank. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1130 lines
51 KiB
TypeScript
1130 lines
51 KiB
TypeScript
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import { useEffect, useMemo, useRef, useState } from "react";
|
||
import { accounting } from "@/lib/accountingClient";
|
||
import { useCompanyId } from "./lib/useCompanyId";
|
||
import { useAuth } from "@/contexts/AuthContext";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Label } from "@/components/ui/label";
|
||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, SelectGroup, SelectLabel } from "@/components/ui/select";
|
||
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
|
||
import { Switch } from "@/components/ui/switch";
|
||
import { Checkbox } from "@/components/ui/checkbox";
|
||
import { Plus, Trash2, Pencil, ArrowLeftRight, Printer, Link2, RefreshCw, Unlink, FileUp, Download, Loader2 } from "lucide-react";
|
||
import { toast } from "sonner";
|
||
import { money, fmtDate } from "./lib/format";
|
||
import { generateCheckPDF } from "./lib/checkPdf";
|
||
import { parseCsv, pick, parseDateStr } from "./lib/csv";
|
||
import { usePlaidLink } from "react-plaid-link";
|
||
import { createLinkToken, exchangePlaidToken, syncPlaidTransactions, disconnectPlaid } from "./lib/plaid";
|
||
import { applyPaymentToBill, matchOpenBills } from "./lib/autoBill";
|
||
|
||
type TxForm = {
|
||
account_id: string;
|
||
date: string;
|
||
amount: string;
|
||
type: "credit" | "debit";
|
||
reference: string;
|
||
coa_account_id: string;
|
||
vendor_id: string;
|
||
customer_id: string;
|
||
memo: string;
|
||
printCheck: boolean;
|
||
};
|
||
|
||
type TransferForm = {
|
||
from_account_id: string;
|
||
to_account_id: string;
|
||
date: string;
|
||
amount: string;
|
||
memo: string;
|
||
};
|
||
|
||
const EMPTY_TX: TxForm = {
|
||
account_id: "",
|
||
date: new Date().toLocaleDateString("en-CA", { timeZone: "America/New_York" }),
|
||
amount: "",
|
||
type: "credit",
|
||
reference: "",
|
||
coa_account_id: "",
|
||
vendor_id: "",
|
||
customer_id: "",
|
||
memo: "",
|
||
printCheck: false,
|
||
};
|
||
|
||
const EMPTY_TRANSFER: TransferForm = {
|
||
from_account_id: "",
|
||
to_account_id: "",
|
||
date: new Date().toLocaleDateString("en-CA", { timeZone: "America/New_York" }),
|
||
amount: "",
|
||
memo: "Account Transfer",
|
||
};
|
||
|
||
export default function AccountingBankingPage() {
|
||
const { companyId, loading: companyLoading, error: companyError, associationId, associationName } = useCompanyId();
|
||
const { user } = useAuth();
|
||
const cid = companyId ?? "";
|
||
const cur = "USD";
|
||
const qc = useQueryClient();
|
||
|
||
const [plaidLinkToken, setPlaidLinkToken] = useState<string | null>(null);
|
||
const [plaidTargetAcct, setPlaidTargetAcct] = useState<string>("");
|
||
const [syncingAcctId, setSyncingAcctId] = useState<string | null>(null);
|
||
|
||
const { data: plaidConnections = [] } = useQuery({
|
||
queryKey: ["plaid-connections", cid],
|
||
enabled: !!cid,
|
||
queryFn: async () =>
|
||
(await accounting.from("plaid_connections").select("*").eq("company_id", cid)).data ?? [],
|
||
});
|
||
const plaidByAcct = new Map((plaidConnections as any[]).map((c) => [c.account_id, c]));
|
||
|
||
const openPlaidLink = async (accountId: string) => {
|
||
if (!user?.id) return toast.error("Must be logged in");
|
||
setPlaidTargetAcct(accountId);
|
||
try {
|
||
const { link_token } = await createLinkToken(user.id, cid);
|
||
setPlaidLinkToken(link_token);
|
||
} catch (e: any) {
|
||
toast.error(e?.message ?? "Failed to start Plaid Link");
|
||
}
|
||
};
|
||
|
||
const syncAccount = async (accountId: string) => {
|
||
setSyncingAcctId(accountId);
|
||
try {
|
||
const result = await syncPlaidTransactions(cid, accountId);
|
||
toast.success(`Synced: +${result.added} new, ${result.modified} updated, ${result.removed} removed`);
|
||
qc.invalidateQueries({ queryKey: ["transactions", cid] });
|
||
qc.invalidateQueries({ queryKey: ["accounts", cid] });
|
||
qc.invalidateQueries({ queryKey: ["plaid-connections", cid] });
|
||
} catch (e: any) {
|
||
toast.error(e?.message ?? "Sync failed");
|
||
} finally {
|
||
setSyncingAcctId(null);
|
||
}
|
||
};
|
||
|
||
const disconnectAccount = async (accountId: string) => {
|
||
if (!confirm("Disconnect this bank feed? Existing transactions are kept.")) return;
|
||
try {
|
||
await disconnectPlaid(accountId);
|
||
toast.success("Bank feed disconnected");
|
||
qc.invalidateQueries({ queryKey: ["plaid-connections", cid] });
|
||
} catch (e: any) {
|
||
toast.error(e?.message ?? "Disconnect failed");
|
||
}
|
||
};
|
||
|
||
const [selectedAccountId, setSelectedAccountId] = useState<string>("");
|
||
const [txDialog, setTxDialog] = useState<{ open: boolean; mode: "deposit" | "payment" | "edit" }>({ open: false, mode: "deposit" });
|
||
const [editId, setEditId] = useState<string | null>(null);
|
||
const [txForm, setTxForm] = useState<TxForm>(EMPTY_TX);
|
||
const [transferOpen, setTransferOpen] = useState(false);
|
||
const [transfer, setTransfer] = useState<TransferForm>(EMPTY_TRANSFER);
|
||
const [acctDialog, setAcctDialog] = useState(false);
|
||
const [acctForm, setAcctForm] = useState({ name: "", code: "", type: "asset" as const, is_bank: true });
|
||
const [search, setSearch] = useState("");
|
||
const [importOpen, setImportOpen] = useState(false);
|
||
const [importing, setImporting] = useState(false);
|
||
const [importResult, setImportResult] = useState<{ inserted: number; skipped: number } | null>(null);
|
||
const importFileRef = useRef<HTMLInputElement>(null);
|
||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||
|
||
const { data: accounts = [] } = useQuery({
|
||
queryKey: ["accounts", cid],
|
||
enabled: !!cid,
|
||
queryFn: async () =>
|
||
(await accounting.from("accounts").select("*").eq("company_id", cid).order("code")).data ?? [],
|
||
});
|
||
|
||
const bankAccounts = useMemo(() => (accounts as any[]).filter((a) => a.is_bank), [accounts]);
|
||
const incomeAccounts = useMemo(() => (accounts as any[]).filter((a) => a.type === "income"), [accounts]);
|
||
const expenseAccounts = useMemo(() => (accounts as any[]).filter((a) => a.type === "expense"), [accounts]);
|
||
|
||
const { data: vendors = [] } = useQuery({
|
||
queryKey: ["vendors-lookup", cid],
|
||
enabled: !!cid,
|
||
queryFn: async () =>
|
||
(await accounting.from("vendors").select("id,name,address").eq("company_id", cid).order("name")).data ?? [],
|
||
});
|
||
|
||
const { data: customers = [] } = useQuery({
|
||
queryKey: ["customers", cid],
|
||
enabled: !!cid,
|
||
queryFn: async () =>
|
||
(await accounting.from("customers").select("id,name").eq("company_id", cid).order("name")).data ?? [],
|
||
});
|
||
|
||
const { data: checkSettings } = useQuery({
|
||
queryKey: ["check-settings", cid],
|
||
enabled: !!cid,
|
||
queryFn: async () =>
|
||
(await accounting.from("check_settings").select("*").eq("company_id", cid).maybeSingle()).data,
|
||
});
|
||
|
||
const activeAccountId = selectedAccountId || bankAccounts[0]?.id || "";
|
||
|
||
const { data: txs = [] } = useQuery({
|
||
queryKey: ["transactions", cid, activeAccountId],
|
||
enabled: !!cid && !!activeAccountId,
|
||
queryFn: async () =>
|
||
(
|
||
await accounting
|
||
.from("transactions")
|
||
.select("*, bank_account:accounts!account_id(name), coa:accounts!coa_account_id(name), vendors(name), customers(name)")
|
||
.eq("company_id", cid)
|
||
.eq("account_id", activeAccountId)
|
||
.order("date", { ascending: true })
|
||
.order("created_at", { ascending: true })
|
||
).data ?? [],
|
||
});
|
||
|
||
const register = useMemo(() => {
|
||
let bal = 0;
|
||
return (txs as any[]).map((tx) => {
|
||
const amt = Number(tx.amount ?? 0);
|
||
if (tx.type === "credit") bal += amt;
|
||
else bal -= amt;
|
||
return { ...tx, running: bal };
|
||
});
|
||
}, [txs]);
|
||
|
||
const filteredRegister = useMemo(() => {
|
||
if (!search.trim()) return register;
|
||
const q = search.toLowerCase();
|
||
return register.filter(
|
||
(r) =>
|
||
r.description?.toLowerCase().includes(q) ||
|
||
r.category?.toLowerCase().includes(q) ||
|
||
r.reference?.toLowerCase().includes(q)
|
||
);
|
||
}, [register, search]);
|
||
|
||
const computedBalance = register.length > 0 ? register[register.length - 1].running : 0;
|
||
const activeAccount = (accounts as any[]).find((a) => a.id === activeAccountId);
|
||
|
||
const selectableIds = useMemo(
|
||
() => filteredRegister.filter((r: any) => !r.reconciliation_id && !r.transfer_id).map((r: any) => r.id),
|
||
[filteredRegister]
|
||
);
|
||
const allSelected = selectableIds.length > 0 && selectableIds.every((id: string) => selected.has(id));
|
||
useEffect(() => { setSelected(new Set()); }, [activeAccountId]);
|
||
|
||
const toggleAll = () => setSelected(allSelected ? new Set() : new Set(selectableIds));
|
||
const toggleOne = (id: string) =>
|
||
setSelected((prev) => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n; });
|
||
|
||
const bulkSetCategory = async (accountId: string) => {
|
||
const acc = (accounts as any[]).find((a) => a.id === accountId);
|
||
if (!acc || selected.size === 0) return;
|
||
const ids = [...selected];
|
||
const rows = (register as any[]).filter((r) => selected.has(r.id));
|
||
|
||
// Accrual A/P: before categorizing a vendor debit to an expense account, check
|
||
// whether it actually settles an open bill (matchOpenBills). A debit that
|
||
// uniquely matches one is a bill payment — clear A/P (coa null, link the bill)
|
||
// instead of re-hitting the expense, which was already booked on the bill.
|
||
// • exactly one match → auto-apply to the bill
|
||
// • more than one match → leave for the user to resolve in Pay Bills (skip)
|
||
// • no match → categorize as a direct expense (below)
|
||
const billPayments: { id: string; bill: any; amount: number }[] = [];
|
||
let ambiguous = 0;
|
||
for (const r of rows) {
|
||
if (r.type !== "debit" || !r.vendor_id) continue;
|
||
const matches = await matchOpenBills({ companyId: cid, vendorId: r.vendor_id, amount: Number(r.amount), date: r.date });
|
||
if (matches.length === 1) billPayments.push({ id: r.id, bill: matches[0], amount: Number(r.amount) });
|
||
else if (matches.length > 1) ambiguous++;
|
||
}
|
||
const handled = new Set([...billPayments.map((p) => p.id)]);
|
||
const categorizeIds = ids.filter((id) => !handled.has(id));
|
||
|
||
if (categorizeIds.length) {
|
||
const { error } = await accounting.from("transactions")
|
||
.update({ coa_account_id: accountId, category: acc.name }).in("id", categorizeIds);
|
||
if (error) return toast.error(error.message);
|
||
}
|
||
for (const p of billPayments) {
|
||
const bal = Number(p.bill.total) - Number(p.bill.paid_amount ?? 0);
|
||
const { error } = await accounting.from("transactions")
|
||
.update({ coa_account_id: null, bill_id: p.bill.id, category: `Bill Payment · ${p.bill.number}` })
|
||
.eq("id", p.id);
|
||
if (error) return toast.error(error.message);
|
||
await applyPaymentToBill(p.bill.id, Math.min(p.amount, bal));
|
||
}
|
||
|
||
const parts: string[] = [];
|
||
if (categorizeIds.length) parts.push(`categorized ${categorizeIds.length}`);
|
||
if (billPayments.length) parts.push(`applied ${billPayments.length} to open bills`);
|
||
toast.success(parts.length ? parts.join(", ") : "No changes");
|
||
if (ambiguous) toast.warning(`${ambiguous} debit${ambiguous !== 1 ? "s" : ""} match multiple open bills — resolve in Pay Bills`);
|
||
qc.invalidateQueries({ queryKey: ["transactions", cid] });
|
||
if (billPayments.length) qc.invalidateQueries({ queryKey: ["bills", cid] });
|
||
setSelected(new Set());
|
||
};
|
||
|
||
const bulkSetDirection = async (type: "debit" | "credit") => {
|
||
if (selected.size === 0) return;
|
||
const ids = [...selected];
|
||
const { error } = await accounting.from("transactions").update({ type }).in("id", ids);
|
||
if (error) return toast.error(error.message);
|
||
toast.success(`Set ${ids.length} transaction${ids.length !== 1 ? "s" : ""} to ${type === "credit" ? "deposit" : "payment"}`);
|
||
qc.invalidateQueries({ queryKey: ["transactions", cid] });
|
||
qc.invalidateQueries({ queryKey: ["accounts", cid] });
|
||
};
|
||
|
||
const saveTx = async () => {
|
||
const { account_id, type, reference, date, coa_account_id, vendor_id, customer_id, memo } = txForm;
|
||
const amount = Math.abs(Number(txForm.amount));
|
||
|
||
if (!account_id) return toast.error("Bank account required");
|
||
if (!amount || amount <= 0) return toast.error("Amount can't be zero");
|
||
if (!coa_account_id) return toast.error(`${type === "credit" ? "Income" : "Expense"} account (COA) required`);
|
||
if (type === "debit" && !vendor_id) return toast.error("Vendor required for payments");
|
||
if (type === "credit" && !customer_id) return toast.error("Homeowner required for deposits");
|
||
|
||
const coaName = (accounts as any[]).find((a) => a.id === coa_account_id)?.name ?? "";
|
||
const vendorName = (vendors as any[]).find((v) => v.id === vendor_id)?.name ?? "";
|
||
const customerName = (customers as any[]).find((c) => c.id === customer_id)?.name ?? "";
|
||
const partyName = type === "debit" ? vendorName : customerName;
|
||
const description = [partyName, coaName, memo].filter(Boolean).join(" · ");
|
||
const category = coaName;
|
||
|
||
// Vendor-payment recognition rule: count the expense for the bill when it is
|
||
// entered (accrual), or — when no bill exists — when the payment is made.
|
||
// • Debit matches an OPEN bill (matchOpenBills: same vendor, amount within
|
||
// $0.01 or partial, date within ±30 days) → this payment clears Accounts
|
||
// Payable (coa null, vendor set → post_transaction_gl posts Dr A/P / Cr
|
||
// Bank); the expense was already recognized on the bill. We then apply it
|
||
// to the matched bill(s).
|
||
// • No matching bill → the payment IS the expense: keep the chosen expense
|
||
// account so it posts Dr Expense / Cr Bank on the payment date.
|
||
// Customer deposits (credits) clear A/R via customer_id and are unchanged.
|
||
const matchedBills = type === "debit" && vendor_id
|
||
? await matchOpenBills({ companyId: cid, vendorId: vendor_id, amount, date })
|
||
: [];
|
||
const debitClearsAp = matchedBills.length > 0;
|
||
|
||
const payload: any = {
|
||
account_id, date, description, amount, type, category, reference: reference || null,
|
||
coa_account_id: debitClearsAp ? null : (coa_account_id || null),
|
||
vendor_id: vendor_id || null,
|
||
customer_id: customer_id || null,
|
||
// Link the settled bill only when a single bill matches (the guard requires
|
||
// coa null on any bill-linked row, which holds here). Multi-bill payments
|
||
// stay unlinked but still clear A/P via the vendor branch.
|
||
bill_id: matchedBills.length === 1 ? matchedBills[0].id : null,
|
||
};
|
||
|
||
if (editId) {
|
||
const { error } = await accounting.from("transactions").update(payload).eq("id", editId);
|
||
if (error) return toast.error(error.message);
|
||
toast.success("Transaction updated");
|
||
} else {
|
||
const { data: inserted, error } = await accounting
|
||
.from("transactions").insert({ ...payload, company_id: cid }).select("id").single();
|
||
if (error || !inserted) return toast.error(error?.message ?? "Failed to record");
|
||
toast.success(type === "credit" ? "Deposit recorded" : "Payment recorded");
|
||
|
||
if (type === "debit" && txForm.printCheck) {
|
||
const vendorRecord = (vendors as any[]).find((v) => v.id === vendor_id);
|
||
await printCheckForPayment({
|
||
vendorId: vendor_id,
|
||
vendorName: vendorRecord?.name ?? "Payee",
|
||
vendorAddress: vendorRecord?.address ?? undefined,
|
||
amount,
|
||
date,
|
||
memo: memo || coaName,
|
||
checkNumber: parseInt(txForm.reference) || (checkSettings as any)?.next_check_number || 1001,
|
||
bankAccountId: account_id,
|
||
});
|
||
}
|
||
|
||
// When the payment cleared A/P, apply it to the matched bill(s) oldest-first
|
||
// so they show paid. The expense lives on the bill, so no expense is booked
|
||
// here. With no matching bill the payment already posted the expense directly
|
||
// (above) — nothing further to do.
|
||
if (debitClearsAp) {
|
||
let remaining = amount;
|
||
for (const b of matchedBills) {
|
||
if (remaining <= 0.005) break;
|
||
const bal = Number(b.total) - Number(b.paid_amount ?? 0);
|
||
const applied = Math.min(bal, remaining);
|
||
await applyPaymentToBill(b.id, applied);
|
||
remaining -= applied;
|
||
}
|
||
qc.invalidateQueries({ queryKey: ["bills", cid] });
|
||
}
|
||
}
|
||
setTxDialog({ open: false, mode: "deposit" });
|
||
setEditId(null);
|
||
setTxForm(EMPTY_TX);
|
||
qc.invalidateQueries({ queryKey: ["transactions", cid] });
|
||
qc.invalidateQueries({ queryKey: ["accounts", cid] });
|
||
qc.invalidateQueries({ queryKey: ["check-settings", cid] });
|
||
};
|
||
|
||
const printCheckForPayment = async ({
|
||
vendorId, vendorName, vendorAddress, amount, date, memo, checkNumber, bankAccountId,
|
||
}: {
|
||
vendorId: string; vendorName: string; vendorAddress?: string;
|
||
amount: number; date: string; memo: string; checkNumber: number; bankAccountId: string;
|
||
}) => {
|
||
const cs = checkSettings as any;
|
||
const bankAccount = (bankAccounts as any[]).find((a) => a.id === bankAccountId);
|
||
|
||
await accounting.from("checks").insert({
|
||
company_id: cid,
|
||
bank_account_id: bankAccountId,
|
||
check_number: checkNumber,
|
||
date,
|
||
payee_vendor_id: vendorId || null,
|
||
payee_name: vendorName,
|
||
amount,
|
||
memo: memo || undefined,
|
||
status: "printed",
|
||
printed_at: new Date().toISOString(),
|
||
});
|
||
|
||
if (cs?.id) {
|
||
await accounting.from("check_settings").update({
|
||
next_check_number: Math.max((cs.next_check_number ?? 1001), checkNumber + 1),
|
||
}).eq("id", cs.id);
|
||
}
|
||
|
||
const dataUrl = generateCheckPDF([{
|
||
companyName: associationName ?? "Association",
|
||
companyAddress: undefined,
|
||
bankName: cs?.bank_name ?? bankAccount?.name ?? undefined,
|
||
bankAddress: cs?.bank_address ?? undefined,
|
||
routingNumber: cs?.routing_number ?? undefined,
|
||
accountNumber: cs?.account_number ?? undefined,
|
||
fractionalRouting: cs?.fractional_routing ?? undefined,
|
||
checkNumber,
|
||
date: fmtDate(date),
|
||
payee: vendorName,
|
||
payeeAddress: vendorAddress || undefined,
|
||
amount,
|
||
memo: memo || undefined,
|
||
printSignature: cs?.print_signature ?? false,
|
||
signatureDataUrl: cs?.signature_url ?? undefined,
|
||
}], {
|
||
style: cs?.default_style ?? "voucher",
|
||
position: cs?.default_position ?? "top",
|
||
fontSize: cs?.font_size ?? "medium",
|
||
offsetX: cs?.offset_x ?? 0,
|
||
offsetY: cs?.offset_y ?? 0,
|
||
micrOffsetY: cs?.micr_offset_y ?? 0,
|
||
});
|
||
|
||
const w = window.open("");
|
||
if (w) {
|
||
w.document.write(`<iframe src="${dataUrl}" style="border:0;width:100%;height:100vh" onload="this.contentWindow.print()"></iframe>`);
|
||
}
|
||
};
|
||
|
||
const deleteTx = async (tx: any) => {
|
||
if (tx.reconciliation_id) return toast.error("Reconciled transactions can't be deleted");
|
||
if (!confirm("Delete this transaction?")) return;
|
||
|
||
if (tx.transfer_id) {
|
||
const { error } = await accounting.from("transactions").delete().eq("transfer_id", tx.transfer_id);
|
||
if (error) return toast.error(error.message);
|
||
} else {
|
||
const { error } = await accounting.from("transactions").delete().eq("id", tx.id);
|
||
if (error) return toast.error(error.message);
|
||
}
|
||
toast.success("Transaction deleted");
|
||
qc.invalidateQueries({ queryKey: ["transactions", cid] });
|
||
qc.invalidateQueries({ queryKey: ["accounts", cid] });
|
||
};
|
||
|
||
const openEdit = (tx: any) => {
|
||
if (tx.reconciliation_id) return toast.error("Reconciled transactions can't be edited");
|
||
setEditId(tx.id);
|
||
setTxForm({
|
||
account_id: tx.account_id ?? activeAccountId,
|
||
date: tx.date,
|
||
amount: tx.type === "debit" ? String(-Math.abs(Number(tx.amount))) : String(tx.amount),
|
||
type: tx.type,
|
||
reference: tx.reference ?? "",
|
||
coa_account_id: tx.coa_account_id ?? "",
|
||
vendor_id: tx.vendor_id ?? "",
|
||
customer_id: tx.customer_id ?? "",
|
||
memo: "",
|
||
printCheck: false,
|
||
});
|
||
setTxDialog({ open: true, mode: "edit" });
|
||
};
|
||
|
||
const openNewTx = () => {
|
||
setEditId(null);
|
||
setTxForm({ ...EMPTY_TX, account_id: activeAccountId, type: "credit" });
|
||
setTxDialog({ open: true, mode: "deposit" });
|
||
};
|
||
|
||
const onAmountChange = (v: string) => {
|
||
setTxForm((prev) => {
|
||
const t = v.trim();
|
||
const nextType: "credit" | "debit" =
|
||
t === "" ? prev.type : (t.startsWith("-") || Number(v) < 0 ? "debit" : "credit");
|
||
return nextType !== prev.type
|
||
? { ...prev, amount: v, type: nextType, coa_account_id: "", vendor_id: "", customer_id: "" }
|
||
: { ...prev, amount: v };
|
||
});
|
||
};
|
||
|
||
const coaOptions = txForm.type === "credit" ? incomeAccounts : expenseAccounts;
|
||
|
||
const saveTransfer = async () => {
|
||
const { from_account_id, to_account_id, date, memo } = transfer;
|
||
const amount = Number(transfer.amount);
|
||
if (!from_account_id || !to_account_id) return toast.error("Both accounts required");
|
||
if (from_account_id === to_account_id) return toast.error("Accounts must be different");
|
||
if (!amount || amount <= 0) return toast.error("Amount must be greater than zero");
|
||
|
||
const transferId = crypto.randomUUID();
|
||
const fromAcc = (accounts as any[]).find((a) => a.id === from_account_id);
|
||
const toAcc = (accounts as any[]).find((a) => a.id === to_account_id);
|
||
|
||
const { error } = await accounting.from("transactions").insert([
|
||
{
|
||
company_id: cid,
|
||
account_id: from_account_id,
|
||
date,
|
||
description: `Transfer to ${toAcc?.name ?? "account"}${memo ? ` — ${memo}` : ""}`,
|
||
amount,
|
||
type: "debit",
|
||
category: "Transfer",
|
||
transfer_id: transferId,
|
||
},
|
||
{
|
||
company_id: cid,
|
||
account_id: to_account_id,
|
||
date,
|
||
description: `Transfer from ${fromAcc?.name ?? "account"}${memo ? ` — ${memo}` : ""}`,
|
||
amount,
|
||
type: "credit",
|
||
category: "Transfer",
|
||
transfer_id: transferId,
|
||
},
|
||
]);
|
||
if (error) return toast.error(error.message);
|
||
toast.success("Transfer recorded");
|
||
setTransferOpen(false);
|
||
setTransfer(EMPTY_TRANSFER);
|
||
qc.invalidateQueries({ queryKey: ["transactions", cid] });
|
||
qc.invalidateQueries({ queryKey: ["accounts", cid] });
|
||
};
|
||
|
||
const downloadRegisterTemplate = () => {
|
||
const csv =
|
||
"date,description,amount,reference,category,cleared\n" +
|
||
"2026-01-15,Opening deposit,1200.00,DEP-001,Income,true\n" +
|
||
"2026-01-18,Landscaping invoice,-350.00,1042,Maintenance,false\n";
|
||
const blob = new Blob([csv], { type: "text/csv" });
|
||
const a = document.createElement("a");
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = "bank-register-template.csv";
|
||
a.click();
|
||
URL.revokeObjectURL(a.href);
|
||
};
|
||
|
||
const handleRegisterImport = async (file: File) => {
|
||
if (!activeAccountId) { toast.error("Select a bank account first"); return; }
|
||
setImporting(true);
|
||
setImportResult(null);
|
||
try {
|
||
const rows = parseCsv(await file.text());
|
||
if (rows.length === 0) { toast.error("CSV is empty or unreadable"); return; }
|
||
|
||
let skipped = 0;
|
||
const payload = rows
|
||
.map((r) => {
|
||
const dateRaw = pick(r.date, r.transaction_date, r["transaction date"], r.posted, r["post date"]);
|
||
if (!dateRaw) { skipped++; return null; }
|
||
const debit = parseFloat(pick(r.debit, r.withdrawal, r.withdrawals, r["debit amount"]) || "0") || 0;
|
||
const credit = parseFloat(pick(r.credit, r.deposit, r.deposits, r["credit amount"]) || "0") || 0;
|
||
const signed = parseFloat(pick(r.amount) || "0") || 0;
|
||
let amount: number;
|
||
let type: "debit" | "credit";
|
||
if (debit > 0 || credit > 0) {
|
||
amount = debit > 0 ? debit : credit;
|
||
type = debit > 0 ? "debit" : "credit";
|
||
} else if (signed !== 0) {
|
||
amount = Math.abs(signed);
|
||
const t = pick(r.type).toLowerCase();
|
||
type = t === "debit" || t === "credit" ? (t as "debit" | "credit") : (signed < 0 ? "debit" : "credit");
|
||
} else {
|
||
skipped++; return null;
|
||
}
|
||
const clearedRaw = pick(r.cleared, r.reconciled, r.status).toLowerCase();
|
||
return {
|
||
company_id: cid,
|
||
account_id: activeAccountId,
|
||
date: parseDateStr(dateRaw),
|
||
description: pick(r.description, r.payee, r.memo, r.name) || "Imported transaction",
|
||
amount,
|
||
type,
|
||
category: pick(r.category) || null,
|
||
reference: pick(r.reference, r["check number"], r["check #"], r.check, r.cheque) || null,
|
||
cleared: ["true", "yes", "1", "cleared", "reconciled", "c"].includes(clearedRaw),
|
||
};
|
||
})
|
||
.filter((x): x is NonNullable<typeof x> => x !== null);
|
||
|
||
if (payload.length === 0) { toast.warning("No rows imported — check the column names against the template."); return; }
|
||
|
||
let inserted = 0;
|
||
for (let i = 0; i < payload.length; i += 500) {
|
||
const chunk = payload.slice(i, i + 500);
|
||
const { error, count } = await accounting.from("transactions").insert(chunk, { count: "exact" });
|
||
if (error) { toast.error(error.message); break; }
|
||
inserted += count ?? chunk.length;
|
||
}
|
||
setImportResult({ inserted, skipped });
|
||
if (inserted > 0) {
|
||
toast.success(`Imported ${inserted} transaction${inserted !== 1 ? "s" : ""}${skipped ? ` (${skipped} skipped)` : ""}`);
|
||
qc.invalidateQueries({ queryKey: ["transactions", cid] });
|
||
qc.invalidateQueries({ queryKey: ["accounts", cid] });
|
||
}
|
||
} catch (e) {
|
||
toast.error(e instanceof Error ? e.message : "Import failed");
|
||
} finally {
|
||
setImporting(false);
|
||
if (importFileRef.current) importFileRef.current.value = "";
|
||
}
|
||
};
|
||
|
||
const saveAccount = async () => {
|
||
if (!acctForm.name.trim()) return toast.error("Name required");
|
||
const { error } = await accounting.from("accounts").insert({ ...acctForm, company_id: cid });
|
||
if (error) return toast.error(error.message);
|
||
toast.success("Account added");
|
||
setAcctDialog(false);
|
||
setAcctForm({ name: "", code: "", type: "asset", is_bank: true });
|
||
qc.invalidateQueries({ queryKey: ["accounts", cid] });
|
||
};
|
||
|
||
const dialogTitle = editId
|
||
? "Edit transaction"
|
||
: (txForm.type === "debit" ? "New payment" : "New transaction");
|
||
|
||
const totalDebits = filteredRegister.reduce((s, r) => s + (r.type === "debit" ? Number(r.amount) : 0), 0);
|
||
const totalCredits = filteredRegister.reduce((s, r) => s + (r.type === "credit" ? Number(r.amount) : 0), 0);
|
||
|
||
if (!associationId) return <p className="text-sm text-muted-foreground">Select an association.</p>;
|
||
if (companyLoading) return <div className="flex justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>;
|
||
if (companyError || !companyId) return <p className="text-sm text-muted-foreground text-center py-12">{companyError || "Accounting setup is not ready."}</p>;
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="flex items-center justify-between">
|
||
<h1 className="text-2xl font-semibold">Banking</h1>
|
||
<div className="flex gap-2">
|
||
<Button variant="outline" size="sm" onClick={() => setAcctDialog(true)}>
|
||
<Plus className="h-4 w-4 mr-1" /> New account
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||
{bankAccounts.map((acc: any) => {
|
||
const conn = plaidByAcct.get(acc.id);
|
||
const isSyncing = syncingAcctId === acc.id;
|
||
return (
|
||
<Card
|
||
key={acc.id}
|
||
className={`cursor-pointer transition-colors ${acc.id === activeAccountId ? "ring-2 ring-primary" : "hover:bg-muted/50"}`}
|
||
onClick={() => setSelectedAccountId(acc.id)}
|
||
>
|
||
<CardContent className="p-4 space-y-2">
|
||
<div className="flex items-start justify-between gap-2">
|
||
<div>
|
||
<div className="text-xs text-muted-foreground uppercase tracking-wide">{acc.type} · bank</div>
|
||
<div className="font-medium mt-0.5">{acc.name}</div>
|
||
<div className="text-xl font-semibold mt-1">{money(acc.balance, cur)}</div>
|
||
</div>
|
||
{conn && (
|
||
<Badge className="bg-emerald-100 text-emerald-700 border-0 shrink-0 text-[10px]">
|
||
<Link2 className="h-2.5 w-2.5 mr-1" />Connected
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
{conn ? (
|
||
<div className="flex gap-1.5 pt-1" onClick={(e) => e.stopPropagation()}>
|
||
<Button size="sm" variant="outline" className="h-7 text-xs flex-1"
|
||
disabled={isSyncing}
|
||
onClick={() => syncAccount(acc.id)}>
|
||
<RefreshCw className={`h-3 w-3 mr-1 ${isSyncing ? "animate-spin" : ""}`} />
|
||
{isSyncing ? "Syncing…" : conn.last_sync_at ? `Synced ${new Date(conn.last_sync_at).toLocaleDateString()}` : "Sync now"}
|
||
</Button>
|
||
<Button size="sm" variant="ghost" className="h-7 text-xs text-destructive hover:text-destructive"
|
||
onClick={() => disconnectAccount(acc.id)}>
|
||
<Unlink className="h-3 w-3" />
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<div onClick={(e) => e.stopPropagation()}>
|
||
<Button size="sm" variant="outline" className="h-7 text-xs w-full"
|
||
onClick={() => openPlaidLink(acc.id)}>
|
||
<Link2 className="h-3 w-3 mr-1" /> Connect bank feed
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
})}
|
||
{bankAccounts.length === 0 && (
|
||
<Card className="col-span-full">
|
||
<CardContent className="p-8 text-center text-muted-foreground">
|
||
No bank accounts yet. Add one to get started.
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</div>
|
||
|
||
{activeAccount && (
|
||
<Card>
|
||
<CardHeader className="pb-3">
|
||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||
<div>
|
||
<CardTitle>{activeAccount.name} — Register</CardTitle>
|
||
<div className="text-sm text-muted-foreground mt-0.5">
|
||
Computed balance: <span className="font-semibold text-foreground">{money(computedBalance, cur)}</span>
|
||
</div>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button size="sm" onClick={openNewTx}>
|
||
<Plus className="h-4 w-4 mr-1" /> New transaction
|
||
</Button>
|
||
<Button size="sm" variant="outline" onClick={() => { setTransfer(EMPTY_TRANSFER); setTransferOpen(true); }}>
|
||
<ArrowLeftRight className="h-4 w-4 mr-1" /> Transfer
|
||
</Button>
|
||
<Button size="sm" variant="outline" onClick={() => { setImportResult(null); setImportOpen(true); }}>
|
||
<FileUp className="h-4 w-4 mr-1" /> Import
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<Input
|
||
placeholder="Search description, category, reference…"
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
className="max-w-sm mt-2"
|
||
/>
|
||
</CardHeader>
|
||
<CardContent className="p-0">
|
||
{selected.size > 0 && (
|
||
<div className="flex flex-wrap items-center gap-3 border-b bg-muted/40 px-4 py-2.5">
|
||
<span className="text-sm font-medium">{selected.size} selected</span>
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="text-xs text-muted-foreground">Category</span>
|
||
<Select value="" onValueChange={bulkSetCategory}>
|
||
<SelectTrigger className="h-8 w-56 text-sm"><SelectValue placeholder="Set category…" /></SelectTrigger>
|
||
<SelectContent>
|
||
{incomeAccounts.length > 0 && (
|
||
<SelectGroup>
|
||
<SelectLabel>Income</SelectLabel>
|
||
{incomeAccounts.map((a: any) => (
|
||
<SelectItem key={a.id} value={a.id}>{a.code ? `${a.code} · ` : ""}{a.name}</SelectItem>
|
||
))}
|
||
</SelectGroup>
|
||
)}
|
||
{expenseAccounts.length > 0 && (
|
||
<SelectGroup>
|
||
<SelectLabel>Expense</SelectLabel>
|
||
{expenseAccounts.map((a: any) => (
|
||
<SelectItem key={a.id} value={a.id}>{a.code ? `${a.code} · ` : ""}{a.name}</SelectItem>
|
||
))}
|
||
</SelectGroup>
|
||
)}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="text-xs text-muted-foreground">Direction</span>
|
||
<Select value="" onValueChange={(v) => bulkSetDirection(v as "debit" | "credit")}>
|
||
<SelectTrigger className="h-8 w-44 text-sm"><SelectValue placeholder="Set direction…" /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="credit">Deposit (money in)</SelectItem>
|
||
<SelectItem value="debit">Payment (money out)</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<Button size="sm" variant="ghost" className="ml-auto" onClick={() => setSelected(new Set())}>Clear</Button>
|
||
</div>
|
||
)}
|
||
<div className="overflow-x-auto">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead className="w-[36px] px-2">
|
||
<Checkbox checked={allSelected} onCheckedChange={toggleAll} disabled={selectableIds.length === 0} aria-label="Select all" />
|
||
</TableHead>
|
||
<TableHead className="w-[100px]">Date</TableHead>
|
||
<TableHead className="w-[80px]">Ref #</TableHead>
|
||
<TableHead>Description</TableHead>
|
||
<TableHead>Category</TableHead>
|
||
<TableHead className="text-right w-[110px]">Payment</TableHead>
|
||
<TableHead className="text-right w-[110px]">Deposit</TableHead>
|
||
<TableHead className="text-right w-[120px]">Balance</TableHead>
|
||
<TableHead className="w-[80px]"></TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{filteredRegister.map((row) => (
|
||
<TableRow key={row.id} className={`hover:bg-muted/40 ${selected.has(row.id) ? "bg-primary/5" : ""}`}>
|
||
<TableCell className="px-2">
|
||
{!row.reconciliation_id && !row.transfer_id && (
|
||
<Checkbox checked={selected.has(row.id)} onCheckedChange={() => toggleOne(row.id)} aria-label="Select transaction" />
|
||
)}
|
||
</TableCell>
|
||
<TableCell className="text-sm">{fmtDate(row.date)}</TableCell>
|
||
<TableCell className="text-sm text-muted-foreground">{row.reference ?? "—"}</TableCell>
|
||
<TableCell className="text-sm">
|
||
<span className="flex items-center gap-1.5">
|
||
{row.transfer_id && (
|
||
<Badge variant="outline" className="text-[10px] py-0 px-1">TFR</Badge>
|
||
)}
|
||
{row.reconciliation_id && (
|
||
<Badge variant="outline" className="text-[10px] py-0 px-1 bg-emerald-50 text-emerald-700 border-emerald-200">R</Badge>
|
||
)}
|
||
{row.description}
|
||
</span>
|
||
</TableCell>
|
||
<TableCell className="text-sm text-muted-foreground">{row.category ?? "—"}</TableCell>
|
||
<TableCell className="text-right text-sm text-red-600">
|
||
{row.type === "debit" ? money(row.amount, cur) : ""}
|
||
</TableCell>
|
||
<TableCell className="text-right text-sm text-emerald-600">
|
||
{row.type === "credit" ? money(row.amount, cur) : ""}
|
||
</TableCell>
|
||
<TableCell className={`text-right text-sm font-medium ${row.running < 0 ? "text-destructive" : ""}`}>
|
||
{money(row.running, cur)}
|
||
</TableCell>
|
||
<TableCell>
|
||
<div className="flex justify-end gap-1">
|
||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => openEdit(row)}>
|
||
<Pencil className="h-3.5 w-3.5" />
|
||
</Button>
|
||
<Button size="icon" variant="ghost" className="h-7 w-7 text-destructive hover:text-destructive" onClick={() => deleteTx(row)}>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
</Button>
|
||
</div>
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
{filteredRegister.length === 0 && (
|
||
<TableRow>
|
||
<TableCell colSpan={9} className="text-center text-muted-foreground py-8">
|
||
No transactions yet. Record a deposit or payment to get started.
|
||
</TableCell>
|
||
</TableRow>
|
||
)}
|
||
{filteredRegister.length > 0 && (
|
||
<TableRow className="bg-muted font-medium">
|
||
<TableCell colSpan={5} className="text-sm">Totals</TableCell>
|
||
<TableCell className="text-right text-sm text-red-600">{money(totalDebits, cur)}</TableCell>
|
||
<TableCell className="text-right text-sm text-emerald-600">{money(totalCredits, cur)}</TableCell>
|
||
<TableCell className={`text-right text-sm ${computedBalance < 0 ? "text-destructive" : ""}`}>
|
||
{money(computedBalance, cur)}
|
||
</TableCell>
|
||
<TableCell />
|
||
</TableRow>
|
||
)}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Deposit / Payment / Edit dialog */}
|
||
<Dialog open={txDialog.open} onOpenChange={(o) => { if (!o) { setTxDialog({ open: false, mode: "deposit" }); setEditId(null); setTxForm(EMPTY_TX); } else setTxDialog((d) => ({ ...d, open: true })); }}>
|
||
<DialogContent className="max-w-lg">
|
||
<DialogHeader><DialogTitle>{dialogTitle}</DialogTitle></DialogHeader>
|
||
<div className="space-y-3">
|
||
|
||
{txDialog.mode === "edit" && (
|
||
<div>
|
||
<Label>Bank account *</Label>
|
||
<Select value={txForm.account_id} onValueChange={(v) => setTxForm({ ...txForm, account_id: v })}>
|
||
<SelectTrigger><SelectValue placeholder="Select" /></SelectTrigger>
|
||
<SelectContent>{bankAccounts.map((a: any) => <SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>)}</SelectContent>
|
||
</Select>
|
||
</div>
|
||
)}
|
||
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<Label>Date *</Label>
|
||
<Input type="date" value={txForm.date} onChange={(e) => setTxForm({ ...txForm, date: e.target.value })} />
|
||
</div>
|
||
<div>
|
||
<Label>Amount <span className="text-muted-foreground text-xs">(+ deposit / − payment)</span> *</Label>
|
||
<Input
|
||
type="number"
|
||
step="0.01"
|
||
placeholder="0.00"
|
||
className={Number(txForm.amount) < 0 ? "text-red-600" : ""}
|
||
value={txForm.amount}
|
||
onChange={(e) => onAmountChange(e.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{txForm.type === "debit" ? (
|
||
<div>
|
||
<Label>Vendor *</Label>
|
||
<Select value={txForm.vendor_id} onValueChange={(v) => setTxForm({ ...txForm, vendor_id: v })}>
|
||
<SelectTrigger><SelectValue placeholder="Select vendor" /></SelectTrigger>
|
||
<SelectContent>
|
||
{(vendors as any[]).map((v: any) => (
|
||
<SelectItem key={v.id} value={v.id}>{v.name}</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
) : (
|
||
<div>
|
||
<Label>Homeowner *</Label>
|
||
<Select value={txForm.customer_id} onValueChange={(v) => setTxForm({ ...txForm, customer_id: v })}>
|
||
<SelectTrigger><SelectValue placeholder="Select homeowner" /></SelectTrigger>
|
||
<SelectContent>
|
||
{(customers as any[]).map((c: any) => (
|
||
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
)}
|
||
|
||
<div>
|
||
<Label>{txForm.type === "credit" ? "Income account *" : "Expense account *"}</Label>
|
||
<Select value={txForm.coa_account_id} onValueChange={(v) => setTxForm({ ...txForm, coa_account_id: v })}>
|
||
<SelectTrigger><SelectValue placeholder={`Select ${txForm.type === "credit" ? "income" : "expense"} account`} /></SelectTrigger>
|
||
<SelectContent>
|
||
{coaOptions.map((a: any) => (
|
||
<SelectItem key={a.id} value={a.id}>
|
||
{a.code ? `${a.code} · ` : ""}{a.name}
|
||
</SelectItem>
|
||
))}
|
||
{coaOptions.length === 0 && (
|
||
<SelectItem value="__none" disabled>
|
||
No {txForm.type === "credit" ? "income" : "expense"} accounts — add them in Chart of Accounts
|
||
</SelectItem>
|
||
)}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<Label>{txForm.type === "debit" ? "Check # *" : "Ref #"}</Label>
|
||
<Input
|
||
maxLength={40}
|
||
placeholder={txForm.type === "debit" ? "Check number" : "Optional"}
|
||
value={txForm.reference}
|
||
onChange={(e) => setTxForm({ ...txForm, reference: e.target.value })}
|
||
className="font-mono"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label>Memo <span className="text-muted-foreground text-xs">(optional)</span></Label>
|
||
<Input maxLength={120} placeholder="Additional notes" value={txForm.memo} onChange={(e) => setTxForm({ ...txForm, memo: e.target.value })} />
|
||
</div>
|
||
</div>
|
||
|
||
{txForm.type === "debit" && !editId && (
|
||
<div className="flex items-center justify-between rounded-md border bg-muted/30 px-4 py-3">
|
||
<div className="flex items-center gap-2">
|
||
<Printer className="h-4 w-4 text-muted-foreground" />
|
||
<div>
|
||
<div className="text-sm font-medium">Print check after saving</div>
|
||
<div className="text-xs text-muted-foreground">Opens print dialog with check #{txForm.reference || "—"}</div>
|
||
</div>
|
||
</div>
|
||
<Switch
|
||
checked={txForm.printCheck}
|
||
onCheckedChange={(v) => setTxForm({ ...txForm, printCheck: v })}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => { setTxDialog({ open: false, mode: "deposit" }); setEditId(null); setTxForm(EMPTY_TX); }}>Cancel</Button>
|
||
<Button onClick={saveTx}>
|
||
{txForm.type === "debit" && txForm.printCheck && !editId ? "Save & Print Check" : "Save"}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* Transfer dialog */}
|
||
<Dialog open={transferOpen} onOpenChange={setTransferOpen}>
|
||
<DialogContent>
|
||
<DialogHeader><DialogTitle>Transfer between accounts</DialogTitle></DialogHeader>
|
||
<div className="space-y-3">
|
||
<div>
|
||
<Label>From account</Label>
|
||
<Select value={transfer.from_account_id} onValueChange={(v) => setTransfer({ ...transfer, from_account_id: v })}>
|
||
<SelectTrigger><SelectValue placeholder="Select account" /></SelectTrigger>
|
||
<SelectContent>{bankAccounts.map((a: any) => <SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>)}</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div>
|
||
<Label>To account</Label>
|
||
<Select value={transfer.to_account_id} onValueChange={(v) => setTransfer({ ...transfer, to_account_id: v })}>
|
||
<SelectTrigger><SelectValue placeholder="Select account" /></SelectTrigger>
|
||
<SelectContent>{bankAccounts.map((a: any) => <SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>)}</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<Label>Date</Label>
|
||
<Input type="date" value={transfer.date} onChange={(e) => setTransfer({ ...transfer, date: e.target.value })} />
|
||
</div>
|
||
<div>
|
||
<Label>Amount</Label>
|
||
<Input type="number" min={0} step="0.01" placeholder="0.00" value={transfer.amount} onChange={(e) => setTransfer({ ...transfer, amount: e.target.value })} />
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<Label>Memo</Label>
|
||
<Input maxLength={200} value={transfer.memo} onChange={(e) => setTransfer({ ...transfer, memo: e.target.value })} />
|
||
</div>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setTransferOpen(false)}>Cancel</Button>
|
||
<Button onClick={saveTransfer}>Record transfer</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* Bank register import dialog */}
|
||
<Dialog open={importOpen} onOpenChange={(o) => { setImportOpen(o); if (!o) setImportResult(null); }}>
|
||
<DialogContent>
|
||
<DialogHeader><DialogTitle>Import bank register</DialogTitle></DialogHeader>
|
||
<div className="space-y-4">
|
||
<p className="text-sm text-muted-foreground">
|
||
Import transactions into <span className="font-medium text-foreground">{activeAccount?.name ?? "this account"}</span>.
|
||
Columns: <code className="text-xs">date, description, amount, reference, category, cleared</code>.
|
||
Use a <span className="font-medium text-foreground">signed amount</span> — <code className="text-xs">+</code> for
|
||
money in, <code className="text-xs">−</code> for money out.
|
||
Imported rows are uncategorized — set the offset account from the register afterward.
|
||
</p>
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button variant="outline" size="sm" onClick={downloadRegisterTemplate}>
|
||
<Download className="h-4 w-4 mr-1" /> Download template
|
||
</Button>
|
||
<Button size="sm" disabled={importing || !activeAccountId} onClick={() => importFileRef.current?.click()}>
|
||
{importing ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <FileUp className="h-4 w-4 mr-1" />}
|
||
{importing ? "Importing…" : "Choose CSV file"}
|
||
</Button>
|
||
<input
|
||
ref={importFileRef}
|
||
type="file"
|
||
accept=".csv,text/csv"
|
||
className="hidden"
|
||
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleRegisterImport(f); }}
|
||
/>
|
||
</div>
|
||
{importResult && (
|
||
<div className="rounded-md border bg-muted/40 px-3 py-2 text-sm">
|
||
Imported <span className="font-semibold">{importResult.inserted}</span> transaction{importResult.inserted !== 1 ? "s" : ""}
|
||
{importResult.skipped > 0 && <> · {importResult.skipped} skipped (missing date/amount)</>}.
|
||
</div>
|
||
)}
|
||
</div>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setImportOpen(false)}>Close</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* New account dialog */}
|
||
<Dialog open={acctDialog} onOpenChange={setAcctDialog}>
|
||
<DialogContent>
|
||
<DialogHeader><DialogTitle>New bank account</DialogTitle></DialogHeader>
|
||
<div className="space-y-3">
|
||
<div><Label>Name</Label><Input maxLength={80} value={acctForm.name} onChange={(e) => setAcctForm({ ...acctForm, name: e.target.value })} placeholder="e.g. Operating Checking" /></div>
|
||
<div><Label>Code</Label><Input maxLength={20} value={acctForm.code} onChange={(e) => setAcctForm({ ...acctForm, code: e.target.value })} placeholder="e.g. 1010" /></div>
|
||
<div>
|
||
<Label>Account type</Label>
|
||
<Select value={acctForm.type} onValueChange={(v) => setAcctForm({ ...acctForm, type: v as any })}>
|
||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
{["asset", "liability", "equity", "income", "expense"].map((x) => (
|
||
<SelectItem key={x} value={x}>{x}</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
<DialogFooter><Button onClick={saveAccount}>Add account</Button></DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* Plaid Link — mounts only when a link token is ready */}
|
||
{plaidLinkToken && (
|
||
<PlaidLinkButton
|
||
linkToken={plaidLinkToken}
|
||
accountId={plaidTargetAcct}
|
||
companyId={cid}
|
||
onDone={() => {
|
||
setPlaidLinkToken(null);
|
||
qc.invalidateQueries({ queryKey: ["plaid-connections", cid] });
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PlaidLinkButton({
|
||
linkToken, accountId, companyId, onDone,
|
||
}: {
|
||
linkToken: string;
|
||
accountId: string;
|
||
companyId: string;
|
||
onDone: () => void;
|
||
}) {
|
||
const { open, ready } = usePlaidLink({
|
||
token: linkToken,
|
||
onSuccess: async (publicToken, metadata) => {
|
||
try {
|
||
const account = metadata.accounts?.[0];
|
||
await exchangePlaidToken({
|
||
publicToken,
|
||
companyId,
|
||
accountId,
|
||
plaidAccountId: account?.id ?? "",
|
||
institutionName: metadata.institution?.name ?? undefined,
|
||
institutionId: metadata.institution?.institution_id ?? undefined,
|
||
mask: account?.mask ?? undefined,
|
||
});
|
||
toast.success(`Connected to ${metadata.institution?.name ?? "bank"} — click Sync now to import transactions`);
|
||
} catch (e: any) {
|
||
toast.error(e?.message ?? "Connection failed");
|
||
}
|
||
onDone();
|
||
},
|
||
onExit: (err) => {
|
||
if (err) toast.error(`Plaid: ${err.display_message ?? err.error_message ?? "Closed"}`);
|
||
onDone();
|
||
},
|
||
});
|
||
|
||
useEffect(() => {
|
||
if (ready) open();
|
||
}, [ready, open]);
|
||
|
||
return null;
|
||
}
|