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:
co-authored by
Claude Fable 5
parent
45438d4d64
commit
28827c2613
@@ -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)
|
||||
|
||||
@@ -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 } })
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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
@@ -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),
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user