diff --git a/app/api/dimensions/tagging/__tests__/lines.test.ts b/app/api/dimensions/tagging/__tests__/lines.test.ts index aff60462..9e8f3243 100644 --- a/app/api/dimensions/tagging/__tests__/lines.test.ts +++ b/app/api/dimensions/tagging/__tests__/lines.test.ts @@ -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) => createMockRequest('/api/dimensions/tagging/lines', { searchParams }) -interface FlatLine { +interface VoucherLine { id: string account_number: string debit_amount: number credit_amount: number dimensions: Record +} + +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 = {}) { + 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 = {}) { return { id: 'line-1', @@ -60,15 +83,7 @@ function makeRawLine(overrides: Record = {}) { 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(response) + const { status, body } = await parseJsonResponse(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(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(response) + const { status, body } = await parseJsonResponse(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(response) + const { status, body } = await parseJsonResponse(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(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(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) diff --git a/app/api/dimensions/tagging/lines/route.ts b/app/api/dimensions/tagging/lines/route.ts index e9c5828f..618933b4 100644 --- a/app/api/dimensions/tagging/lines/route.ts +++ b/app/api/dimensions/tagging/lines/route.ts @@ -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 | 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() + 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 } }) }, ) diff --git a/components/dimensions/BulkTagWorkbench.tsx b/components/dimensions/BulkTagWorkbench.tsx index ff60e769..e9b66ab0 100644 --- a/components/dimensions/BulkTagWorkbench.tsx +++ b/components/dimensions/BulkTagWorkbench.tsx @@ -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 +} + +/** 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 { ) } +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[] + partial: boolean +} { + const seen = new Map>() + 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(null) + const [vouchers, setVouchers] = useState(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>(new Set()) + const [expanded, setExpanded] = useState>(new Set()) const anchorIndexRef = useRef(null) const [picked, setPicked] = useState>({}) const [replaceMode, setReplaceMode] = useState(false) @@ -106,7 +157,7 @@ export default function BulkTagWorkbench() { const [isApplying, setIsApplying] = useState(false) const [rowErrors, setRowErrors] = useState>({}) - 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() - 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() - 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; 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 - retagged += result.retagged ?? 0 - unchanged += result.unchanged ?? 0 - const failedIds = new Set() - 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 + retagged += result.retagged ?? 0 + unchanged += result.unchanged ?? 0 + const failedIds = new Set() + 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 } - : l, - ) + ? prev.map((v) => ({ + ...v, + lines: v.lines.map((l) => + newDimsByLine.has(l.id) + ? { ...l, dimensions: newDimsByLine.get(l.id) as Record } + : 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() }} /> @@ -426,29 +539,39 @@ export default function BulkTagWorkbench() { onCheckedChange={(checked) => setOnlyUntagged(checked === true)} /> - {/* Result list */} - {lines === null && !isLoading ? ( + {vouchers === null && !isLoading ? ( } - 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." /> @@ -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} /> - {lines ? `${lines.length} rader` : ''} + {vouchers ? `${vouchers.length} verifikat` : ''} {totalCapped && ( - 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. )} {isLoading ? ( - ) : lines && lines.length === 0 ? ( + ) : vouchers && vouchers.length === 0 ? ( } - 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 ( - toggleRow(index, e.shiftKey)} - leading={ - { - e.preventDefault() - e.stopPropagation() - toggleRow(index, e.shiftKey) - }} - aria-label={`Markera rad ${line.voucher_series ?? ''}${line.voucher_number ?? ''} ${line.account_number}`} - /> - } - trailing={ -
-

- {formatCurrency(isDebit ? line.debit_amount : line.credit_amount)} -

-

- {isDebit ? 'Debet' : 'Kredit'} -

-
- } - > - - - {line.voucher_series ?? ''} - {line.voucher_number ?? ''} - - {line.description} - {inStornoPair && ( - - - - {formatDate(line.entry_date)} - - {line.account_number} - {dimEntries.length > 0 && } - {dimEntries.map(([dimNo, code]) => ( - - {dimensionLabel(dimNo)}{' '} - {code} - - ))} - - {rowErrors[line.id] && ( -

{rowErrors[line.id]}

- )} -
+ + {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 ( + toggleLine(line.id)} + leading={ +
+ { + e.preventDefault() + e.stopPropagation() + toggleLine(line.id) + }} + aria-label={`Markera rad ${line.account_number} på ${voucherLabel(voucher)}`} + /> +
+ } + trailing={ +

{formatCurrency(amount)}

+ } + > + + {line.account_number} + + + {dimEntries.length === 0 ? ( + Otaggad + ) : ( + dimEntries.map(([dimNo, code]) => ( + + {dimensionLabel(dimNo)}{' '} + {code} + + )) + )} + + {rowErrors[line.id] && ( +

+ {rowErrors[line.id]} +

+ )} +
+ ) + })} + ) }) )} @@ -586,7 +794,9 @@ export default function BulkTagWorkbench() { )}
- {selected.size} rader valda + + {selectedVoucherCount} verifikat · {selected.size} rader +
diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 439cc602..31aa5f4f 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -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), }) /**