'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, RotateCcw } from 'lucide-react' import { formatDate, formatCurrency } from '@/lib/utils' import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver' import { resolveCurrentPeriodId } from '@/lib/bookkeeping/suggest-fiscal-period' 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 { ConfirmationDialog } from '@/components/ui/confirmation-dialog' 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:'). const SORT_STORAGE_KEY_PREFIX = 'Accounted:journal-sort:' const SORT_VALUES = new Set(['date_desc', 'date_asc', 'voucher_asc', 'voucher_desc']) // Page-size selector. Persisted per company, mirroring the sort key convention. // 'all' fetches everything in the current scope (capped server-side at MAX_LIMIT); // the numeric options paginate normally. type PageSizeChoice = '20' | '50' | '100' | 'all' const PAGE_SIZE_STORAGE_KEY_PREFIX = 'Accounted:journal-page-size:' const PAGE_SIZE_OPTIONS = [20, 50, 100] as const const PAGE_SIZE_VALUES = new Set(['20', '50', '100', 'all']) // Sentinel limit sent for "Alla". The route clamps this to its own MAX_LIMIT. const ALL_PAGE_SIZE = 100000 export default function JournalEntryList() { const router = useRouter() const { toast } = useToast() const { canWrite } = useCanWrite() const company = useCompanyOptional()?.company ?? null const t = useTranslations('journal_list') const [entries, setEntries] = useState([]) const [committingId, setCommittingId] = useState(null) const [loading, setLoading] = useState(true) const [expandedId, setExpandedId] = useState(null) const [count, setCount] = useState(0) const [page, setPage] = useState(0) const [attachmentCounts, setAttachmentCounts] = useState>({}) const [noDocRequired, setNoDocRequired] = useState>(new Map()) const [showMissingOnly, setShowMissingOnly] = useState(false) const [selectedIds, setSelectedIds] = useState>(new Set()) const [batchReason, setBatchReason] = useState('') const [batchSubmitting, setBatchSubmitting] = useState(false) const [bulkOpen, setBulkOpen] = useState(false) const [bulkCount, setBulkCount] = useState(null) const [bulkReason, setBulkReason] = useState('') const [bulkSubmitting, setBulkSubmitting] = useState(false) const [correctionEntry, setCorrectionEntry] = useState(null) const [reverseEntryTarget, setReverseEntryTarget] = useState(null) const [isReversing, setIsReversing] = useState(false) const [previewEntryId, setPreviewEntryId] = useState(null) const [sortBy, setSortBy] = useState('date_desc') const [sortHydrated, setSortHydrated] = useState(false) const [periodId, setPeriodId] = useState(null) const [periodHydrated, setPeriodHydrated] = useState(false) const [periods, setPeriods] = useState([]) 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('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('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() 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]) // Fetch fiscal periods AND resolve the initial fiscal-year scope in one pass. // The list is period-oriented (BFL): verifikationsnummer run as an unbroken // series *per räkenskapsår*, so the same number (e.g. A42) recurs once per // year. Showing every year at once makes those look like duplicates and makes // a bare "A42" reference ambiguous — so we default to the räkenskapsår the // user is currently in rather than "all years". An explicit "Alla // räkenskapsår" choice (persisted as ALL_YEARS_VALUE) is still honoured. // Resolving the scope here — not in the dialog's FiscalYearSelector, which // only mounts when opened — keeps the first fetch correct. periodHydrated // gates that first fetch so the list loads already scoped to the resolved year. useEffect(() => { if (!company?.id) { setPeriods([]) setPeriodId(null) setPeriodHydrated(true) return } let cancelled = false ;(async () => { let fetched: FiscalPeriod[] = [] try { const res = await fetch('/api/bookkeeping/fiscal-periods') if (res.ok) { const { data } = await res.json() fetched = (data || []) as FiscalPeriod[] } } catch { // Non-critical — fall through with an empty list (scope stays "all years"). } if (cancelled) return setPeriods(fetched) const stored = typeof window !== 'undefined' ? window.localStorage.getItem(FISCAL_YEAR_STORAGE_KEY_PREFIX + company.id) : null if (stored === FISCAL_YEAR_ALL_VALUE) { // User explicitly chose "all years" — respect it. setPeriodId(null) } else if (stored && fetched.some((p) => p.id === stored)) { setPeriodId(stored) } else { // No (valid) saved scope → default to the current räkenskapsår. const today = new Date().toISOString().split('T')[0] setPeriodId(resolveCurrentPeriodId(fetched, today)) } setPeriodHydrated(true) })() 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 (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) // The pristine empty card vs. the (toggle-bearing) "drafts exist" state hinges // on draftCount. When the committed list comes back empty, resolve the draft // count BEFORE clearing loading so the toggle doesn't flash out for a frame on // a stale count of 0. Every other case refreshes the badge in the background. if (loadedEntries.length === 0 && listMode === 'committed') { await fetchDraftCount() } else { fetchDraftCount() } setLoading(false) // Fetch attachment counts for the loaded entries const ids = loadedEntries.map((e: JournalEntry) => e.id) fetchAttachmentCounts(ids) } // 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) } } // Pure reversal (storno) of a posted verifikat — books a stornoverifikation // with no replacement, per BFL 5 kap 5§. Routes through the engine's // reverseEntry (storno + reverses_id link; original → 'reversed', never // deleted). "Rätta" stays the path for booking a replacement entry instead. const handleReverse = async () => { const target = reverseEntryTarget if (!target) return setIsReversing(true) try { const res = await fetch(`/api/bookkeeping/journal-entries/${target.id}/reverse`, { method: 'POST' }) const result = await res.json() if (res.ok) { const storno = result.data toast({ title: t('toast_reverse_done_title'), description: t('toast_reverse_done_description', { voucher: formatVoucher(storno ?? {}) }), }) setReverseEntryTarget(null) await fetchEntries() } else { toast({ title: t('toast_reverse_failed'), description: getErrorMessage(result, { context: 'journal_entry' }), variant: 'destructive' }) } } catch { toast({ title: t('toast_reverse_failed'), variant: 'destructive' }) } finally { setIsReversing(false) } } // 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 }) } // Pristine, untouched ledger: nothing posted, no drafts, no filters, and we're // on the committed view. ONLY this genuinely-empty case may short-circuit the // whole component — every other empty state (a draft exists, or we're in the // drafts view) must fall through to the main render below so the // Verifikat/Utkast toggle stays reachable. if (!loading && entries.length === 0 && !hasActiveFilters && listMode === 'committed' && draftCount === 0) { return (

{t('empty_title')}

{t('empty_description')}

) } return (
{/* Search (always visible) + filter dialog for everything else */}
setSearchInput(e.target.value)} className="h-8 pl-8 pr-7 text-xs" /> {searchInput && ( )}
{t('filter_dialog_title')}
{/* Räkenskapsår */}
{/* Sortering */}
{/* Verifikationsserie */}
{/* Datumintervall */}
setDateFromInput(e.target.value)} onBlur={applyDateFilter} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault() applyDateFilter() } }} className="h-9 flex-1 text-sm" /> – setDateToInput(e.target.value)} onBlur={applyDateFilter} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault() applyDateFilter() } }} className="h-9 flex-1 text-sm" /> {(dateFrom || dateTo) && ( )}
{/* Visa saknade underlag */}
{showMissingOnly && ( {filteredEntries.length} )}
{/* Reveal the storno + reversed-original rows the default view folds into the surviving correction (3 rows → 1). */}
{ setCollapseCorrections(!on); setPage(0) }} />
{/* 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. */}
{/* 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 && (
{t('scope_label')}
)} {/* Batch-mark "Inget underlag krävs": select-all + contextual action bar */} {(eligibleEntries.length > 0 || selectedIds.size > 0) && (
{selectedIds.size > 0 ? (
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} />
) : ( // Filter-scoped: mark every missing-doc verifikat matching the active // filters across all pages — scales to a post-import flood. )}
)} {loading ? (

{t('loading')}

) : filteredEntries.length === 0 ? ( // Empty placeholder, scoped to the situation: an empty drafts view, a // filtered committed view with no matches, or a committed view with no // posted entries yet (but drafts exist — hence we got here, not the // pristine early return above).
{listMode === 'drafts' || !hasActiveFilters ? ( ) : ( )}

{listMode === 'drafts' ? t('empty_drafts_title') : hasActiveFilters ? t('no_results_title') : t('empty_title')}

{listMode === 'drafts' ? t('empty_drafts_description') : hasActiveFilters ? t('no_results_description') : t('empty_description')}

) : (
{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 (
{selectable && (
e.stopPropagation()}> toggleSelect(entry.id)} aria-label={t('batch_select_row')} />
)} {attachmentCounts[entry.id] ? ( ) : ( NEEDS_ATTACHMENT.has(entry.source_type) && entry.status === 'posted' && ( noDocRequired.has(entry.id) ? ( ) : ( ) ) )}
{/* Mobile: two rows */}
{isExpanded ? ( ) : ( )} e.stopPropagation()} > {formatVoucher(entry)} {formatDate(entry.entry_date)} {entry.out_of_period && ( {t('out_of_period_label')} )} {(entry.status === 'reversed' || entry.status === 'draft' || entry.source_type === 'storno' || entry.source_type === 'correction') && ( )} {attachmentCounts[entry.id] ? ( ) : ( NEEDS_ATTACHMENT.has(entry.source_type) && entry.status === 'posted' && ( noDocRequired.has(entry.id) ? ( ) : ( ) ) )}

{entry.description}

{formatCurrency(voucherTotal)}
{isExpanded && ( {lines.length === 0 ? (

{t('no_lines')}

) : (
{t('account_column')} {t('description_column')} {t('debit')} {t('credit')} {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 ( {showDesc ? desc : ''} {debit > 0 ? debit.toLocaleString('sv-SE', { minimumFractionDigits: 2 }) : ''} {debit > 0 && fx && ( {fx} )} {credit > 0 ? credit.toLocaleString('sv-SE', { minimumFractionDigits: 2 }) : ''} {credit > 0 && fx && ( {fx} )} ) })} {t('sum_label')} {lines.reduce((sum, l) => sum + (Number(l.debit_amount) || 0), 0).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} {lines.reduce((sum, l) => sum + (Number(l.credit_amount) || 0), 0).toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
)} {entry.notes && (

{entry.notes}

)} handleAttachmentCountChange(entry.id, c)} /> {entry.status === 'posted' && NEEDS_ATTACHMENT.has(entry.source_type) && ( { setNoDocRequired((prev) => { const next = new Map(prev) if (exempted) next.set(entry.id, reason ?? null) else next.delete(entry.id) return next }) }} /> )}
{entry.status === 'draft' && ( )} {entry.status === 'posted' && entry.source_type !== 'storno' && entry.source_type !== 'correction' && ( )} {canWrite && entry.status === 'posted' && entry.source_type !== 'storno' && entry.source_type !== 'correction' && ( )}
)} ) })}
)} {/* Filter-scoped bulk "Inget underlag krävs" confirmation */} { if (bulkSubmitting) return setBulkOpen(o) if (!o) { setBulkCount(null) setBulkReason('') } }} > {t('bulk_mark_title')}
{bulkCount === null ? (
{t('bulk_mark_counting')}
) : bulkCount === 0 ? (

{t('bulk_mark_none')}

) : ( <>

{t('bulk_mark_body', { count: bulkCount })}

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} />
)}
{/* Correction dialog */} {correctionEntry && ( { if (!open) setCorrectionEntry(null) }} onCorrected={() => { setCorrectionEntry(null); fetchEntries() }} /> )} {/* Reverse (storno) confirmation dialog */} {reverseEntryTarget && ( { if (!open && !isReversing) setReverseEntryTarget(null) }} onConfirm={handleReverse} isSubmitting={isReversing} title={t('reverse_confirm_title')} warningText={t('reverse_warning')} confirmLabel={t('reverse_confirm_label')} >

{t('reverse_dialog_heading', { voucher: formatVoucher(reverseEntryTarget) })}

{t('reverse_dialog_body')}

)} {/* Attachment preview sheet */} { 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') && (
{/* Page size + result range */}
{showingAll ? t('showing_all', { total: count }) : t('showing_range', { from: count === 0 ? 0 : page * pageSize + 1, to: Math.min((page + 1) * pageSize, count), total: count, })}
{/* Page navigation — hidden when showing all or when everything fits on one page */} {!showingAll && count > pageSize && (
{t('page_of', { page: page + 1, total: Math.ceil(count / pageSize) })}
)}
)} ) }