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 { Textarea } from "@/components/ui/textarea";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from "@/components/ui/dialog";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } 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";
|
||||||
@@ -19,7 +20,7 @@ import {
|
|||||||
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
||||||
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger,
|
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger,
|
||||||
} from "@/components/ui/alert-dialog";
|
} 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 { toast } from "sonner";
|
||||||
import { money } from "./lib/format";
|
import { money } from "./lib/format";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -51,6 +52,19 @@ export default function AccountingChartOfAccountsPage() {
|
|||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [parentId, setParentId] = useState<string>("");
|
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 ──
|
// ── Opening balances state ──
|
||||||
const [obRows, setObRows] = useState<Record<string, { debit: string; credit: string }>>({});
|
const [obRows, setObRows] = useState<Record<string, { debit: string; credit: string }>>({});
|
||||||
const [asOfDate, setAsOfDate] = useState<Date>(new Date());
|
const [asOfDate, setAsOfDate] = useState<Date>(new Date());
|
||||||
@@ -153,6 +167,82 @@ export default function AccountingChartOfAccountsPage() {
|
|||||||
qc.invalidateQueries({ queryKey: ["accounts", cid] });
|
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 ──
|
// ── Opening balances actions ──
|
||||||
const updateOb = (id: string, side: "debit" | "credit", v: string) => {
|
const updateOb = (id: string, side: "debit" | "credit", v: string) => {
|
||||||
setObRows((r) => ({ ...r, [id]: { debit: side === "debit" ? v : "", credit: side === "credit" ? v : "" } }));
|
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] })}
|
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">
|
<Tabs defaultValue="accounts">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="accounts">Accounts</TabsTrigger>
|
<TabsTrigger value="accounts">Accounts</TabsTrigger>
|
||||||
@@ -330,6 +497,22 @@ export default function AccountingChartOfAccountsPage() {
|
|||||||
|
|
||||||
{/* ── Accounts tab ── */}
|
{/* ── Accounts tab ── */}
|
||||||
<TabsContent value="accounts" className="mt-4 space-y-4">
|
<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) => {
|
{TYPES.map((t) => {
|
||||||
const rows = grouped[t.value] ?? [];
|
const rows = grouped[t.value] ?? [];
|
||||||
const subtotal = rows.reduce((s: number, a: any) => s + Number(a.balance), 0);
|
const subtotal = rows.reduce((s: number, a: any) => s + Number(a.balance), 0);
|
||||||
@@ -346,6 +529,13 @@ export default function AccountingChartOfAccountsPage() {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<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 className="w-28">Code</TableHead>
|
||||||
<TableHead>Account name</TableHead>
|
<TableHead>Account name</TableHead>
|
||||||
<TableHead>Parent</TableHead>
|
<TableHead>Parent</TableHead>
|
||||||
@@ -359,6 +549,13 @@ export default function AccountingChartOfAccountsPage() {
|
|||||||
const isChild = !!a.parent_account_id;
|
const isChild = !!a.parent_account_id;
|
||||||
return (
|
return (
|
||||||
<TableRow key={a.id} className={isChild ? "bg-muted/20" : ""}>
|
<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={`font-mono text-xs text-muted-foreground ${isChild ? "pl-8" : ""}`}>{a.code ?? "—"}</TableCell>
|
||||||
<TableCell className={isChild ? "pl-8" : ""}>
|
<TableCell className={isChild ? "pl-8" : ""}>
|
||||||
<div className={`font-medium ${isChild ? "text-sm" : ""}`}>
|
<div className={`font-medium ${isChild ? "text-sm" : ""}`}>
|
||||||
@@ -389,7 +586,7 @@ export default function AccountingChartOfAccountsPage() {
|
|||||||
})}
|
})}
|
||||||
{rows.length === 0 && (
|
{rows.length === 0 && (
|
||||||
<TableRow>
|
<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.
|
No accounts in this category yet.
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
Reference in New Issue
Block a user