diff --git a/app/api/bookkeeping/journal-entries/__tests__/route.test.ts b/app/api/bookkeeping/journal-entries/__tests__/route.test.ts index ea3a3106..4fa28c9e 100644 --- a/app/api/bookkeeping/journal-entries/__tests__/route.test.ts +++ b/app/api/bookkeeping/journal-entries/__tests__/route.test.ts @@ -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' } }) diff --git a/app/api/bookkeeping/journal-entries/route.ts b/app/api/bookkeeping/journal-entries/route.ts index 9b2066a4..5d776bd1 100644 --- a/app/api/bookkeeping/journal-entries/route.ts +++ b/app/api/bookkeeping/journal-entries/route.ts @@ -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' diff --git a/components/bookkeeping/JournalEntryList.tsx b/components/bookkeeping/JournalEntryList.tsx index 437d5ede..e0b4e8f2 100644 --- a/components/bookkeeping/JournalEntryList.tsx +++ b/components/bookkeeping/JournalEntryList.tsx @@ -26,7 +26,7 @@ import { STORAGE_KEY_PREFIX as FISCAL_YEAR_STORAGE_KEY_PREFIX, ALL_YEARS_VALUE as FISCAL_YEAR_ALL_VALUE, } from '@/components/common/FiscalYearSelector' -import { ChevronDown, ChevronRight, Paperclip, AlertTriangle, CircleSlash, Loader2, BookOpen, X, Copy, Lock, Search, SlidersHorizontal } from 'lucide-react' +import { ChevronDown, ChevronRight, ChevronLeft, ChevronsLeft, ChevronsRight, Paperclip, AlertTriangle, CircleSlash, Loader2, BookOpen, X, Copy, Lock, Search, SlidersHorizontal } from 'lucide-react' import { formatDate, formatCurrency } from '@/lib/utils' import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver' import { Input } from '@/components/ui/input' @@ -59,6 +59,16 @@ type SortBy = 'date_desc' | 'date_asc' | 'voucher_asc' | 'voucher_desc' const SORT_STORAGE_KEY_PREFIX = 'Accounted:journal-sort:' const SORT_VALUES = new Set(['date_desc', 'date_asc', 'voucher_asc', 'voucher_desc']) +// Page-size selector. Persisted per company, mirroring the sort key convention. +// 'all' fetches everything in the current scope (capped server-side at MAX_LIMIT); +// the numeric options paginate normally. +type PageSizeChoice = '20' | '50' | '100' | 'all' +const PAGE_SIZE_STORAGE_KEY_PREFIX = 'Accounted:journal-page-size:' +const PAGE_SIZE_OPTIONS = [20, 50, 100] as const +const PAGE_SIZE_VALUES = new Set(['20', '50', '100', 'all']) +// Sentinel limit sent for "Alla". The route clamps this to its own MAX_LIMIT. +const ALL_PAGE_SIZE = 100000 + export default function JournalEntryList() { const router = useRouter() const { toast } = useToast() @@ -96,7 +106,10 @@ export default function JournalEntryList() { const [seriesFilter, setSeriesFilter] = useState('all') const [searchInput, setSearchInput] = useState('') const [search, setSearch] = useState('') - const pageSize = 20 + const [pageSizeChoice, setPageSizeChoice] = useState('20') + const [pageSizeHydrated, setPageSizeHydrated] = useState(false) + const showingAll = pageSizeChoice === 'all' + const pageSize = showingAll ? ALL_PAGE_SIZE : Number(pageSizeChoice) const normalizeDate = (v: string): string | null => { const trimmed = v.trim() @@ -183,6 +196,17 @@ export default function JournalEntryList() { setSortHydrated(true) }, [company?.id]) + // Restore the persisted page-size choice (per company). Same hydration pattern + // as the sort order — read in an effect to avoid an SSR mismatch, and gate the + // first fetch so the list is fetched once at the saved size. + useEffect(() => { + if (typeof window !== 'undefined') { + const stored = window.localStorage.getItem(PAGE_SIZE_STORAGE_KEY_PREFIX + (company?.id ?? 'default')) + if (stored && PAGE_SIZE_VALUES.has(stored as PageSizeChoice)) setPageSizeChoice(stored as PageSizeChoice) + } + setPageSizeHydrated(true) + }, [company?.id]) + // Restore the persisted fiscal-year selection (per company), reading the same // localStorage key FiscalYearSelector writes. The selector lives inside the // filter dialog and only mounts when opened, so we resolve the saved scope @@ -268,9 +292,9 @@ export default function JournalEntryList() { } useEffect(() => { - if (!sortHydrated || !periodHydrated) return + if (!sortHydrated || !periodHydrated || !pageSizeHydrated) return fetchEntries() - }, [periodId, page, sortBy, dateFrom, dateTo, seriesFilter, search, sortHydrated, periodHydrated]) + }, [periodId, page, pageSize, sortBy, dateFrom, dateTo, seriesFilter, search, sortHydrated, periodHydrated, pageSizeHydrated]) const handleAttachmentCountChange = useCallback((entryId: string, count: number) => { setAttachmentCounts((prev) => ({ ...prev, [entryId]: count })) @@ -456,6 +480,16 @@ export default function JournalEntryList() { setPage(0) } + // Change how many verifikat are shown per page. Resets to the first page and + // persists the choice per company (same convention as the sort order). + const handlePageSizeChange = (next: PageSizeChoice) => { + setPageSizeChoice(next) + setPage(0) + if (typeof window !== 'undefined') { + window.localStorage.setItem(PAGE_SIZE_STORAGE_KEY_PREFIX + (company?.id ?? 'default'), next) + } + } + const clearAllFilters = () => { setPeriodId(null) // Mirror the selector's "Alla räkenskapsår" write so the cleared scope @@ -1261,28 +1295,93 @@ export default function JournalEntryList() { onOpenChange={(open) => { if (!open) setPreviewEntryId(null) }} /> - {/* Pagination */} - {count > pageSize && ( -
- - - {t('page_of', { page: page + 1, total: Math.ceil(count / pageSize) })} - - + {/* Pagination + page-size selector. Shown when the result set spans more + than one page at the default size, OR when a non-default page size + ('all' included) is active — so a user who narrowed the list below the + default can always switch the size back. Hidden for an empty result. */} + {count > 0 && (count > PAGE_SIZE_OPTIONS[0] || pageSizeChoice !== '20') && ( +
+ {/* Page size + result range */} +
+ + + + {showingAll + ? t('showing_all', { total: count }) + : t('showing_range', { + from: count === 0 ? 0 : page * pageSize + 1, + to: Math.min((page + 1) * pageSize, count), + total: count, + })} + +
+ + {/* Page navigation — hidden when showing all or when everything fits on one page */} + {!showingAll && count > pageSize && ( +
+ + + + {t('page_of', { page: page + 1, total: Math.ceil(count / pageSize) })} + + + +
+ )}
)}
diff --git a/messages/en.json b/messages/en.json index 99bdbc51..6e61c93b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3031,7 +3031,13 @@ "no_results_description": "No journal entries match your filters. Adjust your search or clear the filters.", "previous": "Previous", "next": "Next", + "first_page": "First page", + "last_page": "Last page", "page_of": "Page {page} of {total}", + "page_size_label": "Rows per page", + "page_size_all": "All", + "showing_range": "Showing {from}–{to} of {total}", + "showing_all": "Showing all {total}", "read_only_tooltip": "You have read-only access to this company", "toast_posted_title": "Journal entry posted", "toast_posted_description": "Journal entry {voucher} has been posted.", diff --git a/messages/sv.json b/messages/sv.json index fe7d75b0..14865448 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -3031,7 +3031,13 @@ "no_results_description": "Inga verifikationer matchar dina filter. Justera sökningen eller rensa filtren.", "previous": "Föregående", "next": "Nästa", + "first_page": "Första sidan", + "last_page": "Sista sidan", "page_of": "Sida {page} av {total}", + "page_size_label": "Rader per sida", + "page_size_all": "Alla", + "showing_range": "Visar {from}–{to} av {total}", + "showing_all": "Visar alla {total}", "read_only_tooltip": "Du har endast läsbehörighet i detta företag", "toast_posted_title": "Verifikat bokfört", "toast_posted_description": "Verifikat {voucher} har bokförts.",