Files
accounted/components/bookkeeping/JournalEntryList.tsx
T
Jakob WennbergandClaude Opus 4.8 45b31ad50f 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>
2026-06-16 15:31:46 +02:00

1390 lines
59 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useState, useEffect, useCallback } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Checkbox } from '@/components/ui/checkbox'
import { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell } from '@/components/ui/table'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogFooter,
DialogClose,
} from '@/components/ui/dialog'
import {
FiscalYearSelector,
STORAGE_KEY_PREFIX as FISCAL_YEAR_STORAGE_KEY_PREFIX,
ALL_YEARS_VALUE as FISCAL_YEAR_ALL_VALUE,
} from '@/components/common/FiscalYearSelector'
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'
import { AccountNumber } from '@/components/ui/account-number'
import { getAccountDescription } from '@/lib/bookkeeping/account-descriptions'
import JournalEntryAttachments from '@/components/bookkeeping/JournalEntryAttachments'
import NoDocRequiredToggle from '@/components/bookkeeping/NoDocRequiredToggle'
import CorrectionEntryDialog from '@/components/bookkeeping/CorrectionEntryDialog'
import JournalEntryStatusBadge from '@/components/bookkeeping/JournalEntryStatusBadge'
import AttachmentPreviewSheet from '@/components/bookkeeping/AttachmentPreviewSheet'
import { useToast } from '@/components/ui/use-toast'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { useCompanyOptional } from '@/contexts/CompanyContext'
import type { FiscalPeriod, JournalEntry, JournalEntryLine } from '@/types'
const NEEDS_ATTACHMENT = new Set([
'manual',
'bank_transaction',
'supplier_invoice_registered',
'supplier_invoice_paid',
'supplier_invoice_cash_payment',
'import',
])
type SortBy = 'date_desc' | 'date_asc' | 'voucher_asc' | 'voucher_desc'
// Per-company persistence of the sort dropdown. Mirrors the localStorage
// convention used by FiscalYearSelector ('Accounted:fiscal-year:<companyId>').
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()
const { canWrite } = useCanWrite()
const company = useCompanyOptional()?.company ?? null
const t = useTranslations('journal_list')
const [entries, setEntries] = useState<JournalEntry[]>([])
const [committingId, setCommittingId] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [expandedId, setExpandedId] = useState<string | null>(null)
const [count, setCount] = useState(0)
const [page, setPage] = useState(0)
const [attachmentCounts, setAttachmentCounts] = useState<Record<string, number>>({})
const [noDocRequired, setNoDocRequired] = useState<Map<string, string | null>>(new Map())
const [showMissingOnly, setShowMissingOnly] = useState(false)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [batchReason, setBatchReason] = useState('')
const [batchSubmitting, setBatchSubmitting] = useState(false)
const [bulkOpen, setBulkOpen] = useState(false)
const [bulkCount, setBulkCount] = useState<number | null>(null)
const [bulkReason, setBulkReason] = useState('')
const [bulkSubmitting, setBulkSubmitting] = useState(false)
const [correctionEntry, setCorrectionEntry] = useState<JournalEntry | null>(null)
const [previewEntryId, setPreviewEntryId] = useState<string | null>(null)
const [sortBy, setSortBy] = useState<SortBy>('date_desc')
const [sortHydrated, setSortHydrated] = useState(false)
const [periodId, setPeriodId] = useState<string | null>(null)
const [periodHydrated, setPeriodHydrated] = useState(false)
const [periods, setPeriods] = useState<FiscalPeriod[]>([])
const [filterOpen, setFilterOpen] = useState(false)
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [dateFromInput, setDateFromInput] = useState('')
const [dateToInput, setDateToInput] = useState('')
const [seriesFilter, setSeriesFilter] = useState<string>('all')
const [searchInput, setSearchInput] = useState('')
const [search, setSearch] = useState('')
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()
if (!trimmed) return null
// YYYY
if (/^\d{4}$/.test(trimmed)) {
const y = parseInt(trimmed, 10)
if (y < 1900 || y > 2100) return null
return `${trimmed}-01-01`
}
// YYYY-MM
if (/^\d{4}-\d{2}$/.test(trimmed)) {
const [y, m] = trimmed.split('-').map(Number)
if (y < 1900 || y > 2100 || m < 1 || m > 12) return null
return `${trimmed}-01`
}
// YYYY-MM-DD
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
const d = new Date(trimmed + 'T00:00:00')
if (isNaN(d.getTime())) return null
// Verify the date didn't roll over (e.g. 2024-02-31 → March)
const [y, m, day] = trimmed.split('-').map(Number)
if (d.getFullYear() !== y || d.getMonth() + 1 !== m || d.getDate() !== day) return null
return trimmed
}
return null
}
const applyDateFilter = () => {
const fromVal = dateFromInput.trim()
const toVal = dateToInput.trim()
const nextFrom = fromVal === '' ? '' : normalizeDate(fromVal) ?? dateFrom
const nextTo = toVal === '' ? '' : normalizeDate(toVal) ?? dateTo
setDateFromInput(nextFrom)
setDateToInput(nextTo)
if (nextFrom !== dateFrom || nextTo !== dateTo) {
setDateFrom(nextFrom)
setDateTo(nextTo)
setPage(0)
}
}
const fetchAttachmentCounts = useCallback(async (entryIds: string[]) => {
if (entryIds.length === 0) return
try {
const res = await fetch(
`/api/documents/counts?journal_entry_ids=${entryIds.join(',')}`
)
const { data } = await res.json()
setAttachmentCounts(data || {})
} catch {
// Non-critical — silently ignore
}
}, [])
const fetchNoDocRequired = useCallback(async () => {
try {
const res = await fetch('/api/bookkeeping/no-doc-required')
if (!res.ok) return
const { data } = await res.json()
const map = new Map<string, string | null>()
for (const row of (data || []) as { journal_entry_id: string; reason: string | null }[]) {
map.set(row.journal_entry_id, row.reason)
}
setNoDocRequired(map)
} catch {
// Non-critical — silently ignore
}
}, [])
useEffect(() => {
fetchNoDocRequired()
}, [fetchNoDocRequired])
// Restore the persisted sort order (per company). Read in an effect rather
// than the useState initializer to avoid an SSR/client hydration mismatch.
// sortHydrated gates the first fetch so the list is fetched once, already in
// the saved order — no flash of the default sort.
useEffect(() => {
if (typeof window !== 'undefined') {
const stored = window.localStorage.getItem(SORT_STORAGE_KEY_PREFIX + (company?.id ?? 'default'))
if (stored && SORT_VALUES.has(stored as SortBy)) setSortBy(stored as SortBy)
}
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
// here — independent of the dialog — to keep the initial fetch correct.
// periodHydrated gates the first fetch so the list loads already scoped.
useEffect(() => {
if (company?.id && typeof window !== 'undefined') {
const stored = window.localStorage.getItem(FISCAL_YEAR_STORAGE_KEY_PREFIX + company.id)
setPeriodId(stored && stored !== FISCAL_YEAR_ALL_VALUE ? stored : null)
} else {
setPeriodId(null)
}
setPeriodHydrated(true)
}, [company?.id])
// Fetch fiscal periods so the active räkenskapsår can be labelled on the
// filter bar without opening the dialog (BFL period-orientation: the user
// should always see which year the ledger is scoped to). Read-only — the
// dialog's FiscalYearSelector still owns selection; this copy resolves the
// name for display.
useEffect(() => {
if (!company?.id) {
setPeriods([])
return
}
let cancelled = false
;(async () => {
try {
const res = await fetch('/api/bookkeeping/fiscal-periods')
if (!res.ok) return
const { data } = await res.json()
if (!cancelled) setPeriods((data || []) as FiscalPeriod[])
} catch {
// Non-critical — the chip falls back to the active-filter count badge.
}
})()
return () => {
cancelled = true
}
}, [company?.id])
// Debounce the free-text search before it reaches the API. Require ≥2 chars:
// a single character matches almost every verifikationstext and isn't a useful
// filter, so 0–1 chars are treated as "no search" instead of firing a query on
// every keystroke (ASVS V2.4).
useEffect(() => {
const handle = setTimeout(() => {
const trimmed = searchInput.trim()
setSearch(trimmed.length >= 2 ? trimmed : '')
setPage(0)
}, 300)
return () => clearTimeout(handle)
}, [searchInput])
async function fetchEntries() {
setLoading(true)
setSelectedIds(new Set()) // selection is page-scoped — reset on reload
const params = new URLSearchParams({
limit: String(pageSize),
offset: String(page * pageSize),
sort_by: sortBy,
})
if (periodId) params.set('period_id', periodId)
if (dateFrom) params.set('date_from', dateFrom)
if (dateTo) params.set('date_to', dateTo)
if (seriesFilter !== 'all') params.set('series', seriesFilter)
if (search) params.set('search', search)
const res = await fetch(`/api/bookkeeping/journal-entries?${params}`)
if (!res.ok) {
setLoading(false)
return
}
const { data, count: total } = await res.json()
const loadedEntries = data || []
setEntries(loadedEntries)
setCount(total || 0)
setLoading(false)
// Fetch attachment counts for the loaded entries
const ids = loadedEntries.map((e: JournalEntry) => e.id)
fetchAttachmentCounts(ids)
}
useEffect(() => {
if (!sortHydrated || !periodHydrated || !pageSizeHydrated) return
fetchEntries()
}, [periodId, page, pageSize, sortBy, dateFrom, dateTo, seriesFilter, search, sortHydrated, periodHydrated, pageSizeHydrated])
const handleAttachmentCountChange = useCallback((entryId: string, count: number) => {
setAttachmentCounts((prev) => ({ ...prev, [entryId]: count }))
}, [])
const toggleExpand = (id: string) => {
setExpandedId(expandedId === id ? null : id)
}
const handleCommit = async (entryId: string) => {
setCommittingId(entryId)
try {
const res = await fetch(`/api/bookkeeping/journal-entries/${entryId}/commit`, { method: 'POST' })
const result = await res.json()
if (res.ok) {
const posted = result.data
toast({
title: t('toast_posted_title'),
description: t('toast_posted_description', { voucher: formatVoucher(posted ?? {}) }),
})
await fetchEntries()
} else {
toast({ title: t('toast_post_failed'), description: getErrorMessage(result, { context: 'journal_entry' }), variant: 'destructive' })
}
} catch {
toast({ title: t('toast_post_failed_generic'), variant: 'destructive' })
} finally {
setCommittingId(null)
}
}
// A posted, document-requiring entry with no attachment yet and not already
// exempt — i.e. the rows that show the warning triangle. Only these can be
// batch-marked "Inget underlag krävs".
const isEligibleForExempt = useCallback(
(entry: JournalEntry) =>
entry.status === 'posted' &&
NEEDS_ATTACHMENT.has(entry.source_type) &&
!attachmentCounts[entry.id] &&
!noDocRequired.has(entry.id),
[attachmentCounts, noDocRequired],
)
const toggleSelect = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const handleBatchExempt = async () => {
const ids = Array.from(selectedIds)
if (ids.length === 0) return
setBatchSubmitting(true)
const reason = batchReason.trim() || null
try {
const res = await fetch('/api/bookkeeping/no-doc-required/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ journal_entry_ids: ids, reason }),
})
const body = await res.json().catch(() => ({}))
if (!res.ok) {
toast({ title: t('no_doc_required_save_failed'), description: body.error, variant: 'destructive' })
return
}
// Reflect the new exemptions locally: triangle → muted "no doc" indicator.
setNoDocRequired((prev) => {
const next = new Map(prev)
for (const id of ids) next.set(id, reason)
return next
})
setSelectedIds(new Set())
setBatchReason('')
toast({
title: t('batch_no_doc_done_title'),
description: t('batch_no_doc_done_description', { count: body.data?.exempted ?? ids.length }),
})
} catch {
toast({ title: t('no_doc_required_save_failed'), variant: 'destructive' })
} finally {
setBatchSubmitting(false)
}
}
// Filter-scoped bulk mark: mark EVERY missing-doc verifikat matching the active
// filters (period/series/date/search), across all pages — the scalable remedy
// for a post-import flood. A dry_run first surfaces the exact count to confirm.
const filterPayload = () => ({
period_id: periodId,
series: seriesFilter !== 'all' ? seriesFilter : null,
date_from: dateFrom || null,
date_to: dateTo || null,
search: search || null,
})
const openBulk = async () => {
setBulkOpen(true)
setBulkCount(null)
setBulkReason('')
try {
const res = await fetch('/api/bookkeeping/no-doc-required/bulk-missing', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...filterPayload(), dry_run: true }),
})
const body = await res.json().catch(() => ({}))
setBulkCount(res.ok ? (body.data?.count ?? 0) : 0)
} catch {
setBulkCount(0)
}
}
const handleBulkConfirm = async () => {
setBulkSubmitting(true)
try {
const res = await fetch('/api/bookkeeping/no-doc-required/bulk-missing', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...filterPayload(), reason: bulkReason.trim() || null }),
})
const body = await res.json().catch(() => ({}))
if (!res.ok) {
toast({ title: t('no_doc_required_save_failed'), description: body.error, variant: 'destructive' })
return
}
setBulkOpen(false)
setBulkCount(null)
setBulkReason('')
setSelectedIds(new Set())
toast({
title: t('batch_no_doc_done_title'),
description: t('batch_no_doc_done_description', { count: body.data?.exempted ?? 0 }),
})
await fetchNoDocRequired()
await fetchEntries()
} catch {
toast({ title: t('no_doc_required_save_failed'), variant: 'destructive' })
} finally {
setBulkSubmitting(false)
}
}
const filteredEntries = showMissingOnly
? entries.filter(
(e) =>
NEEDS_ATTACHMENT.has(e.source_type) &&
!attachmentCounts[e.id] &&
e.status === 'posted' &&
!noDocRequired.has(e.id)
)
: entries
// Count of active dialog filters, shown as a badge on the Filtrera button so
// the user can tell the list is scoped without opening the dialog. Sort order
// is a view preference (always set), not a filter, so it is excluded.
const activeFilterCount =
(periodId ? 1 : 0) +
(seriesFilter !== 'all' ? 1 : 0) +
(dateFrom || dateTo ? 1 : 0) +
(showMissingOnly ? 1 : 0)
// When any filter or search is active we keep the filter bar mounted even
// with zero results, so the user can edit or clear their query. The pristine
// "no entries yet" state below only applies to an untouched, empty ledger.
const hasActiveFilters = Boolean(search) || activeFilterCount > 0
// Resolve the active fiscal-year scope for the bar chip. "All years" (periodId
// null) renders immediately; a specific period waits until its name resolves
// from the fetched list (scopeLabel stays null meanwhile, so the chip never
// flashes the wrong scope). Surfacing this keeps the period visible per BFL
// without the user having to open the filter dialog.
const activePeriod = periodId ? periods.find((p) => p.id === periodId) ?? null : null
const scopeLabel = periodId ? activePeriod?.name ?? null : t('scope_all_years')
// Apply a fiscal-year selection from the dialog. The FiscalYearSelector
// persists the choice to localStorage itself; here we only mirror it into
// local state and reset pagination.
const handlePeriodChange = (next: string | null) => {
setPeriodId(next)
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
// survives a remount/reload instead of being restored from a stale value.
if (company?.id && typeof window !== 'undefined') {
window.localStorage.setItem(FISCAL_YEAR_STORAGE_KEY_PREFIX + company.id, FISCAL_YEAR_ALL_VALUE)
}
setSeriesFilter('all')
setShowMissingOnly(false)
setDateFrom('')
setDateTo('')
setDateFromInput('')
setDateToInput('')
setPage(0)
}
// Rows on this page the user can batch-mark "Inget underlag krävs".
const eligibleEntries = canWrite ? filteredEntries.filter(isEligibleForExempt) : []
const allEligibleSelected =
eligibleEntries.length > 0 && eligibleEntries.every((e) => selectedIds.has(e.id))
const toggleSelectAll = () => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (allEligibleSelected) {
for (const e of eligibleEntries) next.delete(e.id)
} else {
for (const e of eligibleEntries) next.add(e.id)
}
return next
})
}
if (!loading && entries.length === 0 && !hasActiveFilters) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<div className="p-4 rounded-full bg-muted mb-4">
<BookOpen className="h-6 w-6 text-muted-foreground" />
</div>
<h3 className="text-lg font-medium mb-1">{t('empty_title')}</h3>
<p className="text-sm text-muted-foreground text-center max-w-sm">
{t('empty_description')}
</p>
</CardContent>
</Card>
)
}
return (
<div className="space-y-4">
{/* Search (always visible) + filter dialog for everything else */}
<div className="flex items-center gap-2">
<div className="relative flex-1 sm:flex-none sm:w-[280px]">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
<Input
type="text"
inputMode="search"
placeholder={t('search_placeholder')}
aria-label={t('search_placeholder')}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="h-8 pl-8 pr-7 text-xs"
/>
{searchInput && (
<button
type="button"
onClick={() => setSearchInput('')}
className="absolute right-1.5 top-1/2 -translate-y-1/2 p-0.5 rounded-sm hover:bg-muted text-muted-foreground"
title={t('clear_search')}
aria-label={t('clear_search')}
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
<Dialog open={filterOpen} onOpenChange={setFilterOpen}>
<DialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 gap-2 text-xs shrink-0"
aria-label={
activeFilterCount > 0
? t('filter_with_count', { count: activeFilterCount })
: t('filter')
}
>
<SlidersHorizontal className="h-3.5 w-3.5" />
{t('filter')}
{activeFilterCount > 0 && (
<Badge
variant="secondary"
className="h-4 min-w-4 justify-center px-1 text-[10px] tabular-nums"
>
{activeFilterCount}
</Badge>
)}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('filter_dialog_title')}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Räkenskapsår */}
<div className="space-y-2">
<Label className="text-sm font-medium">{t('filter_section_period')}</Label>
<FiscalYearSelector
value={periodId}
onChange={handlePeriodChange}
label={null}
className="w-full"
/>
</div>
{/* Sortering */}
<div className="space-y-2">
<Label className="text-sm font-medium">{t('filter_section_sort')}</Label>
<Select
value={sortBy}
onValueChange={(v) => {
const next = v as SortBy
setSortBy(next)
setPage(0)
if (typeof window !== 'undefined') {
window.localStorage.setItem(SORT_STORAGE_KEY_PREFIX + (company?.id ?? 'default'), next)
}
}}
>
<SelectTrigger className="h-9 w-full text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="date_desc">{t('sort_date_desc')}</SelectItem>
<SelectItem value="date_asc">{t('sort_date_asc')}</SelectItem>
<SelectItem value="voucher_asc">{t('sort_voucher_asc')}</SelectItem>
<SelectItem value="voucher_desc">{t('sort_voucher_desc')}</SelectItem>
</SelectContent>
</Select>
</div>
{/* Verifikationsserie */}
<div className="space-y-2">
<Label className="text-sm font-medium">{t('filter_section_series')}</Label>
<Select value={seriesFilter} onValueChange={(v) => { setSeriesFilter(v); setPage(0) }}>
<SelectTrigger className="h-9 w-full text-sm font-mono" aria-label={t('filter_section_series')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Alla serier</SelectItem>
{'ABCDEFG'.split('').map((letter) => (
<SelectItem key={letter} value={letter} className="font-mono">
Serie {letter}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Datumintervall */}
<div className="space-y-2">
<Label className="text-sm font-medium">{t('filter_section_date')}</Label>
<div className="flex items-center gap-2">
<Input
type="text"
placeholder={t('date_from_placeholder')}
value={dateFromInput}
onChange={(e) => setDateFromInput(e.target.value)}
onBlur={applyDateFilter}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
applyDateFilter()
}
}}
className="h-9 flex-1 text-sm"
/>
<span className="text-sm text-muted-foreground">–</span>
<Input
type="text"
placeholder={t('date_to_placeholder')}
value={dateToInput}
onChange={(e) => setDateToInput(e.target.value)}
onBlur={applyDateFilter}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
applyDateFilter()
}
}}
className="h-9 flex-1 text-sm"
/>
{(dateFrom || dateTo) && (
<button
type="button"
onClick={() => { setDateFrom(''); setDateTo(''); setDateFromInput(''); setDateToInput(''); setPage(0) }}
className="p-1 rounded-sm hover:bg-muted text-muted-foreground shrink-0"
title={t('clear_date_filter')}
aria-label={t('clear_date_filter')}
>
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
{/* Visa saknade underlag */}
<div className="flex items-center gap-2">
<Switch
id="missing-attachments"
checked={showMissingOnly}
onCheckedChange={setShowMissingOnly}
/>
<Label htmlFor="missing-attachments" className="text-sm cursor-pointer">
{t('show_missing')}
</Label>
{showMissingOnly && (
<Badge variant="secondary" className="text-xs tabular-nums">
{filteredEntries.length}
</Badge>
)}
</div>
</div>
<DialogFooter className="sm:justify-between">
<Button
variant="ghost"
size="sm"
onClick={clearAllFilters}
disabled={activeFilterCount === 0}
>
{t('filter_clear_all')}
</Button>
<DialogClose asChild>
<Button variant="outline" size="sm">{t('filter_done')}</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{/* Active fiscal-year scope — visible without opening the filter dialog so
the user always sees which räkenskapsår the ledger is scoped to (BFL
period-correctness). Clicking it opens the dialog to change the scope. */}
{periodHydrated && scopeLabel && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{t('scope_label')}</span>
<button
type="button"
onClick={() => setFilterOpen(true)}
className="inline-flex h-8 items-center gap-1 rounded-md border border-border px-2 font-medium text-foreground transition-colors duration-150 hover:bg-secondary/60"
>
{scopeLabel}
{(activePeriod?.locked_at || activePeriod?.is_closed) && (
<Lock className="h-3 w-3 text-muted-foreground" />
)}
</button>
</div>
)}
{/* Batch-mark "Inget underlag krävs": select-all + contextual action bar */}
{(eligibleEntries.length > 0 || selectedIds.size > 0) && (
<div className="flex flex-col gap-3 rounded-lg border border-border bg-muted/20 p-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2">
<Checkbox
id="select-all-missing"
checked={allEligibleSelected}
onCheckedChange={toggleSelectAll}
disabled={eligibleEntries.length === 0}
/>
<Label htmlFor="select-all-missing" className="text-sm cursor-pointer">
{selectedIds.size > 0
? t('batch_selected_count', { count: selectedIds.size })
: t('batch_select_all', { count: eligibleEntries.length })}
</Label>
</div>
{selectedIds.size > 0 ? (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Input
value={batchReason}
onChange={(e) => setBatchReason(e.target.value)}
placeholder={t('no_doc_required_reason_placeholder')}
list="batch-no-doc-suggestions"
maxLength={200}
className="h-8 text-xs sm:w-56"
disabled={batchSubmitting}
/>
<datalist id="batch-no-doc-suggestions">
<option value={t('no_doc_required_suggestion_bank_fee')} />
<option value={t('no_doc_required_suggestion_interest')} />
<option value={t('no_doc_required_suggestion_internal_transfer')} />
<option value={t('no_doc_required_suggestion_tax_payment')} />
<option value={t('no_doc_required_suggestion_salary')} />
</datalist>
<div className="flex items-center gap-2">
<Button size="sm" onClick={handleBatchExempt} disabled={batchSubmitting}>
{batchSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('batch_mark_no_doc')}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setSelectedIds(new Set())}
disabled={batchSubmitting}
>
{t('batch_clear_selection')}
</Button>
</div>
</div>
) : (
// Filter-scoped: mark every missing-doc verifikat matching the active
// filters across all pages — scales to a post-import flood.
<Button size="sm" variant="outline" onClick={openBulk}>
<CircleSlash className="mr-2 h-4 w-4" />
{t('batch_mark_all_missing')}
</Button>
)}
</div>
)}
{loading ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">{t('loading')}</p>
</CardContent>
</Card>
) : filteredEntries.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<div className="p-4 rounded-full bg-muted mb-4">
<Search className="h-6 w-6 text-muted-foreground" />
</div>
<h3 className="text-lg font-medium mb-1">{t('no_results_title')}</h3>
<p className="text-sm text-muted-foreground text-center max-w-sm">
{t('no_results_description')}
</p>
</CardContent>
</Card>
) : (
<div className="space-y-2">
{filteredEntries.map((entry) => {
const isExpanded = expandedId === entry.id
const lines = (entry.lines || []) as JournalEntryLine[]
// Voucher total = sum of the debit side (= credit side when balanced).
const voucherTotal = lines.reduce((sum, l) => sum + (Number(l.debit_amount) || 0), 0)
const selectable = canWrite && isEligibleForExempt(entry)
return (
<Card key={entry.id}>
<div className="flex items-stretch">
{selectable && (
<div className="flex items-center pl-3" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selectedIds.has(entry.id)}
onCheckedChange={() => toggleSelect(entry.id)}
aria-label={t('batch_select_row')}
/>
</div>
)}
<button
onClick={() => toggleExpand(entry.id)}
aria-expanded={isExpanded}
className="flex-1 min-w-0 p-4 text-left hover:bg-muted/50 transition-colors min-h-[44px]"
>
{/* Desktop: single row */}
<div className="hidden sm:flex items-center gap-3 flex-1">
{isExpanded ? (
<ChevronDown className="h-4 w-4 shrink-0" />
) : (
<ChevronRight className="h-4 w-4 shrink-0" />
)}
<Link
href={`/bookkeeping/${entry.id}`}
className="font-mono text-sm text-primary hover:underline w-16"
onClick={(e) => e.stopPropagation()}
>
{formatVoucher(entry)}
</Link>
<span className="text-sm text-muted-foreground tabular-nums w-24">
{formatDate(entry.entry_date)}
</span>
{entry.out_of_period && (
<Badge
variant="outline"
className="text-xs font-normal shrink-0"
title={t('out_of_period_tooltip')}
>
{t('out_of_period_label')}
</Badge>
)}
{(entry.status === 'reversed' || entry.status === 'draft' || entry.source_type === 'storno' || entry.source_type === 'correction') && (
<JournalEntryStatusBadge entry={entry} showStatus={entry.status === 'reversed' || entry.status === 'draft'} />
)}
<span className="flex-1 truncate">{entry.description}</span>
<span className="shrink-0 w-28 text-right tabular-nums text-sm font-medium">
{formatCurrency(voucherTotal)}
</span>
<Button
asChild
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground transition-colors duration-150 hover:bg-secondary"
>
<span
role="button"
tabIndex={0}
aria-label={t('copy_voucher_tooltip')}
title={t('copy_voucher_tooltip')}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
router.push(`/bookkeeping?copy_from=${entry.id}`)
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
e.stopPropagation()
router.push(`/bookkeeping?copy_from=${entry.id}`)
}
}}
>
<Copy className="h-3.5 w-3.5" />
</span>
</Button>
{attachmentCounts[entry.id] ? (
<Button
asChild
variant="ghost"
size="icon"
className="h-8 w-8 mr-1 text-muted-foreground transition-colors duration-150 hover:bg-secondary"
>
<span
role="button"
tabIndex={0}
aria-label={t('view_attachments')}
title={t('attachment_count_tooltip', { count: attachmentCounts[entry.id] })}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setPreviewEntryId(entry.id)
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
e.stopPropagation()
setPreviewEntryId(entry.id)
}
}}
>
<span className="flex items-center gap-0.5">
<Paperclip className="h-3.5 w-3.5" />
<span className="text-xs">{attachmentCounts[entry.id]}</span>
</span>
</span>
</Button>
) : (
NEEDS_ATTACHMENT.has(entry.source_type) && entry.status === 'posted' && (
noDocRequired.has(entry.id) ? (
<span className="mr-1" title={t('no_doc_required_indicator_tooltip')}>
<CircleSlash className="h-3.5 w-3.5 text-muted-foreground" />
</span>
) : (
<span className="mr-1" title={t('missing_attachment_tooltip')}>
<AlertTriangle className="h-3.5 w-3.5 text-warning-foreground" />
</span>
)
)
)}
</div>
{/* Mobile: two rows */}
<div className="sm:hidden">
<div className="flex items-center gap-2">
{isExpanded ? (
<ChevronDown className="h-4 w-4 shrink-0" />
) : (
<ChevronRight className="h-4 w-4 shrink-0" />
)}
<Link
href={`/bookkeeping/${entry.id}`}
className="font-mono text-sm text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
{formatVoucher(entry)}
</Link>
<span className="text-sm text-muted-foreground tabular-nums">
{formatDate(entry.entry_date)}
</span>
{entry.out_of_period && (
<Badge
variant="outline"
className="text-xs font-normal shrink-0"
title={t('out_of_period_tooltip_mobile')}
>
{t('out_of_period_label')}
</Badge>
)}
{(entry.status === 'reversed' || entry.status === 'draft' || entry.source_type === 'storno' || entry.source_type === 'correction') && (
<JournalEntryStatusBadge entry={entry} showStatus={entry.status === 'reversed' || entry.status === 'draft'} />
)}
<span className="ml-auto flex items-center gap-1">
<Button
asChild
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground transition-colors duration-150 hover:bg-secondary"
>
<span
role="button"
tabIndex={0}
aria-label={t('copy_voucher_tooltip')}
title={t('copy_voucher_tooltip')}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
router.push(`/bookkeeping?copy_from=${entry.id}`)
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
e.stopPropagation()
router.push(`/bookkeeping?copy_from=${entry.id}`)
}
}}
>
<Copy className="h-3.5 w-3.5" />
</span>
</Button>
{attachmentCounts[entry.id] ? (
<Button
asChild
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground transition-colors duration-150 hover:bg-secondary"
>
<span
role="button"
tabIndex={0}
aria-label={t('view_attachments')}
title={t('attachment_count_tooltip', { count: attachmentCounts[entry.id] })}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setPreviewEntryId(entry.id)
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
e.stopPropagation()
setPreviewEntryId(entry.id)
}
}}
>
<span className="flex items-center gap-0.5">
<Paperclip className="h-3.5 w-3.5" />
<span className="text-xs">{attachmentCounts[entry.id]}</span>
</span>
</span>
</Button>
) : (
NEEDS_ATTACHMENT.has(entry.source_type) && entry.status === 'posted' && (
noDocRequired.has(entry.id) ? (
<span title={t('no_doc_required_indicator_tooltip')}>
<CircleSlash className="h-3.5 w-3.5 text-muted-foreground" />
</span>
) : (
<span title={t('missing_attachment_tooltip')}>
<AlertTriangle className="h-3.5 w-3.5 text-warning-foreground" />
</span>
)
)
)}
</span>
</div>
<div className="mt-1 ml-6 flex items-center justify-between gap-2">
<p className="text-sm truncate">{entry.description}</p>
<span className="shrink-0 tabular-nums text-sm font-medium">
{formatCurrency(voucherTotal)}
</span>
</div>
</div>
</button>
</div>
{isExpanded && (
<CardContent className="pt-0 pb-4">
{lines.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">{t('no_lines')}</p>
) : (
<div className="rounded-lg border overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('account_column')}</TableHead>
<TableHead>{t('description_column')}</TableHead>
<TableHead className="text-right">{t('debit')}</TableHead>
<TableHead className="text-right">{t('credit')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{lines
.slice()
.sort((a, b) => a.sort_order - b.sort_order)
.map((line) => {
const accountName = getAccountDescription(line.account_number)?.name
const desc = line.line_description
const showDesc = desc
&& desc.toLowerCase() !== accountName?.toLowerCase()
&& desc.toLowerCase() !== entry.description?.toLowerCase()
const debit = Number(line.debit_amount) || 0
const credit = Number(line.credit_amount) || 0
const fx = line.currency && line.currency !== 'SEK' && line.amount_in_currency != null
? `${Number(line.amount_in_currency).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} ${line.currency}`
: null
return (
<TableRow key={line.id}>
<TableCell className="align-top whitespace-nowrap">
<AccountNumber number={line.account_number} showName />
</TableCell>
<TableCell className="align-top text-muted-foreground">
{showDesc ? desc : ''}
</TableCell>
<TableCell className="align-top text-right tabular-nums">
{debit > 0 ? debit.toLocaleString('sv-SE', { minimumFractionDigits: 2 }) : ''}
{debit > 0 && fx && (
<span className="block text-xs text-muted-foreground tabular-nums">{fx}</span>
)}
</TableCell>
<TableCell className="align-top text-right tabular-nums">
{credit > 0 ? credit.toLocaleString('sv-SE', { minimumFractionDigits: 2 }) : ''}
{credit > 0 && fx && (
<span className="block text-xs text-muted-foreground tabular-nums">{fx}</span>
)}
</TableCell>
</TableRow>
)
})}
</TableBody>
<TableFooter>
<TableRow>
<TableCell colSpan={2} className="font-medium">{t('sum_label')}</TableCell>
<TableCell className="text-right tabular-nums font-medium">
{lines.reduce((sum, l) => sum + (Number(l.debit_amount) || 0), 0).toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
</TableCell>
<TableCell className="text-right tabular-nums font-medium">
{lines.reduce((sum, l) => sum + (Number(l.credit_amount) || 0), 0).toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
)}
{entry.notes && (
<p className="mt-3 text-xs text-muted-foreground italic px-1">
{entry.notes}
</p>
)}
<JournalEntryAttachments
journalEntryId={entry.id}
onCountChange={(c) => handleAttachmentCountChange(entry.id, c)}
/>
{entry.status === 'posted' && NEEDS_ATTACHMENT.has(entry.source_type) && (
<NoDocRequiredToggle
entryId={entry.id}
initialExempt={noDocRequired.has(entry.id)}
initialReason={noDocRequired.get(entry.id) ?? null}
canWrite={canWrite}
onChange={(exempted, reason) => {
setNoDocRequired((prev) => {
const next = new Map(prev)
if (exempted) next.set(entry.id, reason ?? null)
else next.delete(entry.id)
return next
})
}}
/>
)}
<div className="mt-4 pt-3 border-t flex flex-col sm:flex-row gap-2">
{entry.status === 'draft' && (
<Button
size="sm"
className="w-full sm:w-auto"
onClick={() => handleCommit(entry.id)}
disabled={!canWrite || committingId === entry.id}
title={!canWrite ? t('read_only_tooltip') : undefined}
>
{!canWrite ? <Lock className="mr-2 h-4 w-4" /> : committingId === entry.id && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('post')}
</Button>
)}
<Button variant="outline" size="sm" className="w-full sm:w-auto" asChild>
<Link href={`/bookkeeping/${entry.id}`}>{t('show_details')}</Link>
</Button>
{entry.status === 'posted' && entry.source_type !== 'storno' && entry.source_type !== 'correction' && (
<Button
variant="outline"
size="sm"
className="w-full sm:w-auto"
onClick={() => setCorrectionEntry(entry)}
>
{t('create_correction')}
</Button>
)}
<Button
variant="outline"
size="sm"
className="w-full sm:w-auto"
onClick={() => router.push(`/bookkeeping?copy_from=${entry.id}`)}
>
<Copy className="mr-2 h-4 w-4" />
{t('copy')}
</Button>
</div>
</CardContent>
)}
</Card>
)
})}
</div>
)}
{/* Filter-scoped bulk "Inget underlag krävs" confirmation */}
<Dialog
open={bulkOpen}
onOpenChange={(o) => {
if (bulkSubmitting) return
setBulkOpen(o)
if (!o) {
setBulkCount(null)
setBulkReason('')
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('bulk_mark_title')}</DialogTitle>
</DialogHeader>
<div className="space-y-3 text-sm">
{bulkCount === null ? (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t('bulk_mark_counting')}
</div>
) : bulkCount === 0 ? (
<p className="text-muted-foreground">{t('bulk_mark_none')}</p>
) : (
<>
<p>{t('bulk_mark_body', { count: bulkCount })}</p>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">{t('no_doc_required_reason_add')}</Label>
<Input
value={bulkReason}
onChange={(e) => setBulkReason(e.target.value)}
placeholder={t('no_doc_required_reason_placeholder')}
list="bulk-no-doc-suggestions"
maxLength={200}
className="h-8 text-xs"
disabled={bulkSubmitting}
/>
<datalist id="bulk-no-doc-suggestions">
<option value={t('no_doc_required_suggestion_bank_fee')} />
<option value={t('no_doc_required_suggestion_interest')} />
<option value={t('no_doc_required_suggestion_internal_transfer')} />
<option value={t('no_doc_required_suggestion_tax_payment')} />
<option value={t('no_doc_required_suggestion_salary')} />
</datalist>
</div>
</>
)}
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={() => setBulkOpen(false)} disabled={bulkSubmitting}>
{t('bulk_cancel')}
</Button>
<Button size="sm" onClick={handleBulkConfirm} disabled={bulkSubmitting || !bulkCount}>
{bulkSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('bulk_mark_confirm', { count: bulkCount ?? 0 })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Correction dialog */}
{correctionEntry && (
<CorrectionEntryDialog
entry={correctionEntry}
open={!!correctionEntry}
onOpenChange={(open) => { if (!open) setCorrectionEntry(null) }}
onCorrected={() => { setCorrectionEntry(null); fetchEntries() }}
/>
)}
{/* Attachment preview sheet */}
<AttachmentPreviewSheet
entryId={previewEntryId}
open={previewEntryId !== null}
onOpenChange={(open) => { if (!open) setPreviewEntryId(null) }}
/>
{/* 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>
)
}