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" />