Reconcile screen: edit transactions inline

Add an edit (pencil) action to each row in the reconciliation working
list, reusing the add dialog in edit mode. On edit, category/vendor are
optional (so imported GL register rows can be edited without forcing
re-categorization); category/coa are only overwritten when a category
is chosen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-18 19:17:59 -04:00
parent cfd42a3852
commit 422b828cdb
@@ -18,7 +18,7 @@ import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table"; import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
import { ArrowLeft, CheckCircle2, AlertTriangle, FileDown, Search, Loader2, ArrowUp, ArrowDown, ChevronsUpDown, Plus, Ban } from "lucide-react"; import { ArrowLeft, CheckCircle2, AlertTriangle, FileDown, Search, Loader2, ArrowUp, ArrowDown, ChevronsUpDown, Plus, Ban, Pencil } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { toast } from "sonner"; import { toast } from "sonner";
import { money, fmtDate } from "./lib/format"; import { money, fmtDate } from "./lib/format";
@@ -93,6 +93,7 @@ export default function AccountingReconcileDetailPage() {
// Add a deposit/withdrawal directly from the reconciliation screen. // Add a deposit/withdrawal directly from the reconciliation screen.
const [addOpen, setAddOpen] = useState(false); const [addOpen, setAddOpen] = useState(false);
const [editId, setEditId] = useState<string | null>(null);
const [addSaving, setAddSaving] = 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; vendor_id: string }>( 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: "" }, { type: "credit", date: "", amount: "", description: "", coa_account_id: "", reference: "", vendor_id: "" },
@@ -156,7 +157,7 @@ export default function AccountingReconcileDetailPage() {
// and not voided. // and not voided.
const { data } = await accounting const { data } = await accounting
.from("transactions") .from("transactions")
.select("id,date,description,reference,amount,type,cleared,reconciliation_id,voided,vendors(name),customers(name)") .select("id,date,description,reference,amount,type,cleared,reconciliation_id,voided,coa_account_id,vendor_id,category,vendors(name),customers(name)")
.eq("account_id", accountId) .eq("account_id", accountId)
.is("reconciliation_id", null) .is("reconciliation_id", null)
.eq("voided", false) .eq("voided", false)
@@ -346,6 +347,7 @@ export default function AccountingReconcileDetailPage() {
}; };
const openAdd = (type: "credit" | "debit") => { const openAdd = (type: "credit" | "debit") => {
setEditId(null);
setAddTx({ setAddTx({
type, type,
date: active?.statement_end_date ?? new Date().toLocaleDateString("en-CA", { timeZone: "America/New_York" }), date: active?.statement_end_date ?? new Date().toLocaleDateString("en-CA", { timeZone: "America/New_York" }),
@@ -354,16 +356,53 @@ export default function AccountingReconcileDetailPage() {
setAddOpen(true); setAddOpen(true);
}; };
// Edit an existing transaction from the reconciliation working list.
const openEdit = (t: any) => {
setEditId(t.id);
setAddTx({
type: t.type,
date: t.date,
amount: String(Math.abs(Number(t.amount))),
description: t.description ?? "",
coa_account_id: t.coa_account_id ?? "",
reference: t.reference ?? "",
vendor_id: t.vendor_id ?? "",
});
setAddOpen(true);
};
const addTransaction = async () => { const addTransaction = async () => {
if (!active) return; if (!active) return;
const amt = Number(addTx.amount); const amt = Number(addTx.amount);
if (!amt || amt <= 0) return toast.error("Enter an amount"); if (!amt || amt <= 0) return toast.error("Enter an amount");
if (!addTx.description.trim()) return toast.error("Enter a description"); if (!addTx.description.trim()) return toast.error("Enter a description");
if (!addTx.coa_account_id) return toast.error("Pick a category account"); // Category/vendor are required when creating; on edit they're optional so
if (addTx.type === "debit" && !addTx.vendor_id) return toast.error("Vendor is required for withdrawals"); // 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");
setAddSaving(true); setAddSaving(true);
try { try {
const coaName = (allAccounts as any[]).find((a) => a.id === addTx.coa_account_id)?.name ?? ""; const coaName = (allAccounts as any[]).find((a) => a.id === addTx.coa_account_id)?.name ?? "";
if (editId) {
// Only overwrite category/coa when a category was chosen, so we don't
// wipe an imported row's existing category text.
const payload: any = {
date: addTx.date,
description: addTx.description.trim(),
amount: Math.abs(amt),
type: addTx.type,
reference: addTx.reference.trim() || null,
vendor_id: addTx.vendor_id || 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);
if (error) return toast.error(error.message);
setAddOpen(false);
toast.success("Transaction updated");
qc.invalidateQueries({ queryKey: ["recon-txs", accountId] });
qc.invalidateQueries({ queryKey: ["transactions", cid] });
return;
}
const { data, error } = await accounting const { data, error } = await accounting
.from("transactions") .from("transactions")
.insert({ .insert({
@@ -553,7 +592,7 @@ export default function AccountingReconcileDetailPage() {
<SortHead col="reference" label="Ref #" sort={sort} onSort={toggleSort} /> <SortHead col="reference" label="Ref #" sort={sort} onSort={toggleSort} />
<SortHead col="deposit" label="Deposit" sort={sort} onSort={toggleSort} align="right" /> <SortHead col="deposit" label="Deposit" sort={sort} onSort={toggleSort} align="right" />
<SortHead col="withdrawal" label="Withdrawal" sort={sort} onSort={toggleSort} align="right" /> <SortHead col="withdrawal" label="Withdrawal" sort={sort} onSort={toggleSort} align="right" />
<TableHead className="w-10 text-right">Void</TableHead> <TableHead className="w-16 text-right">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -587,10 +626,16 @@ export default function AccountingReconcileDetailPage() {
{t.type === "debit" ? money(t.amount, cur) : ""} {t.type === "debit" ? money(t.amount, cur) : ""}
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:text-primary"
title="Edit this transaction" onClick={() => openEdit(t)}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:text-destructive" <Button variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:text-destructive"
title="Void this transaction" onClick={() => voidTx(t)}> title="Void this transaction" onClick={() => voidTx(t)}>
<Ban className="h-4 w-4" /> <Ban className="h-4 w-4" />
</Button> </Button>
</div>
</TableCell> </TableCell>
</TableRow> </TableRow>
); );
@@ -787,10 +832,11 @@ export default function AccountingReconcileDetailPage() {
<Dialog open={addOpen} onOpenChange={setAddOpen}> <Dialog open={addOpen} onOpenChange={setAddOpen}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Add {addTx.type === "credit" ? "Deposit" : "Withdrawal"}</DialogTitle> <DialogTitle>{editId ? "Edit" : "Add"} {addTx.type === "credit" ? "Deposit" : "Withdrawal"}</DialogTitle>
<DialogDescription> <DialogDescription>
Record a {addTx.type === "credit" ? "deposit (money in)" : "withdrawal (money out)"} on{" "} {editId
{(account as any)?.name ?? "this account"}. It's added to this reconciliation automatically. ? <>Edit this {addTx.type === "credit" ? "deposit" : "withdrawal"} on {(account as any)?.name ?? "this account"}.</>
: <>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> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-3"> <div className="space-y-3">
@@ -845,7 +891,7 @@ export default function AccountingReconcileDetailPage() {
<Button variant="ghost" onClick={() => setAddOpen(false)}>Cancel</Button> <Button variant="ghost" onClick={() => setAddOpen(false)}>Cancel</Button>
<Button onClick={addTransaction} disabled={addSaving}> <Button onClick={addTransaction} disabled={addSaving}>
{addSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} {addSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Add {addTx.type === "credit" ? "Deposit" : "Withdrawal"} {editId ? "Save Changes" : `Add ${addTx.type === "credit" ? "Deposit" : "Withdrawal"}`}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>