'use client' import { Fragment, useState, useEffect, useCallback, useMemo, useRef } from 'react' import { useTranslations } from 'next-intl' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { SegmentedControl } from '@/components/ui/segmented-control' import { ToolbarSearch } from '@/components/ui/toolbar-search' import { Switch } from '@/components/ui/switch' import { Skeleton } from '@/components/ui/skeleton' import { TH_CLASS, TD_CLASS, QUIET_LINK_CLASS } from '@/components/ui/dry-table' import { useToast } from '@/components/ui/use-toast' import { AccountNumber } from '@/components/ui/account-number' import { DestructiveConfirmDialog, useDestructiveConfirm, } from '@/components/ui/destructive-confirm-dialog' import { AddAccountDialog } from './AddAccountDialog' import { EditAccountDialog } from './EditAccountDialog' import { PruneAccountsDialog } from './PruneAccountsDialog' import { ChevronRight, Plus, Pencil, Trash2, Loader2, CheckCircle2, } from 'lucide-react' import { cn } from '@/lib/utils' import type { BASAccount } from '@/types' import { useCompanySettings } from '@/lib/reference-data/hooks' import type { BASReferenceAccount } from '@/lib/bookkeeping/bas-reference' import { isStandardBASAccountNumber } from '@/lib/bookkeeping/bas-account-numbers' import { ensureBasLoaded } from '@/lib/bookkeeping/bas-lazy' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- interface ReferenceAccount extends BASReferenceAccount { is_activated: boolean is_active: boolean is_system_account: boolean } // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- export default function ChartOfAccountsManager() { const { toast } = useToast() const t = useTranslations('chart_of_accounts') const tNav = useTranslations('nav') const tCommon = useTranslations('common') const { dialogProps: confirmDialogProps, confirm } = useDestructiveConfirm() const classLabel = (cls: number): string => { if (cls < 1 || cls > 8) return '' return t(`class_${cls}` as const) } const typeLabel = (type: string): string => { const key = `type_${type}` as const try { return t(key) } catch { return type } } // View state. Mina konton renders flat (concept scene 30), so its band // rows track the classes the user has explicitly FOLDED AWAY; the BAS // catalog (~1300 rows) keeps the inverse default and tracks the classes // the user has opened. const [view, setView] = useState<'my-accounts' | 'bas-catalog'>('my-accounts') const [searchQuery, setSearchQuery] = useState('') const [collapsedMyClasses, setCollapsedMyClasses] = useState>(new Set()) const [expandedCatalogClasses, setExpandedCatalogClasses] = useState>(new Set()) const [hideK2Excluded, setHideK2Excluded] = useState(null) const { settings: companySettings } = useCompanySettings() // Off by default: the list endpoint's `?active=false` means "no filter", so // leaving it off keeps first paint on the smaller active-only payload. // A deactivated account is otherwise invisible everywhere and unrecoverable. const [showInactive, setShowInactive] = useState(false) // Data state const [accounts, setAccounts] = useState([]) const [referenceAccounts, setReferenceAccounts] = useState([]) const [usageCounts, setUsageCounts] = useState>(new Map()) const [loading, setLoading] = useState(true) // The BAS catalog + K2 default are fetched lazily the first time the user // opens that tab, so they never block the default "Mina konton" view. const [referenceLoaded, setReferenceLoaded] = useState(false) const [referenceLoading, setReferenceLoading] = useState(false) // Dialog state const [addDialogOpen, setAddDialogOpen] = useState(false) const [editAccount, setEditAccount] = useState(null) const [pruneDialogOpen, setPruneDialogOpen] = useState(false) // Action states const [togglingAccount, setTogglingAccount] = useState(null) const [deletingAccount, setDeletingAccount] = useState(null) const [activatingAccounts, setActivatingAccounts] = useState>(new Set()) // ------------------------------------------- // Data fetching // ------------------------------------------- // Read through a ref, not the state value: making fetchAccounts depend on // showInactive would put it in the mount effect's dep list and re-run the // whole blocking load on every toggle (the same double-fetch the comment // below warns about for hideK2Excluded). const showInactiveRef = useRef(showInactive) showInactiveRef.current = showInactive const fetchAccounts = useCallback(async () => { // `?active=false` disables the filter entirely (active + inactive), it does // not select inactive rows only — see list_company_accounts. const res = await fetch( showInactiveRef.current ? '/api/bookkeeping/accounts?active=false' : '/api/bookkeeping/accounts', ) const { data } = await res.json() setAccounts(data || []) }, []) // The server sends only the company's per-account activation status; the BAS // catalog is static and already bundled here, so we merge client-side rather // than re-download ~1,300 catalog rows on every load. const fetchReference = useCallback(async () => { const res = await fetch('/api/bookkeeping/accounts/reference') const { data } = await res.json() const userMap = new Map( (data || []).map( (a: { account_number: string; is_active: boolean; is_system_account: boolean }) => [a.account_number, a], ), ) // The full chart is a lazily loaded chunk: only the BAS-katalog tab pays // for it, not every route that renders this component's parent. const BAS_REFERENCE = await ensureBasLoaded() const merged: ReferenceAccount[] = BAS_REFERENCE.map((ref) => { const userAccount = userMap.get(ref.account_number) return { ...ref, is_activated: !!userAccount, is_active: userAccount?.is_active ?? false, is_system_account: userAccount?.is_system_account ?? false, } }) setReferenceAccounts(merged) }, []) // Per-account posting counts — drives the "Verifikat" column. Non-fatal: // the page works without it, cells just show a dash. const fetchUsage = useCallback(async () => { try { const res = await fetch('/api/bookkeeping/accounts/usage') if (!res.ok) return const { data } = await res.json() setUsageCounts( new Map( (data || []).map((u: { account_number: string; usage_count: number }) => [ u.account_number, Number(u.usage_count), ]), ), ) } catch { // Leave the map empty — usage display is informational only. } }, []) // First paint blocks only on the user's own chart (the default view). Usage // counts drive the informational "Verifikat" column and load in the // background; the BAS catalog + K2 setting are deferred to tab open. This // must NOT depend on hideK2Excluded: doing so re-ran the whole load a second // time once the K2 default was set, doubling every fetch on each visit. useEffect(() => { let cancelled = false async function load() { setLoading(true) await fetchAccounts() if (!cancelled) setLoading(false) } load() void fetchUsage() return () => { cancelled = true } }, [fetchAccounts, fetchUsage]) // Refetch when the inactive filter flips. Skips the first run (the mount // effect above already fetched) and deliberately does not raise `loading`: // swapping a filter should not blank the table back to the skeleton. const showInactiveInitial = useRef(true) useEffect(() => { if (showInactiveInitial.current) { showInactiveInitial.current = false return } void fetchAccounts() }, [showInactive, fetchAccounts]) // Loads the BAS catalog and the K2 default on demand, once, the first time // the user opens the "BAS-katalog" tab. const ensureReferenceLoaded = useCallback(async () => { if (referenceLoaded || referenceLoading) return setReferenceLoading(true) try { await Promise.all([ fetchReference(), (async () => { if (hideK2Excluded !== null) return // Default to hiding K2-excluded accounts if the company uses K2 // (plan_type === 'k1'). Settings come from the session cache. setHideK2Excluded( (companySettings as { plan_type?: string } | null)?.plan_type === 'k1', ) })(), ]) setReferenceLoaded(true) } finally { setReferenceLoading(false) } }, [referenceLoaded, referenceLoading, fetchReference, hideK2Excluded, companySettings]) const refreshAll = useCallback(async () => { await Promise.all([ fetchAccounts(), fetchUsage(), // Only refresh the catalog if the user has actually opened that tab. ...(referenceLoaded ? [fetchReference()] : []), ]) }, [fetchAccounts, fetchUsage, fetchReference, referenceLoaded]) // ------------------------------------------- // Actions // ------------------------------------------- async function toggleActive(account: BASAccount) { // Deactivating an account that carries postings hides it from the // kontoplan and from new verifikat while its balances stay in the books. // That is legal and reversible, but it should never happen silently: it is // what made a used account unreachable in the first place. The count is // already in memory from fetchUsage, so no extra request. const usage = usageCounts.get(account.account_number) ?? 0 if (account.is_active && usage > 0) { const confirmed = await confirm({ title: t('deactivate_confirm_title', { number: account.account_number }), description: t('deactivate_confirm', { count: usage }), confirmLabel: t('deactivate_confirm_action'), variant: 'warning', }) if (!confirmed) return } setTogglingAccount(account.account_number) try { const res = await fetch(`/api/bookkeeping/accounts/${account.account_number}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ is_active: !account.is_active }), }) if (!res.ok) throw new Error(t('toast_update_failed')) await refreshAll() } catch { toast({ title: t('toast_update_failed'), variant: 'destructive' }) } finally { setTogglingAccount(null) } } async function deleteAccount(account: BASAccount) { const confirmed = await confirm({ title: t('delete_confirm_title'), description: t('delete_confirm', { number: account.account_number, name: account.account_name }), confirmLabel: t('delete_confirm_action'), cancelLabel: tCommon('cancel'), }) if (!confirmed) return setDeletingAccount(account.account_number) try { const res = await fetch(`/api/bookkeeping/accounts/${account.account_number}`, { method: 'DELETE', }) if (!res.ok) { // The route answers thrown errors with the canonical envelope // `{ error: { code, message } }`. `new Error(data.error)` would // stringify that object to "[object Object]" and the reason would be // lost; hand getErrorMessage the parsed body plus the status instead. const body = await res.json().catch(() => null) toast({ title: getUserErrorMessage(body, { statusCode: res.status }), variant: 'destructive', }) return } toast({ title: t('toast_account_deleted'), description: `${account.account_number} ${account.account_name}` }) await refreshAll() } catch (err) { toast({ title: err instanceof Error ? getUserErrorMessage(err) : t('toast_delete_failed'), variant: 'destructive', }) } finally { setDeletingAccount(null) } } async function activateBASAccount(accountNumber: string) { setActivatingAccounts((prev) => new Set(prev).add(accountNumber)) try { const res = await fetch('/api/bookkeeping/accounts/activate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ account_numbers: [accountNumber] }), }) if (!res.ok) throw new Error(t('toast_activate_failed')) // `reactivated` covers an account the company already had but had turned // off. Without it the catalog tab flipped the row back on in silence. const { activated, reactivated } = await res.json() if (activated > 0) { toast({ title: t('toast_activated_title'), description: t('toast_activated_description', { number: accountNumber }) }) } else if (reactivated > 0) { toast({ title: t('toast_reactivated_title'), description: t('toast_reactivated_description', { number: accountNumber }) }) } await refreshAll() } catch { toast({ title: t('toast_activate_failed'), variant: 'destructive' }) } finally { setActivatingAccounts((prev) => { const next = new Set(prev) next.delete(accountNumber) return next }) } } // ------------------------------------------- // Toggle class expansion (band rows) // ------------------------------------------- function toggleMyClass(cls: number) { setCollapsedMyClasses((prev) => { const next = new Set(prev) if (next.has(cls)) next.delete(cls) else next.add(cls) return next }) } function toggleCatalogClass(cls: number) { setExpandedCatalogClasses((prev) => { const next = new Set(prev) if (next.has(cls)) next.delete(cls) else next.add(cls) return next }) } // ------------------------------------------- // Filtered & grouped data // ------------------------------------------- const filteredAccounts = useMemo(() => { if (!searchQuery) return accounts const q = searchQuery.toLowerCase() return accounts.filter( (a) => a.account_number.includes(q) || a.account_name.toLowerCase().includes(q) ) }, [accounts, searchQuery]) const groupedAccounts = useMemo(() => { const grouped: Record = {} for (const a of filteredAccounts) { const cls = a.account_class if (!grouped[cls]) grouped[cls] = [] grouped[cls].push(a) } return grouped }, [filteredAccounts]) const filteredReference = useMemo(() => { let filtered = referenceAccounts if (hideK2Excluded) { filtered = filtered.filter((a) => !a.k2_excluded) } if (searchQuery) { const q = searchQuery.toLowerCase() filtered = filtered.filter( (a) => a.account_number.includes(q) || a.account_name.toLowerCase().includes(q) ) } return filtered }, [referenceAccounts, searchQuery, hideK2Excluded]) const groupedReference = useMemo(() => { const grouped: Record = {} for (const a of filteredReference) { const cls = a.account_class if (!grouped[cls]) grouped[cls] = [] grouped[cls].push(a) } return grouped }, [filteredReference]) // ------------------------------------------- // Render // ------------------------------------------- const shownCount = view === 'my-accounts' ? filteredAccounts.length : filteredReference.length const totalCount = view === 'my-accounts' ? accounts.length : referenceAccounts.length // Page header + toolbar render even while loading so the chrome is stable. const header = (

