feat(dimensions): voucher-level retro-tagging workbench — reversal pairs hidden by default (#874)

* feat(dimensions): voucher-level retro-tagging workbench, reversal pairs hidden by default

UX rework of the BulkTagWorkbench (follow-up to #867): the verifikat is now
the unit of work, matching how users think ('that invoice belongs to project
X') and how every Swedish bookkeeping tool presents entries.

- /api/dimensions/tagging/lines returns voucher-grouped results via a
  two-step query: filters select QUALIFYING vouchers (line-level predicates
  become 'voucher has such a line' through the inner join), then the
  complete line set for each — tagging a voucher always covers the whole
  verifikat, never the filtered subset of one
- reversal pairs are EXCLUDED by default: an annulled entry and its storno
  net to zero in every dimension bucket as long as both sides carry the
  same tag, so retro-tagging them is a no-op with an asymmetry foot-gun
  attached; 'Visa annullerade' opts them back in, and the blocking
  motverifikat confirmation survives only in that view (correction rebooks
  remain taggable — only the original+storno pair is hidden)
- voucher rows: label, description, date, line count, distinct tag chips +
  'Delvis taggad' state, single total amount (no debit/credit columns);
  chevron expands to per-line rows (signed amount, per-line checkboxes)
  for the mixed case (a voucher split across projects)
- selection stays line-id based under the hood (the retag RPC is per
  line); shift-click ranges operate on vouchers; apply groups by resulting
  map and now chunks to the apply route's 500-line cap; failed vouchers
  auto-expand with their Swedish RPC errors inline
- 'endast otaggade' now means 'vouchers with at least one untagged line';
  the cap counts vouchers (default 150, max 300)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: pull counter-vouchers into the annulled view even outside filters (review)

Without this, a pair leg whose counter fell outside the date range would
show no motverifikat warning at all — one-sided tagging would slip
through silently, exactly the Srf U 14 skew the guard exists to prevent.
Also scope the line fetch explicitly through the parent company filter
(defense in depth).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-03 10:11:07 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 45438d4d64
commit 28827c2613
4 changed files with 706 additions and 308 deletions
@@ -1,9 +1,11 @@
/**
* Tests for GET /api/dimensions/tagging/lines (bulk retro-tagging browser).
* Tests for GET /api/dimensions/tagging/lines (bulk retro-tagging browser,
* voucher-level rework).
*
* Covers: 401, query validation (400), the happy path (flattened DTO,
* date-sorted, total_capped false), the hard-cap contract (limit+1 fetch →
* total_capped true), and the DB error path.
* Covers: 401, query validation (400), the voucher-grouped happy path (two
* queries: qualifying entries → complete line sets), the hard-cap contract
* (limit+1 entries → total_capped true, second query only for the page), the
* empty result short-circuit (no line query), and the DB error paths.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
@@ -33,25 +35,46 @@ const noParams = { params: Promise.resolve({}) }
const request = (searchParams?: Record<string, string>) =>
createMockRequest('/api/dimensions/tagging/lines', { searchParams })
interface FlatLine {
interface VoucherLine {
id: string
account_number: string
debit_amount: number
credit_amount: number
dimensions: Record<string, string>
}
interface Voucher {
journal_entry_id: string
entry_date: string
voucher_number: number | null
voucher_series: string | null
description: string
annulled: boolean
reversed_by_id: string | null
reverses_id: string | null
fiscal_period_id: string
lines: VoucherLine[]
}
type LinesBody = { data: { lines: FlatLine[]; total_capped: boolean } }
type VouchersBody = { data: { vouchers: Voucher[]; total_capped: boolean } }
/** Raw row as the Supabase select returns it (nested journal_entries). */
/** Raw entry row as the Supabase step-1 select returns it. */
function makeRawEntry(overrides: Record<string, unknown> = {}) {
return {
id: 'entry-1',
entry_date: '2026-03-10',
voucher_number: 42,
voucher_series: 'A',
description: 'Inköp material',
reversed_by_id: null,
reverses_id: null,
fiscal_period_id: 'period-1',
journal_entry_lines: [{ id: 'line-1' }],
...overrides,
}
}
/** Raw line row as the Supabase step-2 select returns it. */
function makeRawLine(overrides: Record<string, unknown> = {}) {
return {
id: 'line-1',
@@ -60,15 +83,7 @@ function makeRawLine(overrides: Record<string, unknown> = {}) {
credit_amount: 0,
dimensions: { '1': 'KS01' },
journal_entry_id: 'entry-1',
journal_entries: {
entry_date: '2026-03-10',
voucher_number: 42,
voucher_series: 'A',
description: 'Inköp material',
reversed_by_id: null,
reverses_id: null,
fiscal_period_id: 'period-1',
},
sort_order: 0,
...overrides,
}
}
@@ -108,89 +123,166 @@ describe('GET /api/dimensions/tagging/lines', () => {
expect(response.status).toBe(400)
})
it('returns flattened lines sorted by entry date, total_capped false', async () => {
it('groups complete line sets under their vouchers', async () => {
// Step 1: qualifying entries (annulled linkage rides along for the pair
// guard in the opt-in view).
enqueue({
data: [
makeRawLine({
id: 'line-2',
journal_entries: {
entry_date: '2026-04-01',
voucher_number: 50,
voucher_series: 'A',
description: 'Senare verifikat',
reversed_by_id: 'entry-9',
reverses_id: null,
fiscal_period_id: 'period-1',
},
makeRawEntry(),
makeRawEntry({
id: 'entry-2',
entry_date: '2026-04-01',
voucher_number: 50,
description: 'Senare verifikat',
reversed_by_id: 'entry-9',
}),
makeRawLine({ id: 'line-1', dimensions: {} }),
],
})
// Step 2: the COMPLETE line sets — entry-1 has a line the account filter
// would not have matched; it must still be present (whole-verifikat
// contract, null dimensions normalized to {}).
enqueue({
data: [
makeRawLine(),
makeRawLine({ id: 'line-2', account_number: '1930', debit_amount: 0, credit_amount: 100, dimensions: null, sort_order: 1 }),
makeRawLine({ id: 'line-3', journal_entry_id: 'entry-2', dimensions: {} }),
],
})
const response = await GET(request(), noParams)
const { status, body } = await parseJsonResponse<LinesBody>(response)
const { status, body } = await parseJsonResponse<VouchersBody>(response)
expect(status).toBe(200)
expect(body.data.total_capped).toBe(false)
expect(body.data.lines).toHaveLength(2)
// Sorted by entry_date: line-1 (2026-03-10) before line-2 (2026-04-01).
expect(body.data.lines[0]).toMatchObject({
id: 'line-1',
account_number: '4010',
debit_amount: 100,
credit_amount: 0,
dimensions: {},
expect(body.data.vouchers).toHaveLength(2)
const [first, second] = body.data.vouchers
expect(first).toMatchObject({
journal_entry_id: 'entry-1',
entry_date: '2026-03-10',
voucher_number: 42,
voucher_series: 'A',
description: 'Inköp material',
fiscal_period_id: 'period-1',
annulled: false,
})
// Reversal linkage rides along for the storno-pair warning.
expect(body.data.lines[1].reversed_by_id).toBe('entry-9')
expect(first.lines).toHaveLength(2)
expect(first.lines[0]).toMatchObject({ id: 'line-1', dimensions: { '1': 'KS01' } })
expect(first.lines[1]).toMatchObject({ id: 'line-2', account_number: '1930', dimensions: {} })
expect(second.annulled).toBe(true)
expect(second.reversed_by_id).toBe('entry-9')
expect(second.lines.map((l) => l.id)).toEqual(['line-3'])
})
it('normalizes a null dimensions map to {}', async () => {
enqueue({ data: [makeRawLine({ dimensions: null })] })
const response = await GET(request(), noParams)
const { status, body } = await parseJsonResponse<LinesBody>(response)
expect(status).toBe(200)
expect(body.data.lines[0].dimensions).toEqual({})
})
it('caps the result at limit and reports total_capped', async () => {
// limit=2 → route fetches 3; a third row means "there is more".
it('caps the result at limit vouchers and reports total_capped', async () => {
// limit=2 → route fetches 3 entries; a third means "there is more".
enqueue({
data: [
makeRawLine({ id: 'line-1' }),
makeRawLine({ id: 'line-2' }),
makeRawLine({ id: 'line-3' }),
makeRawEntry({ id: 'entry-1' }),
makeRawEntry({ id: 'entry-2' }),
makeRawEntry({ id: 'entry-3' }),
],
})
enqueue({
data: [
makeRawLine({ id: 'line-1', journal_entry_id: 'entry-1' }),
makeRawLine({ id: 'line-2', journal_entry_id: 'entry-2' }),
],
})
const response = await GET(request({ limit: '2' }), noParams)
const { status, body } = await parseJsonResponse<LinesBody>(response)
const { status, body } = await parseJsonResponse<VouchersBody>(response)
expect(status).toBe(200)
expect(body.data.lines).toHaveLength(2)
expect(body.data.vouchers).toHaveLength(2)
expect(body.data.vouchers.map((v) => v.journal_entry_id)).toEqual(['entry-1', 'entry-2'])
expect(body.data.total_capped).toBe(true)
})
it('returns an empty list when nothing matches', async () => {
it('short-circuits an empty entry page without a line query', async () => {
enqueue({ data: [] })
const response = await GET(request({ only_untagged: '1' }), noParams)
const { status, body } = await parseJsonResponse<LinesBody>(response)
const { status, body } = await parseJsonResponse<VouchersBody>(response)
expect(status).toBe(200)
expect(body.data.lines).toEqual([])
expect(body.data.vouchers).toEqual([])
expect(body.data.total_capped).toBe(false)
})
it('returns 500 when the query fails', async () => {
it('include_annulled=1 pulls in a counter-voucher that fell outside the filters', async () => {
// Step 1 returns only the storno leg (its original, entry-0, is outside
// the date range). The route must fetch entry-0 anyway — otherwise the
// workbench's motverifikat guard cannot see the missing leg and one-sided
// tagging slips through silently.
enqueue({ data: [makeRawEntry({ reverses_id: 'entry-0', entry_date: '2026-05-01' })] })
// Counter-voucher fetch by id.
enqueue({
data: [
makeRawEntry({
id: 'entry-0',
entry_date: '2026-02-01',
voucher_number: 40,
description: 'Original',
reversed_by_id: 'entry-1',
}),
],
})
// Complete line sets for BOTH vouchers.
enqueue({
data: [
makeRawLine({ id: 'line-0', journal_entry_id: 'entry-0' }),
makeRawLine(),
],
})
const response = await GET(request({ include_annulled: '1' }), noParams)
const { status, body } = await parseJsonResponse<VouchersBody>(response)
expect(status).toBe(200)
expect(body.data.vouchers).toHaveLength(2)
// Sorted by entry_date: the pulled-in original first.
expect(body.data.vouchers[0]).toMatchObject({
journal_entry_id: 'entry-0',
annulled: true,
lines: [{ id: 'line-0' }],
})
expect(body.data.vouchers[1].annulled).toBe(true)
})
it('include_annulled=1 skips the counter fetch when both legs already qualified', async () => {
enqueue({
data: [
makeRawEntry({ reversed_by_id: 'entry-2' }),
makeRawEntry({ id: 'entry-2', reverses_id: 'entry-1', voucher_number: 43 }),
],
})
// No counter query — next enqueued result is the line fetch.
enqueue({
data: [
makeRawLine(),
makeRawLine({ id: 'line-2', journal_entry_id: 'entry-2' }),
],
})
const response = await GET(request({ include_annulled: '1' }), noParams)
const { status, body } = await parseJsonResponse<VouchersBody>(response)
expect(status).toBe(200)
expect(body.data.vouchers).toHaveLength(2)
expect(body.data.vouchers.every((v) => v.annulled)).toBe(true)
})
it('returns 500 when the entry query fails', async () => {
enqueue({ error: { message: 'relation missing' } })
const response = await GET(request(), noParams)
expect(response.status).toBe(500)
})
it('returns 500 when the line query fails', async () => {
enqueue({ data: [makeRawEntry()] })
enqueue({ error: { message: 'relation missing' } })
const response = await GET(request(), noParams)
+156 -68
View File
@@ -1,18 +1,27 @@
/**
* GET /api/dimensions/tagging/lines — posted journal-entry lines for the bulk
* retro-tagging workbench (dimensions plan PR6 §3).
* GET /api/dimensions/tagging/lines — posted VOUCHERS (with their complete
* line sets) for the bulk retro-tagging workbench (dimensions plan PR6 §3,
* voucher-level rework).
*
* Read-only line browser: filter by period, entry-date range, account range,
* free text (ilike on the entry description) and "only untagged" (empty
* dimensions map). Hard cap instead of pagination for v1 — the route fetches
* limit+1 rows and reports `total_capped: true` so the UI can show a
* "narrow your filter" notice.
* The verifikat is the unit of work: filters select qualifying vouchers
* (period, entry-date range, free text, "has a line in the account range",
* "has an untagged line") and the response carries every line of each
* qualifying voucher so tagging a voucher means tagging a complete verifikat
* — never the filtered subset of one.
*
* Response: 200 { data: { lines: [...], total_capped: boolean } } where each
* line is flattened ({ id, account_number, debit_amount, credit_amount,
* dimensions, journal_entry_id, entry_date, voucher_number, voucher_series,
* description, reversed_by_id, reverses_id, fiscal_period_id }). The reversal
* linkage rides along so the workbench can warn about storno pairs.
* Reversal pairs are EXCLUDED by default: an annulled entry and its storno
* net to zero in every dimension bucket as long as both sides carry the same
* tag, so retro-tagging them is a no-op with an asymmetry foot-gun attached.
* `include_annulled=1` opts them back in (the workbench keeps its blocking
* motverifikat confirmation in that view only).
*
* Hard cap counts VOUCHERS (limit+1 fetch → `total_capped: true`); the UI
* shows a "narrow your filter" notice.
*
* Response: 200 { data: { vouchers: [...], total_capped } } where each
* voucher is { journal_entry_id, entry_date, voucher_number, voucher_series,
* description, annulled, lines: [{ id, account_number, debit_amount,
* credit_amount, dimensions }] }.
*/
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
@@ -24,24 +33,25 @@ import { escapeLikePattern } from '@/lib/invoices/duplicate-payment-guard'
ensureInitialized()
interface RawTaggingLine {
interface RawEntry {
id: string
entry_date: string
voucher_number: number | null
voucher_series: string | null
description: string
reversed_by_id: string | null
reverses_id: string | null
fiscal_period_id: string
}
interface RawLine {
id: string
account_number: string
debit_amount: number
credit_amount: number
dimensions: Record<string, string> | null
journal_entry_id: string
// Supabase types !inner joins as arrays; for many-to-one (line → entry) it
// returns a single object at runtime (same caveat as lib/reports/general-ledger.ts).
journal_entries: {
entry_date: string
voucher_number: number | null
voucher_series: string | null
description: string
reversed_by_id: string | null
reverses_id: string | null
fiscal_period_id: string
}
sort_order: number | null
}
export const GET = withRouteContext(
@@ -56,74 +66,152 @@ export const GET = withRouteContext(
if (!validation.success) return validation.response
const q = validation.data
let query = supabase
.from('journal_entry_lines')
// Step 1: qualifying vouchers. Line-level filters (account range, only
// untagged) become "voucher HAS such a line" via the inner join — the
// parent row appears once regardless of how many lines match.
let entryQuery = supabase
.from('journal_entries')
.select(
'id, account_number, debit_amount, credit_amount, dimensions, journal_entry_id, journal_entries!inner(entry_date, voucher_number, voucher_series, description, reversed_by_id, reverses_id, fiscal_period_id, company_id, status)',
'id, entry_date, voucher_number, voucher_series, description, reversed_by_id, reverses_id, fiscal_period_id, journal_entry_lines!inner(id)',
)
.eq('journal_entries.company_id', companyId)
.eq('company_id', companyId)
// Posted only — drafts are edited directly in the voucher editor and the
// retag RPC rejects them anyway.
.eq('journal_entries.status', 'posted')
.eq('status', 'posted')
if (q.period_id) query = query.eq('journal_entries.fiscal_period_id', q.period_id)
if (q.date_from) query = query.gte('journal_entries.entry_date', q.date_from)
if (q.date_to) query = query.lte('journal_entries.entry_date', q.date_to)
if (q.account_from) query = query.gte('account_number', q.account_from)
if (q.account_to) query = query.lte('account_number', q.account_to)
if (q.include_annulled !== '1') {
// Default view: no reversal pairs. Both sides net to zero in every
// dimension bucket when kept together, so they are pure noise here —
// and hiding them makes tagging one side without the other impossible.
entryQuery = entryQuery.is('reversed_by_id', null).is('reverses_id', null)
}
if (q.period_id) entryQuery = entryQuery.eq('fiscal_period_id', q.period_id)
if (q.date_from) entryQuery = entryQuery.gte('entry_date', q.date_from)
if (q.date_to) entryQuery = entryQuery.lte('entry_date', q.date_to)
if (q.text) {
// Escape LIKE wildcards (\ % _) so they match literally — same posture
// as the journal-entries list route.
query = query.ilike('journal_entries.description', `%${escapeLikePattern(q.text)}%`)
entryQuery = entryQuery.ilike('description', `%${escapeLikePattern(q.text)}%`)
}
if (q.account_from) {
entryQuery = entryQuery.gte('journal_entry_lines.account_number', q.account_from)
}
if (q.account_to) {
entryQuery = entryQuery.lte('journal_entry_lines.account_number', q.account_to)
}
if (q.only_untagged === '1') {
// dimensions is NOT NULL DEFAULT '{}' (substrate migration), so the
// empty-map equality is the complete "untagged" predicate.
query = query.eq('dimensions', '{}')
// empty-map equality is the complete "untagged" predicate. Combined
// with an account range this reads "has an untagged line in the range".
entryQuery = entryQuery.eq('journal_entry_lines.dimensions', '{}')
}
// Deterministic order on the line PK; fetch one row past the cap so the
// response can say "there is more" without a count query.
const { data, error } = await query
const { data: entryData, error: entryError } = await entryQuery
.order('entry_date', { ascending: true })
.order('voucher_series', { ascending: true })
.order('voucher_number', { ascending: true })
.order('id', { ascending: true })
.limit(q.limit + 1)
if (error) {
log.error('tagging line browse failed', error)
return errorResponse(error, log, { requestId })
if (entryError) {
log.error('tagging voucher browse failed', entryError)
return errorResponse(entryError, log, { requestId })
}
const raw = (data ?? []) as unknown as RawTaggingLine[]
const totalCapped = raw.length > q.limit
const page = totalCapped ? raw.slice(0, q.limit) : raw
const rawEntries = (entryData ?? []) as unknown as RawEntry[]
const totalCapped = rawEntries.length > q.limit
const entries = totalCapped ? rawEntries.slice(0, q.limit) : rawEntries
const lines = page
.map((l) => ({
if (entries.length === 0) {
return NextResponse.json({ data: { vouchers: [], total_capped: totalCapped } })
}
// Pair completion (annulled view only): if a pair leg qualified but its
// counter-entry fell outside the filters (e.g. the storno is in a later
// month than the date range), pull the counter in anyway. Without it the
// workbench's motverifikat guard cannot see the missing leg and the user
// could tag one side alone — exactly the P&L skew the guard exists to
// prevent (Srf U 14 gross reporting). Counters ride on top of the cap:
// they are required for correctness, not part of the browsed page.
if (q.include_annulled === '1') {
const present = new Set(entries.map((e) => e.id))
const counterIds = [
...new Set(
entries
.flatMap((e) => [e.reversed_by_id, e.reverses_id])
.filter((id): id is string => id !== null && !present.has(id)),
),
]
if (counterIds.length > 0) {
const { data: counterData, error: counterError } = await supabase
.from('journal_entries')
.select(
'id, entry_date, voucher_number, voucher_series, description, reversed_by_id, reverses_id, fiscal_period_id',
)
.eq('company_id', companyId)
.eq('status', 'posted')
.in('id', counterIds)
if (counterError) {
log.error('tagging counter-voucher fetch failed', counterError)
return errorResponse(counterError, log, { requestId })
}
entries.push(...((counterData ?? []) as unknown as RawEntry[]))
entries.sort(
(a, b) =>
a.entry_date.localeCompare(b.entry_date) ||
(a.voucher_series ?? '').localeCompare(b.voucher_series ?? '') ||
(a.voucher_number ?? 0) - (b.voucher_number ?? 0) ||
a.id.localeCompare(b.id),
)
}
}
// Step 2: the COMPLETE line set for each qualifying voucher, so voucher-
// level tagging always covers the whole verifikat. The entry IDs already
// come from the company-filtered step-1 query; the explicit parent scope
// here is defense in depth (repo convention).
const { data: lineData, error: lineError } = await supabase
.from('journal_entry_lines')
.select('id, account_number, debit_amount, credit_amount, dimensions, journal_entry_id, sort_order, journal_entries!inner(company_id)')
.eq('journal_entries.company_id', companyId)
.in('journal_entry_id', entries.map((e) => e.id))
.order('journal_entry_id', { ascending: true })
.order('sort_order', { ascending: true })
.order('id', { ascending: true })
if (lineError) {
log.error('tagging voucher line fetch failed', lineError)
return errorResponse(lineError, log, { requestId })
}
const linesByEntry = new Map<string, RawLine[]>()
for (const line of (lineData ?? []) as unknown as RawLine[]) {
const bucket = linesByEntry.get(line.journal_entry_id) ?? []
bucket.push(line)
linesByEntry.set(line.journal_entry_id, bucket)
}
const vouchers = entries.map((e) => ({
journal_entry_id: e.id,
entry_date: e.entry_date,
voucher_number: e.voucher_number,
voucher_series: e.voucher_series,
description: e.description,
annulled: Boolean(e.reversed_by_id || e.reverses_id),
reversed_by_id: e.reversed_by_id,
reverses_id: e.reverses_id,
fiscal_period_id: e.fiscal_period_id,
lines: (linesByEntry.get(e.id) ?? []).map((l) => ({
id: l.id,
account_number: l.account_number,
debit_amount: l.debit_amount,
credit_amount: l.credit_amount,
dimensions: l.dimensions ?? {},
journal_entry_id: l.journal_entry_id,
entry_date: l.journal_entries.entry_date,
voucher_number: l.journal_entries.voucher_number,
voucher_series: l.journal_entries.voucher_series,
description: l.journal_entries.description,
reversed_by_id: l.journal_entries.reversed_by_id,
reverses_id: l.journal_entries.reverses_id,
fiscal_period_id: l.journal_entries.fiscal_period_id,
}))
// Presentation order: date, then voucher, then line id. Sorting happens
// after the cap (the cap follows insertion-ordered PKs) — acceptable for
// the v1 hard-cap contract; the UI shows a narrow-your-filter notice.
.sort(
(a, b) =>
a.entry_date.localeCompare(b.entry_date) ||
(a.voucher_series ?? '').localeCompare(b.voucher_series ?? '') ||
(a.voucher_number ?? 0) - (b.voucher_number ?? 0) ||
a.id.localeCompare(b.id),
)
})),
}))
return NextResponse.json({ data: { lines, total_capped: totalCapped } })
return NextResponse.json({ data: { vouchers, total_capped: totalCapped } })
},
)
+386 -176
View File
@@ -1,7 +1,16 @@
'use client'
import { useCallback, useMemo, useRef, useState } from 'react'
import { AlertTriangle, Loader2, Search, Tags, Undo2, X } from 'lucide-react'
import {
AlertTriangle,
ChevronDown,
ChevronRight,
Loader2,
Search,
Tags,
Undo2,
X,
} from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
@@ -29,21 +38,27 @@ import { useCanWrite } from '@/lib/hooks/use-can-write'
import { formatCurrency, formatDate } from '@/lib/utils'
import LineDimensionFields from '@/components/dimensions/LineDimensionFields'
/** Flattened line DTO from GET /api/dimensions/tagging/lines. */
/** Line DTO inside a voucher from GET /api/dimensions/tagging/lines. */
interface TaggingLine {
id: string
account_number: string
debit_amount: number
credit_amount: number
dimensions: Record<string, string>
}
/** Voucher DTO from GET /api/dimensions/tagging/lines. */
interface TaggingVoucher {
journal_entry_id: string
entry_date: string
voucher_number: number | null
voucher_series: string | null
description: string
annulled: boolean
reversed_by_id: string | null
reverses_id: string | null
fiscal_period_id: string
lines: TaggingLine[]
}
interface ApplyResult {
@@ -53,6 +68,8 @@ interface ApplyResult {
}
const ACCOUNT_RE = /^\d{4}$/
/** POST /api/dimensions/tagging/apply accepts at most 500 line_ids per call. */
const APPLY_CHUNK = 500
function dimensionLabel(sieDimNo: string): string {
if (sieDimNo === '1') return 'KS'
@@ -69,16 +86,48 @@ function mapKey(dims: Record<string, string>): string {
)
}
function voucherLabel(v: TaggingVoucher): string {
return `${v.voucher_series ?? ''}${v.voucher_number ?? ''}`
}
/**
* Bulk retro-tagging workbench (dimensions plan PR6 §3, Retrofit UX): browse
* posted lines, select (shift-click ranges supported), pick KS/Projekt values
* and apply them through the audited retag RPC. Merge mode (default) layers
* the picked values onto each line's existing map; "Ersätt tagg" replaces the
* whole map — used to consolidate typo/phantom codes.
* Distinct non-empty bags across a voucher's lines (insertion order), plus
* whether the voucher mixes tagged and untagged lines ("delvis taggad").
*/
function voucherTagState(v: TaggingVoucher): {
bags: Record<string, string>[]
partial: boolean
} {
const seen = new Map<string, Record<string, string>>()
let tagged = 0
for (const line of v.lines) {
if (Object.keys(line.dimensions).length === 0) continue
tagged++
const key = mapKey(line.dimensions)
if (!seen.has(key)) seen.set(key, line.dimensions)
}
return {
bags: [...seen.values()],
partial: tagged > 0 && tagged < v.lines.length,
}
}
/**
* Bulk retro-tagging workbench (dimensions plan PR6 §3, voucher-level
* rework): browse posted VERIFIKAT, select whole vouchers (shift-click
* ranges), pick KS/Projekt values and apply them to every line through the
* audited retag RPC — retroactive tagging produces exactly what tagging at
* creation would have (the producers stamp all lines too). Rows expand to
* their lines for the mixed case (a voucher split across projects).
*
* Strings are hardcoded Swedish per the dimensions-surface convention
* (DimensionCombobox/LineDimensionFields): this operates directly on
* verifikat, a stays-Swedish surface per .claude/rules/i18n.md.
* Reversal pairs are hidden by default (they net to zero in every dimension
* bucket when kept symmetric — tagging them is a no-op, and tagging one side
* only is the one way to skew project P&L). "Visa annullerade" opts them in;
* the blocking motverifikat confirmation survives only there.
*
* Merge mode (default) layers picked values onto each line's existing map;
* "Ersätt tagg" replaces the whole map — used to consolidate typo/phantom
* codes. Strings hardcoded Swedish per the dimensions-surface convention.
*/
export default function BulkTagWorkbench() {
const { toast } = useToast()
@@ -91,14 +140,16 @@ export default function BulkTagWorkbench() {
const [accountTo, setAccountTo] = useState('')
const [text, setText] = useState('')
const [onlyUntagged, setOnlyUntagged] = useState(false)
const [showAnnulled, setShowAnnulled] = useState(false)
// Result set (null = never fetched)
const [lines, setLines] = useState<TaggingLine[] | null>(null)
const [vouchers, setVouchers] = useState<TaggingVoucher[] | null>(null)
const [totalCapped, setTotalCapped] = useState(false)
const [isLoading, setIsLoading] = useState(false)
// Selection + apply panel
// Selection (line-id based — the retag RPC is per line), expansion + apply
const [selected, setSelected] = useState<Set<string>>(new Set())
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const anchorIndexRef = useRef<number | null>(null)
const [picked, setPicked] = useState<Record<string, string>>({})
const [replaceMode, setReplaceMode] = useState(false)
@@ -106,7 +157,7 @@ export default function BulkTagWorkbench() {
const [isApplying, setIsApplying] = useState(false)
const [rowErrors, setRowErrors] = useState<Record<string, string>>({})
const loadLines = useCallback(async () => {
const loadVouchers = useCallback(async () => {
for (const [label, value] of [
['Konto från', accountFrom],
['Konto till', accountTo],
@@ -130,94 +181,135 @@ export default function BulkTagWorkbench() {
if (accountTo) params.set('account_to', accountTo)
if (text.trim()) params.set('text', text.trim())
if (onlyUntagged) params.set('only_untagged', '1')
if (showAnnulled) params.set('include_annulled', '1')
const res = await fetch(`/api/dimensions/tagging/lines?${params.toString()}`)
const json = await res.json().catch(() => null)
if (!res.ok) throw json ?? new Error()
setLines((json?.data?.lines ?? []) as TaggingLine[])
setVouchers((json?.data?.vouchers ?? []) as TaggingVoucher[])
setTotalCapped(Boolean(json?.data?.total_capped))
setSelected(new Set())
setExpanded(new Set())
setRowErrors({})
anchorIndexRef.current = null
} catch (err) {
toast({
title: 'Kunde inte hämta rader',
title: 'Kunde inte hämta verifikat',
description: getErrorMessage(err, { locale: 'sv' }),
variant: 'destructive',
})
} finally {
setIsLoading(false)
}
}, [accountFrom, accountTo, dateFrom, dateTo, text, onlyUntagged, toast])
}, [accountFrom, accountTo, dateFrom, dateTo, text, onlyUntagged, showAnnulled, toast])
const toggleRow = useCallback(
/** Selection state of one voucher: 'none' | 'some' | 'all'. */
const voucherSelection = useCallback(
(v: TaggingVoucher): 'none' | 'some' | 'all' => {
let count = 0
for (const line of v.lines) if (selected.has(line.id)) count++
if (count === 0) return 'none'
return count === v.lines.length ? 'all' : 'some'
},
[selected],
)
const toggleVoucher = useCallback(
(index: number, shiftKey: boolean) => {
if (!lines) return
if (!vouchers) return
setSelected((prev) => {
const next = new Set(prev)
const anchor = anchorIndexRef.current
if (shiftKey && anchor !== null && anchor !== index) {
// Range selection: the whole range takes the clicked row's NEW state.
const target = !prev.has(lines[index].id)
const [lo, hi] = anchor < index ? [anchor, index] : [index, anchor]
for (let i = lo; i <= hi; i++) {
if (target) next.add(lines[i].id)
else next.delete(lines[i].id)
const setVoucher = (v: TaggingVoucher, on: boolean) => {
for (const line of v.lines) {
if (on) next.add(line.id)
else next.delete(line.id)
}
} else if (next.has(lines[index].id)) {
next.delete(lines[index].id)
}
const clicked = vouchers[index]
const target = !clicked.lines.every((l) => prev.has(l.id))
if (shiftKey && anchor !== null && anchor !== index) {
// Range selection: the whole range takes the clicked voucher's NEW state.
const [lo, hi] = anchor < index ? [anchor, index] : [index, anchor]
for (let i = lo; i <= hi; i++) setVoucher(vouchers[i], target)
} else {
next.add(lines[index].id)
setVoucher(clicked, target)
}
return next
})
anchorIndexRef.current = index
},
[lines],
[vouchers],
)
const toggleLine = useCallback((lineId: string) => {
setSelected((prev) => {
const next = new Set(prev)
if (next.has(lineId)) next.delete(lineId)
else next.add(lineId)
return next
})
}, [])
const toggleExpanded = useCallback((entryId: string) => {
setExpanded((prev) => {
const next = new Set(prev)
if (next.has(entryId)) next.delete(entryId)
else next.add(entryId)
return next
})
}, [])
const allSelected =
lines !== null && lines.length > 0 && lines.every((l) => selected.has(l.id))
const someSelected = lines !== null && lines.some((l) => selected.has(l.id))
vouchers !== null &&
vouchers.length > 0 &&
vouchers.every((v) => v.lines.every((l) => selected.has(l.id)))
const someSelected = selected.size > 0
const toggleAll = useCallback(() => {
if (!lines) return
setSelected(allSelected ? new Set() : new Set(lines.map((l) => l.id)))
if (!vouchers) return
setSelected(
allSelected ? new Set() : new Set(vouchers.flatMap((v) => v.lines.map((l) => l.id))),
)
anchorIndexRef.current = null
}, [lines, allSelected])
}, [vouchers, allSelected])
// Reversal-pair warning: a selected line whose entry is half of a storno
// pair, where the paired entry's lines are loaded but not (all) selected.
const selectedVoucherCount = useMemo(() => {
if (!vouchers) return 0
return vouchers.filter((v) => v.lines.some((l) => selected.has(l.id))).length
}, [vouchers, selected])
// Reversal-pair guard — only reachable when annullerade are shown: a
// selected voucher whose counter-entry is loaded but not fully selected.
const missingPairLineIds = useMemo(() => {
if (!lines || selected.size === 0) return [] as string[]
if (!vouchers || selected.size === 0) return [] as string[]
const pairEntryIds = new Set<string>()
for (const line of lines) {
if (!selected.has(line.id)) continue
if (line.reversed_by_id) pairEntryIds.add(line.reversed_by_id)
if (line.reverses_id) pairEntryIds.add(line.reverses_id)
for (const v of vouchers) {
if (!v.lines.some((l) => selected.has(l.id))) continue
if (v.reversed_by_id) pairEntryIds.add(v.reversed_by_id)
if (v.reverses_id) pairEntryIds.add(v.reverses_id)
}
if (pairEntryIds.size === 0) return [] as string[]
return lines
.filter((l) => pairEntryIds.has(l.journal_entry_id) && !selected.has(l.id))
.map((l) => l.id)
}, [lines, selected])
return vouchers
.filter((v) => pairEntryIds.has(v.journal_entry_id))
.flatMap((v) => v.lines.map((l) => l.id))
.filter((id) => !selected.has(id))
}, [vouchers, selected])
// Voucher labels of the unselected counter-vouchers — the blocking
// confirmation names them so the skew risk is concrete (#867 review:
// Srf U 14 gross reporting; an asymmetric storno pair silently skews
// project P&L, so the advisory alone is not enough).
const missingPairVouchers = useMemo(() => {
if (!lines || missingPairLineIds.length === 0) return [] as string[]
if (!vouchers || missingPairLineIds.length === 0) return [] as string[]
const ids = new Set(missingPairLineIds)
const labels = new Set<string>()
for (const line of lines) {
if (ids.has(line.id)) {
labels.add(`${line.voucher_series ?? ''}${line.voucher_number ?? ''}`)
}
for (const v of vouchers) {
if (v.lines.some((l) => ids.has(l.id))) labels.add(voucherLabel(v))
}
return [...labels]
}, [lines, missingPairLineIds])
}, [vouchers, missingPairLineIds])
const includeCounterVouchers = useCallback(() => {
setSelected((prev) => {
@@ -248,10 +340,12 @@ export default function BulkTagWorkbench() {
const { dialogProps: confirmDialogProps, confirm } = useDestructiveConfirm()
const handleApply = useCallback(async () => {
if (!lines || !canApply) return
if (!vouchers || !canApply) return
// Storno-pair guard: tagging one leg of a reversal pair without the
// other skews project P&L. Blocking confirmation, not just the banner.
// Only reachable when "Visa annullerade" is on — the default view
// excludes pairs entirely.
if (missingPairLineIds.length > 0) {
const ok = await confirm({
title: 'Motverifikat är inte valda',
@@ -260,11 +354,13 @@ export default function BulkTagWorkbench() {
})
if (!ok) return
}
const selectedLines = lines.filter((l) => selected.has(l.id))
const selectedLines = vouchers.flatMap((v) => v.lines).filter((l) => selected.has(l.id))
// Per-line resulting map, grouped so each distinct map is one POST
// (the API takes ONE dimensions object per call). Usually 1 group; more
// when merge mode meets heterogeneous existing tags.
// (the API takes ONE dimensions object per call), then chunked to the
// apply route's 500-line cap. Usually 1 group; more when merge mode
// meets heterogeneous existing tags.
const groups = new Map<string, { dimensions: Record<string, string>; ids: string[] }>()
for (const line of selectedLines) {
const dims = replaceMode ? { ...picked } : { ...line.dimensions, ...picked }
@@ -282,54 +378,71 @@ export default function BulkTagWorkbench() {
try {
for (const group of groups.values()) {
const res = await fetch('/api/dimensions/tagging/apply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
line_ids: group.ids,
dimensions: group.dimensions,
reason: reason.trim(),
}),
})
const json = await res.json().catch(() => null)
if (!res.ok) {
const message = getErrorMessage(json, { locale: 'sv' })
for (const id of group.ids) failed.push({ line_id: id, error: message })
continue
}
const result = (json?.data ?? {}) as Partial<ApplyResult>
retagged += result.retagged ?? 0
unchanged += result.unchanged ?? 0
const failedIds = new Set<string>()
for (const f of result.failed ?? []) {
failed.push(f)
failedIds.add(f.line_id)
}
for (const id of group.ids) {
if (!failedIds.has(id)) newDimsByLine.set(id, group.dimensions)
for (let i = 0; i < group.ids.length; i += APPLY_CHUNK) {
const chunk = group.ids.slice(i, i + APPLY_CHUNK)
const res = await fetch('/api/dimensions/tagging/apply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
line_ids: chunk,
dimensions: group.dimensions,
reason: reason.trim(),
}),
})
const json = await res.json().catch(() => null)
if (!res.ok) {
const message = getErrorMessage(json, { locale: 'sv' })
for (const id of chunk) failed.push({ line_id: id, error: message })
continue
}
const result = (json?.data ?? {}) as Partial<ApplyResult>
retagged += result.retagged ?? 0
unchanged += result.unchanged ?? 0
const failedIds = new Set<string>()
for (const f of result.failed ?? []) {
failed.push(f)
failedIds.add(f.line_id)
}
for (const id of chunk) {
if (!failedIds.has(id)) newDimsByLine.set(id, group.dimensions)
}
}
}
} finally {
setIsApplying(false)
}
// Succeeded rows get their new map locally (no refetch); failed rows stay
// selected with their Swedish RPC error shown inline.
setLines((prev) =>
// Succeeded lines get their new map locally (no refetch); failed lines
// stay selected with their Swedish RPC error shown inline, and their
// vouchers auto-expand so the error is visible.
setVouchers((prev) =>
prev
? prev.map((l) =>
newDimsByLine.has(l.id)
? { ...l, dimensions: newDimsByLine.get(l.id) as Record<string, string> }
: l,
)
? prev.map((v) => ({
...v,
lines: v.lines.map((l) =>
newDimsByLine.has(l.id)
? { ...l, dimensions: newDimsByLine.get(l.id) as Record<string, string> }
: l,
),
}))
: prev,
)
setSelected(new Set(failed.map((f) => f.line_id)))
setRowErrors(Object.fromEntries(failed.map((f) => [f.line_id, f.error])))
if (failed.length > 0 && vouchers) {
const failedIds = new Set(failed.map((f) => f.line_id))
setExpanded((prev) => {
const next = new Set(prev)
for (const v of vouchers) {
if (v.lines.some((l) => failedIds.has(l.id))) next.add(v.journal_entry_id)
}
return next
})
}
toast({
title: failed.length > 0 ? 'Omtaggningen slutfördes delvis' : 'Rader omtaggade',
description: `${retagged} ändrade, ${unchanged} oförändrade${
title: failed.length > 0 ? 'Omtaggningen slutfördes delvis' : 'Verifikat omtaggade',
description: `${retagged} rader ändrade, ${unchanged} oförändrade${
failed.length > 0 ? `, ${failed.length} misslyckades` : ''
}.`,
variant: failed.length > 0 ? 'destructive' : undefined,
@@ -339,7 +452,7 @@ export default function BulkTagWorkbench() {
setPicked({})
setReason('')
}
}, [lines, canApply, selected, replaceMode, picked, reason, toast, missingPairLineIds, missingPairVouchers, confirm])
}, [vouchers, canApply, selected, replaceMode, picked, reason, toast, missingPairLineIds, missingPairVouchers, confirm])
const headerChecked: boolean | 'indeterminate' = allSelected
? true
@@ -415,7 +528,7 @@ export default function BulkTagWorkbench() {
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') void loadLines()
if (e.key === 'Enter') void loadVouchers()
}}
/>
</div>
@@ -426,29 +539,39 @@ export default function BulkTagWorkbench() {
onCheckedChange={(checked) => setOnlyUntagged(checked === true)}
/>
<Label htmlFor="tag-only-untagged" className="text-sm font-normal">
Endast otaggade rader
Endast otaggade
</Label>
</div>
<Button onClick={() => void loadLines()} disabled={isLoading}>
<div className="flex items-center gap-2">
<Checkbox
id="tag-show-annulled"
checked={showAnnulled}
onCheckedChange={(checked) => setShowAnnulled(checked === true)}
/>
<Label htmlFor="tag-show-annulled" className="text-sm font-normal">
Visa annullerade
</Label>
</div>
<Button onClick={() => void loadVouchers()} disabled={isLoading}>
{isLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Search className="mr-2 h-4 w-4" />
)}
Hämta rader
Hämta verifikat
</Button>
</div>
</CardContent>
</Card>
{/* Result list */}
{lines === null && !isLoading ? (
{vouchers === null && !isLoading ? (
<Card>
<CardContent className="p-0">
<DataListEmpty
icon={<Tags className="h-6 w-6" />}
title="Hämta rader att tagga"
description="Välj filter ovan och klicka på Hämta rader för att bläddra bland bokförda verifikatrader."
title="Hämta verifikat att tagga"
description="Välj filter ovan och klicka på Hämta verifikat för att bläddra bland bokförda verifikat."
/>
</CardContent>
</Card>
@@ -461,100 +584,185 @@ export default function BulkTagWorkbench() {
e.preventDefault()
toggleAll()
}}
aria-label="Markera alla rader"
disabled={!lines || lines.length === 0}
aria-label="Markera alla verifikat"
disabled={!vouchers || vouchers.length === 0}
/>
<span className="text-xs text-muted-foreground">
{lines ? `${lines.length} rader` : ''}
{vouchers ? `${vouchers.length} verifikat` : ''}
</span>
{totalCapped && (
<span className="ml-auto text-xs text-muted-foreground">
Visar de första {lines?.length ?? 0} raderna — förfina filtren för att se
fler.
Visar de första {vouchers?.length ?? 0} verifikaten — förfina filtren för
att se fler.
</span>
)}
</DataListHeader>
{isLoading ? (
<DataListLoading />
) : lines && lines.length === 0 ? (
) : vouchers && vouchers.length === 0 ? (
<DataListEmpty
icon={<Search className="h-6 w-6" />}
title="Inga rader matchade filtren"
title="Inga verifikat matchade filtren"
description="Justera datum, kontointervall eller söktext och försök igen."
/>
) : (
(lines ?? []).map((line, index) => {
const isSelected = selected.has(line.id)
const inStornoPair = Boolean(line.reversed_by_id || line.reverses_id)
const dimEntries = Object.entries(line.dimensions)
const isDebit = line.debit_amount > 0
(vouchers ?? []).map((voucher, index) => {
const sel = voucherSelection(voucher)
const isExpanded = expanded.has(voucher.journal_entry_id)
const { bags, partial } = voucherTagState(voucher)
const total = voucher.lines.reduce((sum, l) => sum + l.debit_amount, 0)
const hasError = voucher.lines.some((l) => rowErrors[l.id])
return (
<DataListRow
key={line.id}
selected={isSelected}
className="select-none"
onClick={(e) => toggleRow(index, e.shiftKey)}
leading={
<Checkbox
checked={isSelected}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
toggleRow(index, e.shiftKey)
}}
aria-label={`Markera rad ${line.voucher_series ?? ''}${line.voucher_number ?? ''} ${line.account_number}`}
/>
}
trailing={
<div className="text-right">
<p className="text-sm tabular-nums">
{formatCurrency(isDebit ? line.debit_amount : line.credit_amount)}
</p>
<p className="text-xs text-muted-foreground">
{isDebit ? 'Debet' : 'Kredit'}
</p>
</div>
}
>
<DataListPrimary className="flex items-center gap-2">
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{line.voucher_series ?? ''}
{line.voucher_number ?? ''}
</span>
<span className="truncate">{line.description}</span>
{inStornoPair && (
<span
title="Verifikatet ingår i ett storno-par"
className="inline-flex shrink-0"
>
<Undo2
className="h-3.5 w-3.5 text-muted-foreground"
aria-hidden="true"
<div key={voucher.journal_entry_id}>
<DataListRow
selected={sel !== 'none'}
className="select-none"
onClick={(e) => toggleVoucher(index, e.shiftKey)}
leading={
<div className="flex items-center gap-1">
<Checkbox
checked={sel === 'all' ? true : sel === 'some' ? 'indeterminate' : false}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
toggleVoucher(index, e.shiftKey)
}}
aria-label={`Markera verifikat ${voucherLabel(voucher)}`}
/>
<span className="sr-only">Ingår i ett storno-par</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={(e) => {
e.stopPropagation()
toggleExpanded(voucher.journal_entry_id)
}}
aria-label={
isExpanded
? `Dölj rader för ${voucherLabel(voucher)}`
: `Visa rader för ${voucherLabel(voucher)}`
}
aria-expanded={isExpanded}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
</div>
}
trailing={
<p className="text-sm tabular-nums">{formatCurrency(total)}</p>
}
>
<DataListPrimary className="flex items-center gap-2">
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{voucherLabel(voucher)}
</span>
<span className="truncate">{voucher.description}</span>
{voucher.annulled && (
<span
title="Verifikatet ingår i ett storno-par"
className="inline-flex shrink-0"
>
<Undo2
className="h-3.5 w-3.5 text-muted-foreground"
aria-hidden="true"
/>
<span className="sr-only">Ingår i ett storno-par</span>
</span>
)}
</DataListPrimary>
<DataListMeta>
<span className="tabular-nums">{formatDate(voucher.entry_date)}</span>
<DataListMetaSeparator />
<span>{voucher.lines.length} rader</span>
{(bags.length > 0 || partial) && <DataListMetaSeparator />}
{bags.map((bag) => (
<Badge
key={mapKey(bag)}
variant="outline"
className="px-1.5 py-0 text-[10px] font-normal"
>
{Object.entries(bag)
.sort(([a], [b]) => Number(a) - Number(b))
.map(([dimNo, code]) => `${dimensionLabel(dimNo)} ${code}`)
.join(' · ')}
</Badge>
))}
{partial && (
<Badge
variant="secondary"
className="px-1.5 py-0 text-[10px] font-normal"
>
Delvis taggad
</Badge>
)}
</DataListMeta>
{hasError && !isExpanded && (
<p className="mt-1 text-xs text-destructive">
Vissa rader kunde inte taggas — visa raderna för detaljer.
</p>
)}
</DataListPrimary>
<DataListMeta>
<span className="tabular-nums">{formatDate(line.entry_date)}</span>
<DataListMetaSeparator />
<span className="font-mono">{line.account_number}</span>
{dimEntries.length > 0 && <DataListMetaSeparator />}
{dimEntries.map(([dimNo, code]) => (
<Badge
key={dimNo}
variant="outline"
className="px-1.5 py-0 text-[10px] font-normal"
>
{dimensionLabel(dimNo)}{' '}
<span className="ml-1 font-mono">{code}</span>
</Badge>
))}
</DataListMeta>
{rowErrors[line.id] && (
<p className="mt-1 text-xs text-destructive">{rowErrors[line.id]}</p>
)}
</DataListRow>
</DataListRow>
{isExpanded &&
voucher.lines.map((line) => {
const isSelected = selected.has(line.id)
const dimEntries = Object.entries(line.dimensions)
const isDebit = line.debit_amount > 0
const amount = isDebit ? line.debit_amount : -line.credit_amount
return (
<DataListRow
key={line.id}
selected={isSelected}
className="select-none bg-muted/30"
onClick={() => toggleLine(line.id)}
leading={
<div className="flex items-center gap-1 pl-7">
<Checkbox
checked={isSelected}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
toggleLine(line.id)
}}
aria-label={`Markera rad ${line.account_number} på ${voucherLabel(voucher)}`}
/>
</div>
}
trailing={
<p className="text-sm tabular-nums">{formatCurrency(amount)}</p>
}
>
<DataListPrimary className="flex items-center gap-2">
<span className="font-mono text-xs">{line.account_number}</span>
</DataListPrimary>
<DataListMeta>
{dimEntries.length === 0 ? (
<span className="text-muted-foreground">Otaggad</span>
) : (
dimEntries.map(([dimNo, code]) => (
<Badge
key={dimNo}
variant="outline"
className="px-1.5 py-0 text-[10px] font-normal"
>
{dimensionLabel(dimNo)}{' '}
<span className="ml-1 font-mono">{code}</span>
</Badge>
))
)}
</DataListMeta>
{rowErrors[line.id] && (
<p className="mt-1 text-xs text-destructive">
{rowErrors[line.id]}
</p>
)}
</DataListRow>
)
})}
</div>
)
})
)}
@@ -586,7 +794,9 @@ export default function BulkTagWorkbench() {
)}
<div className="flex flex-wrap items-center gap-3">
<Badge variant="secondary">{selected.size} rader valda</Badge>
<Badge variant="secondary">
{selectedVoucherCount} verifikat · {selected.size} rader
</Badge>
<Button
variant="ghost"
size="sm"
@@ -631,13 +841,13 @@ export default function BulkTagWorkbench() {
disabled={isApplying}
className="flex-1"
/>
<Button onClick={() => void handleApply()} disabled={!canApply}>{/* storno-pair confirm inside */}
<Button onClick={() => void handleApply()} disabled={!canApply}>
{isApplying ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Tags className="mr-2 h-4 w-4" />
)}
Tagga {selected.size} rader
Tagga {selectedVoucherCount} verifikat
</Button>
</div>
</div>
+10 -2
View File
@@ -2202,9 +2202,17 @@ export const DimensionTaggingLinesQuerySchema = z.object({
account_to: accountNumber.optional(),
/** Free-text ilike filter on journal_entries.description. */
text: z.string().trim().max(200).optional(),
/** '1' → only lines whose dimensions map is empty ({}). */
/** '1' → only vouchers with at least one untagged line ({} dimensions). */
only_untagged: z.enum(['0', '1']).optional(),
limit: z.coerce.number().int().min(1).max(500).default(200),
/**
* '1' → include reversal pairs (annulled entries + their stornos). Excluded
* by default: a pair nets to zero in every dimension bucket when both sides
* carry the same tag, so retro-tagging it is a no-op — and showing it
* invites tagging one side only, which skews project P&L.
*/
include_annulled: z.enum(['0', '1']).optional(),
/** Cap counts VOUCHERS since the voucher-level rework. */
limit: z.coerce.number().int().min(1).max(300).default(150),
})
/**