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
+14
View File
@@ -701,3 +701,17 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-07-30] InvoiceMatchDialog classifies stale targets as matchable, settled, or not open instead of calling every invalid status fully paid: paid and zero-balance targets need different copy from cancelled, credited, disputed, reversed, draft, or malformed targets, while valid partially paid invoices keep the existing amount-difference flow. Blocked targets do not fetch or show a voucher preview or a confirm-outcome panel because neither match route has a reachable success path for them.
[2026-07-30] Issue #1289 ruta 05 null-rate fallback is report-local and requires both the 30x1/30x2/30x3 suffix and a matching 25/12/6 % moms account label; explicit configured values win. Declined historical backfill, account-creation derivation, and a DB NOT NULL guard: 3011 is custom rather than BAS 2026, NULL is valid across class 3, and history or mixed vouchers cannot safely set future defaults.
[2026-07-30] Skattekonto look-alike rows show ränteberäkningsdatum inline as muted text rather than as a fourth column: a retroactive omprövningsbeslut arrives as one transaction per re-charged month sharing date, text and amount, and only that field separates them. Kept it an exception marker per design convention 5 (12 % of prod rows qualify) instead of a column that is empty on the rest, and paired the month-differs rule with a same-band twin check so no two rows can ever render identically.
[2026-07-30] Amends design convention 11: `.stagger-enter` no longer re-runs on client-fetch completion for a list that is already rendered. The verifikat list now keeps its rows mounted and dims them during a refetch instead of collapsing to a skeleton and springing back, because the height change under the pointer was causing real mis-clicks on filter/sort/page changes. Stagger still runs on server render and on first client load. Chose dim-in-place over row-count-matched skeletons: matching heights only equalises the two states, it does not remove the transition.
[2026-07-30] Voucher rows get a hover-revealed "öppna i ny flik" affordance (`components/ui/open-in-new-tab.tsx`) next to the existing link rather than flipping the link itself to target="_blank". Working a filtered list means leaving and returning, and filter/scroll state is React-only, so a second tab is the only way to keep the list; cmd-click already worked on most rows but nothing advertised it. Kept the primary click navigating in place so existing habits are unaffected, and used HOVER_REVEAL_CLASS so the control is reachable on touch.
[2026-07-30] A makulerad (reversed) verifikat now renders struck through and dimmed in the verifikatlista, matching what Grundbok already did. Applied per data cell, not on the <tr>: text-decoration propagates to descendants and a child cannot opt out, so striking the row would have drawn a line through its action controls and status chips. Renamed journal_status.status_cancelled "Makulerad" -> "Avbruten" only: 'cancelled' is what the engine sets instead of DELETE for an aborted entry, never for a storno, and "Avbruten" is already the word used for that status in another namespace. Deliberately did NOT rename status_reversed "Omförd" -> "Makulerad" even though Grundbok already renders that state as "Makulerad": the compliance review flagged that in Swedish practice "makulerad" can imply a voided document with no ongoing audit trail, whereas a storno'd verifikation must stay traceable under BFL 5 kap 5 §, so the two states are legally distinct and the wording is a founder call, not a drive-by. The Omförd/Makulerad split between verifikatlistan and Grundbok therefore survives this PR and is still open.
[2026-07-30] Vouchers-per-account was a naming problem, not a missing report: /reports/huvudbok?account=1930 already lists a single account's verifikat with click-through. The command palette's token-AND matcher dropped the query "verifikat per konto" because the huvudbok entry never carried the word "verifikat", and the report library had no search at all. Added the synonym set (verifikat, kontoanalys, kontokort, stäm av) to both, via a new optional ReportDescriptor.searchTerms so the vocabulary lives with the catalog rather than being duplicated per surface. Kept "Huvudbok" as the visible name; Fortnox's "Kontoanalys" is a searchable synonym only, and "Kontoutdrag" is deliberately NOT one because it already means bank statement here and in Fortnox.
[2026-07-30] HOVER_REVEAL_CLASS gains focus-within:opacity-100. focus-visible only matches the element itself, so using the constant on a non-focusable wrapper <span> (the skattekonto Matcha/Bokför cluster, which previously hand-rolled focus-within) left those buttons transparent while a keyboard user tabbed through them. Fixed in the shared constant rather than per call site, since the constant is documented as the one true row-control helper and every wrapper use has the same trap.
[2026-07-30] Report-vocabulary synonyms live in ReportDescriptor.searchTerms, NOT in the command-palette keywords, when they are words another report already owns. "stäm av"/"avstämning" on the huvudbok palette entry hijacked Enter from Bankavstämning, because the palette auto-selects the first hit and huvudbok is listed above it. The library search shows a list and has no such failure mode, so broad task-vocabulary belongs there.
+60 -16
View File
@@ -10,7 +10,13 @@ import { HelpPopover } from '@/components/ui/help-popover'
import { AttnLine } from '@/components/ui/attn-line'
import { EmptyState } from '@/components/ui/empty-state'
import { Skeleton } from '@/components/ui/skeleton'
import { TH_CLASS, TD_CLASS, QUIET_LINK_CLASS } from '@/components/ui/dry-table'
import {
TH_CLASS,
TD_CLASS,
QUIET_LINK_CLASS,
HOVER_REVEAL_CLASS,
} from '@/components/ui/dry-table'
import { OpenInNewTab } from '@/components/ui/open-in-new-tab'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import {
Dialog,
@@ -29,6 +35,7 @@ import {
formatDateTime,
} from '@/lib/utils'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import { rowsNeedingInterestDate } from '@/lib/skatteverket/interest-period'
import {
AlertCircle,
Copy,
@@ -605,6 +612,19 @@ type TableSection = {
rows: SkattekontoTransactionWithSuggestion[]
}
/**
* The date this row shows in the Datum column. Upcoming and overdue rows lead
* with their due date; genomförda rows lead with the transaction date.
*/
function rowDisplayDate(
row: StoredSkattekontoTransaction,
section: TableSection['key'],
): string {
return section === 'upcoming' || section === 'overdue'
? (row.forfallodatum ?? row.transaktionsdatum)
: row.transaktionsdatum
}
function SkattekontoTable({
tx,
onBokfor,
@@ -623,7 +643,23 @@ function SkattekontoTable({
{ key: 'overdue', label: t('band_overdue'), rows: tx?.overdue ?? [] },
{ key: 'booked', label: t('band_booked'), rows: tx?.booked ?? [] },
]
const sections = allSections.filter((s) => s.rows.length > 0)
// Rows from a retroactive omprövningsbeslut share date, text and amount, so
// they render identically unless we surface ränteberäkningsdatum. Resolved
// per band, since rows are only confusable with the rows beside them.
const sections = allSections
.filter((s) => s.rows.length > 0)
.map((s) => ({
...s,
interestDateRowIds: rowsNeedingInterestDate(
s.rows.map((r) => ({
id: r.id,
displayDate: rowDisplayDate(r, s.key),
transaktionstext: r.transaktionstext,
belopp: Number(r.belopp_skatteverket),
ranteberakningsdatum: r.ranteberakningsdatum,
})),
),
}))
if (sections.length === 0) {
return (
@@ -663,6 +699,7 @@ function SkattekontoTable({
onBokfor={onBokfor}
onMatch={onMatch}
bookingId={bookingId}
showInterestDate={section.interestDateRowIds.has(row.id)}
/>
))}
</Fragment>
@@ -679,20 +716,19 @@ function SkattekontoRow({
onBokfor,
onMatch,
bookingId,
showInterestDate,
}: {
row: SkattekontoTransactionWithSuggestion
section: TableSection['key']
onBokfor: (id: string) => void
onMatch: (row: StoredSkattekontoTransaction) => void
bookingId: string | null
showInterestDate: boolean
}) {
const t = useTranslations('skattekonto')
const amount = Number(row.belopp_skatteverket)
const isBooked = !!row.journal_entry_id
const displayDate =
section === 'upcoming' || section === 'overdue'
? (row.forfallodatum ?? row.transaktionsdatum)
: row.transaktionsdatum
const displayDate = rowDisplayDate(row, section)
return (
<tr className="group transition-colors duration-150 hover:bg-secondary/35">
@@ -702,6 +738,14 @@ function SkattekontoRow({
<td className={TD_CLASS}>
<span className="inline-flex flex-wrap items-center gap-2">
{row.transaktionstext}
{/* A retroactive beslut arrives as one row per re-charged month,
identical apart from ränteberäkningsdatum. Without this the rows
read as duplicates from the automatic hämtning. */}
{showInterestDate && row.ranteberakningsdatum && (
<span className="text-[12px] tabular-nums text-muted-foreground">
{t('interest_from', { date: formatDate(row.ranteberakningsdatum) })}
</span>
)}
{/* Chips mark exceptions: only a *genomförd* row that is still
unbooked deviates; upcoming rows are unbooked by nature. */}
{section === 'booked' && !isBooked && (
@@ -736,17 +780,17 @@ function SkattekontoRow({
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right')}>
{isBooked ? (
<Link
href={`/bookkeeping/${row.journal_entry_id}`}
className={cn(
QUIET_LINK_CLASS,
'opacity-0 transition-opacity focus-visible:opacity-100 group-hover:opacity-100',
)}
>
{t('action_show_voucher')}
</Link>
<span className="inline-flex items-center justify-end gap-1">
<Link
href={`/bookkeeping/${row.journal_entry_id}`}
className={cn(QUIET_LINK_CLASS, HOVER_REVEAL_CLASS)}
>
{t('action_show_voucher')}
</Link>
<OpenInNewTab href={`/bookkeeping/${row.journal_entry_id}`} />
</span>
) : (
<span className="inline-flex items-center gap-3 opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100">
<span className={cn('inline-flex items-center gap-3', HOVER_REVEAL_CLASS)}>
<button
type="button"
onClick={() => onMatch(row)}
+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>
)
}
+1 -1
View File
@@ -48,7 +48,7 @@ export const AGENT_GROUND_RULES: string[] = [
' • Är verifikationen den SENASTE i sin serie kan den även raderas helt ("Radera verifikat"): då återanvänds löpnumret och ingen lucka uppstår.',
' • Konton som finns i BAS-kontoplanen men saknas i företagets kontoplan läggs till AUTOMATISKT vid bokföring och rättelse. Be aldrig användaren registrera standardkonton manuellt innan de bokför: bara okända kontonummer eller avaktiverade konton stoppar.',
' • När en bokning makuleras (storno utan rättelse) släpps den kopplade banktransaktionen och blir bokföringsbar igen i transaktionsvyn: användaren kan alltid klicka på transaktionen och bokföra om. Vid en rättelse följer transaktionen och underlaget med till rättelseverifikationen.',
' • En storno på 0 kr med status "Makulerad" i kedjan är resterna av ett avbrutet rättelseförsök: den påverkar inga saldon. Oförklarade luckor i löpnummerserien dokumenteras via verifikationsluckor (gnubok_list_voucher_gaps / gnubok_explain_voucher_gap).',
' • En storno på 0 kr med status "Avbruten" i kedjan är resterna av ett avbrutet rättelseförsök: den påverkar inga saldon. Oförklarade luckor i löpnummerserien dokumenteras via verifikationsluckor (gnubok_list_voucher_gaps / gnubok_explain_voucher_gap).',
'',
// -- Representation: headcount + per-person VAT cap --
'- REPRESENTATION (måltid/restaurang): innan du bokför, fånga ANTAL deltagare, vilka de var (namn + företag), och syftet. Antalet är inte valfritt: momsavdraget beräknas per person. Fråga "Hur många var ni, och vilka?" om det inte redan framgår.',
@@ -0,0 +1,61 @@
import { describe, it, expect } from 'vitest'
import { REPORT_CATALOG, reportMatchesQuery, getReport } from '@/lib/reports/catalog'
describe('reportMatchesQuery', () => {
it('returns everything for an empty query', () => {
expect(reportMatchesQuery('Huvudbok', '')).toBe(true)
expect(reportMatchesQuery('Huvudbok', ' ')).toBe(true)
})
it('requires every token, so extra words narrow', () => {
expect(reportMatchesQuery('Huvudbok verifikat per konto', 'verifikat konto')).toBe(true)
expect(reportMatchesQuery('Huvudbok verifikat per konto', 'verifikat faktura')).toBe(false)
})
it('ignores case and diacritics in both directions', () => {
expect(reportMatchesQuery('Stäm av bank', 'stam av')).toBe(true)
expect(reportMatchesQuery('Stam av bank', 'stäm')).toBe(true)
expect(reportMatchesQuery('Balansrapport', 'BALANS')).toBe(true)
})
it('matches on a substring so partial words still find the report', () => {
expect(reportMatchesQuery('Momsdeklaration', 'moms')).toBe(true)
})
})
describe('huvudbok is reachable by the words a bookkeeper uses', () => {
const huvudbok = getReport('huvudbok')!
const haystack = `Huvudbok ${huvudbok.searchTerms ?? ''}`
// The phrase from the churn report that returned zero hits before.
it.each([
'verifikat per konto',
'verifikationer',
'kontoanalys',
'kontokort',
'stäm av konto',
'account statement',
])('finds huvudbok for %j', (query) => {
expect(reportMatchesQuery(haystack, query)).toBe(true)
})
})
describe('catalog search terms', () => {
it('never lets searchTerms shadow another report by slug', () => {
// A report's synonyms must not be so broad that they swallow a query
// aimed squarely at a different report's own name.
const huvudbok = getReport('huvudbok')!
const terms = huvudbok.searchTerms ?? ''
for (const slug of ['balansrapport', 'resultatrapport', 'vat-declaration']) {
expect(reportMatchesQuery(terms, slug)).toBe(false)
}
})
it('keeps searchTerms lowercase-comparable and free of punctuation noise', () => {
for (const report of REPORT_CATALOG) {
if (!report.searchTerms) continue
expect(report.searchTerms).toBe(report.searchTerms.trim())
expect(report.searchTerms).not.toMatch(/[,;]/)
}
})
})
+33
View File
@@ -67,6 +67,13 @@ export interface ReportDescriptor {
* lib/reports/__tests__/dimension-statutory-guard.test.ts.
*/
dimensions?: boolean
/**
* Extra words the library search should match, beyond the translated name
* and description. For the vocabulary a user brings from another product or
* from the task they are doing ("verifikat per konto", "kontoanalys"), which
* is often not the word we chose for the report.
*/
searchTerms?: string
/** Only shown when company_settings.dimensions_enabled is true. */
needsDimensions?: boolean
/**
@@ -249,6 +256,11 @@ export const REPORT_CATALOG: ReportDescriptor[] = [
params: 'fiscal-range',
exports: ['xlsx'],
dimensions: true,
// This is the "show me the verifikat behind account 1930" report, which
// is what people search for when reconciling before årsredovisningen.
// Fortnox calls it Kontoanalys, Björn Lundén Kontokontroll.
searchTerms:
'verifikat verifikationer per konto kontoanalys kontokort kontohistorik stäm av stämma avstämning ledger account statement vouchers',
},
{
slug: 'grundbok',
@@ -364,3 +376,24 @@ export function getLibrarySections(
),
})).filter((s) => s.items.length > 0)
}
/**
* Token-AND match used by the report library's search box.
*
* Every whitespace-separated token in the query must appear somewhere in the
* haystack, so narrowing words keep narrowing. Case- and diacritic-insensitive
* so "stam av" finds "stäm av" and a Swedish keyboard is not required.
*/
export function reportMatchesQuery(haystack: string, query: string): boolean {
const tokens = fold(query).split(/\s+/).filter(Boolean)
if (tokens.length === 0) return true
const hay = fold(haystack)
return tokens.every((token) => hay.includes(token))
}
function fold(value: string): string {
return value
.toLowerCase()
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
}
@@ -0,0 +1,94 @@
import { describe, it, expect } from 'vitest'
import {
rowsNeedingInterestDate,
type InterestPeriodRow,
} from '@/lib/skatteverket/interest-period'
function row(overrides: Partial<InterestPeriodRow> & { id: string }): InterestPeriodRow {
return {
displayDate: '2026-05-07',
transaktionstext: 'Debiterad preliminärskatt',
belopp: -4519,
ranteberakningsdatum: '2026-05-12',
...overrides,
}
}
describe('rowsNeedingInterestDate', () => {
it('marks every row of a retroactive omprövningsbeslut', () => {
// The real Center Node AB case: one beslut dated 2026-05-07 re-charging
// preliminary tax across 15 months. Same date, text and amount on all of
// them; only ränteberäkningsdatum differs.
const rows = [
'2025-02-13',
'2025-03-13',
'2025-04-15',
'2026-03-13',
'2026-04-14',
].map((d, i) =>
row({
id: `r${i}`,
transaktionstext: 'Beslut 260506 debiterad preliminärskatt',
ranteberakningsdatum: d,
}),
)
const marked = rowsNeedingInterestDate(rows)
expect(marked.size).toBe(5)
for (const r of rows) expect(marked.has(r.id)).toBe(true)
})
it('leaves an ordinary row alone when the interest date is in the shown month', () => {
const marked = rowsNeedingInterestDate([
row({ id: 'a', displayDate: '2026-05-07', ranteberakningsdatum: '2026-05-12' }),
])
expect(marked.size).toBe(0)
})
it('marks a row whose interest date falls in another month', () => {
const marked = rowsNeedingInterestDate([
row({ id: 'a', displayDate: '2026-05-07', ranteberakningsdatum: '2025-11-13' }),
])
expect([...marked]).toEqual(['a'])
})
it('marks same-month twins that would otherwise render identically', () => {
// Guards the case the month rule alone would miss: a decision split
// inside one month still produces two indistinguishable rows.
const marked = rowsNeedingInterestDate([
row({ id: 'a', ranteberakningsdatum: '2026-05-12' }),
row({ id: 'b', ranteberakningsdatum: '2026-05-20' }),
])
expect(marked.has('a')).toBe(true)
expect(marked.has('b')).toBe(true)
})
it('does not mark rows that differ in amount or text', () => {
const marked = rowsNeedingInterestDate([
row({ id: 'a', belopp: -4519 }),
row({ id: 'b', belopp: -1570 }),
row({ id: 'c', transaktionstext: 'Intäktsränta' }),
])
expect(marked.size).toBe(0)
})
it('skips rows with no interest date rather than rendering an empty marker', () => {
const marked = rowsNeedingInterestDate([
row({ id: 'a', ranteberakningsdatum: null }),
row({ id: 'b', ranteberakningsdatum: null }),
])
expect(marked.size).toBe(0)
})
it('tolerates malformed dates without throwing', () => {
const marked = rowsNeedingInterestDate([
row({ id: 'a', displayDate: '', ranteberakningsdatum: '2026-05-12' }),
])
expect(marked.has('a')).toBe(true)
})
it('handles an empty band', () => {
expect(rowsNeedingInterestDate([]).size).toBe(0)
})
})
+77
View File
@@ -0,0 +1,77 @@
/**
* Disambiguation of look-alike skattekonto rows.
*
* When Skatteverket issues a retroactive omprövningsbeslut it does not send one
* transaction: it splits the decision across every month it re-charges and
* sends one transaction per month. Those rows share transaktionsdatum,
* transaktionstext and belopp, and carry their own transaktionsidentitet. The
* only human-readable field that separates them is `ranteberakningsdatum`.
*
* The skattekonto table shows Datum / Händelse / Belopp, so such a decision
* renders as N pixel-identical rows. That reads as duplicates from the
* automatic Skatteverket fetch, which is a well-known failure mode in this
* market and has cost us at least one customer who booked all of them and then
* could not tell whether he had to reverse fourteen of them.
*
* We surface `ranteberakningsdatum` only on rows where it actually carries
* information, so it stays an exception marker (design convention 5) rather
* than noise repeated on every row. A row qualifies when either:
* 1. its interest date falls in a different month than the date shown in the
* Datum column (the retroactive-decision signature), or
* 2. another row in the same band is otherwise indistinguishable from it.
*
* Rule 2 catches the look-alikes the month rule alone would miss, such as a
* decision split inside a single month. It cannot separate rows that share a
* ränteberäkningsdatum, or that both lack one, since that is the only field
* left to distinguish them; in practice Skatteverket gives each month of a
* decision its own date, which is exactly the case this exists for.
*/
export interface InterestPeriodRow {
id: string
/** The date actually rendered in the Datum column for this row. */
displayDate: string
transaktionstext: string
belopp: number
ranteberakningsdatum: string | null | undefined
}
/** `yyyy-MM` from an ISO date or timestamp; '' when the input is unusable. */
function monthOf(value: string): string {
if (typeof value !== 'string' || value.length < 7) return ''
return value.slice(0, 7)
}
/**
* Identity as the table renders it. Two rows with the same key are
* indistinguishable to the reader.
*/
function renderedIdentity(row: InterestPeriodRow): string {
return `${row.displayDate}|${row.transaktionstext}|${row.belopp}`
}
/**
* Ids of the rows that should display their ränteberäkningsdatum.
*
* Pass one band (upcoming / overdue / booked) at a time: rows are only
* confusable with the rows they are rendered next to.
*/
export function rowsNeedingInterestDate(rows: InterestPeriodRow[]): Set<string> {
const seen = new Map<string, number>()
for (const row of rows) {
const key = renderedIdentity(row)
seen.set(key, (seen.get(key) ?? 0) + 1)
}
const result = new Set<string>()
for (const row of rows) {
if (!row.ranteberakningsdatum) continue
const spansAnotherMonth =
monthOf(row.ranteberakningsdatum) !== monthOf(row.displayDate)
const hasTwin = (seen.get(renderedIdentity(row)) ?? 0) > 1
if (spansAnotherMonth || hasTwin) result.add(row.id)
}
return result
}
+5
View File
@@ -1,6 +1,7 @@
{
"common": {
"active_company": "Active company",
"open_in_new_tab": "Open in new tab",
"update_available": "A new version is available.",
"reload": "Reload",
"save": "Save",
@@ -5435,6 +5436,9 @@
"col_report": "Report",
"col_description": "Description",
"col_last_opened": "Last opened",
"search_placeholder": "Search reports",
"search_clear": "Clear search",
"search_no_results": "No report matches \"{query}\".",
"opened_today": "today",
"opened_yesterday": "yesterday",
"help_text": "Every report opens for the selected fiscal year and can be narrowed to any period inside the report. Last opened remembers what you use the most.",
@@ -6225,6 +6229,7 @@
"band_booked": "Completed",
"no_transactions": "No transactions yet. Sync to fetch the tax account.",
"chip_not_booked": "Not booked",
"interest_from": "interest from {date}",
"chip_possible_duplicate": "Possible duplicate of {voucher}",
"chip_draft": "draft",
"action_show_voucher": "Show voucher",
+6 -1
View File
@@ -1,6 +1,7 @@
{
"common": {
"active_company": "Aktivt företag",
"open_in_new_tab": "Öppna i ny flik",
"update_available": "En ny version finns tillgänglig.",
"reload": "Ladda om",
"save": "Spara",
@@ -4087,7 +4088,7 @@
"status_draft": "Utkast",
"status_posted": "Bokförd",
"status_reversed": "Omförd",
"status_cancelled": "Makulerad",
"status_cancelled": "Avbruten",
"source_storno": "Storno",
"source_correction": "Rättelse",
"source_label_manual": "Manuell",
@@ -5435,6 +5436,9 @@
"col_report": "Rapport",
"col_description": "Beskrivning",
"col_last_opened": "Senast öppnad",
"search_placeholder": "Sök rapport",
"search_clear": "Rensa sökning",
"search_no_results": "Ingen rapport matchar \"{query}\".",
"opened_today": "idag",
"opened_yesterday": "igår",
"help_text": "Alla rapporter öppnas för det valda räkenskapsåret och kan avgränsas till valfri period inne i rapporten. Senast öppnad kommer ihåg vad du använder mest.",
@@ -6225,6 +6229,7 @@
"band_booked": "Genomförda",
"no_transactions": "Inga transaktioner ännu. Synkronisera för att hämta skattekontot.",
"chip_not_booked": "Ej bokförd",
"interest_from": "ränta fr.o.m. {date}",
"chip_possible_duplicate": "Möjlig dublett av {voucher}",
"chip_draft": "utkast",
"action_show_voucher": "Visa verifikat",