Files
accounted/components/bookkeeping/ChartOfAccountsManager.tsx
T
7a9036caa6 fix(kontoplan): remove the per-class active counter (#2273)
* fix(kontoplan): counter reflects the filtered selection

The per-class band row in the chart of accounts always rendered
"{active}/{total} aktiva" with total taken from the already-filtered
class group. With the "Utan verifikat" filter (#2231) or a search
active, that read as "43/43 aktiva": full coverage of a class that
was really a subset.

A narrowed band now counts what it shows against the unnarrowed class
("12 av 43 visas" / "12 of 43 shown"); an unnarrowed band keeps the
active ratio unchanged. Search and the Verifikat filter narrow Mina
konton; search narrows the BAS catalog. The K2 toggle is treated as
scope rather than a filter, since it defaults from the company's
regelverk and would otherwise flip every catalog band for K2
companies without the user touching anything.

Fixes #2263

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qgLgdt4mLmha1ZLFMwq1u

* fix(kontoplan): remove the per-class active counter

The band row in Kontoplan rendered "{active}/{total} aktiva" from the
already-filtered class group, so under the Verifikat filter (#2231) or
a search it always read "N/N aktiva": full coverage of a class that
was really a subset. The reporter asked for the text to go, and the
issue offered removal as one of its two fixes.

Removing the counter is the fix from first principles: nothing consumes
it, the tab chip and the page footer ("Visar N av M konton") already
carry the only counts the page needs, and a counter that does not exist
cannot drift from the list again. This drops the countLabel parameter
from bandRow, the activeCount and activatedCount derivations, and the
now-unused chart_of_accounts.active_count_label key in both locales.
It also reverts the filtered-mode helper and memo split from the first
commit on this branch.

Fixes #2263

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qgLgdt4mLmha1ZLFMwq1u

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 17:14:21 +02:00

960 lines
41 KiB
TypeScript

'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, CHECKBOX_REVEAL_CLASS } from '@/components/ui/dry-table'
import { Checkbox } from '@/components/ui/checkbox'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
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,
ListFilter,
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
}
/**
* Column-header filter on "Verifikat" (#2186). "unused" means the account is
* absent from get_account_usage_counts, i.e. never posted to: the set a
* migrated chart wants pruned, since a short kontoplan is what keeps manual
* bookings off the wrong account.
*/
type UsageFilter = 'all' | 'unused' | 'used'
const USAGE_FILTER_KEY = {
all: 'usage_filter_all',
unused: 'usage_filter_unused',
used: 'usage_filter_used',
} as const
// ---------------------------------------------------------------------------
// 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<Set<number>>(new Set())
const [expandedCatalogClasses, setExpandedCatalogClasses] = useState<Set<number>>(new Set())
const [hideK2Excluded, setHideK2Excluded] = useState<boolean | null>(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)
const [usageFilter, setUsageFilter] = useState<UsageFilter>('all')
// Row selection for the bulk inactivate (#2186), keyed by account number
// (the PUT/deactivate routes key on it too). Cleared on view switch.
const [selectedNumbers, setSelectedNumbers] = useState<Set<string>>(new Set())
const [bulkDeactivating, setBulkDeactivating] = useState(false)
// The usage map loads after first paint; until it has, "utan verifikat"
// would match every account, so the filter stays disabled.
const [usageLoaded, setUsageLoaded] = useState(false)
// Data state
const [accounts, setAccounts] = useState<BASAccount[]>([])
const [referenceAccounts, setReferenceAccounts] = useState<ReferenceAccount[]>([])
const [usageCounts, setUsageCounts] = useState<Map<string, number>>(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<BASAccount | null>(null)
const [pruneDialogOpen, setPruneDialogOpen] = useState(false)
// Action states
const [togglingAccount, setTogglingAccount] = useState<string | null>(null)
const [deletingAccount, setDeletingAccount] = useState<string | null>(null)
const [activatingAccounts, setActivatingAccounts] = useState<Set<string>>(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<string, { account_number: string; is_active: boolean; is_system_account: boolean }>(
(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),
]),
),
)
setUsageLoaded(true)
} 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)
}
}
// Bulk inactivate of the selection (#2186). Only never-used, non-system,
// active accounts go: a used account hides its balances from the kontoplan,
// so it keeps the per-row toggle with its own confirm. The route enforces
// the same partition server-side; the client partitions first so the
// confirm can say exactly what will and will not happen (convention 10).
async function bulkDeactivate() {
const byNumber = new Map(accounts.map((a) => [a.account_number, a]))
const unused: string[] = []
let used = 0
let system = 0
for (const number of selectedNumbers) {
const account = byNumber.get(number)
if (!account || !account.is_active) continue
if (account.is_system_account) {
system += 1
continue
}
if (usageCounts.has(number)) {
used += 1
continue
}
unused.push(number)
}
if (unused.length === 0) {
toast({ title: t('bulk_deactivate_none_unused') })
return
}
const skippedNote = [
used > 0 ? t('bulk_deactivate_skipped_used', { count: used }) : null,
system > 0 ? t('bulk_deactivate_skipped_system', { count: system }) : null,
]
.filter(Boolean)
.join(' ')
const confirmed = await confirm({
title: t('bulk_deactivate_confirm_title', { count: unused.length }),
description: [t('bulk_deactivate_confirm', { count: unused.length }), skippedNote]
.filter(Boolean)
.join(' '),
confirmLabel: t('deactivate_confirm_action'),
variant: 'warning',
})
if (!confirmed) return
setBulkDeactivating(true)
try {
const res = await fetch('/api/bookkeeping/accounts/deactivate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ account_numbers: unused }),
})
if (!res.ok) throw new Error(t('toast_update_failed'))
const body = (await res.json().catch(() => ({}))) as { deactivated?: number }
toast({ title: t('bulk_deactivate_done', { count: Number(body.deactivated ?? 0) }) })
setSelectedNumbers(new Set())
await refreshAll()
} catch {
toast({ title: t('toast_update_failed'), variant: 'destructive' })
} finally {
setBulkDeactivating(false)
}
}
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(() => {
const q = searchQuery.toLowerCase()
return accounts.filter((a) => {
if (q && !(a.account_number.includes(q) || a.account_name.toLowerCase().includes(q))) {
return false
}
// Absence from the usage map means never posted to (see /usage).
if (usageFilter === 'unused' && usageCounts.has(a.account_number)) return false
if (usageFilter === 'used' && !usageCounts.has(a.account_number)) return false
return true
})
}, [accounts, searchQuery, usageFilter, usageCounts])
const visibleNumbers = useMemo(
() => filteredAccounts.map((a) => a.account_number),
[filteredAccounts],
)
const allVisibleSelected =
visibleNumbers.length > 0 && visibleNumbers.every((n) => selectedNumbers.has(n))
const toggleSelect = (number: string) =>
setSelectedNumbers((prev) => {
const next = new Set(prev)
if (next.has(number)) next.delete(number)
else next.add(number)
return next
})
const toggleSelectAllVisible = () =>
setSelectedNumbers(allVisibleSelected ? new Set() : new Set(visibleNumbers))
const groupedAccounts = useMemo(() => {
const grouped: Record<number, BASAccount[]> = {}
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<number, ReferenceAccount[]> = {}
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 = (
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<h1 className="font-display text-2xl leading-8 tracking-tight">{tNav('chart_of_accounts')}</h1>
<div className="flex items-center gap-4">
<Badge variant="outline" className="font-normal">{t('bas_version_chip')}</Badge>
<button type="button" className={QUIET_LINK_CLASS} onClick={() => setPruneDialogOpen(true)}>
{t('prune_button')}
</button>
<Button onClick={() => setAddDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
{t('add_own')}
</Button>
</div>
</div>
)
const switchView = (next: 'my-accounts' | 'bas-catalog') => {
setView(next)
setSelectedNumbers(new Set())
setCollapsedMyClasses(new Set())
setExpandedCatalogClasses(new Set())
if (next === 'bas-catalog') void ensureReferenceLoaded()
}
// Concept band row (Klass N: label), clickable to fold the class away.
// It carries no per-class counter: the old "{active}/{total} aktiva" was
// derived from the filtered list and read as full coverage under the
// Verifikat filter (#2263). Shown/total lives once, in the page footer.
const bandRow = (
cls: number,
open: boolean,
onToggle: () => void,
colSpan: number,
) => (
<tr key={`band-${cls}`}>
<td colSpan={colSpan} className="border-b border-border p-0">
<button
type="button"
onClick={onToggle}
aria-expanded={open}
className="flex w-full items-center gap-1.5 bg-muted/40 px-3 py-[7px] text-left text-[11px] font-semibold uppercase tracking-[0.06em] text-muted-foreground transition-colors duration-150 hover:bg-muted/60"
>
<ChevronRight className={cn('h-3 w-3 shrink-0 transition-transform duration-200', open && 'rotate-90')} />
{t('class_heading', { cls, label: classLabel(cls) })}
</button>
</td>
</tr>
)
return (
<div className="space-y-8">
{header}
{/* Toolbar: seg + search; the K2 filter rides far right in catalog view */}
<div className="flex flex-wrap items-center gap-2">
<SegmentedControl
value={view}
onChange={switchView}
options={[
{ value: 'my-accounts', label: t('tab_my_accounts'), count: accounts.length },
{ value: 'bas-catalog', label: t('tab_bas_catalog') },
]}
/>
<ToolbarSearch
placeholder={t('search_placeholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
containerClassName="min-w-[190px] max-w-xs flex-1"
/>
{view === 'my-accounts' && (
<label className="ml-auto flex items-center gap-2 text-sm">
<Switch
checked={showInactive}
onCheckedChange={setShowInactive}
className="scale-75"
/>
<span className="text-muted-foreground">{t('show_inactive')}</span>
</label>
)}
{view === 'bas-catalog' && (
<label className="ml-auto flex items-center gap-2 text-sm">
<Switch
checked={hideK2Excluded ?? false}
onCheckedChange={setHideK2Excluded}
className="scale-75"
/>
<span className="text-muted-foreground">{t('hide_k2_excluded')}</span>
</label>
)}
</div>
{loading ? (
<div className="space-y-2 py-2">
{[1, 2, 3, 4, 5, 6].map((i) => (
<Skeleton key={i} className="h-8 w-full" />
))}
</div>
) : view === 'my-accounts' ? (
filteredAccounts.length === 0 ? (
<p className="px-1 py-12 text-center text-sm text-muted-foreground">
{searchQuery || usageFilter !== 'all' ? t('no_matches') : t('no_accounts')}
</p>
) : (
<div>
{/* Bulk bar: appears with the first selection, carries the count
and the one action the selection exists for. */}
{selectedNumbers.size > 0 && (
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 border-b border-border px-1 py-2 text-[12.5px] animate-fade-in">
<span className="whitespace-nowrap">
<strong className="font-semibold tabular-nums">{selectedNumbers.size}</strong>{' '}
{t('bulkbar_selected', { count: selectedNumbers.size })}
</span>
<Button size="sm" onClick={bulkDeactivate} disabled={bulkDeactivating}>
{bulkDeactivating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('deactivate_confirm_action')}
</Button>
{!allVisibleSelected && (
<button type="button" className={QUIET_LINK_CLASS} onClick={toggleSelectAllVisible}>
{t('bulk_select_all', { count: visibleNumbers.length })}
</button>
)}
<button
type="button"
className={QUIET_LINK_CLASS}
onClick={() => setSelectedNumbers(new Set())}
disabled={bulkDeactivating}
>
{t('bulk_clear_selection')}
</button>
</div>
)}
<div className="overflow-x-auto">
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
<th className={cn(TH_CLASS, 'w-[36px] pr-0')}>
<Checkbox
checked={allVisibleSelected}
onCheckedChange={toggleSelectAllVisible}
aria-label={t('select_all_aria')}
className="border-foreground"
/>
</th>
<th className={cn(TH_CLASS, 'w-[96px]')}>{t('col_account')}</th>
<th className={cn(TH_CLASS, 'w-full')}>{t('col_name')}</th>
<th className={cn(TH_CLASS, 'hidden text-right sm:table-cell')}>{t('col_sru')}</th>
<th className={cn(TH_CLASS, 'hidden md:table-cell')}>{t('col_type')}</th>
<th className={cn(TH_CLASS, 'hidden text-right sm:table-cell')}>
{/* The header is the filter (#2186): pick all / never
posted to / posted to, the way the verifikat list
filters from its headers. */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={!usageLoaded}
aria-label={t('usage_filter_label')}
className={cn(
'inline-flex items-center gap-1 transition-colors duration-150 hover:text-foreground disabled:opacity-50',
usageFilter !== 'all' && 'text-foreground',
)}
>
{usageFilter === 'all' ? t('col_usage') : t(USAGE_FILTER_KEY[usageFilter])}
<ListFilter className="h-3 w-3" aria-hidden />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup
value={usageFilter}
onValueChange={(v) => setUsageFilter(v as UsageFilter)}
>
{(['all', 'unused', 'used'] as const).map((value) => (
<DropdownMenuRadioItem key={value} value={value}>
{t(USAGE_FILTER_KEY[value])}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</th>
<th className={TH_CLASS}>{t('col_active')}</th>
<th className={cn(TH_CLASS, 'w-[84px]')} aria-hidden="true"></th>
</tr>
</thead>
<tbody className="stagger-enter">
{Object.entries(groupedAccounts)
.sort(([a], [b]) => Number(a) - Number(b))
.map(([cls, classAccounts]) => {
const classNum = Number(cls)
const open = !collapsedMyClasses.has(classNum) || !!searchQuery
return (
<Fragment key={cls}>
{bandRow(classNum, open, () => toggleMyClass(classNum), 8)}
{open &&
classAccounts.map((account) => (
<tr
key={account.id}
className={cn(
'group transition-colors duration-150 hover:bg-secondary/35',
// Dim the text rather than the whole row: a
// row-level opacity would drag the "Inaktiv"
// chip below readable contrast with it.
!account.is_active && 'text-muted-foreground',
)}
>
<td className={cn(TD_CLASS, 'w-[36px] pr-0 py-[9px]')}>
<Checkbox
checked={selectedNumbers.has(account.account_number)}
onCheckedChange={() => toggleSelect(account.account_number)}
aria-label={t('select_row_aria', { number: account.account_number })}
className={cn(
'border-foreground',
CHECKBOX_REVEAL_CLASS,
selectedNumbers.has(account.account_number) && 'opacity-100',
)}
/>
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap tabular-nums')}>
<AccountNumber number={account.account_number} name={account.account_name} />
</td>
<td className={cn(TD_CLASS, 'max-w-0 w-full')}>
<span className="flex min-w-0 items-center gap-1.5">
<span className="truncate">{account.account_name}</span>
{account.is_system_account && (
<span className="shrink-0 text-[10px] uppercase tracking-wider text-muted-foreground">
{t('system_badge')}
</span>
)}
{!isStandardBASAccountNumber(account.account_number) && (
<span className="shrink-0 text-[10px] uppercase tracking-wider text-muted-foreground">
{t('own_badge')}
</span>
)}
{/* Exception chip: only visible while the
"Visa inaktiva" filter is on, so it never
lands on every row. */}
{!account.is_active && (
<Badge variant="secondary" className="shrink-0">
{t('prune_inactive_badge')}
</Badge>
)}
</span>
</td>
<td className={cn(TD_CLASS, 'hidden whitespace-nowrap text-right tabular-nums text-muted-foreground sm:table-cell')}>
{account.sru_code || ''}
</td>
<td className={cn(TD_CLASS, 'hidden whitespace-nowrap text-muted-foreground md:table-cell')}>
{typeLabel(account.account_type)}
</td>
<td className={cn(TD_CLASS, 'hidden whitespace-nowrap text-right tabular-nums text-muted-foreground sm:table-cell')}>
{usageCounts.get(account.account_number) ?? ''}
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap py-[9px]')}>
<Switch
checked={account.is_active}
onCheckedChange={() => toggleActive(account)}
disabled={togglingAccount === account.account_number}
aria-label={t('col_active') + ' ' + account.account_number}
className="scale-75"
/>
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right py-[5px]')}>
<span
className={cn(
'inline-flex items-center justify-end gap-1 transition-opacity duration-150',
'opacity-0 focus-within:opacity-100 group-hover:opacity-100',
)}
>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
aria-label={tCommon('edit')}
onClick={() => setEditAccount(account)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
{!account.is_system_account && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
aria-label={t('delete_confirm_action')}
onClick={() => deleteAccount(account)}
disabled={deletingAccount === account.account_number}
>
{deletingAccount === account.account_number ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
</Button>
)}
</span>
</td>
</tr>
))}
</Fragment>
)
})}
</tbody>
</table>
</div>
</div>
)
) : (
<div>
{referenceLoading && referenceAccounts.length === 0 ? (
<div className="space-y-2 py-2">
{[1, 2, 3, 4, 5, 6].map((i) => (
<Skeleton key={i} className="h-8 w-full" />
))}
</div>
) : filteredReference.length === 0 ? (
<p className="px-1 py-12 text-center text-sm text-muted-foreground">{t('no_matches')}</p>
) : (
<div className="overflow-x-auto">
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
<th className={cn(TH_CLASS, 'w-[96px]')}>{t('col_account')}</th>
<th className={cn(TH_CLASS, 'w-full')}>{t('col_name')}</th>
<th className={cn(TH_CLASS, 'hidden text-right sm:table-cell')}>{t('col_sru')}</th>
<th className={cn(TH_CLASS, 'hidden md:table-cell')}>{t('col_type')}</th>
<th className={cn(TH_CLASS, 'text-right')}>{t('col_status')}</th>
</tr>
</thead>
<tbody className="stagger-enter">
{Object.entries(groupedReference)
.sort(([a], [b]) => Number(a) - Number(b))
.map(([cls, classAccounts]) => {
const classNum = Number(cls)
const open = expandedCatalogClasses.has(classNum) || !!searchQuery
return (
<Fragment key={cls}>
{bandRow(classNum, open, () => toggleCatalogClass(classNum), 5)}
{open &&
classAccounts.map((account) => (
<tr
key={account.account_number}
className="group transition-colors duration-150 hover:bg-secondary/35"
>
<td className={cn(TD_CLASS, 'whitespace-nowrap tabular-nums')}>
<AccountNumber number={account.account_number} name={account.account_name} />
</td>
<td
className={cn(TD_CLASS, 'max-w-0 w-full')}
title={account.description || undefined}
>
<span className="block truncate">{account.account_name}</span>
</td>
<td className={cn(TD_CLASS, 'hidden whitespace-nowrap text-right tabular-nums text-muted-foreground sm:table-cell')}>
{account.sru_code || ''}
</td>
<td className={cn(TD_CLASS, 'hidden whitespace-nowrap text-muted-foreground md:table-cell')}>
{typeLabel(account.account_type)}
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right py-[9px]')}>
{/* 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 ? (
<span className="inline-flex items-center gap-1 text-xs text-success">
<CheckCircle2 className="h-3.5 w-3.5" />
{t('activated')}
</span>
) : (
<Button
variant="outline"
size="sm"
className="h-7 px-3.5 text-xs"
onClick={() => activateBASAccount(account.account_number)}
disabled={activatingAccounts.has(account.account_number)}
>
{activatingAccounts.has(account.account_number) ? (
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
) : (
<Plus className="mr-1 h-3 w-3" />
)}
{account.is_activated ? t('reactivate') : t('add')}
</Button>
)}
</td>
</tr>
))}
</Fragment>
)
})}
</tbody>
</table>
</div>
)}
</div>
)}
{/* Footer note (concept pgnote) */}
{!loading && totalCount > 0 && (
<p className="px-1 text-xs text-muted-foreground">
{t('footer_note', { shown: shownCount, total: totalCount })}
</p>
)}
{/* Dialogs */}
<DestructiveConfirmDialog {...confirmDialogProps} />
<AddAccountDialog
open={addDialogOpen}
onOpenChange={setAddDialogOpen}
onCreated={refreshAll}
/>
<PruneAccountsDialog
open={pruneDialogOpen}
onOpenChange={setPruneDialogOpen}
onPruned={refreshAll}
/>
{editAccount && (
<EditAccountDialog
open={!!editAccount}
onOpenChange={(open) => { if (!open) setEditAccount(null) }}
account={editAccount}
onSaved={refreshAll}
/>
)}
</div>
)
}