'use client' import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslations } from 'next-intl' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' import { Input } from '@/components/ui/input' import { useToast } from '@/components/ui/use-toast' import { DestructiveConfirmDialog, useDestructiveConfirm, } from '@/components/ui/destructive-confirm-dialog' import { Loader2, Search, Trash2 } from 'lucide-react' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' interface PruneCandidate { account_number: string account_name: string account_class: number plan_type: string | null is_active: boolean in_bas_reference: boolean } interface PruneAccountsDialogProps { open: boolean onOpenChange: (open: boolean) => void /** Called after a successful prune so the parent can refetch the chart. */ onPruned: () => void } /** * Bulk deletion of unused accounts. Fetches the server-computed deletable set * (dry run) and presents it in two groups — imported/custom accounts and * untouched accounts from the seeded base plan. Nothing is preselected: * deletion is opt-in per account or per group. Used accounts never appear * here; the server re-verifies every guard at execute time anyway. */ export function PruneAccountsDialog({ open, onOpenChange, onPruned }: PruneAccountsDialogProps) { const t = useTranslations('chart_of_accounts') const tCommon = useTranslations('common') const { toast } = useToast() const { dialogProps: confirmDialogProps, confirm } = useDestructiveConfirm() const [loading, setLoading] = useState(false) const [candidates, setCandidates] = useState([]) const [usedCount, setUsedCount] = useState(0) const [selected, setSelected] = useState>(new Set()) const [searchQuery, setSearchQuery] = useState('') const [isDeleting, setIsDeleting] = useState(false) // Grouping only: imported/manually added accounts (the typical cleanup // target) are listed separately from unused accounts in the seeded base // plan, so users see what came from an import at a glance. const isImportedOrCustom = useCallback( (c: PruneCandidate) => !c.in_bas_reference || c.plan_type === 'full_bas', [], ) useEffect(() => { if (!open) return let cancelled = false async function load() { setLoading(true) setCandidates([]) setSelected(new Set()) setSearchQuery('') try { const res = await fetch('/api/bookkeeping/accounts/prune', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dry_run: true }), }) if (!res.ok) throw new Error() const { data } = await res.json() if (cancelled) return const deletable: PruneCandidate[] = data?.deletable ?? [] setCandidates(deletable) setUsedCount((data?.used ?? []).length) // Nothing starts selected — deletion is opt-in per account (or per // group via the header checkbox), never a preloaded default. } catch { if (!cancelled) { toast({ title: t('toast_prune_failed'), variant: 'destructive' }) onOpenChange(false) } } finally { if (!cancelled) setLoading(false) } } load() return () => { cancelled = true } }, [open, onOpenChange, t, toast]) // Charts bloated by an import easily reach hundreds of candidates — filter // by number or name, same semantics as the kontoplan page search. Selection // is keyed by account number, so it survives filter changes; the confirm // button always shows the true selected count. const filteredCandidates = useMemo(() => { if (!searchQuery) return candidates const q = searchQuery.toLowerCase() return candidates.filter( (c) => c.account_number.includes(q) || c.account_name.toLowerCase().includes(q), ) }, [candidates, searchQuery]) const customGroup = useMemo( () => filteredCandidates.filter(isImportedOrCustom), [filteredCandidates, isImportedOrCustom], ) const seedGroup = useMemo( () => filteredCandidates.filter((c) => !isImportedOrCustom(c)), [filteredCandidates, isImportedOrCustom], ) // Master toggle spans both groups, scoped to the current filter so it only // ever selects what the user can actually see. const allFilteredChecked = filteredCandidates.length > 0 && filteredCandidates.every((c) => selected.has(c.account_number)) function toggleAccount(accountNumber: string) { setSelected((prev) => { const next = new Set(prev) if (next.has(accountNumber)) { next.delete(accountNumber) } else { next.add(accountNumber) } return next }) } function toggleGroup(group: PruneCandidate[], checked: boolean) { setSelected((prev) => { const next = new Set(prev) for (const c of group) { if (checked) { next.add(c.account_number) } else { next.delete(c.account_number) } } return next }) } async function handlePrune() { if (selected.size === 0) return const confirmed = await confirm({ title: t('prune_confirm_title', { count: selected.size }), description: t('prune_confirm_body'), confirmLabel: t('prune_confirm', { count: selected.size }), cancelLabel: tCommon('cancel'), }) if (!confirmed) return setIsDeleting(true) try { const res = await fetch('/api/bookkeeping/accounts/prune', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dry_run: false, account_numbers: [...selected] }), }) const body = await res.json().catch(() => null) if (!res.ok) { // `new Error(body.error)` collapses the canonical envelope // `{ error: { code, message } }` to "[object Object]" and loses the // route's own reason (an account still referenced by a posted entry // is refused with a specific message). Map the body plus the status. toast({ title: getUserErrorMessage(body, { statusCode: res.status }), variant: 'destructive', }) return } const deleted: string[] = body?.data?.deleted ?? [] const skipped: string[] = [ ...(body?.data?.skipped ?? []), ...(body?.data?.not_found ?? []), ] toast({ title: t('toast_pruned_title'), description: skipped.length > 0 ? t('toast_pruned_with_skipped', { deleted: deleted.length, skipped: skipped.length }) : t('toast_pruned_description', { deleted: deleted.length }), }) onPruned() onOpenChange(false) } catch (err) { toast({ title: err instanceof Error && getUserErrorMessage(err) ? getUserErrorMessage(err) : t('toast_prune_failed'), variant: 'destructive', }) } finally { setIsDeleting(false) } } function renderGroup(group: PruneCandidate[], titleKey: 'prune_group_custom' | 'prune_group_seed', hintKey: 'prune_group_custom_hint' | 'prune_group_seed_hint') { if (group.length === 0) return null const allChecked = group.every((c) => selected.has(c.account_number)) return (
toggleGroup(group, checked === true)} className="mt-1" aria-label={t(titleKey)} />

{t(titleKey)}{' '} ({group.length})

{t(hintKey)}

{group.map((c) => ( ))}
) } return ( <> !isDeleting && onOpenChange(v)}> {t('prune_title')} {t('prune_description')} {loading ? (
{t('prune_loading')}
) : candidates.length === 0 ? (

{t('prune_empty')}

) : ( // min-w-0: DialogContent is a grid, and grid items default to // min-width auto — without this, the nowrap (truncate) account // names propagate their full width up and blow the dialog open // horizontally instead of truncating.
setSearchQuery(e.target.value)} className="pl-9" />
{filteredCandidates.length > 0 && ( )}
{filteredCandidates.length === 0 ? (

{t('no_matches')}

) : ( <> {renderGroup(customGroup, 'prune_group_custom', 'prune_group_custom_hint')} {renderGroup(seedGroup, 'prune_group_seed', 'prune_group_seed_hint')} )} {usedCount > 0 && (

{t('prune_used_note', { count: usedCount })}

)}
)}
) }