mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
Reconciliation: add transfers, non-vendor expenses, non-owner deposits
- Transfer button records two transfer_id-linked legs (GL via post_transfer_gl); this account's leg auto-clears into the open reconciliation - Withdrawals: vendor now optional so non-vendor expenses (bank fees, gov) post Dr Expense / Cr Bank via the category account - Deposits: optional Owner field — pick to clear A/R, or leave blank for a non-owner deposit (interest, refund) booking income Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string | null>(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() {
|
||||
<Button size="sm" variant="outline" className="h-9 gap-1" onClick={openBill}>
|
||||
<Plus className="h-3.5 w-3.5" /> Bill
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="h-9 gap-1" onClick={openTransfer}>
|
||||
<ArrowLeftRight className="h-3.5 w-3.5" /> Transfer
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -925,16 +1014,28 @@ export default function AccountingReconcileDetailPage() {
|
||||
</div>
|
||||
{addTx.type === "debit" && (
|
||||
<div>
|
||||
<Label>Vendor <span className="text-destructive">*</span></Label>
|
||||
<Select value={addTx.vendor_id} onValueChange={(v) => setAddTx({ ...addTx, vendor_id: v })}>
|
||||
<SelectTrigger><SelectValue placeholder="Select vendor" /></SelectTrigger>
|
||||
<Label>Vendor <span className="text-muted-foreground font-normal">(optional)</span></Label>
|
||||
<Select value={addTx.vendor_id || "__none"} onValueChange={(v) => setAddTx({ ...addTx, vendor_id: v === "__none" ? "" : v })}>
|
||||
<SelectTrigger><SelectValue placeholder="No vendor" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none">No vendor</SelectItem>
|
||||
{(vendors as any[]).map((v: any) => <SelectItem key={v.id} value={v.id}>{v.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{(vendors as any[]).length === 0 && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">No vendors yet — add one on the Vendors page first.</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-muted-foreground">Leave blank for a non-vendor expense (e.g. bank fee, government).</p>
|
||||
</div>
|
||||
)}
|
||||
{addTx.type === "credit" && (
|
||||
<div>
|
||||
<Label>Owner <span className="text-muted-foreground font-normal">(optional)</span></Label>
|
||||
<Select value={addTx.customer_id || "__none"} onValueChange={(v) => setAddTx({ ...addTx, customer_id: v === "__none" ? "" : v })}>
|
||||
<SelectTrigger><SelectValue placeholder="No owner" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none">No owner</SelectItem>
|
||||
{(customers as any[]).map((c: any) => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Pick an owner to clear A/R, or leave blank for a non-owner deposit (interest, refund, etc.).</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
@@ -1020,6 +1121,64 @@ export default function AccountingReconcileDetailPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Transfer modal */}
|
||||
<Dialog open={transferOpen} onOpenChange={setTransferOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Record Transfer</DialogTitle>
|
||||
<DialogDescription>
|
||||
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.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>Direction</Label>
|
||||
<Select value={transfer.direction} onValueChange={(v) => setTransfer({ ...transfer, direction: v as "out" | "in" })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="out">Transfer out (money leaves this account)</SelectItem>
|
||||
<SelectItem value="in">Transfer in (money enters this account)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{transfer.direction === "out" ? "To account" : "From account"}</Label>
|
||||
<Select value={transfer.other_account_id} onValueChange={(v) => setTransfer({ ...transfer, other_account_id: v })}>
|
||||
<SelectTrigger><SelectValue placeholder="Select bank account" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{bankAccounts.map((a: any) => <SelectItem key={a.id} value={a.id}>{a.code ? `${a.code} · ` : ""}{a.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{bankAccounts.length === 0 && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">No other bank accounts found for this association.</p>
|
||||
)}
|
||||
</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" step="0.01" min="0" value={transfer.amount} onChange={(e) => setTransfer({ ...transfer, amount: e.target.value })} placeholder="0.00" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Memo (optional)</Label>
|
||||
<Input value={transfer.memo} onChange={(e) => setTransfer({ ...transfer, memo: e.target.value })} placeholder="e.g. Move to reserves" />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setTransferOpen(false)}>Cancel</Button>
|
||||
<Button onClick={saveTransfer} disabled={transferSaving}>
|
||||
{transferSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Record Transfer
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Adjustment modal */}
|
||||
<Dialog open={adjOpen} onOpenChange={setAdjOpen}>
|
||||
<DialogContent>
|
||||
|
||||
Reference in New Issue
Block a user