Files
accounted/components/bookkeeping/JournalEntryList.tsx
T
Mattsson 241959513b Fix/mcp and req (#753)
* feat(api): test-mode API keys force dry-run on the v1 REST API

A key created with mode='test' (prefix gnubok_sk_test_) binds to the real
company, but the v1 wrapper forces dry_run on every write so nothing is
persisted or sent. Mutations on endpoints that can't be simulated
(dryRunSupported=false or unregistered) are refused with 403
TEST_KEY_WRITE_BLOCKED — fail-closed. Reads pass through unchanged and every
test-key response carries X-Gnubok-Mode: test. Live keys are unaffected
(mode defaults to 'live').

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

* feat(invoices): company default "Vår referens" + per-line sales-account override

Add company_settings.default_our_reference (settings form, schema, type); the
invoice editor pre-fills our_reference from it on new invoices only, never
overwriting an edited draft. Separately, add an optional per-line
försäljningskonto (class-3) override in the editor — left blank, the engine
still derives the revenue account from the VAT rate, and reverse-charge/export
lines ignore the override.

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

* feat(invoices): render a Swish payment QR on invoice PDFs

Build the Swish "Type C" QR payload offline (no Swish API call) and embed it as
a PNG in the invoice PDF payment box when Swish display is enabled, the invoice
is in SEK, and the amount is positive. Also surface the invoice number in the
payment box. Wired through every PDF render path: send, mark-sent and pdf
routes (both legacy and v1), the recurring-schedule sender, and the staged-send
commit.

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

* feat(bookkeeping): draft exclusion + correction-chain collapse on verifikationslista

Extend list_fiscal_period_entries_with_related with two opt-in params:
p_exclude_draft (keep drafts off the committed list — they get their own
surface) and p_collapse_corrections (render a correction group as the single
live correction, hiding the mechanical storno and the reversed original).
Both default false; nothing is deleted, every voucher keeps its number, and a
"show all" toggle exposes the full chain.

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

* fix(reports): link multi-year SIE periods so resultatrapport shows the prior year

SIE import now sets fiscal_periods.previous_period_id in both directions when
creating a period, so multi-year files chain correctly regardless of #RAR order.
A backfill migration repairs periods imported before this (idempotent; only
touches NULL links on first-of-month periods). generateResultatrapport falls
back to the date-adjacent prior period when the chain is still null, so the
comparison column works for legacy data too.

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

* fix(articles): hide the VAT field for non-momsregistrerade companies

The article form reads company_settings.vat_registered and, when false, hides
the moms field and forces vat_rate to 0 on submit — mirroring the invoice
editor so a non-VAT-registered company never sets a rate it can't charge.

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

* feat(import): allow file-based imports in the sandbox

Bank-file, CSV/Excel and SIE imports run entirely on uploaded data with no
external service, so they're now reachable in the sandbox. Only the API-backed
options that need live third-party credentials (PSD2 bank connection, provider
migration) stay disabled. Updates the sandbox notice copy to match.

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

* feat(bookkeeping): add edit draft functionality for journal entries

* feat(database): add default "Vår referens" column to company_settings for invoicing

* fix(tests): set SHOW_SWISH_ON_INVOICE to false in PDF template mocks

* @
fix(payments): use roundOre for Swish amount formatting

Replace naive Math.round(x*100)/100 with roundOre from @/lib/money to
satisfy the antipattern guard.

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-18 11:49:33 +02:00

1467 lines
62 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('')
// Verifikat (committed) vs Utkast (drafts) view. Drafts are excluded from the
// committed list server-side and surfaced here behind a count badge.
const [listMode, setListMode] = useState<'committed' | 'drafts'>('committed')
// Collapse correction groups to the live correction (hide storno + reversed
// original). Toggled off via the filter dialog to reveal the full chain.
const [collapseCorrections, setCollapseCorrections] = useState(true)
const [draftCount, setDraftCount] = useState(0)
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 01 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 (listMode === 'drafts') {
// Drafts get their own view spanning all years — they're work-in-progress
// and shouldn't be hidden by the selected fiscal-year scope.
params.set('status', 'draft')
} else {
params.set('exclude_draft', 'true')
if (collapseCorrections) params.set('collapse_corrections', 'true')
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)
fetchDraftCount()
}
// Cheap count-only query for the "Utkast" badge — all years, so the badge
// surfaces drafts regardless of the selected fiscal-year scope.
async function fetchDraftCount() {
try {
const res = await fetch('/api/bookkeeping/journal-entries?status=draft&limit=1')
if (!res.ok) return
const { count: total } = await res.json()
setDraftCount(total || 0)
} catch {
// Non-fatal: the badge keeps its last value.
}
}
useEffect(() => {
if (!sortHydrated || !periodHydrated || !pageSizeHydrated) return
fetchEntries()
}, [periodId, page, pageSize, sortBy, dateFrom, dateTo, seriesFilter, search, listMode, collapseCorrections, sortHydrated, periodHydrated, pageSizeHydrated])
const handleAttachmentCountChange = useCallback((entryId: string, count: number) => {
setAttachmentCounts((prev) => ({ ...prev, [entryId]: count }))
}, [])
const toggleExpand = (id: string) => {
setExpandedId(expandedId === id ? null : id)
}
function switchMode(mode: 'committed' | 'drafts') {
if (mode === listMode) return
setListMode(mode)
setPage(0)
setSelectedIds(new Set())
if (mode === 'drafts') setShowMissingOnly(false)
}
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>
{/* Reveal the storno + reversed-original rows the default view folds
into the surviving correction (3 rows → 1). */}
<div className="flex items-center gap-2">
<Switch
id="show-correction-chain"
checked={!collapseCorrections}
onCheckedChange={(on) => { setCollapseCorrections(!on); setPage(0) }}
/>
<Label htmlFor="show-correction-chain" className="text-sm cursor-pointer">
{t('show_correction_chain')}
</Label>
</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>
{/* Verifikat vs Utkast. Drafts live in their own view with a count badge so
they don't sink to the last page of the committed list. */}
<div className="flex items-center gap-2">
<div className="inline-flex rounded-md border border-border p-0.5">
<button
type="button"
onClick={() => switchMode('committed')}
className={`h-7 rounded px-3 text-xs font-medium transition-colors ${listMode === 'committed' ? 'bg-secondary text-foreground' : 'text-muted-foreground hover:text-foreground'}`}
>
{t('mode_vouchers')}
</button>
<button
type="button"
onClick={() => switchMode('drafts')}
className={`inline-flex h-7 items-center gap-1.5 rounded px-3 text-xs font-medium transition-colors ${listMode === 'drafts' ? 'bg-secondary text-foreground' : 'text-muted-foreground hover:text-foreground'}`}
>
{t('mode_drafts')}
{draftCount > 0 && (
<Badge variant="secondary" className="h-4 min-w-4 justify-center px-1 text-[10px] tabular-nums">
{draftCount}
</Badge>
)}
</button>
</div>
</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. */}
{listMode === 'committed' && 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>
)
}