diff --git a/app/api/bookkeeping/accounts/__tests__/accounts.test.ts b/app/api/bookkeeping/accounts/__tests__/accounts.test.ts index b5c2c864..dcf17a20 100644 --- a/app/api/bookkeeping/accounts/__tests__/accounts.test.ts +++ b/app/api/bookkeeping/accounts/__tests__/accounts.test.ts @@ -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() + }) +}) diff --git a/app/api/bookkeeping/accounts/deactivate/route.ts b/app/api/bookkeeping/accounts/deactivate/route.ts new file mode 100644 index 00000000..104548dd --- /dev/null +++ b/app/api/bookkeeping/accounts/deactivate/route.ts @@ -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( + ((usage ?? []) as { account_number: string }[]).map((u) => u.account_number), + ) + + const byNumber = new Map( + ((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 }, +) diff --git a/components/bookkeeping/ChartOfAccountsManager.tsx b/components/bookkeeping/ChartOfAccountsManager.tsx index 6757b95a..365a64e9 100644 --- a/components/bookkeeping/ChartOfAccountsManager.tsx +++ b/components/bookkeeping/ChartOfAccountsManager.tsx @@ -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('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>(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([]) @@ -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 = {} @@ -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 ? (

- {searchQuery ? t('no_matches') : t('no_accounts')} + {searchQuery || usageFilter !== 'all' ? t('no_matches') : t('no_accounts')}

) : ( +
+ {/* Bulk bar: appears with the first selection, carries the count + and the one action the selection exists for. */} + {selectedNumbers.size > 0 && ( +
+ + {selectedNumbers.size}{' '} + {t('bulkbar_selected', { count: selectedNumbers.size })} + + + {!allVisibleSelected && ( + + )} + +
+ )}
+ - + @@ -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', )} > + @@ -641,6 +837,7 @@ export default function ChartOfAccountsManager() {
+ + {t('col_account')} {t('col_name')} {t('col_sru')} {t('col_type')}{t('col_usage')} + {/* The header is the filter (#2186): pick all / never + posted to / posted to, the way the verifikat list + filters from its headers. */} + + + + + + setUsageFilter(v as UsageFilter)} + > + {(['all', 'unused', 'used'] as const).map((value) => ( + + {t(USAGE_FILTER_KEY[value])} + + ))} + + + + {t('col_active')}
+ 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', + )} + /> +
+
) ) : (
diff --git a/messages/en.json b/messages/en.json index 984db25c..0d38b8c0 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/sv.json b/messages/sv.json index 08e80003..e9fc9f0e 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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",