From 03d3c5ee8d22e8156af3055b7333397d1a97da2d Mon Sep 17 00:00:00 2001 From: renee-png Date: Fri, 12 Jun 2026 20:11:11 -0400 Subject: [PATCH] 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 --- src/lib/chartOfAccountsSource.ts | 5 +- .../AccountingChartOfAccountsPage.tsx | 142 +++++++++++++++--- 2 files changed, 128 insertions(+), 19 deletions(-) diff --git a/src/lib/chartOfAccountsSource.ts b/src/lib/chartOfAccountsSource.ts index 42777d8..5c95474 100644 --- a/src/lib/chartOfAccountsSource.ts +++ b/src/lib/chartOfAccountsSource.ts @@ -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)); diff --git a/src/pages/accounting/AccountingChartOfAccountsPage.tsx b/src/pages/accounting/AccountingChartOfAccountsPage.tsx index 191b1d6..1731357 100644 --- a/src/pages/accounting/AccountingChartOfAccountsPage.tsx +++ b/src/pages/accounting/AccountingChartOfAccountsPage.tsx @@ -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(""); + // ── Archive / delete state ── + const [showArchived, setShowArchived] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + // ── Bulk selection / edit state ── const [selectedIds, setSelectedIds] = useState>(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 = {}; 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 = {}; + 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() {

Chart of Accounts

-

{(accounts as any[]).length} accounts across {TYPES.length} categories

+

+ {activeAccounts.length} accounts across {TYPES.length} categories + {archivedCount > 0 && · {archivedCount} archived} +

+ { if (deleteTarget) removeAccount(deleteTarget); setDeleteTarget(null); }} + > + Delete + + + + + {/* ── Bulk delete confirm ── */} Delete {selectedIds.size} account{selectedIds.size === 1 ? "" : "s"}? - This permanently removes the selected accounts. Accounts that have posted transactions may fail to delete. + This permanently removes the selected accounts. Accounts with posted activity are skipped and can be archived instead. Cancel @@ -541,6 +625,12 @@ export default function AccountingChartOfAccountsPage() { {/* ── Accounts tab ── */} +
+ + +
{selectedIds.size > 0 && (
{selectedIds.size} selected @@ -548,6 +638,14 @@ export default function AccountingChartOfAccountsPage() { + + {showArchived && ( + + )} @@ -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 ( - +
{isChild && }{a.name} + {a.is_archived && Archived}
{a.description &&
{a.description}
} {a.is_bank &&
Bank account
} @@ -620,7 +719,16 @@ export default function AccountingChartOfAccountsPage() { - + ) : ( + + )} +
@@ -699,7 +807,7 @@ export default function AccountingChartOfAccountsPage() { {TYPES.map((t) => { - const rs = grouped[t.value] ?? []; + const rs = obGrouped[t.value] ?? []; if (!rs.length) return null; return (