{tNav('chart_of_accounts')}

{t('bas_version_chip')}
) const switchView = (next: 'my-accounts' | 'bas-catalog') => { setView(next) setCollapsedMyClasses(new Set()) setExpandedCatalogClasses(new Set()) if (next === 'bas-catalog') void ensureReferenceLoaded() } // Concept band row (Klass N: label), clickable to fold the class away. const bandRow = ( cls: number, open: boolean, onToggle: () => void, countLabel: string, colSpan: number, ) => ( ) return (
{header} {/* Toolbar: seg + search; the K2 filter rides far right in catalog view */}
setSearchQuery(e.target.value)} containerClassName="min-w-[190px] max-w-xs flex-1" /> {view === 'my-accounts' && ( )} {view === 'bas-catalog' && ( )}
{loading ? (
{[1, 2, 3, 4, 5, 6].map((i) => ( ))}
) : view === 'my-accounts' ? ( filteredAccounts.length === 0 ? (

{searchQuery ? t('no_matches') : t('no_accounts')}

) : (
{Object.entries(groupedAccounts) .sort(([a], [b]) => Number(a) - Number(b)) .map(([cls, classAccounts]) => { const classNum = Number(cls) const open = !collapsedMyClasses.has(classNum) || !!searchQuery const activeCount = classAccounts.filter((a) => a.is_active).length return ( {bandRow( classNum, open, () => toggleMyClass(classNum), t('active_count_label', { active: activeCount, total: classAccounts.length }), 7, )} {open && classAccounts.map((account) => ( ))} ) })}
{t('col_account')} {t('col_name')} {t('col_sru')} {t('col_type')} {t('col_usage')} {t('col_active')}
{account.account_name} {account.is_system_account && ( {t('system_badge')} )} {!isStandardBASAccountNumber(account.account_number) && ( {t('own_badge')} )} {/* Exception chip: only visible while the "Visa inaktiva" filter is on, so it never lands on every row. */} {!account.is_active && ( {t('prune_inactive_badge')} )} {account.sru_code || ''} {typeLabel(account.account_type)} {usageCounts.get(account.account_number) ?? ''} toggleActive(account)} disabled={togglingAccount === account.account_number} aria-label={t('col_active') + ' ' + account.account_number} className="scale-75" /> {!account.is_system_account && ( )}
) ) : (
{referenceLoading && referenceAccounts.length === 0 ? (
{[1, 2, 3, 4, 5, 6].map((i) => ( ))}
) : filteredReference.length === 0 ? (

{t('no_matches')}

) : (
{Object.entries(groupedReference) .sort(([a], [b]) => Number(a) - Number(b)) .map(([cls, classAccounts]) => { const classNum = Number(cls) const open = expandedCatalogClasses.has(classNum) || !!searchQuery // Row existence alone is not activation: a deactivated // account is in the chart but not usable, so it must not // count as active here either. const activatedCount = classAccounts.filter((a) => a.is_activated && a.is_active).length return ( {bandRow( classNum, open, () => toggleCatalogClass(classNum), t('active_count_label', { active: activatedCount, total: classAccounts.length }), 5, )} {open && classAccounts.map((account) => ( ))} ) })}
{t('col_account')} {t('col_name')} {t('col_sru')} {t('col_type')} {t('col_status')}
{account.account_name} {account.sru_code || ''} {typeLabel(account.account_type)} {/* An account the company holds but has deactivated is NOT activated: it falls through to the button, relabelled so the user sees it is coming back, not new. */} {account.is_activated && account.is_active ? ( {t('activated')} ) : ( )}
)}
)} {/* Footer note (concept pgnote) */} {!loading && totalCount > 0 && (

{t('footer_note', { shown: shownCount, total: totalCount })}

)} {/* Dialogs */} {editAccount && ( { if (!open) setEditAccount(null) }} account={editAccount} onSaved={refreshAll} /> )}
) }