Chart of Accounts: archive accounts + safe deletes

- accounting.accounts.is_archived; COA mirror sets public is_active accordingly
  so association-side pickers hide archived accounts
- COA page: archive/restore per row + bulk, show-archived toggle, delete
  confirm dialog with 'Archive instead'; FK-blocked deletes (posted activity)
  get a friendly message + one-click archive; bulk delete skips blocked
  accounts per-id instead of failing the batch
- fetchChartOfAccounts excludes archived platform accounts from pickers;
  parent dropdowns and Opening Balances only offer active accounts

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 20:11:11 -04:00
parent df8623ff9f
commit 03d3c5ee8d
2 changed files with 128 additions and 19 deletions
+3 -2
View File
@@ -41,7 +41,7 @@ function fromPlatform(row: any, associationId: string): NormalizedAccount {
account_name: row.name ?? "Unnamed Account",
account_type: row.type ?? "Uncategorized",
parent_account_id: row.parent_account_id ?? null,
is_active: true, // accounting.accounts has no active flag; all are active
is_active: !row.is_archived,
association_id: associationId,
association_ids: [associationId],
accounting_system: "platform",
@@ -77,8 +77,9 @@ export async function fetchChartOfAccounts(
const { data, error } = await accounting
.from("accounts")
.select("id, code, name, type, subtype, parent_account_id, description")
.select("id, code, name, type, subtype, parent_account_id, description, is_archived")
.eq("company_id", company.id)
.eq("is_archived", false) // archived accounts stay out of pickers
.order("code", { ascending: true });
if (error) throw error;
return (data ?? []).map((row) => fromPlatform(row, associationId));
@@ -20,7 +20,8 @@ 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, X } from "lucide-react";
import { Plus, Trash2, CalendarIcon, Lock, Pencil, RotateCcw, Save, Info, Loader2, Upload, X, Archive, ArchiveRestore } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { toast } from "sonner";
import { money } from "./lib/format";
import { cn } from "@/lib/utils";
@@ -53,6 +54,10 @@ export default function AccountingChartOfAccountsPage() {
const [description, setDescription] = useState("");
const [parentId, setParentId] = useState<string>("");
// ── Archive / delete state ──
const [showArchived, setShowArchived] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<any | null>(null);
// ── Bulk selection / edit state ──
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [bulkEditOpen, setBulkEditOpen] = useState(false);
@@ -134,12 +139,27 @@ export default function AccountingChartOfAccountsPage() {
setObRows(map);
}, [balances]);
const activeAccounts = useMemo(() => (accounts as any[]).filter((a) => !a.is_archived), [accounts]);
const archivedCount = (accounts as any[]).length - activeAccounts.length;
// Accounts tab listing (respects the "show archived" toggle)
const grouped = useMemo(() => {
const out: Record<string, any[]> = {};
for (const t of TYPES) out[t.value] = [];
for (const a of accounts as any[]) (out[a.type] ??= []).push(a);
for (const a of accounts as any[]) {
if (!showArchived && a.is_archived) continue;
(out[a.type] ??= []).push(a);
}
return out;
}, [accounts]);
}, [accounts, showArchived]);
// Opening Balances tab always works with active accounts only
const obGrouped = useMemo(() => {
const out: Record<string, any[]> = {};
for (const t of TYPES) out[t.value] = [];
for (const a of activeAccounts) (out[a.type] ??= []).push(a);
return out;
}, [activeAccounts]);
// Existing category names (for the Income Statement subgroups) — used as autocomplete suggestions.
const existingCategories = useMemo(
@@ -193,9 +213,35 @@ export default function AccountingChartOfAccountsPage() {
qc.invalidateQueries({ queryKey: ["accounts", cid] });
};
const removeAccount = async (id: string) => {
const { error } = await accounting.from("accounts").delete().eq("id", id);
// FK violations (journal entries, bills, register rows, …) mean the account
// has history — the books must keep it, so archiving is the answer.
const isFkViolation = (e: any) => e?.code === "23503" || /foreign key|violates/i.test(e?.message || "");
const removeAccount = async (a: any) => {
const { error } = await accounting.from("accounts").delete().eq("id", a.id);
if (error) {
if (isFkViolation(error)) {
toast.error(`"${a.name}" has posted activity and can't be deleted — archive it instead.`, {
action: { label: "Archive", onClick: () => setArchived([a.id], true) },
});
} else {
toast.error(error.message);
}
return;
}
toast.success(`"${a.name}" deleted`);
qc.invalidateQueries({ queryKey: ["accounts", cid] });
};
const setArchived = async (ids: string[], archived: boolean) => {
const { error } = await accounting.from("accounts").update({ is_archived: archived }).in("id", ids);
if (error) return toast.error(error.message);
toast.success(
ids.length === 1
? `Account ${archived ? "archived" : "restored"}`
: `${ids.length} accounts ${archived ? "archived" : "restored"}`,
);
clearSelection();
qc.invalidateQueries({ queryKey: ["accounts", cid] });
};
@@ -260,12 +306,23 @@ export default function AccountingChartOfAccountsPage() {
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;
// Delete one-by-one so accounts with history don't block the rest —
// those get reported (and can be archived) instead of failing the batch.
const blocked: string[] = [];
let deleted = 0;
for (const id of ids) {
const { error } = await accounting.from("accounts").delete().eq("id", id);
if (!error) { deleted++; continue; }
if (isFkViolation(error)) blocked.push(id);
else throw error;
}
if (deleted > 0) toast.success(`Deleted ${deleted} account${deleted === 1 ? "" : "s"}`);
if (blocked.length > 0) {
toast.error(
`${blocked.length} account${blocked.length === 1 ? " has" : "s have"} posted activity and can't be deleted — archive ${blocked.length === 1 ? "it" : "them"} instead.`,
{ action: { label: "Archive", onClick: () => setArchived(blocked, true) } },
);
}
toast.success(`Deleted ${ids.length} account${ids.length === 1 ? "" : "s"}`);
setBulkDeleteOpen(false);
clearSelection();
qc.invalidateQueries({ queryKey: ["accounts", cid] });
@@ -375,7 +432,10 @@ export default function AccountingChartOfAccountsPage() {
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold">Chart of Accounts</h1>
<p className="text-sm text-muted-foreground">{(accounts as any[]).length} accounts across {TYPES.length} categories</p>
<p className="text-sm text-muted-foreground">
{activeAccounts.length} accounts across {TYPES.length} categories
{archivedCount > 0 && <span> · {archivedCount} archived</span>}
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => setImportOpen(true)}>
@@ -408,7 +468,7 @@ export default function AccountingChartOfAccountsPage() {
<SelectTrigger><SelectValue placeholder="None — top-level account" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__">None (top-level)</SelectItem>
{(accounts as any[])
{activeAccounts
.filter((a: any) => a.id !== editId && a.type === type)
.map((a: any) => (
<SelectItem key={a.id} value={a.id}>
@@ -475,7 +535,7 @@ export default function AccountingChartOfAccountsPage() {
<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) => (
{activeAccounts.filter((a) => !selectedIds.has(a.id)).map((a) => (
<SelectItem key={a.id} value={a.id}>{a.code ? `${a.code} · ` : ""}{a.name}</SelectItem>
))}
</SelectContent>
@@ -517,12 +577,36 @@ export default function AccountingChartOfAccountsPage() {
</DialogContent>
</Dialog>
{/* ── Single delete confirm ── */}
<AlertDialog open={!!deleteTarget} onOpenChange={(v) => { if (!v) setDeleteTarget(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete "{deleteTarget?.name}"?</AlertDialogTitle>
<AlertDialogDescription>
This permanently removes the account. Accounts with posted activity can't be deleted archive those instead to keep your books intact.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Button variant="outline" onClick={() => { if (deleteTarget) setArchived([deleteTarget.id], true); setDeleteTarget(null); }}>
<Archive className="mr-1 h-3.5 w-3.5" /> Archive instead
</Button>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => { if (deleteTarget) removeAccount(deleteTarget); setDeleteTarget(null); }}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* ── 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>
<AlertDialogDescription>This permanently removes the selected accounts. Accounts with posted activity are skipped and can be archived instead.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
@@ -541,6 +625,12 @@ export default function AccountingChartOfAccountsPage() {
{/* ── Accounts tab ── */}
<TabsContent value="accounts" className="mt-4 space-y-4">
<div className="flex items-center justify-end gap-2">
<Switch id="show-archived" checked={showArchived} onCheckedChange={setShowArchived} />
<Label htmlFor="show-archived" className="text-sm text-muted-foreground cursor-pointer">
Show archived{archivedCount > 0 ? ` (${archivedCount})` : ""}
</Label>
</div>
{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>
@@ -548,6 +638,14 @@ export default function AccountingChartOfAccountsPage() {
<Button size="sm" variant="outline" onClick={openBulkEdit}>
<Pencil className="mr-1 h-3.5 w-3.5" /> Edit
</Button>
<Button size="sm" variant="outline" onClick={() => setArchived([...selectedIds], true)}>
<Archive className="mr-1 h-3.5 w-3.5" /> Archive
</Button>
{showArchived && (
<Button size="sm" variant="outline" onClick={() => setArchived([...selectedIds], false)}>
<ArchiveRestore className="mr-1 h-3.5 w-3.5" /> Restore
</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>
@@ -592,7 +690,7 @@ export default function AccountingChartOfAccountsPage() {
const parent = (accounts as any[]).find((p) => p.id === a.parent_account_id);
const isChild = !!a.parent_account_id;
return (
<TableRow key={a.id} className={isChild ? "bg-muted/20" : ""}>
<TableRow key={a.id} className={cn(isChild && "bg-muted/20", a.is_archived && "opacity-60")}>
<TableCell>
<Checkbox
aria-label={`Select ${a.name}`}
@@ -604,6 +702,7 @@ export default function AccountingChartOfAccountsPage() {
<TableCell className={isChild ? "pl-8" : ""}>
<div className={`font-medium ${isChild ? "text-sm" : ""}`}>
{isChild && <span className="mr-1 text-muted-foreground"></span>}{a.name}
{a.is_archived && <Badge variant="outline" className="ml-2 text-[10px] text-muted-foreground">Archived</Badge>}
</div>
{a.description && <div className="text-xs text-muted-foreground">{a.description}</div>}
{a.is_bank && <div className="text-xs text-muted-foreground">Bank account</div>}
@@ -620,7 +719,16 @@ export default function AccountingChartOfAccountsPage() {
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => { openEdit(a); }}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button size="icon" variant="ghost" className="h-7 w-7 text-destructive hover:text-destructive" onClick={() => removeAccount(a.id)}>
{a.is_archived ? (
<Button size="icon" variant="ghost" className="h-7 w-7" title="Restore account" onClick={() => setArchived([a.id], false)}>
<ArchiveRestore className="h-3.5 w-3.5" />
</Button>
) : (
<Button size="icon" variant="ghost" className="h-7 w-7" title="Archive account (keeps history, hides from pickers)" onClick={() => setArchived([a.id], true)}>
<Archive className="h-3.5 w-3.5" />
</Button>
)}
<Button size="icon" variant="ghost" className="h-7 w-7 text-destructive hover:text-destructive" title="Delete account" onClick={() => setDeleteTarget(a)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
@@ -699,7 +807,7 @@ export default function AccountingChartOfAccountsPage() {
</TableHeader>
<TableBody>
{TYPES.map((t) => {
const rs = grouped[t.value] ?? [];
const rs = obGrouped[t.value] ?? [];
if (!rs.length) return null;
return (
<Fragment key={t.value}>