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
+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)}