mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
Chart of Accounts: merge two accounts into one
Adds a Merge action (per row) that folds all of an account's history into another account of the same type, then deletes the empty source. Backed by a SECURITY DEFINER accounting.merge_accounts RPC that reassigns every reference — journal lines, transactions, bill items, deposits, sales receipts, assessments, checks, expenses, budgets, bank/reconciliation rows, child accounts — and sums opening balances. Admin/manager only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -20,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, X, Archive, ArchiveRestore } from "lucide-react";
|
||||
import { Plus, Trash2, CalendarIcon, Lock, Pencil, RotateCcw, Save, Info, Loader2, Upload, X, Archive, ArchiveRestore, Combine } from "lucide-react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { toast } from "sonner";
|
||||
import { money } from "./lib/format";
|
||||
@@ -58,6 +58,11 @@ export default function AccountingChartOfAccountsPage() {
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<any | null>(null);
|
||||
|
||||
// ── Merge state ──
|
||||
const [mergeSource, setMergeSource] = useState<any | null>(null);
|
||||
const [mergeTargetId, setMergeTargetId] = useState<string>("");
|
||||
const [merging, setMerging] = useState(false);
|
||||
|
||||
// ── Bulk selection / edit state ──
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [bulkEditOpen, setBulkEditOpen] = useState(false);
|
||||
@@ -235,6 +240,26 @@ export default function AccountingChartOfAccountsPage() {
|
||||
qc.invalidateQueries({ queryKey: ["accounts", cid] });
|
||||
};
|
||||
|
||||
// Merge `mergeSource` into the chosen target: all GL lines, bills, deposits,
|
||||
// budgets, child accounts, etc. move to the target, then the source is deleted.
|
||||
const runMerge = async () => {
|
||||
if (!mergeSource || !mergeTargetId) return;
|
||||
setMerging(true);
|
||||
try {
|
||||
const { data, error } = await (accounting as any).rpc("merge_accounts", {
|
||||
_source: mergeSource.id,
|
||||
_target: mergeTargetId,
|
||||
});
|
||||
if (error) return toast.error(error.message);
|
||||
const moved = data?.journal_lines_moved ?? 0;
|
||||
toast.success(`Merged "${data?.merged ?? mergeSource.name}" into "${data?.into ?? ""}"${moved ? ` — ${moved} GL line(s) moved` : ""}`);
|
||||
setMergeSource(null); setMergeTargetId("");
|
||||
qc.invalidateQueries({ queryKey: ["accounts", cid] });
|
||||
} finally {
|
||||
setMerging(false);
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
@@ -603,6 +628,42 @@ export default function AccountingChartOfAccountsPage() {
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* ── Merge accounts ── */}
|
||||
<Dialog open={!!mergeSource} onOpenChange={(v) => { if (!v) { setMergeSource(null); setMergeTargetId(""); } }}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Merge account</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-1">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Move all history from{" "}
|
||||
<span className="font-medium text-foreground">{mergeSource?.code ? `${mergeSource.code} · ` : ""}{mergeSource?.name}</span>{" "}
|
||||
into another account, then delete it. Every journal entry, bill, deposit, budget line and child account moves over. This can't be undone.
|
||||
</p>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Merge into</Label>
|
||||
<Select value={mergeTargetId} onValueChange={setMergeTargetId}>
|
||||
<SelectTrigger className="mt-1"><SelectValue placeholder="Choose target account…" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{accounts
|
||||
.filter((a: any) => mergeSource && a.id !== mergeSource.id && a.type === mergeSource.type && !a.is_archived)
|
||||
.map((a: any) => (
|
||||
<SelectItem key={a.id} value={a.id}>{a.code ? `${a.code} · ` : ""}{a.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Only active {mergeSource?.type} accounts are eligible (same type).</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => { setMergeSource(null); setMergeTargetId(""); }}>Cancel</Button>
|
||||
<Button onClick={runMerge} disabled={!mergeTargetId || merging}>
|
||||
{merging && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}<Combine className="mr-1 h-3.5 w-3.5" /> Merge
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ── Bulk delete confirm ── */}
|
||||
<AlertDialog open={bulkDeleteOpen} onOpenChange={setBulkDeleteOpen}>
|
||||
<AlertDialogContent>
|
||||
@@ -730,6 +791,9 @@ export default function AccountingChartOfAccountsPage() {
|
||||
<Archive className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" title="Merge into another account" onClick={() => { setMergeSource(a); setMergeTargetId(""); }}>
|
||||
<Combine 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>
|
||||
|
||||
Reference in New Issue
Block a user