* 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:
@@ -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' } })
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<SortBy>(['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<PageSizeChoice>(['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<string>('all')
|
||||
const [searchInput, setSearchInput] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
const pageSize = 20
|
||||
const [pageSizeChoice, setPageSizeChoice] = useState<PageSizeChoice>('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 && (
|
||||
<div className="flex justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage(page - 1)}
|
||||
>
|
||||
{t('previous')}
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground self-center">
|
||||
{t('page_of', { page: page + 1, total: Math.ceil(count / pageSize) })}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={(page + 1) * pageSize >= count}
|
||||
onClick={() => setPage(page + 1)}
|
||||
>
|
||||
{t('next')}
|
||||
</Button>
|
||||
{/* 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') && (
|
||||
<div className="flex flex-col gap-3 pt-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
{/* Page size + result range */}
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Label htmlFor="journal-page-size" className="text-xs font-normal shrink-0">
|
||||
{t('page_size_label')}
|
||||
</Label>
|
||||
<Select value={pageSizeChoice} onValueChange={(v) => handlePageSizeChange(v as PageSizeChoice)}>
|
||||
<SelectTrigger id="journal-page-size" className="h-8 w-[88px] text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PAGE_SIZE_OPTIONS.map((n) => (
|
||||
<SelectItem key={n} value={String(n)} className="text-xs tabular-nums">
|
||||
{n}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="all" className="text-xs">{t('page_size_all')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="tabular-nums whitespace-nowrap">
|
||||
{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,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Page navigation — hidden when showing all or when everything fits on one page */}
|
||||
{!showingAll && count > pageSize && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage(0)}
|
||||
aria-label={t('first_page')}
|
||||
title={t('first_page')}
|
||||
>
|
||||
<ChevronsLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage(page - 1)}
|
||||
aria-label={t('previous')}
|
||||
title={t('previous')}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="px-2 text-xs text-muted-foreground tabular-nums self-center whitespace-nowrap">
|
||||
{t('page_of', { page: page + 1, total: Math.ceil(count / pageSize) })}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
disabled={(page + 1) * pageSize >= count}
|
||||
onClick={() => setPage(page + 1)}
|
||||
aria-label={t('next')}
|
||||
title={t('next')}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
disabled={(page + 1) * pageSize >= count}
|
||||
onClick={() => setPage(Math.ceil(count / pageSize) - 1)}
|
||||
aria-label={t('last_page')}
|
||||
title={t('last_page')}
|
||||
>
|
||||
<ChevronsRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user