fa394e3759
Four fixes from the exit mail Anders Orback (Center Node AB) sent hours after churning. His five points were mostly one job: reconciling skattekontot against banken before årsredovisningen. Skattekonto look-alike rows. Skatteverket splits a retroactive omprövningsbeslut across every month it re-charges and sends one transaction per month, sharing date, text and amount; only ranteberakningsdatum separates them, and we stored it but rendered it nowhere. A real company posted 15 such vouchers (67 785 kr across Feb 2025-Apr 2026) unable to tell them from duplicates of the automatic hämtning. Surface the field when it carries information: its month differs from the Datum column, or another row in the same band is otherwise indistinguishable. The list-to-voucher round trip. The verifikat list collapsed to a skeleton on every refetch and sprang back, moving rows under the pointer; only the first load shows a skeleton now. Filter state is React-only, so leaving the list loses it: add a hover-revealed open-in-new-tab affordance on the voucher list and the skattekonto page, where the link had been behind a hand-rolled opacity-0 that coarse pointers never trigger. Makulerad rendering. A stornoed verifikat now reads as struck out, per data cell rather than on the row, because text-decoration propagates and a child cannot opt out. Vouchers-per-account discoverability. /reports/huvudbok?account=1930 already existed; the palette matcher requires every token and the entry never contained the word "verifikat". Add ReportDescriptor.searchTerms plus a report-library search box. Also fixes a false "Saknar underlag" compliance chip that flashed before attachment counts resolved, and a keyboard-access regression where HOVER_REVEAL_CLASS carried focus-visible only, hiding controls inside a non-focusable wrapper from keyboard users. No migration. No write paths, storno paths or posted entries touched. Follow-ups filed: #1300 #1301 #1302 #1303 #1304 #1305 #1306 #1307 #1308. Open decision: #1305 (Omförd vs Makulerad). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1651 lines
73 KiB
TypeScript
1651 lines
73 KiB
TypeScript
'use client'
|
|
|
|
import { Fragment, useState, useEffect, useCallback } from 'react'
|
|
import Link from 'next/link'
|
|
import { useRouter } from 'next/navigation'
|
|
import { useTranslations } from 'next-intl'
|
|
import {
|
|
DataList,
|
|
DataListEmpty,
|
|
DataListLoading,
|
|
} from '@/components/ui/data-list'
|
|
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 {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
DialogFooter,
|
|
DialogClose,
|
|
} from '@/components/ui/dialog'
|
|
import {
|
|
STORAGE_KEY_PREFIX as FISCAL_YEAR_STORAGE_KEY_PREFIX,
|
|
ALL_YEARS_VALUE as FISCAL_YEAR_ALL_VALUE,
|
|
} from '@/components/common/FiscalYearSelector'
|
|
import { FyPicker } from '@/components/common/FyPicker'
|
|
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
|
import { OpenInNewTab } from '@/components/ui/open-in-new-tab'
|
|
import {
|
|
TH_CLASS,
|
|
TD_CLASS,
|
|
VTH_CLASS,
|
|
VTD_CLASS,
|
|
QUIET_LINK_CLASS,
|
|
RowFoldout,
|
|
} from '@/components/ui/dry-table'
|
|
import { ChevronRight, ChevronLeft, ChevronsLeft, ChevronsRight, Copy, Paperclip, CircleSlash, Loader2, BookOpen, X, Lock, Search, SlidersHorizontal, RotateCcw } from 'lucide-react'
|
|
import { cn, 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:<companyId>').
|
|
const SORT_STORAGE_KEY_PREFIX = 'Accounted:journal-sort:'
|
|
const SORT_VALUES = new Set<SortBy>(['date_desc', 'date_asc', 'voucher_asc', 'voucher_desc'])
|
|
|
|
// Compact row density (support feedback: "kompakt visning av verifikat").
|
|
// Persisted per company, mirroring the sort key convention.
|
|
|
|
// 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)
|
|
// Confirm-before-posting (convention 10): the draft the user is about to
|
|
// commit, plus the predicted voucher label ("A-218") for the dialog copy.
|
|
const [commitTarget, setCommitTarget] = useState<JournalEntry | null>(null)
|
|
const [commitVoucherPreview, setCommitVoucherPreview] = useState<string | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
// Only the very first load may replace the table with a skeleton. Every
|
|
// later refetch (filter, sort, page, search) keeps the rows on screen and
|
|
// dims them, so the list never collapses to a spinner and springs back to
|
|
// full height under the pointer. Growing lists were causing real mis-clicks.
|
|
const [hasLoaded, setHasLoaded] = useState(false)
|
|
const [expandedId, setExpandedId] = useState<string | null>(null)
|
|
const [count, setCount] = useState(0)
|
|
const [page, setPage] = useState(0)
|
|
const [attachmentCounts, setAttachmentCounts] = useState<Record<string, number>>({})
|
|
// Counts arrive in a second request, after the rows are already painted.
|
|
// Until they land, every row looks like it has no underlag, so rendering the
|
|
// chip eagerly flashes a false "Saknar underlag" compliance warning on every
|
|
// load, sort, filter and page change. Render nothing until we actually know.
|
|
const [attachmentCountsLoaded, setAttachmentCountsLoaded] = useState(false)
|
|
// Entries with inline rättelser (journal_entry_rattelse_log rows): drives
|
|
// the "Rättad" marker so a rättelse is discoverable from the list
|
|
// (BFL 5 kap 5 §), not only on the detail page.
|
|
const [rattelseFlags, setRattelseFlags] = useState<Set<string>>(new Set())
|
|
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 [reverseEntryTarget, setReverseEntryTarget] = useState<JournalEntry | null>(null)
|
|
const [isReversing, setIsReversing] = useState(false)
|
|
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 [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) {
|
|
setAttachmentCounts({})
|
|
setAttachmentCountsLoaded(true)
|
|
return
|
|
}
|
|
// Deliberately keeps the previous counts in place while refetching: they
|
|
// are keyed by entry id, so a row that survives the refetch keeps its true
|
|
// count and a new row is covered by the loaded flag below.
|
|
setAttachmentCountsLoaded(false)
|
|
// The counts route caps each request at 50 IDs, so a large page ("Alla", or
|
|
// 100/page) must be split into chunks and merged. Without this the whole
|
|
// request 400s and every document-requiring row falsely shows the
|
|
// missing-underlag warning until it's expanded.
|
|
const COUNTS_BATCH_SIZE = 50
|
|
const batches: string[][] = []
|
|
for (let i = 0; i < entryIds.length; i += COUNTS_BATCH_SIZE) {
|
|
batches.push(entryIds.slice(i, i + COUNTS_BATCH_SIZE))
|
|
}
|
|
try {
|
|
const results = await Promise.all(
|
|
batches.map(async (batch) => {
|
|
const res = await fetch(
|
|
`/api/documents/counts?journal_entry_ids=${batch.join(',')}`
|
|
)
|
|
if (!res.ok) return {} as Record<string, number>
|
|
const { data } = await res.json()
|
|
return (data || {}) as Record<string, number>
|
|
})
|
|
)
|
|
setAttachmentCounts(Object.assign({}, ...results))
|
|
} catch {
|
|
// Non-critical: silently ignore
|
|
} finally {
|
|
setAttachmentCountsLoaded(true)
|
|
}
|
|
}, [])
|
|
|
|
const fetchRattelseFlags = useCallback(async (entryIds: string[]) => {
|
|
if (entryIds.length === 0) {
|
|
setRattelseFlags(new Set())
|
|
return
|
|
}
|
|
try {
|
|
const res = await fetch(
|
|
`/api/bookkeeping/journal-entries/rattelse-flags?ids=${entryIds.join(',')}`
|
|
)
|
|
if (!res.ok) return
|
|
const { data } = await res.json()
|
|
setRattelseFlags(new Set((data || []) as string[]))
|
|
} 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])
|
|
|
|
// 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) {
|
|
setPeriodId(null)
|
|
setPeriodHydrated(true)
|
|
return
|
|
}
|
|
|
|
const stored =
|
|
typeof window !== 'undefined'
|
|
? window.localStorage.getItem(FISCAL_YEAR_STORAGE_KEY_PREFIX + company.id)
|
|
: null
|
|
// Optimistic hydration: a saved scope unblocks the first entries fetch
|
|
// immediately instead of serializing it behind the fiscal-periods
|
|
// round-trip (the common returning-user case). The fetch below still
|
|
// validates a saved period id and re-scopes to the current räkenskapsår
|
|
// if it went stale (e.g. the period was deleted), the entries effect
|
|
// then refires with the corrected scope.
|
|
if (stored) {
|
|
// FISCAL_YEAR_ALL_VALUE = user explicitly chose "all years", respect it.
|
|
setPeriodId(stored === FISCAL_YEAR_ALL_VALUE ? null : stored)
|
|
setPeriodHydrated(true)
|
|
}
|
|
|
|
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
|
|
|
|
if (stored === FISCAL_YEAR_ALL_VALUE) return
|
|
if (stored && fetched.some((p) => p.id === stored)) return
|
|
|
|
// 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) {
|
|
setHasLoaded(true)
|
|
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()
|
|
}
|
|
setHasLoaded(true)
|
|
setLoading(false)
|
|
|
|
// Fetch attachment counts + rättelse markers for the loaded entries
|
|
const ids = loadedEntries.map((e: JournalEntry) => e.id)
|
|
fetchAttachmentCounts(ids)
|
|
fetchRattelseFlags(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)
|
|
}
|
|
|
|
// Open the confirm dialog and fetch the predicted voucher number. The
|
|
// prediction is indicative (numbers are assigned atomically at commit,
|
|
// and a draft in another period/series may land elsewhere); the success
|
|
// toast always shows the real one.
|
|
const openCommitConfirm = (entry: JournalEntry) => {
|
|
setCommitTarget(entry)
|
|
setCommitVoucherPreview(null)
|
|
fetch('/api/bookkeeping/voucher-sequences/next')
|
|
.then((r) => r.json())
|
|
.then(({ data }) => {
|
|
if (data?.next != null) setCommitVoucherPreview(`${data.series}${data.next}`)
|
|
})
|
|
.catch(() => {})
|
|
}
|
|
|
|
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) {
|
|
// Resolve via the parsed body plus the status: the route answers
|
|
// thrown errors with the canonical envelope `{ error: { code,
|
|
// message } }`, and rendering that object as the toast description
|
|
// would crash React ("Objects are not valid as a React child").
|
|
toast({
|
|
title: t('no_doc_required_save_failed'),
|
|
description: getErrorMessage(body, { statusCode: res.status }),
|
|
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: getErrorMessage(body, { statusCode: res.status }),
|
|
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.
|
|
// Filters that live inside the Filtrera dialog. The fiscal-year scope is its
|
|
// own control now, so it no longer counts toward the dialog badge (it would
|
|
// otherwise always read "1" for the default year).
|
|
const dialogFilterCount =
|
|
(seriesFilter !== 'all' ? 1 : 0) +
|
|
(dateFrom || dateTo ? 1 : 0) +
|
|
(showMissingOnly ? 1 : 0)
|
|
// Year scope included: drives empty-state messaging + keeping the bar mounted.
|
|
const activeFilterCount = (periodId ? 1 : 0) + dialogFilterCount
|
|
|
|
// 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
|
|
|
|
// Apply a fiscal-year selection. The FiscalYearSelector (now an inline control
|
|
// in the toolbar, not buried in the filter dialog) 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 (
|
|
<DataList className="stagger-enter">
|
|
<DataListEmpty
|
|
icon={<BookOpen className="h-6 w-6" />}
|
|
title={t('empty_title')}
|
|
description={t('empty_description')}
|
|
/>
|
|
</DataList>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Control bar: view toggle + search + filters + active fiscal-year scope
|
|
on one aligned row (wraps on narrow screens) rather than four stacked rows. */}
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{/* 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="inline-flex shrink-0 gap-0.5 rounded-lg bg-muted/70 p-[3px]" role="tablist">
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={listMode === 'committed'}
|
|
onClick={() => switchMode('committed')}
|
|
className={cn(
|
|
'rounded-md px-3.5 py-[5px] text-[12.5px] transition-colors duration-150',
|
|
listMode === 'committed'
|
|
? 'border border-border bg-card font-medium text-foreground'
|
|
: 'text-muted-foreground hover:text-foreground',
|
|
)}
|
|
>
|
|
{t('mode_vouchers')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={listMode === 'drafts'}
|
|
onClick={() => switchMode('drafts')}
|
|
className={cn(
|
|
'inline-flex items-center gap-1.5 rounded-md px-3.5 py-[5px] text-[12.5px] transition-colors duration-150',
|
|
listMode === 'drafts'
|
|
? 'border border-border bg-card font-medium text-foreground'
|
|
: 'text-muted-foreground hover:text-foreground',
|
|
)}
|
|
>
|
|
{t('mode_drafts')}
|
|
{draftCount > 0 && (
|
|
<span className="rounded-full bg-secondary px-1.5 text-[10px] font-medium tabular-nums">
|
|
{draftCount}
|
|
</span>
|
|
)}
|
|
</button>
|
|
</div>
|
|
<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={
|
|
dialogFilterCount > 0
|
|
? t('filter_with_count', { count: dialogFilterCount })
|
|
: t('filter')
|
|
}
|
|
>
|
|
<SlidersHorizontal className="h-3.5 w-3.5" />
|
|
{t('filter')}
|
|
{dialogFilterCount > 0 && (
|
|
<Badge
|
|
variant="secondary"
|
|
className="h-4 min-w-4 justify-center px-1 text-[10px] tabular-nums"
|
|
>
|
|
{dialogFilterCount}
|
|
</Badge>
|
|
)}
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>{t('filter_dialog_title')}</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4">
|
|
{/* 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>
|
|
{/* The page context picker (convention 8): fiscal-year scope as a
|
|
chip-dropdown far right in the toolbar, one click to change,
|
|
always visible per BFL. Persists to localStorage (same key as
|
|
before); first-load scope resolution still happens
|
|
authoritatively in the period effect. */}
|
|
{periodHydrated && (
|
|
<div className="sm:ml-auto">
|
|
<FyPicker value={periodId} onChange={handlePeriodChange} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{loading && !hasLoaded ? (
|
|
<DataList className="stagger-enter">
|
|
<DataListLoading />
|
|
</DataList>
|
|
) : 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). Carries the same busy treatment as the
|
|
// table branch: widening a filter from an empty result would otherwise
|
|
// look identical to "still nothing" for the whole request.
|
|
<DataList
|
|
className={cn('stagger-enter', loading && 'opacity-60')}
|
|
aria-busy={loading || undefined}
|
|
>
|
|
<DataListEmpty
|
|
icon={
|
|
listMode === 'drafts' || !hasActiveFilters ? (
|
|
<BookOpen className="h-6 w-6" />
|
|
) : (
|
|
<Search className="h-6 w-6" />
|
|
)
|
|
}
|
|
title={
|
|
listMode === 'drafts'
|
|
? t('empty_drafts_title')
|
|
: hasActiveFilters
|
|
? t('no_results_title')
|
|
: t('empty_title')
|
|
}
|
|
description={
|
|
listMode === 'drafts'
|
|
? t('empty_drafts_description')
|
|
: hasActiveFilters
|
|
? t('no_results_description')
|
|
: t('empty_description')
|
|
}
|
|
/>
|
|
</DataList>
|
|
) : (
|
|
<div
|
|
aria-busy={loading || undefined}
|
|
className={cn(
|
|
'transition-opacity duration-150',
|
|
loading && 'pointer-events-none opacity-60',
|
|
)}
|
|
>
|
|
{/* Bulkbar (concept): hidden until at least one verifikat is
|
|
selected via the hover checkboxes, then it pops in with the
|
|
count and the batch actions. Select-all and the filter-scoped
|
|
bulk mark live inside it as quiet actions. */}
|
|
{selectedIds.size > 0 && (
|
|
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 border-b border-border px-1 py-2.5 text-[12.5px] animate-fade-in">
|
|
<span className="whitespace-nowrap">
|
|
<strong className="font-semibold tabular-nums">{selectedIds.size}</strong>{' '}
|
|
{t('bulkbar_selected', { count: selectedIds.size })}
|
|
</span>
|
|
<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 w-56 text-xs"
|
|
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>
|
|
<Button size="sm" onClick={handleBatchExempt} disabled={batchSubmitting}>
|
|
{batchSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
{t('batch_mark_no_doc')}
|
|
</Button>
|
|
{!allEligibleSelected && (
|
|
<button type="button" className={QUIET_LINK_CLASS} onClick={toggleSelectAll}>
|
|
{t('batch_select_all', { count: eligibleEntries.length })}
|
|
</button>
|
|
)}
|
|
{/* Filter-scoped: mark every missing-doc verifikat matching the
|
|
active filters across all pages: scales to a post-import flood. */}
|
|
<button type="button" className={QUIET_LINK_CLASS} onClick={openBulk}>
|
|
{t('batch_mark_all_missing')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={QUIET_LINK_CLASS}
|
|
onClick={() => setSelectedIds(new Set())}
|
|
disabled={batchSubmitting}
|
|
>
|
|
{t('batch_clear_selection')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full border-collapse text-[13px]">
|
|
<thead>
|
|
<tr>
|
|
<th className={cn(TH_CLASS, 'w-[26px] !pl-1')} aria-hidden="true"></th>
|
|
<th className={TH_CLASS}>{t('th_voucher')}</th>
|
|
<th className={cn(TH_CLASS, 'hidden sm:table-cell')}>{t('th_date')}</th>
|
|
<th className={cn(TH_CLASS, 'w-full')}>{t('th_description')}</th>
|
|
<th className={cn(TH_CLASS, 'text-right')}>{t('th_amount')}</th>
|
|
<th className={TH_CLASS} aria-hidden="true"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="stagger-enter">
|
|
{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)
|
|
// A makulerad verifikat should read as struck out, the way
|
|
// Grundbok already renders it. Applied per data cell rather
|
|
// than on the row: text-decoration propagates to descendants
|
|
// and a child cannot opt out, so striking the <tr> would draw
|
|
// a line through the row's action controls too.
|
|
//
|
|
// No opacity on the row: the strike plus the Makulerad chip
|
|
// already carry the state, and dimming muted-foreground text
|
|
// pushes the date column under the AA contrast floor.
|
|
const struckCell = entry.status === 'reversed' ? 'line-through' : undefined
|
|
|
|
return (
|
|
<Fragment key={entry.id}>
|
|
<tr
|
|
className={cn(
|
|
'group cursor-pointer transition-colors duration-150',
|
|
isExpanded ? 'bg-secondary/25' : 'hover:bg-secondary/35',
|
|
selectedIds.has(entry.id) && 'bg-secondary/40',
|
|
)}
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-expanded={isExpanded}
|
|
onClick={() => toggleExpand(entry.id)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault()
|
|
toggleExpand(entry.id)
|
|
}
|
|
}}
|
|
>
|
|
{/* Hover-revealed selection checkbox (concept .cb) */}
|
|
<td
|
|
className={cn(TD_CLASS, 'w-[26px] !pl-1 py-[9px]')}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{selectable && (
|
|
<Checkbox
|
|
checked={selectedIds.has(entry.id)}
|
|
onCheckedChange={() => toggleSelect(entry.id)}
|
|
aria-label={t('batch_select_row')}
|
|
className={cn(
|
|
'transition-opacity duration-150',
|
|
selectedIds.has(entry.id)
|
|
? 'opacity-100'
|
|
: 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100',
|
|
)}
|
|
/>
|
|
)}
|
|
</td>
|
|
<td className={cn(TD_CLASS, 'whitespace-nowrap')}>
|
|
<span className="inline-flex items-center gap-1">
|
|
<Link
|
|
href={`/bookkeeping/${entry.id}`}
|
|
className={cn(
|
|
'font-mono text-[13px] tabular-nums hover:underline',
|
|
struckCell,
|
|
)}
|
|
onClick={(e) => e.stopPropagation()}
|
|
// The row's Enter/Space handler calls
|
|
// preventDefault(), so without this the voucher
|
|
// link expands the row instead of opening it.
|
|
onKeyDown={(e) => e.stopPropagation()}
|
|
>
|
|
{formatVoucher(entry)}
|
|
</Link>
|
|
<OpenInNewTab href={`/bookkeeping/${entry.id}`} />
|
|
</span>
|
|
</td>
|
|
<td className={cn(TD_CLASS, 'hidden sm:table-cell whitespace-nowrap tabular-nums text-muted-foreground', struckCell)}>
|
|
{formatDate(entry.entry_date)}
|
|
</td>
|
|
<td className={cn(TD_CLASS, 'max-w-0 w-full')}>
|
|
<span className="flex min-w-0 items-center gap-2">
|
|
<span className={cn('truncate', struckCell)}>{entry.description}</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'} />
|
|
)}
|
|
{rattelseFlags.has(entry.id) && (
|
|
<Badge
|
|
variant="outline"
|
|
className="text-xs font-normal shrink-0"
|
|
title={t('rattelse_badge_tooltip')}
|
|
>
|
|
{t('rattelse_badge')}
|
|
</Badge>
|
|
)}
|
|
</span>
|
|
</td>
|
|
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right tabular-nums rr-mask', struckCell)}>
|
|
{formatCurrency(voucherTotal, 'SEK', { minimumFractionDigits: 2 })}
|
|
</td>
|
|
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right py-[9px]')}>
|
|
<span className="inline-flex items-center justify-end gap-2">
|
|
{attachmentCounts[entry.id] ? (
|
|
<button
|
|
type="button"
|
|
aria-label={t('view_attachments')}
|
|
title={t('attachment_count_tooltip', { count: attachmentCounts[entry.id] })}
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
setPreviewEntryId(entry.id)
|
|
}}
|
|
className="inline-flex items-center gap-0.5 rounded text-muted-foreground transition-colors duration-150 hover:text-foreground"
|
|
>
|
|
<Paperclip className="h-3.5 w-3.5" />
|
|
<span className="text-xs tabular-nums">{attachmentCounts[entry.id]}</span>
|
|
</button>
|
|
) : (
|
|
attachmentCountsLoaded && 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>
|
|
) : (
|
|
<Badge variant="warning" title={t('missing_attachment_tooltip')}>
|
|
{t('missing_attachment_chip')}
|
|
</Badge>
|
|
)
|
|
)
|
|
)}
|
|
{entry.status === 'draft' && (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="h-7 px-3.5 text-xs"
|
|
disabled={!canWrite || committingId === entry.id}
|
|
title={!canWrite ? t('read_only_tooltip') : undefined}
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
openCommitConfirm(entry)
|
|
}}
|
|
>
|
|
{committingId === entry.id && <Loader2 className="mr-1.5 h-3 w-3 animate-spin" />}
|
|
{t('post')}
|
|
</Button>
|
|
)}
|
|
{canWrite && (
|
|
<button
|
|
type="button"
|
|
aria-label={t('copy_voucher_tooltip')}
|
|
title={t('copy_voucher_tooltip')}
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
router.push(`/bookkeeping?copy_from=${entry.id}`)
|
|
}}
|
|
className={cn(
|
|
// p-2 grows the tap target to 30px without
|
|
// changing row height (the row is ~40px from
|
|
// the description cell).
|
|
'inline-flex items-center rounded p-2 text-muted-foreground transition-opacity duration-150 hover:text-foreground',
|
|
// Quiet at rest on desktop, but the table has no
|
|
// mobile card to fall back on, so touch keeps the
|
|
// icon visible.
|
|
'opacity-100 md:opacity-0 md:group-hover:opacity-100 md:focus-visible:opacity-100',
|
|
)}
|
|
>
|
|
<Copy className="h-3.5 w-3.5" />
|
|
</button>
|
|
)}
|
|
<ChevronRight
|
|
className={cn(
|
|
'h-3.5 w-3.5 text-muted-foreground transition-all duration-200',
|
|
isExpanded
|
|
? 'rotate-90 opacity-100'
|
|
: 'opacity-0 group-hover:opacity-100',
|
|
)}
|
|
/>
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
{isExpanded && (
|
|
<tr data-no-stagger>
|
|
<td colSpan={6} className="border-b border-border p-0">
|
|
<RowFoldout>
|
|
<div className="px-1 pb-6 pt-1 sm:pl-9 sm:pr-4">
|
|
{lines.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground py-2">{t('no_lines')}</p>
|
|
) : (
|
|
<table className="w-full border-collapse text-[12.5px]" aria-label={formatVoucher(entry)}>
|
|
<thead>
|
|
<tr>
|
|
<th className={cn(VTH_CLASS, 'w-[180px]')}>{t('account_column')}</th>
|
|
<th className={VTH_CLASS}>{t('description_column')}</th>
|
|
<th className={cn(VTH_CLASS, 'text-right')}>{t('debit')}</th>
|
|
<th className={cn(VTH_CLASS, 'text-right')}>{t('credit')}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{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 (
|
|
<tr key={line.id}>
|
|
<td className={cn(VTD_CLASS, 'whitespace-nowrap')}>
|
|
<AccountNumber number={line.account_number} showName />
|
|
</td>
|
|
<td className={cn(VTD_CLASS, 'text-muted-foreground')}>
|
|
{showDesc ? desc : ''}
|
|
</td>
|
|
<td className={cn(VTD_CLASS, 'text-right tabular-nums whitespace-nowrap')}>
|
|
{debit > 0 ? debit.toLocaleString('sv-SE', { minimumFractionDigits: 2 }) : ''}
|
|
{debit > 0 && fx && (
|
|
<span className="block text-xs text-muted-foreground tabular-nums">{fx}</span>
|
|
)}
|
|
</td>
|
|
<td className={cn(VTD_CLASS, 'text-right tabular-nums whitespace-nowrap')}>
|
|
{credit > 0 ? credit.toLocaleString('sv-SE', { minimumFractionDigits: 2 }) : ''}
|
|
{credit > 0 && fx && (
|
|
<span className="block text-xs text-muted-foreground tabular-nums">{fx}</span>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
<tr>
|
|
<td colSpan={2} className="py-[7px] pr-4 font-medium">{t('sum_label')}</td>
|
|
<td className="py-[7px] pr-4 text-right tabular-nums font-medium">
|
|
{lines.reduce((sum, l) => sum + (Number(l.debit_amount) || 0), 0).toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
|
|
</td>
|
|
<td className="py-[7px] pr-4 text-right tabular-nums font-medium">
|
|
{lines.reduce((sum, l) => sum + (Number(l.credit_amount) || 0), 0).toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
|
|
{entry.notes && (
|
|
<p className="mt-3 text-xs text-muted-foreground italic">
|
|
{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
|
|
})
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Quiet link actions (concept vact); posting keeps
|
|
its pill because it changes legal state. */}
|
|
<div className="mt-4 flex flex-wrap items-center gap-x-5 gap-y-2">
|
|
{entry.status === 'draft' && (
|
|
<Button
|
|
size="sm"
|
|
onClick={() => openCommitConfirm(entry)}
|
|
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>
|
|
)}
|
|
<Link href={`/bookkeeping/${entry.id}`} className={QUIET_LINK_CLASS}>
|
|
{t('show_details')}
|
|
</Link>
|
|
{entry.status === 'posted' && entry.source_type !== 'storno' && entry.source_type !== 'correction' && (
|
|
<button type="button" className={QUIET_LINK_CLASS} onClick={() => setCorrectionEntry(entry)}>
|
|
{t('create_correction')}
|
|
</button>
|
|
)}
|
|
{canWrite && entry.status === 'posted' && entry.source_type !== 'storno' && entry.source_type !== 'correction' && (
|
|
<button type="button" className={QUIET_LINK_CLASS} onClick={() => setReverseEntryTarget(entry)}>
|
|
{t('reverse_action')}
|
|
</button>
|
|
)}
|
|
{canWrite && (
|
|
<button type="button" className={QUIET_LINK_CLASS} onClick={() => router.push(`/bookkeeping?copy_from=${entry.id}`)}>
|
|
{t('copy')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</RowFoldout>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</Fragment>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</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() }}
|
|
/>
|
|
)}
|
|
|
|
{/* Confirm-before-posting for drafts (convention 10): describes the
|
|
outcome ("Bokförs som A-218 ...") before the commit runs. */}
|
|
{commitTarget && (
|
|
<ConfirmDialog
|
|
open={!!commitTarget}
|
|
onOpenChange={(open) => {
|
|
if (!open && committingId === null) setCommitTarget(null)
|
|
}}
|
|
title={t('confirm_post_title')}
|
|
description={
|
|
commitVoucherPreview
|
|
? t('confirm_post_description', {
|
|
voucher: commitVoucherPreview,
|
|
description: commitTarget.description || '',
|
|
amount: formatCurrency(
|
|
((commitTarget.lines || []) as JournalEntryLine[]).reduce(
|
|
(sum, l) => sum + (Number(l.debit_amount) || 0),
|
|
0,
|
|
),
|
|
),
|
|
})
|
|
: t('confirm_post_description_generic', {
|
|
description: commitTarget.description || '',
|
|
})
|
|
}
|
|
confirmLabel={t('post')}
|
|
onConfirm={async () => {
|
|
await handleCommit(commitTarget.id)
|
|
setCommitTarget(null)
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Reverse (storno) confirmation dialog */}
|
|
{reverseEntryTarget && (
|
|
<ConfirmationDialog
|
|
open={!!reverseEntryTarget}
|
|
onOpenChange={(open) => { if (!open && !isReversing) setReverseEntryTarget(null) }}
|
|
onConfirm={handleReverse}
|
|
isSubmitting={isReversing}
|
|
title={t('reverse_confirm_title')}
|
|
warningText={t('reverse_warning')}
|
|
confirmLabel={t('reverse_confirm_label')}
|
|
>
|
|
<div className="flex items-start gap-3 rounded-lg border bg-muted/50 p-4">
|
|
<RotateCcw className="h-5 w-5 text-muted-foreground mt-0.5 shrink-0" />
|
|
<div className="text-sm">
|
|
<p className="font-medium mb-1">{t('reverse_dialog_heading', { voucher: formatVoucher(reverseEntryTarget) })}</p>
|
|
<p className="text-muted-foreground">{t('reverse_dialog_body')}</p>
|
|
</div>
|
|
</div>
|
|
</ConfirmationDialog>
|
|
)}
|
|
|
|
{/* 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>
|
|
)
|
|
}
|