fix(skattekonto): look-alike beslut rows, the list-to-voucher round trip, makulerad rendering, huvudbok discoverability (#1297)

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>
This commit is contained in:
Jakob Wennberg
2026-07-30 18:27:29 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 144cc51458
commit fa394e3759
14 changed files with 584 additions and 58 deletions
+66 -15
View File
@@ -30,6 +30,7 @@ import {
} 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,
@@ -99,10 +100,20 @@ export default function JournalEntryList() {
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.
@@ -188,8 +199,13 @@ export default function JournalEntryList() {
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
@@ -213,6 +229,8 @@ export default function JournalEntryList() {
setAttachmentCounts(Object.assign({}, ...results))
} catch {
// Non-critical: silently ignore
} finally {
setAttachmentCountsLoaded(true)
}
}, [])
@@ -373,6 +391,7 @@ export default function JournalEntryList() {
const res = await fetch(`/api/bookkeeping/journal-entries?${params}`)
if (!res.ok) {
setHasLoaded(true)
setLoading(false)
return
}
@@ -390,6 +409,7 @@ export default function JournalEntryList() {
} else {
fetchDraftCount()
}
setHasLoaded(true)
setLoading(false)
// Fetch attachment counts + rättelse markers for the loaded entries
@@ -965,7 +985,7 @@ export default function JournalEntryList() {
)}
</div>
{loading ? (
{loading && !hasLoaded ? (
<DataList className="stagger-enter">
<DataListLoading />
</DataList>
@@ -973,8 +993,13 @@ export default function JournalEntryList() {
// 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).
<DataList className="stagger-enter">
// 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 ? (
@@ -1000,7 +1025,13 @@ export default function JournalEntryList() {
/>
</DataList>
) : (
<div>
<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
@@ -1071,6 +1102,16 @@ export default function JournalEntryList() {
// 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}>
@@ -1111,20 +1152,30 @@ export default function JournalEntryList() {
)}
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap')}>
<Link
href={`/bookkeeping/${entry.id}`}
className="font-mono text-[13px] tabular-nums hover:underline"
onClick={(e) => e.stopPropagation()}
>
{formatVoucher(entry)}
</Link>
<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')}>
<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="truncate">{entry.description}</span>
<span className={cn('truncate', struckCell)}>{entry.description}</span>
{entry.out_of_period && (
<Badge
variant="outline"
@@ -1148,7 +1199,7 @@ export default function JournalEntryList() {
)}
</span>
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right tabular-nums rr-mask')}>
<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]')}>
@@ -1168,7 +1219,7 @@ export default function JournalEntryList() {
<span className="text-xs tabular-nums">{attachmentCounts[entry.id]}</span>
</button>
) : (
NEEDS_ATTACHMENT.has(entry.source_type) && entry.status === 'posted' && (
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" />
+8 -1
View File
@@ -58,7 +58,14 @@ const PAGE_ENTRIES: Entry[] = [
{ id: 'rapport-balansrapport', label: 'Visa rapport: Balansrapport', icon: BarChart3, href: '/reports/balansrapport', keywords: 'rapport balans tillgångar skulder saldo per konto' },
{ id: 'rapport-saldobalans', label: 'Visa rapport: Saldobalans', icon: BarChart3, href: '/reports/trial-balance', keywords: 'rapport saldobalans trial balance saldo per konto' },
{ id: 'rapport-moms', label: 'Visa rapport: Momsdeklaration', icon: BarChart3, href: '/reports/vat-declaration', keywords: 'rapport moms vat deklaration' },
{ id: 'rapport-huvudbok', label: 'Visa rapport: Huvudbok', icon: BookOpen, href: '/reports/huvudbok', keywords: 'rapport huvudbok ledger general konto saldo transaktioner per konto kontoutdrag kontoanalys kontokort kontohistorik balance account statement transactions' },
// "verifikat" is the word a bookkeeper reaches for ("verifikat per konto"),
// and matches() requires every typed token, so leaving it out made the exact
// phrase return nothing even though this report is precisely the answer.
// Deliberately NOT carrying "stäm av"/"avstämning" here: the palette
// auto-selects the first hit and huvudbok is listed above Bankavstämning, so
// those words would hijack Enter from the reconciliation page. They live in
// ReportDescriptor.searchTerms instead, where the library shows a list.
{ id: 'rapport-huvudbok', label: 'Visa rapport: Huvudbok', icon: BookOpen, href: '/reports/huvudbok', keywords: 'rapport huvudbok ledger general konto saldo transaktioner per konto verifikat verifikationer verifikationer per konto kontoutdrag kontoanalys kontokort kontohistorik balance account statement transactions vouchers' },
{ id: 'rapport-kundreskontra', label: 'Visa rapport: Kundreskontra', icon: Users, href: '/reports/kundreskontra', keywords: 'rapport kundreskontra ar kundfordringar' },
{ id: 'rapport-bankavstamning', label: 'Bankavstämning', hint: 'Stäm av bank mot bokföring', icon: ArrowLeftRight, href: '/reports/bank-reconciliation', keywords: 'avstämning stäm av bank matcha banktransaktioner reconcile reconciliation 1930' },
{ id: 'importera', label: 'Importera', icon: Upload, href: '/import' },
+87 -23
View File
@@ -1,10 +1,17 @@
'use client'
import { useMemo, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Search, X } from 'lucide-react'
import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { cn, formatDate } from '@/lib/utils'
import { getLibrarySections, type ReportDescriptor } from '@/lib/reports/catalog'
import {
getLibrarySections,
reportMatchesQuery,
type ReportDescriptor,
} from '@/lib/reports/catalog'
import type { EntityType } from '@/types'
/**
@@ -28,7 +35,26 @@ export function ReportLibrary({
onOpen: (slug: string) => void
}) {
const t = useTranslations('reports')
const sections = getLibrarySections(entityType, hasEmployees, dimensionsEnabled)
const [query, setQuery] = useState('')
const allSections = getLibrarySections(entityType, hasEmployees, dimensionsEnabled)
// Matched against the translated name and description plus the descriptor's
// synonyms, so the vocabulary someone arrives with ("verifikat per konto")
// reaches the report even when we named it something else ("Huvudbok").
const sections = useMemo(() => {
if (!query.trim()) return allSections
return allSections
.map((section) => ({
...section,
items: section.items.filter((item) =>
reportMatchesQuery(
`${t(item.labelKey)} ${t(item.descKey)} ${item.searchTerms ?? ''}`,
query,
),
),
}))
.filter((section) => section.items.length > 0)
}, [allSections, query, t])
const lastOpenedLabel = (slug: string): string => {
const at = openedAt[slug]
@@ -40,27 +66,65 @@ export function ReportLibrary({
}
return (
<div className="overflow-x-auto" role="region" aria-label={t('title')}>
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
<th className={cn(TH_CLASS, 'w-[240px]')}>{t('col_report')}</th>
<th className={TH_CLASS}>{t('col_description')}</th>
<th className={cn(TH_CLASS, 'w-[130px] text-right')}>{t('col_last_opened')}</th>
</tr>
</thead>
<tbody className="stagger-enter">
{sections.map((section) => (
<SectionRows
key={section.category}
label={t(section.labelKey)}
items={section.items}
lastOpenedLabel={lastOpenedLabel}
onOpen={onOpen}
/>
))}
</tbody>
</table>
<div className="space-y-4">
<div className="relative max-w-sm">
<Search
className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground"
aria-hidden="true"
/>
<Input
// Deliberately not type="search": WebKit adds its own cancel button,
// which would sit next to the X below as a second clear affordance.
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t('search_placeholder')}
aria-label={t('search_placeholder')}
className="h-9 pl-9 pr-9 text-[13px]"
/>
{query && (
<button
type="button"
onClick={() => setQuery('')}
aria-label={t('search_clear')}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-muted-foreground transition-colors duration-150 hover:text-foreground"
>
<X className="h-3.5 w-3.5" aria-hidden="true" />
</button>
)}
</div>
{sections.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
{t('search_no_results', { query: query.trim() })}
</p>
) : (
<div className="overflow-x-auto" role="region" aria-label={t('title')}>
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
<th className={cn(TH_CLASS, 'w-[240px]')}>{t('col_report')}</th>
<th className={TH_CLASS}>{t('col_description')}</th>
<th className={cn(TH_CLASS, 'w-[130px] text-right')}>{t('col_last_opened')}</th>
</tr>
</thead>
{/* Stagger is the entry animation for the library as it loads. It
must not re-run per keystroke while filtering, or every widening
edit replays a 360ms cascade under the user's eyes. */}
<tbody className={query.trim() ? undefined : 'stagger-enter'}>
{sections.map((section) => (
<SectionRows
key={section.category}
label={t(section.labelKey)}
items={section.items}
lastOpenedLabel={lastOpenedLabel}
onOpen={onOpen}
/>
))}
</tbody>
</table>
</div>
)}
</div>
)
}
+5 -1
View File
@@ -20,8 +20,12 @@ export const QUIET_LINK_CLASS =
// pointers never fire hover, so without pointer-coarse: the control would be
// permanently invisible and the action unreachable on touch. Always use this
// constant instead of hand-rolling `opacity-0 group-hover:opacity-100`.
//
// focus-within is what makes this safe on a WRAPPER: focus-visible only matches
// the element itself, so a non-focusable <span> holding the buttons would stay
// transparent while a keyboard user tabbed through the controls inside it.
export const HOVER_REVEAL_CLASS =
'opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100'
'opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 focus-within:opacity-100 pointer-coarse:opacity-100'
// Animated row expansion (concept vwrap/vinner): grid-rows 0fr -> 1fr on
// mount; the global reduced-motion rule collapses the transition.
+67
View File
@@ -0,0 +1,67 @@
'use client'
import Link from 'next/link'
import { ExternalLink } from 'lucide-react'
import { useTranslations } from 'next-intl'
import { cn } from '@/lib/utils'
import { HOVER_REVEAL_CLASS } from '@/components/ui/dry-table'
/**
* Escape hatch out of a list without losing it.
*
* Working a filtered list (kontoavstämning, granskning) means opening one
* record, fixing it, and coming back to the same list. Navigating in place
* throws away the filter, the page and the scroll position, so the only way
* to keep them is to open the record in a second tab. Most of our row links
* are plain anchors, so cmd-click already works, but nothing on screen ever
* says so and a few call sites are buttons where it does not work at all.
*
* Sits next to the record's own link rather than replacing it: the primary
* click keeps navigating in place, which is what people expect. Hover-revealed
* per the row-control convention, and always visible on coarse pointers.
*/
export function OpenInNewTab({
href,
label,
className,
}: {
href: string
/** Overrides the default "Öppna i ny flik" for a more specific target. */
label?: string
className?: string
}) {
const t = useTranslations('common')
const text = label ?? t('open_in_new_tab')
return (
<Link
href={href}
target="_blank"
rel="noopener noreferrer"
// Named by the visually-hidden text below rather than aria-label, so the
// title renders a tooltip for sighted users without screen readers
// announcing the same string twice.
title={text}
onClick={(e) => e.stopPropagation()}
// Rows that own this control are themselves interactive: JournalEntryList
// gives its <tr> an Enter/Space handler that calls preventDefault() and
// expands the row. Without this, Enter on a focused link would expand the
// row instead of opening the voucher, so the control would be usable with
// a mouse but not a keyboard.
onKeyDown={(e) => e.stopPropagation()}
className={cn(
HOVER_REVEAL_CLASS,
'relative inline-flex shrink-0 items-center rounded p-1 text-muted-foreground',
// The icon stays 14px so the row keeps its density, but the pointer
// target is padded out to the 40px the design rules require.
'before:absolute before:left-1/2 before:top-1/2 before:h-10 before:w-10',
'before:-translate-x-1/2 before:-translate-y-1/2 before:content-[""]',
'transition-colors duration-150 hover:text-foreground',
className,
)}
>
<ExternalLink className="h-3.5 w-3.5" aria-hidden="true" />
<span className="sr-only">{text}</span>
</Link>
)
}