Reconciliation: add deposits/withdrawals directly from the reconcile screen

- Deposit / Withdrawal buttons in the statement-transactions header open a
  dialog (date, amount, description, income/expense category, reference)
- New transaction posts to the bank account, is marked cleared, and is
  auto-checked into the current reconciliation; category picker excludes bank
  and archived accounts. gl-managed companies post the offset via the existing
  transaction GL trigger; import-mode books add register-only (consistent with
  Banking), so imported GL isn't double-counted

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 23:26:10 -04:00
parent 2d6f7ea17b
commit 512abcc1a2
@@ -18,7 +18,7 @@ import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} 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 } from "lucide-react";
import { ArrowLeft, CheckCircle2, AlertTriangle, FileDown, Search, Loader2, ArrowUp, ArrowDown, ChevronsUpDown, Plus } from "lucide-react";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import { money, fmtDate } from "./lib/format";
@@ -87,6 +87,13 @@ export default function AccountingReconcileDetailPage() {
const [adjAccount, setAdjAccount] = useState("");
const [adjNote, setAdjNote] = useState("Bank reconciliation adjustment");
// Add a deposit/withdrawal directly from the reconciliation screen.
const [addOpen, setAddOpen] = useState(false);
const [addSaving, setAddSaving] = useState(false);
const [addTx, setAddTx] = useState<{ type: "credit" | "debit"; date: string; amount: string; description: string; coa_account_id: string; reference: string }>(
{ type: "credit", date: "", amount: "", description: "", coa_account_id: "", reference: "" },
);
const { data: account } = useQuery({
queryKey: ["account", accountId],
enabled: !!accountId,
@@ -118,7 +125,7 @@ export default function AccountingReconcileDetailPage() {
queryKey: ["accounts", cid],
enabled: !!cid,
queryFn: async () =>
(await accounting.from("accounts").select("id,name,type").eq("company_id", cid).eq("is_archived", false).order("name")).data ?? [],
(await accounting.from("accounts").select("id,name,type,is_bank").eq("company_id", cid).eq("is_archived", false).order("name")).data ?? [],
});
const { data: txs = [] } = useQuery({
@@ -317,6 +324,51 @@ export default function AccountingReconcileDetailPage() {
setChecked((prev) => new Set(prev).add(data.id));
};
const openAdd = (type: "credit" | "debit") => {
setAddTx({
type,
date: active?.statement_end_date ?? new Date().toLocaleDateString("en-CA", { timeZone: "America/New_York" }),
amount: "", description: "", coa_account_id: "", reference: "",
});
setAddOpen(true);
};
const addTransaction = async () => {
if (!active) return;
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");
if (!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 ?? "";
const { data, error } = await accounting
.from("transactions")
.insert({
company_id: cid,
account_id: accountId,
date: addTx.date,
description: addTx.description.trim(),
amount: Math.abs(amt),
type: addTx.type, // credit = deposit (money in), debit = withdrawal (money out)
category: coaName,
coa_account_id: addTx.coa_account_id,
reference: addTx.reference.trim() || null,
cleared: true,
})
.select("id")
.single();
if (error || !data) return toast.error(error?.message ?? "Failed to add transaction");
setAddOpen(false);
toast.success(`${addTx.type === "credit" ? "Deposit" : "Withdrawal"} added`);
qc.invalidateQueries({ queryKey: ["recon-txs", accountId] });
qc.invalidateQueries({ queryKey: ["transactions", cid] });
setChecked((prev) => new Set(prev).add(data.id)); // auto-clear into this reconciliation
} finally {
setAddSaving(false);
}
};
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>;
@@ -370,6 +422,12 @@ export default function AccountingReconcileDetailPage() {
<SelectItem value="withdrawals">Withdrawals only</SelectItem>
</SelectContent>
</Select>
<Button size="sm" variant="outline" className="h-9 gap-1 text-emerald-700" onClick={() => openAdd("credit")}>
<Plus className="h-3.5 w-3.5" /> Deposit
</Button>
<Button size="sm" variant="outline" className="h-9 gap-1 text-red-700" onClick={() => openAdd("debit")}>
<Plus className="h-3.5 w-3.5" /> Withdrawal
</Button>
</div>
</CardHeader>
<CardContent>
@@ -590,6 +648,60 @@ export default function AccountingReconcileDetailPage() {
</DialogContent>
</Dialog>
{/* Add deposit / withdrawal modal */}
<Dialog open={addOpen} onOpenChange={setAddOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add {addTx.type === "credit" ? "Deposit" : "Withdrawal"}</DialogTitle>
<DialogDescription>
Record a {addTx.type === "credit" ? "deposit (money in)" : "withdrawal (money out)"} on{" "}
{(account as any)?.name ?? "this account"}. It's added to this reconciliation automatically.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Date</Label>
<Input type="date" value={addTx.date} onChange={(e) => setAddTx({ ...addTx, date: e.target.value })} />
</div>
<div>
<Label>Amount</Label>
<Input type="number" step="0.01" min="0" value={addTx.amount} onChange={(e) => setAddTx({ ...addTx, amount: e.target.value })} placeholder="0.00" />
</div>
</div>
<div>
<Label>Description</Label>
<Input value={addTx.description} onChange={(e) => setAddTx({ ...addTx, description: e.target.value })}
placeholder={addTx.type === "credit" ? "e.g. Interest earned" : "e.g. Bank service charge"} />
</div>
<div>
<Label>{addTx.type === "credit" ? "Income" : "Expense"} account (category)</Label>
<Select value={addTx.coa_account_id} onValueChange={(v) => setAddTx({ ...addTx, coa_account_id: v })}>
<SelectTrigger><SelectValue placeholder="Select account" /></SelectTrigger>
<SelectContent>
{(allAccounts as any[])
.filter((a: any) => a.id !== accountId && !a.is_bank)
.map((a: any) => (
<SelectItem key={a.id} value={a.id}>{a.name} ({a.type})</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>Reference (optional)</Label>
<Input value={addTx.reference} onChange={(e) => setAddTx({ ...addTx, reference: e.target.value })} placeholder="Check # / memo" />
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setAddOpen(false)}>Cancel</Button>
<Button onClick={addTransaction} disabled={addSaving}>
{addSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Add {addTx.type === "credit" ? "Deposit" : "Withdrawal"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Adjustment modal */}
<Dialog open={adjOpen} onOpenChange={setAdjOpen}>
<DialogContent>