Accounting COA: bulk edit + bulk delete of accounts

Add row checkboxes (with per-category select-all) to the accounting Chart of
Accounts. A selection bar exposes Edit/Delete/Clear. Bulk edit applies any of
type, parent account, bank flag, reserve flag to all selected (each field has
a "No change" option); bulk delete removes selected accounts. Mirrors the
existing bulk-edit UX on the per-association chart_of_accounts page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 14:54:19 -04:00
parent 6f68619b9c
commit 36787b193d
@@ -9,6 +9,7 @@ import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
@@ -19,7 +20,7 @@ import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Plus, Trash2, CalendarIcon, Lock, Pencil, RotateCcw, Save, Info, Loader2, Upload } from "lucide-react";
import { Plus, Trash2, CalendarIcon, Lock, Pencil, RotateCcw, Save, Info, Loader2, Upload, X } from "lucide-react";
import { toast } from "sonner";
import { money } from "./lib/format";
import { cn } from "@/lib/utils";
@@ -51,6 +52,19 @@ export default function AccountingChartOfAccountsPage() {
const [description, setDescription] = useState("");
const [parentId, setParentId] = useState<string>("");
// ── Bulk selection / edit state ──
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [bulkEditOpen, setBulkEditOpen] = useState(false);
const [bulkEditing, setBulkEditing] = useState(false);
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
const [bulkDeleting, setBulkDeleting] = useState(false);
const [bulkEdit, setBulkEdit] = useState({
type: "no_change",
parent_account_id: "no_change",
is_bank: "no_change",
is_reserve: "no_change",
});
// ── Opening balances state ──
const [obRows, setObRows] = useState<Record<string, { debit: string; credit: string }>>({});
const [asOfDate, setAsOfDate] = useState<Date>(new Date());
@@ -153,6 +167,82 @@ export default function AccountingChartOfAccountsPage() {
qc.invalidateQueries({ queryKey: ["accounts", cid] });
};
// ── Bulk selection / edit actions ──
const toggleSelect = (id: string) =>
setSelectedIds((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
const toggleSelectMany = (ids: string[], checked: boolean) =>
setSelectedIds((prev) => {
const next = new Set(prev);
ids.forEach((id) => (checked ? next.add(id) : next.delete(id)));
return next;
});
const clearSelection = () => setSelectedIds(new Set());
const openBulkEdit = () => {
setBulkEdit({ type: "no_change", parent_account_id: "no_change", is_bank: "no_change", is_reserve: "no_change" });
setBulkEditOpen(true);
};
const handleBulkEdit = async () => {
const ids = [...selectedIds];
if (!ids.length) return;
const patch: Record<string, any> = {};
if (bulkEdit.type !== "no_change") patch.type = bulkEdit.type;
if (bulkEdit.parent_account_id !== "no_change")
patch.parent_account_id = bulkEdit.parent_account_id === "none" ? null : bulkEdit.parent_account_id;
if (bulkEdit.is_bank !== "no_change") patch.is_bank = bulkEdit.is_bank === "true";
if (bulkEdit.is_reserve !== "no_change") patch.is_reserve = bulkEdit.is_reserve === "true";
if (Object.keys(patch).length === 0) return toast.error("No changes selected");
if (patch.parent_account_id && ids.includes(patch.parent_account_id))
return toast.error("A selected account can't be set as its own parent");
setBulkEditing(true);
try {
for (let i = 0; i < ids.length; i += 50) {
const batch = ids.slice(i, i + 50);
const { error } = await accounting.from("accounts").update(patch).in("id", batch);
if (error) throw error;
}
toast.success(`Updated ${ids.length} account${ids.length === 1 ? "" : "s"}`);
setBulkEditOpen(false);
clearSelection();
qc.invalidateQueries({ queryKey: ["accounts", cid] });
} catch (e: any) {
toast.error(e.message || "Bulk update failed");
} finally {
setBulkEditing(false);
}
};
const handleBulkDelete = async () => {
const ids = [...selectedIds];
if (!ids.length) return;
setBulkDeleting(true);
try {
for (let i = 0; i < ids.length; i += 50) {
const batch = ids.slice(i, i + 50);
const { error } = await accounting.from("accounts").delete().in("id", batch);
if (error) throw error;
}
toast.success(`Deleted ${ids.length} account${ids.length === 1 ? "" : "s"}`);
setBulkDeleteOpen(false);
clearSelection();
qc.invalidateQueries({ queryKey: ["accounts", cid] });
} catch (e: any) {
toast.error(e.message || "Bulk delete failed");
} finally {
setBulkDeleting(false);
}
};
// ── Opening balances actions ──
const updateOb = (id: string, side: "debit" | "credit", v: string) => {
setObRows((r) => ({ ...r, [id]: { debit: side === "debit" ? v : "", credit: side === "credit" ? v : "" } }));
@@ -322,6 +412,83 @@ export default function AccountingChartOfAccountsPage() {
onSuccess={() => qc.invalidateQueries({ queryKey: ["accounts", cid] })}
/>
{/* ── Bulk edit dialog ── */}
<Dialog open={bulkEditOpen} onOpenChange={setBulkEditOpen}>
<DialogContent className="max-w-md">
<DialogHeader><DialogTitle>Edit {selectedIds.size} account{selectedIds.size === 1 ? "" : "s"}</DialogTitle></DialogHeader>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">Only fields you change are applied. Leave a field on No change to keep each accounts existing value.</p>
<div>
<Label>Type</Label>
<Select value={bulkEdit.type} onValueChange={(v) => setBulkEdit((p) => ({ ...p, type: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="no_change">No change</SelectItem>
{TYPES.map((t) => <SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div>
<Label>Parent account</Label>
<Select value={bulkEdit.parent_account_id} onValueChange={(v) => setBulkEdit((p) => ({ ...p, parent_account_id: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="no_change">No change</SelectItem>
<SelectItem value="none">None (top-level)</SelectItem>
{(accounts as any[]).filter((a) => !selectedIds.has(a.id)).map((a) => (
<SelectItem key={a.id} value={a.id}>{a.code ? `${a.code} · ` : ""}{a.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>Bank / cash / card account</Label>
<Select value={bulkEdit.is_bank} onValueChange={(v) => setBulkEdit((p) => ({ ...p, is_bank: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="no_change">No change</SelectItem>
<SelectItem value="true">Yes</SelectItem>
<SelectItem value="false">No</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Reserve fund account</Label>
<Select value={bulkEdit.is_reserve} onValueChange={(v) => setBulkEdit((p) => ({ ...p, is_reserve: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="no_change">No change</SelectItem>
<SelectItem value="true">Yes</SelectItem>
<SelectItem value="false">No</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setBulkEditOpen(false)}>Cancel</Button>
<Button onClick={handleBulkEdit} disabled={bulkEditing}>
{bulkEditing && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}Apply to {selectedIds.size}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ── Bulk delete confirm ── */}
<AlertDialog open={bulkDeleteOpen} onOpenChange={setBulkDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete {selectedIds.size} account{selectedIds.size === 1 ? "" : "s"}?</AlertDialogTitle>
<AlertDialogDescription>This permanently removes the selected accounts. Accounts that have posted transactions may fail to delete.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleBulkDelete} disabled={bulkDeleting} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
{bulkDeleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Tabs defaultValue="accounts">
<TabsList>
<TabsTrigger value="accounts">Accounts</TabsTrigger>
@@ -330,6 +497,22 @@ export default function AccountingChartOfAccountsPage() {
{/* ── Accounts tab ── */}
<TabsContent value="accounts" className="mt-4 space-y-4">
{selectedIds.size > 0 && (
<div className="flex items-center justify-between rounded-md border bg-muted/40 px-4 py-2">
<span className="text-sm font-medium">{selectedIds.size} selected</span>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={openBulkEdit}>
<Pencil className="mr-1 h-3.5 w-3.5" /> Edit
</Button>
<Button size="sm" variant="outline" className="text-destructive hover:text-destructive" onClick={() => setBulkDeleteOpen(true)}>
<Trash2 className="mr-1 h-3.5 w-3.5" /> Delete
</Button>
<Button size="sm" variant="ghost" onClick={clearSelection}>
<X className="mr-1 h-3.5 w-3.5" /> Clear
</Button>
</div>
</div>
)}
{TYPES.map((t) => {
const rows = grouped[t.value] ?? [];
const subtotal = rows.reduce((s: number, a: any) => s + Number(a.balance), 0);
@@ -346,6 +529,13 @@ export default function AccountingChartOfAccountsPage() {
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">
<Checkbox
aria-label={`Select all ${t.label}`}
checked={rows.length > 0 && rows.every((a: any) => selectedIds.has(a.id))}
onCheckedChange={(c) => toggleSelectMany(rows.map((a: any) => a.id), !!c)}
/>
</TableHead>
<TableHead className="w-28">Code</TableHead>
<TableHead>Account name</TableHead>
<TableHead>Parent</TableHead>
@@ -359,6 +549,13 @@ export default function AccountingChartOfAccountsPage() {
const isChild = !!a.parent_account_id;
return (
<TableRow key={a.id} className={isChild ? "bg-muted/20" : ""}>
<TableCell>
<Checkbox
aria-label={`Select ${a.name}`}
checked={selectedIds.has(a.id)}
onCheckedChange={() => toggleSelect(a.id)}
/>
</TableCell>
<TableCell className={`font-mono text-xs text-muted-foreground ${isChild ? "pl-8" : ""}`}>{a.code ?? "—"}</TableCell>
<TableCell className={isChild ? "pl-8" : ""}>
<div className={`font-medium ${isChild ? "text-sm" : ""}`}>
@@ -389,7 +586,7 @@ export default function AccountingChartOfAccountsPage() {
})}
{rows.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-center text-sm text-muted-foreground py-6">
<TableCell colSpan={6} className="text-center text-sm text-muted-foreground py-6">
No accounts in this category yet.
</TableCell>
</TableRow>