mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
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:
@@ -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 account’s 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>
|
||||
|
||||
Reference in New Issue
Block a user