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:
co-authored by
Claude Opus 5
parent
144cc51458
commit
fa394e3759
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user