After a migration the chart carries hundreds of accounts nobody ever posted to, and a short kontoplan is what keeps manual bookings off the wrong account. Inactivating them one switch at a time was the only way. - The "Verifikat" column header on Mina konton is now a filter (all / without vouchers / with vouchers), the way the verifikat list filters from its headers. "Without vouchers" means absent from get_account_usage_counts, i.e. never posted to. - Rows get a selection checkbox (rest-muted, solid on hover/checked, same class as the other list pages) with select-all in the header and a bulk bar carrying the count, Inaktivera, select-all-listed and clear. - New POST /api/bookkeeping/accounts/deactivate mirrors /activate: only never-used, non-system, active accounts flip; used accounts are skipped unless include_used is set, system accounts always, and the response says what was skipped. The client partitions the selection first so the confirm states exactly what will happen before anything posts. Closes #2186 Claude-Session: https://claude.ai/code/session_01QPQLwHNEiQfiCNLSMzXMiQ Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Fable 5.1
parent
1e91c126ff
commit
601e521584
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Tests for /api/bookkeeping/accounts (list/create), /[number] (update/delete)
|
||||
* and /activate.
|
||||
* /activate and /deactivate.
|
||||
*
|
||||
* The DELETE usage check is asserted with a call-capturing mock: the count
|
||||
* query must be scoped to the caller's company via the journal_entries join —
|
||||
@@ -29,6 +29,7 @@ vi.mock('@/lib/auth/require-write', () => ({
|
||||
import { GET as listGET, POST as createPOST } from '../route'
|
||||
import { DELETE, PUT } from '../[number]/route'
|
||||
import { POST as activatePOST } from '../activate/route'
|
||||
import { POST as deactivatePOST } from '../deactivate/route'
|
||||
|
||||
interface CapturedCall {
|
||||
method: string
|
||||
@@ -624,3 +625,134 @@ describe('POST /api/bookkeeping/accounts/activate', () => {
|
||||
expect(calls.some((c) => c.method === 'insert')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/bookkeeping/accounts/deactivate', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase: {},
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
const req = createMockRequest('/api/bookkeeping/accounts/deactivate', {
|
||||
method: 'POST',
|
||||
body: { account_numbers: ['4010'] },
|
||||
})
|
||||
const res = await deactivatePOST(req, routeParams)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 400 when account_numbers is missing or empty', async () => {
|
||||
const { supabase } = createCapturingSupabase([])
|
||||
auth(supabase)
|
||||
const req = createMockRequest('/api/bookkeeping/accounts/deactivate', {
|
||||
method: 'POST',
|
||||
body: { account_numbers: [] },
|
||||
})
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(
|
||||
await deactivatePOST(req, routeParams)
|
||||
)
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toBe('account_numbers array required')
|
||||
})
|
||||
|
||||
it('returns 403 for a viewer', async () => {
|
||||
const { supabase } = createCapturingSupabase([])
|
||||
auth(supabase)
|
||||
requireWriteMock.mockResolvedValue({
|
||||
ok: false,
|
||||
response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }),
|
||||
})
|
||||
const req = createMockRequest('/api/bookkeeping/accounts/deactivate', {
|
||||
method: 'POST',
|
||||
body: { account_numbers: ['4010'] },
|
||||
})
|
||||
const res = await deactivatePOST(req, routeParams)
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
// The post-migration sweep: only never-used, non-system, active accounts
|
||||
// flip; everything else is reported back so the UI can say what it skipped.
|
||||
it('deactivates the never-used accounts and reports what it skipped', async () => {
|
||||
const { supabase, calls } = createCapturingSupabase([
|
||||
{
|
||||
data: [
|
||||
{ account_number: '4010', is_active: true, is_system_account: false }, // unused
|
||||
{ account_number: '4020', is_active: true, is_system_account: false }, // unused
|
||||
{ account_number: '5010', is_active: true, is_system_account: false }, // used
|
||||
{ account_number: '1930', is_active: true, is_system_account: true }, // system
|
||||
{ account_number: '6110', is_active: false, is_system_account: false }, // already off
|
||||
],
|
||||
},
|
||||
{ data: [{ account_number: '5010', usage_count: 12 }] }, // get_account_usage_counts
|
||||
{ data: [{ account_number: '4010' }, { account_number: '4020' }] }, // update result
|
||||
])
|
||||
auth(supabase)
|
||||
const req = createMockRequest('/api/bookkeeping/accounts/deactivate', {
|
||||
method: 'POST',
|
||||
body: { account_numbers: ['4010', '4020', '5010', '1930', '6110', '9999'] },
|
||||
})
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
deactivated: number
|
||||
skipped_system: string[]
|
||||
skipped_used: string[]
|
||||
skipped_inactive: number
|
||||
unknown: string[]
|
||||
}>(await deactivatePOST(req, routeParams))
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.deactivated).toBe(2)
|
||||
expect(body.skipped_used).toEqual(['5010'])
|
||||
expect(body.skipped_system).toEqual(['1930'])
|
||||
expect(body.skipped_inactive).toBe(1)
|
||||
expect(body.unknown).toEqual(['9999'])
|
||||
|
||||
// Usage is read through the company-scoped RPC, never an embed.
|
||||
expect(calls.find((c) => c.method === 'rpc')?.args).toEqual([
|
||||
'get_account_usage_counts',
|
||||
{ p_company_id: 'company-1' },
|
||||
])
|
||||
// The update is scoped to the company and to exactly the unused set.
|
||||
const updateCall = calls.find((c) => c.method === 'update')
|
||||
expect(updateCall?.args).toEqual([{ is_active: false }])
|
||||
const inAfterUpdate = calls.slice(calls.indexOf(updateCall!)).find((c) => c.method === 'in')
|
||||
expect(inAfterUpdate?.args).toEqual(['account_number', ['4010', '4020']])
|
||||
})
|
||||
|
||||
it('includes used accounts only when include_used is set', async () => {
|
||||
const { supabase } = createCapturingSupabase([
|
||||
{ data: [{ account_number: '5010', is_active: true, is_system_account: false }] },
|
||||
{ data: [{ account_number: '5010', usage_count: 3 }] },
|
||||
{ data: [{ account_number: '5010' }] },
|
||||
])
|
||||
auth(supabase)
|
||||
const req = createMockRequest('/api/bookkeeping/accounts/deactivate', {
|
||||
method: 'POST',
|
||||
body: { account_numbers: ['5010'], include_used: true },
|
||||
})
|
||||
const { status, body } = await parseJsonResponse<{ deactivated: number; skipped_used: string[] }>(
|
||||
await deactivatePOST(req, routeParams)
|
||||
)
|
||||
expect(status).toBe(200)
|
||||
expect(body.deactivated).toBe(1)
|
||||
expect(body.skipped_used).toEqual([])
|
||||
})
|
||||
|
||||
it('skips the update entirely when nothing qualifies', async () => {
|
||||
const { supabase, calls } = createCapturingSupabase([
|
||||
{ data: [{ account_number: '5010', is_active: true, is_system_account: false }] },
|
||||
{ data: [{ account_number: '5010', usage_count: 3 }] },
|
||||
])
|
||||
auth(supabase)
|
||||
const req = createMockRequest('/api/bookkeeping/accounts/deactivate', {
|
||||
method: 'POST',
|
||||
body: { account_numbers: ['5010'] },
|
||||
})
|
||||
const { status, body } = await parseJsonResponse<{ deactivated: number; skipped_used: string[] }>(
|
||||
await deactivatePOST(req, routeParams)
|
||||
)
|
||||
expect(status).toBe(200)
|
||||
expect(body.deactivated).toBe(0)
|
||||
expect(body.skipped_used).toEqual(['5010'])
|
||||
expect(calls.find((c) => c.method === 'update')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
/**
|
||||
* POST /api/bookkeeping/accounts/deactivate
|
||||
*
|
||||
* Batch-deactivate accounts in the company's chart. Accepts
|
||||
* { account_numbers: string[], include_used?: boolean }. The mirror of
|
||||
* /activate, built for the post-migration sweep (#2186): a chart imported from
|
||||
* a previous system carries hundreds of accounts that were never posted to,
|
||||
* and a short chart is what keeps manual bookings from landing on the wrong
|
||||
* one.
|
||||
*
|
||||
* - System accounts are never deactivated here (skipped_system).
|
||||
* - Accounts with postings are skipped unless include_used is true
|
||||
* (skipped_used): deactivating a used account is legal and reversible, but
|
||||
* it hides balances from the kontoplan, so the bulk path defaults to the
|
||||
* never-used set and leaves used accounts to the per-row toggle with its
|
||||
* confirm. Usage comes from get_account_usage_counts, the same company-
|
||||
* scoped RPC the DELETE guard and the prune dialog read.
|
||||
* - Already-inactive numbers are counted in skipped_inactive; numbers not in
|
||||
* the chart at all are reported in `unknown` rather than rejected.
|
||||
*/
|
||||
const DeactivateSchema = z.object({
|
||||
account_numbers: z.array(z.string().min(1).max(10)).min(1).max(2000),
|
||||
include_used: z.boolean().optional().default(false),
|
||||
})
|
||||
|
||||
interface ChartRow {
|
||||
account_number: string
|
||||
is_active: boolean
|
||||
is_system_account: boolean
|
||||
}
|
||||
|
||||
export const POST = withRouteContext(
|
||||
'bookkeeping.accounts.deactivate',
|
||||
async (request, ctx) => {
|
||||
const { supabase, companyId } = ctx
|
||||
|
||||
const raw = await request.json().catch(() => null)
|
||||
const parsed = DeactivateSchema.safeParse(raw)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'account_numbers array required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const uniqueNumbers = [...new Set(parsed.data.account_numbers)]
|
||||
const includeUsed = parsed.data.include_used
|
||||
|
||||
const { data: existing, error: fetchError } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('account_number, is_active, is_system_account')
|
||||
.eq('company_id', companyId)
|
||||
.in('account_number', uniqueNumbers)
|
||||
|
||||
if (fetchError) {
|
||||
return NextResponse.json({ error: getUserErrorMessage(fetchError) }, { status: 500 })
|
||||
}
|
||||
|
||||
const { data: usage, error: usageError } = await supabase.rpc('get_account_usage_counts', {
|
||||
p_company_id: companyId,
|
||||
})
|
||||
if (usageError) {
|
||||
return NextResponse.json({ error: getUserErrorMessage(usageError) }, { status: 500 })
|
||||
}
|
||||
// Accounts never posted to are simply absent from the RPC result.
|
||||
const usedNumbers = new Set<string>(
|
||||
((usage ?? []) as { account_number: string }[]).map((u) => u.account_number),
|
||||
)
|
||||
|
||||
const byNumber = new Map<string, ChartRow>(
|
||||
((existing || []) as ChartRow[]).map((a) => [a.account_number, a]),
|
||||
)
|
||||
|
||||
const toDeactivate: string[] = []
|
||||
const skippedSystem: string[] = []
|
||||
const skippedUsed: string[] = []
|
||||
const unknown: string[] = []
|
||||
let skippedInactive = 0
|
||||
|
||||
for (const num of uniqueNumbers) {
|
||||
const row = byNumber.get(num)
|
||||
if (!row) {
|
||||
unknown.push(num)
|
||||
continue
|
||||
}
|
||||
if (!row.is_active) {
|
||||
skippedInactive += 1
|
||||
continue
|
||||
}
|
||||
if (row.is_system_account) {
|
||||
skippedSystem.push(num)
|
||||
continue
|
||||
}
|
||||
if (usedNumbers.has(num) && !includeUsed) {
|
||||
skippedUsed.push(num)
|
||||
continue
|
||||
}
|
||||
toDeactivate.push(num)
|
||||
}
|
||||
|
||||
let deactivatedRows: { account_number: string }[] = []
|
||||
if (toDeactivate.length > 0) {
|
||||
const { data, error } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.update({ is_active: false })
|
||||
.eq('company_id', companyId)
|
||||
.in('account_number', toDeactivate)
|
||||
.select('account_number')
|
||||
if (error) {
|
||||
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
|
||||
}
|
||||
deactivatedRows = data || []
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: deactivatedRows,
|
||||
deactivated: deactivatedRows.length,
|
||||
skipped_system: skippedSystem,
|
||||
skipped_used: skippedUsed,
|
||||
skipped_inactive: skippedInactive,
|
||||
unknown,
|
||||
})
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
@@ -8,7 +8,15 @@ 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 { 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 {
|
||||
@@ -20,6 +28,7 @@ import { EditAccountDialog } from './EditAccountDialog'
|
||||
import { PruneAccountsDialog } from './PruneAccountsDialog'
|
||||
import {
|
||||
ChevronRight,
|
||||
ListFilter,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
@@ -44,6 +53,20 @@ interface ReferenceAccount extends BASReferenceAccount {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -83,6 +106,14 @@ export default function ChartOfAccountsManager() {
|
||||
// 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[]>([])
|
||||
@@ -166,6 +197,7 @@ export default function ChartOfAccountsManager() {
|
||||
]),
|
||||
),
|
||||
)
|
||||
setUsageLoaded(true)
|
||||
} catch {
|
||||
// Leave the map empty — usage display is informational only.
|
||||
}
|
||||
@@ -271,6 +303,68 @@ export default function ChartOfAccountsManager() {
|
||||
}
|
||||
}
|
||||
|
||||
// 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'),
|
||||
@@ -364,12 +458,33 @@ export default function ChartOfAccountsManager() {
|
||||
// -------------------------------------------
|
||||
|
||||
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])
|
||||
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[]> = {}
|
||||
@@ -431,6 +546,7 @@ export default function ChartOfAccountsManager() {
|
||||
|
||||
const switchView = (next: 'my-accounts' | 'bas-catalog') => {
|
||||
setView(next)
|
||||
setSelectedNumbers(new Set())
|
||||
setCollapsedMyClasses(new Set())
|
||||
setExpandedCatalogClasses(new Set())
|
||||
if (next === 'bas-catalog') void ensureReferenceLoaded()
|
||||
@@ -511,18 +627,86 @@ export default function ChartOfAccountsManager() {
|
||||
) : view === 'my-accounts' ? (
|
||||
filteredAccounts.length === 0 ? (
|
||||
<p className="px-1 py-12 text-center text-sm text-muted-foreground">
|
||||
{searchQuery ? t('no_matches') : t('no_accounts')}
|
||||
{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')}>{t('col_usage')}</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>
|
||||
@@ -541,7 +725,7 @@ export default function ChartOfAccountsManager() {
|
||||
open,
|
||||
() => toggleMyClass(classNum),
|
||||
t('active_count_label', { active: activeCount, total: classAccounts.length }),
|
||||
7,
|
||||
8,
|
||||
)}
|
||||
{open &&
|
||||
classAccounts.map((account) => (
|
||||
@@ -555,6 +739,18 @@ export default function ChartOfAccountsManager() {
|
||||
!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>
|
||||
@@ -641,6 +837,7 @@ export default function ChartOfAccountsManager() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div>
|
||||
|
||||
@@ -5285,6 +5285,21 @@
|
||||
"prune_empty": "No unused accounts to clean up — your chart of accounts is already clean.",
|
||||
"prune_confirm": "Delete {count} accounts",
|
||||
"prune_select_all": "Select all unused accounts",
|
||||
"select_all_aria": "Select all listed accounts",
|
||||
"select_row_aria": "Select account {number}",
|
||||
"usage_filter_label": "Filter by vouchers",
|
||||
"usage_filter_all": "All accounts",
|
||||
"usage_filter_unused": "Without vouchers",
|
||||
"usage_filter_used": "With vouchers",
|
||||
"bulkbar_selected": "{count, plural, one {account selected} other {accounts selected}}",
|
||||
"bulk_select_all": "Select all listed ({count})",
|
||||
"bulk_clear_selection": "Clear selection",
|
||||
"bulk_deactivate_confirm_title": "Deactivate {count} accounts?",
|
||||
"bulk_deactivate_confirm": "{count} accounts without vouchers will be deactivated. Existing bookkeeping is unaffected, and the accounts can be reactivated from the BAS catalog.",
|
||||
"bulk_deactivate_skipped_used": "{count} selected accounts with vouchers are skipped: deactivate those one at a time if that is intended.",
|
||||
"bulk_deactivate_skipped_system": "{count} system accounts are skipped.",
|
||||
"bulk_deactivate_none_unused": "No unused accounts in the selection. Accounts with vouchers are deactivated one at a time.",
|
||||
"bulk_deactivate_done": "{count} accounts deactivated",
|
||||
"prune_confirm_title": "Delete {count} accounts?",
|
||||
"prune_confirm_body": "The selected accounts will be permanently deleted. This cannot be undone.",
|
||||
"toast_pruned_title": "Chart of accounts cleaned",
|
||||
|
||||
@@ -5285,6 +5285,21 @@
|
||||
"prune_empty": "Inga oanvända konton att rensa — din kontoplan är redan ren.",
|
||||
"prune_confirm": "Ta bort {count} konton",
|
||||
"prune_select_all": "Markera alla oanvända konton",
|
||||
"select_all_aria": "Markera alla visade konton",
|
||||
"select_row_aria": "Markera konto {number}",
|
||||
"usage_filter_label": "Filtrera på verifikat",
|
||||
"usage_filter_all": "Alla konton",
|
||||
"usage_filter_unused": "Utan verifikat",
|
||||
"usage_filter_used": "Med verifikat",
|
||||
"bulkbar_selected": "{count, plural, one {konto markerat} other {konton markerade}}",
|
||||
"bulk_select_all": "Markera alla visade ({count})",
|
||||
"bulk_clear_selection": "Avmarkera",
|
||||
"bulk_deactivate_confirm_title": "Inaktivera {count} konton?",
|
||||
"bulk_deactivate_confirm": "{count} konton utan verifikat inaktiveras. Befintlig bokföring påverkas inte, och kontona kan aktiveras igen från BAS-katalogen.",
|
||||
"bulk_deactivate_skipped_used": "{count} markerade konton med verifikat hoppas över: inaktivera dem ett i taget om det är avsiktligt.",
|
||||
"bulk_deactivate_skipped_system": "{count} systemkonton hoppas över.",
|
||||
"bulk_deactivate_none_unused": "Inga oanvända konton i urvalet. Konton med verifikat inaktiveras ett i taget.",
|
||||
"bulk_deactivate_done": "{count} konton inaktiverade",
|
||||
"prune_confirm_title": "Ta bort {count} konton?",
|
||||
"prune_confirm_body": "De valda kontona tas bort permanent. Detta går inte att ångra.",
|
||||
"toast_pruned_title": "Kontoplan rensad",
|
||||
|
||||
Reference in New Issue
Block a user