mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
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:
@@ -41,7 +41,7 @@ function fromPlatform(row: any, associationId: string): NormalizedAccount {
|
|||||||
account_name: row.name ?? "Unnamed Account",
|
account_name: row.name ?? "Unnamed Account",
|
||||||
account_type: row.type ?? "Uncategorized",
|
account_type: row.type ?? "Uncategorized",
|
||||||
parent_account_id: row.parent_account_id ?? null,
|
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_id: associationId,
|
||||||
association_ids: [associationId],
|
association_ids: [associationId],
|
||||||
accounting_system: "platform",
|
accounting_system: "platform",
|
||||||
@@ -77,8 +77,9 @@ export async function fetchChartOfAccounts(
|
|||||||
|
|
||||||
const { data, error } = await accounting
|
const { data, error } = await accounting
|
||||||
.from("accounts")
|
.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("company_id", company.id)
|
||||||
|
.eq("is_archived", false) // archived accounts stay out of pickers
|
||||||
.order("code", { ascending: true });
|
.order("code", { ascending: true });
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
return (data ?? []).map((row) => fromPlatform(row, associationId));
|
return (data ?? []).map((row) => fromPlatform(row, associationId));
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ 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, 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 { toast } from "sonner";
|
||||||
import { money } from "./lib/format";
|
import { money } from "./lib/format";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -53,6 +54,10 @@ export default function AccountingChartOfAccountsPage() {
|
|||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [parentId, setParentId] = useState<string>("");
|
const [parentId, setParentId] = useState<string>("");
|
||||||
|
|
||||||
|
// ── Archive / delete state ──
|
||||||
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<any | null>(null);
|
||||||
|
|
||||||
// ── Bulk selection / edit state ──
|
// ── Bulk selection / edit state ──
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
const [bulkEditOpen, setBulkEditOpen] = useState(false);
|
const [bulkEditOpen, setBulkEditOpen] = useState(false);
|
||||||
@@ -134,12 +139,27 @@ export default function AccountingChartOfAccountsPage() {
|
|||||||
setObRows(map);
|
setObRows(map);
|
||||||
}, [balances]);
|
}, [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 grouped = useMemo(() => {
|
||||||
const out: Record<string, any[]> = {};
|
const out: Record<string, any[]> = {};
|
||||||
for (const t of TYPES) out[t.value] = [];
|
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;
|
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.
|
// Existing category names (for the Income Statement subgroups) — used as autocomplete suggestions.
|
||||||
const existingCategories = useMemo(
|
const existingCategories = useMemo(
|
||||||
@@ -193,9 +213,35 @@ export default function AccountingChartOfAccountsPage() {
|
|||||||
qc.invalidateQueries({ queryKey: ["accounts", cid] });
|
qc.invalidateQueries({ queryKey: ["accounts", cid] });
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeAccount = async (id: string) => {
|
// FK violations (journal entries, bills, register rows, …) mean the account
|
||||||
const { error } = await accounting.from("accounts").delete().eq("id", id);
|
// 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);
|
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] });
|
qc.invalidateQueries({ queryKey: ["accounts", cid] });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -260,12 +306,23 @@ export default function AccountingChartOfAccountsPage() {
|
|||||||
if (!ids.length) return;
|
if (!ids.length) return;
|
||||||
setBulkDeleting(true);
|
setBulkDeleting(true);
|
||||||
try {
|
try {
|
||||||
for (let i = 0; i < ids.length; i += 50) {
|
// Delete one-by-one so accounts with history don't block the rest —
|
||||||
const batch = ids.slice(i, i + 50);
|
// those get reported (and can be archived) instead of failing the batch.
|
||||||
const { error } = await accounting.from("accounts").delete().in("id", batch);
|
const blocked: string[] = [];
|
||||||
if (error) throw error;
|
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);
|
setBulkDeleteOpen(false);
|
||||||
clearSelection();
|
clearSelection();
|
||||||
qc.invalidateQueries({ queryKey: ["accounts", cid] });
|
qc.invalidateQueries({ queryKey: ["accounts", cid] });
|
||||||
@@ -375,7 +432,10 @@ export default function AccountingChartOfAccountsPage() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold">Chart of Accounts</h1>
|
<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>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="outline" onClick={() => setImportOpen(true)}>
|
<Button variant="outline" onClick={() => setImportOpen(true)}>
|
||||||
@@ -408,7 +468,7 @@ export default function AccountingChartOfAccountsPage() {
|
|||||||
<SelectTrigger><SelectValue placeholder="None — top-level account" /></SelectTrigger>
|
<SelectTrigger><SelectValue placeholder="None — top-level account" /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="__none__">None (top-level)</SelectItem>
|
<SelectItem value="__none__">None (top-level)</SelectItem>
|
||||||
{(accounts as any[])
|
{activeAccounts
|
||||||
.filter((a: any) => a.id !== editId && a.type === type)
|
.filter((a: any) => a.id !== editId && a.type === type)
|
||||||
.map((a: any) => (
|
.map((a: any) => (
|
||||||
<SelectItem key={a.id} value={a.id}>
|
<SelectItem key={a.id} value={a.id}>
|
||||||
@@ -475,7 +535,7 @@ export default function AccountingChartOfAccountsPage() {
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="no_change">No change</SelectItem>
|
<SelectItem value="no_change">No change</SelectItem>
|
||||||
<SelectItem value="none">None (top-level)</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>
|
<SelectItem key={a.id} value={a.id}>{a.code ? `${a.code} · ` : ""}{a.name}</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -517,12 +577,36 @@ export default function AccountingChartOfAccountsPage() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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 ── */}
|
{/* ── Bulk delete confirm ── */}
|
||||||
<AlertDialog open={bulkDeleteOpen} onOpenChange={setBulkDeleteOpen}>
|
<AlertDialog open={bulkDeleteOpen} onOpenChange={setBulkDeleteOpen}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Delete {selectedIds.size} account{selectedIds.size === 1 ? "" : "s"}?</AlertDialogTitle>
|
<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>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
@@ -541,6 +625,12 @@ 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">
|
||||||
|
<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 && (
|
{selectedIds.size > 0 && (
|
||||||
<div className="flex items-center justify-between rounded-md border bg-muted/40 px-4 py-2">
|
<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>
|
<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}>
|
<Button size="sm" variant="outline" onClick={openBulkEdit}>
|
||||||
<Pencil className="mr-1 h-3.5 w-3.5" /> Edit
|
<Pencil className="mr-1 h-3.5 w-3.5" /> Edit
|
||||||
</Button>
|
</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)}>
|
<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
|
<Trash2 className="mr-1 h-3.5 w-3.5" /> Delete
|
||||||
</Button>
|
</Button>
|
||||||
@@ -592,7 +690,7 @@ export default function AccountingChartOfAccountsPage() {
|
|||||||
const parent = (accounts as any[]).find((p) => p.id === a.parent_account_id);
|
const parent = (accounts as any[]).find((p) => p.id === a.parent_account_id);
|
||||||
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={cn(isChild && "bg-muted/20", a.is_archived && "opacity-60")}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
aria-label={`Select ${a.name}`}
|
aria-label={`Select ${a.name}`}
|
||||||
@@ -604,6 +702,7 @@ export default function AccountingChartOfAccountsPage() {
|
|||||||
<TableCell className={isChild ? "pl-8" : ""}>
|
<TableCell className={isChild ? "pl-8" : ""}>
|
||||||
<div className={`font-medium ${isChild ? "text-sm" : ""}`}>
|
<div className={`font-medium ${isChild ? "text-sm" : ""}`}>
|
||||||
{isChild && <span className="mr-1 text-muted-foreground">↳</span>}{a.name}
|
{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>
|
</div>
|
||||||
{a.description && <div className="text-xs text-muted-foreground">{a.description}</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>}
|
{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); }}>
|
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => { openEdit(a); }}>
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</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" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -699,7 +807,7 @@ export default function AccountingChartOfAccountsPage() {
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{TYPES.map((t) => {
|
{TYPES.map((t) => {
|
||||||
const rs = grouped[t.value] ?? [];
|
const rs = obGrouped[t.value] ?? [];
|
||||||
if (!rs.length) return null;
|
if (!rs.length) return null;
|
||||||
return (
|
return (
|
||||||
<Fragment key={t.value}>
|
<Fragment key={t.value}>
|
||||||
|
|||||||
Reference in New Issue
Block a user