From fa394e375934b255057036bd72f7e738652e878a Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:27:29 +0200 Subject: [PATCH] fix(skattekonto): look-alike beslut rows, the list-to-voucher round trip, makulerad rendering, huvudbok discoverability (#1297) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- DECISIONS.md | 14 +++ app/(dashboard)/skattekonto/page.tsx | 76 +++++++++--- components/bookkeeping/JournalEntryList.tsx | 81 ++++++++++--- components/common/CommandPalette.tsx | 9 +- components/reports/ReportLibrary.tsx | 110 ++++++++++++++---- components/ui/dry-table.tsx | 6 +- components/ui/open-in-new-tab.tsx | 67 +++++++++++ lib/agent/intents/shared-rules.ts | 2 +- lib/reports/__tests__/report-search.test.ts | 61 ++++++++++ lib/reports/catalog.ts | 33 ++++++ .../__tests__/interest-period.test.ts | 94 +++++++++++++++ lib/skatteverket/interest-period.ts | 77 ++++++++++++ messages/en.json | 5 + messages/sv.json | 7 +- 14 files changed, 584 insertions(+), 58 deletions(-) create mode 100644 components/ui/open-in-new-tab.tsx create mode 100644 lib/reports/__tests__/report-search.test.ts create mode 100644 lib/skatteverket/__tests__/interest-period.test.ts create mode 100644 lib/skatteverket/interest-period.ts diff --git a/DECISIONS.md b/DECISIONS.md index 38000abb..fee0bd22 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -701,3 +701,17 @@ One line per decision: `[YYYY-MM-DD] : `. 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 : 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 (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. diff --git a/app/(dashboard)/skattekonto/page.tsx b/app/(dashboard)/skattekonto/page.tsx index 879050ba..a0777df6 100644 --- a/app/(dashboard)/skattekonto/page.tsx +++ b/app/(dashboard)/skattekonto/page.tsx @@ -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)} /> ))} @@ -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 ( @@ -702,6 +738,14 @@ function SkattekontoRow({ {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 && ( + + {t('interest_from', { date: formatDate(row.ranteberakningsdatum) })} + + )} {/* 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({ {isBooked ? ( - - {t('action_show_voucher')} - + + + {t('action_show_voucher')} + + + ) : ( - + ) : ( - NEEDS_ATTACHMENT.has(entry.source_type) && entry.status === 'posted' && ( + attachmentCountsLoaded && NEEDS_ATTACHMENT.has(entry.source_type) && entry.status === 'posted' && ( noDocRequired.has(entry.id) ? ( diff --git a/components/common/CommandPalette.tsx b/components/common/CommandPalette.tsx index 5c2efa82..08bcc4da 100644 --- a/components/common/CommandPalette.tsx +++ b/components/common/CommandPalette.tsx @@ -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' }, diff --git a/components/reports/ReportLibrary.tsx b/components/reports/ReportLibrary.tsx index fa579dca..ffb2f28f 100644 --- a/components/reports/ReportLibrary.tsx +++ b/components/reports/ReportLibrary.tsx @@ -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 ( -
- - - - - - - - - - {sections.map((section) => ( - - ))} - -
{t('col_report')}{t('col_description')}{t('col_last_opened')}
+
+
+
+ + {sections.length === 0 ? ( +

+ {t('search_no_results', { query: query.trim() })} +

+ ) : ( +
+ + + + + + + + + {/* 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. */} + + {sections.map((section) => ( + + ))} + +
{t('col_report')}{t('col_description')}{t('col_last_opened')}
+
+ )}
) } diff --git a/components/ui/dry-table.tsx b/components/ui/dry-table.tsx index ba787220..6ef6a921 100644 --- a/components/ui/dry-table.tsx +++ b/components/ui/dry-table.tsx @@ -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 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. diff --git a/components/ui/open-in-new-tab.tsx b/components/ui/open-in-new-tab.tsx new file mode 100644 index 00000000..5973e8aa --- /dev/null +++ b/components/ui/open-in-new-tab.tsx @@ -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 ( + e.stopPropagation()} + // Rows that own this control are themselves interactive: JournalEntryList + // gives its 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, + )} + > +