diff --git a/src/pages/accounting/AccountingReconcileDetailPage.tsx b/src/pages/accounting/AccountingReconcileDetailPage.tsx index b43b384..ca3f8ce 100644 --- a/src/pages/accounting/AccountingReconcileDetailPage.tsx +++ b/src/pages/accounting/AccountingReconcileDetailPage.tsx @@ -18,7 +18,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, SelectGroup, SelectLabel, } from "@/components/ui/select"; import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table"; -import { ArrowLeft, CheckCircle2, AlertTriangle, FileDown, Search, Loader2, ArrowUp, ArrowDown, ChevronsUpDown, Plus, Ban, Pencil, RotateCcw } from "lucide-react"; +import { ArrowLeft, CheckCircle2, AlertTriangle, FileDown, Search, Loader2, ArrowUp, ArrowDown, ChevronsUpDown, Plus, Ban, Pencil, RotateCcw, ArrowLeftRight } from "lucide-react"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; import { money, fmtDate } from "./lib/format"; @@ -95,8 +95,14 @@ export default function AccountingReconcileDetailPage() { const [addOpen, setAddOpen] = useState(false); const [editId, setEditId] = useState(null); const [addSaving, setAddSaving] = useState(false); - const [addTx, setAddTx] = useState<{ type: "credit" | "debit"; date: string; amount: string; description: string; coa_account_id: string; reference: string; vendor_id: string }>( - { type: "credit", date: "", amount: "", description: "", coa_account_id: "", reference: "", vendor_id: "" }, + const [addTx, setAddTx] = useState<{ type: "credit" | "debit"; date: string; amount: string; description: string; coa_account_id: string; reference: string; vendor_id: string; customer_id: string }>( + { type: "credit", date: "", amount: "", description: "", coa_account_id: "", reference: "", vendor_id: "", customer_id: "" }, + ); + // Bank-to-bank transfer recorded from the reconciliation screen. + const [transferOpen, setTransferOpen] = useState(false); + const [transferSaving, setTransferSaving] = useState(false); + const [transfer, setTransfer] = useState<{ direction: "out" | "in"; other_account_id: string; date: string; amount: string; memo: string }>( + { direction: "out", other_account_id: "", date: "", amount: "", memo: "" }, ); // Add an accrual bill (expense on the bill date) + the withdrawal that pays it. const [billOpen, setBillOpen] = useState(false); @@ -146,6 +152,19 @@ export default function AccountingReconcileDetailPage() { (await accounting.from("vendors").select("id,name").eq("company_id", cid).order("name")).data ?? [], }); + const { data: customers = [] } = useQuery({ + queryKey: ["customers-lookup", cid], + enabled: !!cid, + queryFn: async () => + (await accounting.from("customers").select("id,name").eq("company_id", cid).order("name")).data ?? [], + }); + + // Other bank accounts available as the far side of a transfer. + const bankAccounts = useMemo( + () => (allAccounts as any[]).filter((a: any) => a.is_bank && a.id !== accountId), + [allAccounts, accountId], + ); + const { data: txs = [] } = useQuery({ queryKey: ["recon-txs", accountId, active?.statement_end_date], enabled: !!accountId && !!active, @@ -157,7 +176,7 @@ export default function AccountingReconcileDetailPage() { // and not voided. const { data } = await accounting .from("transactions") - .select("id,date,description,reference,amount,type,cleared,reconciliation_id,voided,coa_account_id,vendor_id,category,vendors(name),customers(name)") + .select("id,date,description,reference,amount,type,cleared,reconciliation_id,voided,coa_account_id,vendor_id,customer_id,category,vendors(name),customers(name)") .eq("account_id", accountId) .is("reconciliation_id", null) .eq("voided", false) @@ -351,7 +370,7 @@ export default function AccountingReconcileDetailPage() { setAddTx({ type, date: active?.statement_end_date ?? new Date().toLocaleDateString("en-CA", { timeZone: "America/New_York" }), - amount: "", description: "", coa_account_id: "", reference: "", vendor_id: "", + amount: "", description: "", coa_account_id: "", reference: "", vendor_id: "", customer_id: "", }); setAddOpen(true); }; @@ -367,6 +386,7 @@ export default function AccountingReconcileDetailPage() { coa_account_id: t.coa_account_id ?? "", reference: t.reference ?? "", vendor_id: t.vendor_id ?? "", + customer_id: t.customer_id ?? "", }); setAddOpen(true); }; @@ -376,10 +396,18 @@ export default function AccountingReconcileDetailPage() { const amt = Number(addTx.amount); if (!amt || amt <= 0) return toast.error("Enter an amount"); if (!addTx.description.trim()) return toast.error("Enter a description"); - // Category/vendor are required when creating; on edit they're optional so - // imported (GL) register rows can be edited without re-categorizing. - if (!editId && !addTx.coa_account_id) return toast.error("Pick a category account"); - if (!editId && addTx.type === "debit" && !addTx.vendor_id) return toast.error("Vendor is required for withdrawals"); + // A category account is required so the entry posts to the GL. Vendor (on a + // withdrawal) and owner (on a deposit) are BOTH optional — this is what lets + // you record non-vendor expenses and non-owner deposits. On edit everything + // is optional so imported (GL) register rows can be edited without + // re-categorizing. For a deposit an owner can stand in for the category + // (clears A/R instead of booking income). + if (!editId) { + if (addTx.type === "credit" && !addTx.coa_account_id && !addTx.customer_id) + return toast.error("Pick a category account or an owner"); + if (addTx.type === "debit" && !addTx.coa_account_id) + return toast.error("Pick a category account"); + } setAddSaving(true); try { const coaName = (allAccounts as any[]).find((a) => a.id === addTx.coa_account_id)?.name ?? ""; @@ -392,7 +420,8 @@ export default function AccountingReconcileDetailPage() { amount: Math.abs(amt), type: addTx.type, reference: addTx.reference.trim() || null, - vendor_id: addTx.vendor_id || null, + vendor_id: addTx.type === "debit" ? (addTx.vendor_id || null) : null, + customer_id: addTx.type === "credit" ? (addTx.customer_id || null) : null, }; if (addTx.coa_account_id) { payload.coa_account_id = addTx.coa_account_id; payload.category = coaName; } const { error } = await accounting.from("transactions").update(payload).eq("id", editId); @@ -413,8 +442,9 @@ export default function AccountingReconcileDetailPage() { amount: Math.abs(amt), type: addTx.type, // credit = deposit (money in), debit = withdrawal (money out) category: coaName, - coa_account_id: addTx.coa_account_id, + coa_account_id: addTx.coa_account_id || null, vendor_id: addTx.type === "debit" ? (addTx.vendor_id || null) : null, + customer_id: addTx.type === "credit" ? (addTx.customer_id || null) : null, reference: addTx.reference.trim() || null, cleared: true, }) @@ -431,6 +461,62 @@ export default function AccountingReconcileDetailPage() { } }; + const openTransfer = () => { + setTransfer({ + direction: "out", + other_account_id: "", + date: active?.statement_end_date ?? new Date().toLocaleDateString("en-CA", { timeZone: "America/New_York" }), + amount: "", memo: "", + }); + setTransferOpen(true); + }; + + // Record a bank-to-bank transfer as two transfer_id-linked legs (the GL trigger + // routes them to post_transfer_gl). The leg on THIS account is auto-cleared into + // the open reconciliation; the far leg lives on the other account's register. + const saveTransfer = async () => { + if (!active) return; + const amt = Number(transfer.amount); + if (!amt || amt <= 0) return toast.error("Enter an amount"); + if (!transfer.other_account_id) return toast.error("Pick the other account"); + if (transfer.other_account_id === accountId) return toast.error("Accounts must be different"); + setTransferSaving(true); + try { + const transferId = crypto.randomUUID(); + const otherAcc = (allAccounts as any[]).find((a) => a.id === transfer.other_account_id); + const thisAcc = account as any; + // "out" = money leaves THIS account (debit here / credit the other). + const thisType: "credit" | "debit" = transfer.direction === "out" ? "debit" : "credit"; + const otherType: "credit" | "debit" = transfer.direction === "out" ? "credit" : "debit"; + const otherName = otherAcc?.name ?? "account"; + const thisName = thisAcc?.name ?? "account"; + const memo = transfer.memo.trim(); + const rows = [ + { + company_id: cid, account_id: accountId, date: transfer.date, type: thisType, amount: amt, + description: `Transfer ${transfer.direction === "out" ? "to" : "from"} ${otherName}${memo ? ` — ${memo}` : ""}`, + category: "Transfer", transfer_id: transferId, cleared: true, + }, + { + company_id: cid, account_id: transfer.other_account_id, date: transfer.date, type: otherType, amount: amt, + description: `Transfer ${transfer.direction === "out" ? "from" : "to"} ${thisName}${memo ? ` — ${memo}` : ""}`, + category: "Transfer", transfer_id: transferId, + }, + ]; + const { data, error } = await accounting.from("transactions").insert(rows).select("id,account_id"); + if (error || !data) return toast.error(error?.message ?? "Failed to record transfer"); + setTransferOpen(false); + toast.success("Transfer recorded"); + qc.invalidateQueries({ queryKey: ["recon-txs", accountId] }); + qc.invalidateQueries({ queryKey: ["transactions", cid] }); + qc.invalidateQueries({ queryKey: ["accounts", cid] }); + const thisLeg = (data as any[]).find((r) => r.account_id === accountId); + if (thisLeg) setChecked((prev) => new Set(prev).add(thisLeg.id)); // auto-clear into this reconciliation + } finally { + setTransferSaving(false); + } + }; + const openBill = () => { const today = active?.statement_end_date ?? new Date().toLocaleDateString("en-CA", { timeZone: "America/New_York" }); setBill({ bill_date: today, payment_date: today, amount: "", description: "", coa_account_id: "", vendor_id: "", reference: "" }); @@ -606,6 +692,9 @@ export default function AccountingReconcileDetailPage() { + @@ -925,16 +1014,28 @@ export default function AccountingReconcileDetailPage() { {addTx.type === "debit" && (
- - setAddTx({ ...addTx, vendor_id: v === "__none" ? "" : v })}> + + No vendor {(vendors as any[]).map((v: any) => {v.name})} - {(vendors as any[]).length === 0 && ( -

No vendors yet — add one on the Vendors page first.

- )} +

Leave blank for a non-vendor expense (e.g. bank fee, government).

+
+ )} + {addTx.type === "credit" && ( +
+ + +

Pick an owner to clear A/R, or leave blank for a non-owner deposit (interest, refund, etc.).

)}
@@ -1020,6 +1121,64 @@ export default function AccountingReconcileDetailPage() { + {/* Transfer modal */} + + + + Record Transfer + + Move money between {(account as any)?.name ?? "this account"} and another bank account. Both legs are + recorded; the one on this account is added to this reconciliation automatically. + + +
+
+ + +
+
+ + + {bankAccounts.length === 0 && ( +

No other bank accounts found for this association.

+ )} +
+
+
+ + setTransfer({ ...transfer, date: e.target.value })} /> +
+
+ + setTransfer({ ...transfer, amount: e.target.value })} placeholder="0.00" /> +
+
+
+ + setTransfer({ ...transfer, memo: e.target.value })} placeholder="e.g. Move to reserves" /> +
+
+ + + + +
+
+ {/* Adjustment modal */}