feat(bookkeeping): page-size selector + First/Last pagination on verifikationslista (#738) (#743)

* feat(bookkeeping): page-size selector + First/Last pagination on verifikationslista

Closes #738.

The voucher list (verifikationslista) had a hardcoded page size of 20 and only
Previous/Next buttons. Adds:

- Page-size selector: 20 / 50 / 100 / Alla. Persisted per company in
  localStorage (same convention as the sort order and FiscalYearSelector) and
  hydrated in an effect so the first fetch already uses the saved size.
  "Alla" loads everything in the current scope and hides the pager.
- Pagination footer: First / Previous / Next / Last icon buttons, a page
  indicator, and a "Visar 1–20 av N" result-range label.
- Server: clamp limit to [1, 100000] and offset to >=0 so "Alla" sends a
  bounded large limit (defense in depth, ASVS V1.2.5).

Sorting asc/desc on date and voucher already existed in the filter dialog;
amount-column sorting is intentionally out of scope (journal_entries has no
stored total — the voucher amount is summed client-side from debit lines — so
ordering by it needs a schema change).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(bookkeeping): keep page-size selector reachable + clarify offset clamp

Addresses PR review feedback:

- Pagination footer now shows whenever a non-default page size (50/100/Alla)
  is active, not only when count > 20. A user who picks a larger size and then
  filters the list below 20 rows can still switch the size back. Default-20
  users are unchanged — no selector under 21 rows. Empty results stay hidden.
- offset clamp reads `rawOffset >= 0` instead of `> 0` (behaviour identical;
  clearer intent).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-06-16 15:31:46 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 36e3f6ceb0
commit 45b31ad50f
5 changed files with 160 additions and 28 deletions
@@ -156,6 +156,21 @@ describe('GET /api/bookkeeping/journal-entries', () => {
expect(mockSupabase.rpc).not.toHaveBeenCalled()
})
it('accepts a large limit (the "Alla" page size) and a negative offset without erroring', async () => {
enqueue({ data: [], error: null, count: 0 })
const request = createMockRequest('/api/bookkeeping/journal-entries', {
// 'Alla' sends a large limit; the route clamps it to MAX_LIMIT. A negative
// offset is floored to 0. Both are bounded server-side (ASVS V1.2.5).
searchParams: { limit: '999999', offset: '-5', include_related: 'false' },
})
const response = await GET(request)
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
expect(mockSupabase.from).toHaveBeenCalledWith('journal_entries')
})
it('returns 500 on database error', async () => {
enqueue({ data: null, error: { message: 'DB error' } })
+8 -2
View File
@@ -24,8 +24,14 @@ export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const periodId = searchParams.get('period_id')
const status = searchParams.get('status')
const limit = parseInt(searchParams.get('limit') || '50')
const offset = parseInt(searchParams.get('offset') || '0')
// Clamp pagination to bound DB work against oversized/pathological inputs
// (compliance A.8.28 / ASVS V1.2.5). The UI page-size selector offers
// 20/50/100/Alla; "Alla" sends a large limit which is capped at MAX_LIMIT.
const MAX_LIMIT = 100000
const rawLimit = parseInt(searchParams.get('limit') || '50', 10)
const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(rawLimit, 1), MAX_LIMIT) : 50
const rawOffset = parseInt(searchParams.get('offset') || '0', 10)
const offset = Number.isFinite(rawOffset) && rawOffset >= 0 ? rawOffset : 0
const dateFrom = searchParams.get('date_from')
const dateTo = searchParams.get('date_to')
const sortDate = searchParams.get('sort_date') // 'asc' | 'desc'