Files
accounted/app/(dashboard)/skattekonto/page.tsx
T
Jakob WennbergandClaude Opus 5 fa394e3759 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>
2026-07-30 18:27:29 +02:00

924 lines
32 KiB
TypeScript

'use client'
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { PageHeader } from '@/components/ui/page-header'
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,
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,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import { cn } from '@/lib/utils'
import {
formatCurrency,
formatDate,
formatDateLong,
formatDateTime,
} from '@/lib/utils'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import { rowsNeedingInterestDate } from '@/lib/skatteverket/interest-period'
import {
AlertCircle,
Copy,
ExternalLink,
Landmark,
RefreshCw,
} from 'lucide-react'
import { useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
import type {
SkatteverketSaldoResponse,
SkattekontoTransactionWithSuggestion,
StoredSkattekontoTransaction,
} from '@/extensions/general/skatteverket/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface SaldoEnvelope {
data: SkatteverketSaldoResponse | null
fetchedAt: string | null
lastSyncedAt: string | null
}
interface TransaktionerEnvelope {
data: {
booked: SkattekontoTransactionWithSuggestion[]
overdue: StoredSkattekontoTransaction[]
upcoming: StoredSkattekontoTransaction[]
}
}
interface MatchCandidate {
journal_entry_id: string
voucher_number: number | null
voucher_series: string | null
entry_date: string
description: string
status: 'draft' | 'posted' | 'reversed'
matched_amount: number
matched_side: 'debit' | 'credit'
}
export default function SkattekontoPage() {
const { toast } = useToast()
const t = useTranslations('skattekonto')
const hasSkvCapability = useCapability(CAPABILITY.skatteverket)
const [showPayment, setShowPayment] = useState(false)
const [saldo, setSaldo] = useState<SaldoEnvelope | null>(null)
const [tx, setTx] = useState<TransaktionerEnvelope['data'] | null>(null)
const [loading, setLoading] = useState(true)
const [syncing, setSyncing] = useState(false)
const [bookingId, setBookingId] = useState<string | null>(null)
const [notConnected, setNotConnected] = useState(false)
const [loadError, setLoadError] = useState(false)
// Set when a sync fails with an auth error while a connection exists
// (expired session, missing scope, revoked token). Rendered as a banner —
// the stored data below stays visible and usable.
const [reconnectMessage, setReconnectMessage] = useState<string | null>(null)
const [matchOpenFor, setMatchOpenFor] = useState<StoredSkattekontoTransaction | null>(
null,
)
const [matchCandidates, setMatchCandidates] = useState<MatchCandidate[] | null>(null)
const [matchLoading, setMatchLoading] = useState(false)
const [matchSubmitting, setMatchSubmitting] = useState<string | null>(null)
const reload = useCallback(async () => {
setLoading(true)
setLoadError(false)
try {
const [saldoRes, txRes] = await Promise.all([
fetch('/api/extensions/ext/skatteverket/skattekonto/saldo'),
fetch('/api/extensions/ext/skatteverket/skattekonto/transaktioner'),
])
if (saldoRes.status === 401) {
setNotConnected(true)
return
}
// A non-401 response proves a connection now exists: clear a stale
// not-connected state so a reload after connecting (in another tab or
// via the visibility refetch below) actually flips the page over.
setNotConnected(false)
// A non-auth failure must NOT fall through to the "inget saldo hämtat
// ännu"-tomvy — that reads as "not configured" when the truth is "the
// fetch broke". Surface it as an error with a retry instead.
if (!saldoRes.ok) {
setLoadError(true)
return
}
const saldoJson = (await saldoRes.json()) as SaldoEnvelope
setSaldo(saldoJson)
if (txRes.ok) {
const txJson = (await txRes.json()) as TransaktionerEnvelope
setTx(txJson.data)
}
} catch {
setLoadError(true)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void reload()
}, [reload])
// Auto-recover from the "inte anslutet" empty state when the user returns
// to this tab: the connect flow lives in Inställningar (often completed in
// another tab or after a mobile BankID app-switch), so no in-window signal
// can reach this page. Only fires while notConnected is showing: a routine
// tab switch on a healthy page must not flash the loading state. Throttled
// so rapid tab toggling doesn't hammer the API.
const notConnectedRef = useRef(false)
useEffect(() => {
notConnectedRef.current = notConnected
}, [notConnected])
const lastVisibilityReloadRef = useRef(0)
useEffect(() => {
function onVisible() {
if (document.visibilityState !== 'visible') return
if (!notConnectedRef.current) return
const now = Date.now()
if (now - lastVisibilityReloadRef.current < 5_000) return
lastVisibilityReloadRef.current = now
void reload()
}
document.addEventListener('visibilitychange', onVisible)
return () => document.removeEventListener('visibilitychange', onVisible)
}, [reload])
async function syncNow() {
setSyncing(true)
try {
const res = await fetch('/api/extensions/ext/skatteverket/skattekonto/sync', {
method: 'POST',
})
const json = await res.json()
if (!res.ok) {
// 401 covers several distinct auth states (see handleSkvError in the
// skatteverket extension). Only NOT_CONNECTED means "no connection
// exists" — the rest (SESSION_EXPIRED, MISSING_SCOPE, TOKEN_REVOKED,
// …) fire while Inställningar truthfully shows the stored token as
// "Ansluten". Showing the full "inte anslutet"-tomvy for those
// contradicts the settings panel; show the server's actual reason
// with a reconnect CTA instead.
if (res.status === 401) {
if (json.code === 'NOT_CONNECTED') {
setNotConnected(true)
} else {
setReconnectMessage(
typeof json.error === 'string' && json.error
? json.error
: 'Anslutningen mot Skatteverket behöver förnyas. Anslut igen med BankID.',
)
}
return
}
// Map the parsed body plus the status, never `new Error(json.error)`:
// the Error constructor stringifies a non-string body field, and the
// mapper would discard the route's own Swedish reason.
toast({
title: 'Synk misslyckades',
description: getUserErrorMessage(json, { statusCode: res.status }),
variant: 'destructive',
})
return
}
setReconnectMessage(null)
toast({
title: 'Skattekonto synkroniserat',
description: `${json.data.booked} bokförda, ${json.data.upcoming} kommande`,
})
await reload()
} catch (err) {
toast({
title: 'Synk misslyckades',
description: err instanceof Error ? getUserErrorMessage(err) : undefined,
variant: 'destructive',
})
} finally {
setSyncing(false)
}
}
async function bokfor(id: string) {
setBookingId(id)
try {
const res = await fetch(
`/api/extensions/ext/skatteverket/skattekonto/transaktioner/${id}/bokfor`,
{ method: 'POST' },
)
const json = await res.json()
if (!res.ok) {
toast({
title: 'Kunde inte bokföra',
description: getUserErrorMessage(json, { statusCode: res.status }),
variant: 'destructive',
})
return
}
toast({
title: 'Utkast skapat',
description: 'Granska och bokför verifikatet i Bokföring.',
})
// Take the user to the draft so they can review.
window.location.href = `/bookkeeping/${json.data.entry.id}`
} catch (err) {
toast({
title: 'Kunde inte bokföra',
description: err instanceof Error ? getUserErrorMessage(err) : undefined,
variant: 'destructive',
})
} finally {
setBookingId(null)
}
}
async function openMatch(row: StoredSkattekontoTransaction) {
setMatchOpenFor(row)
setMatchCandidates(null)
setMatchLoading(true)
try {
const res = await fetch(
`/api/extensions/ext/skatteverket/skattekonto/transaktioner/${row.id}/match-candidates`,
)
const json = await res.json()
if (!res.ok) {
toast({
title: 'Kunde inte hämta kandidater',
description: getUserErrorMessage(json, { statusCode: res.status }),
variant: 'destructive',
})
setMatchOpenFor(null)
return
}
setMatchCandidates(json.data.candidates as MatchCandidate[])
} catch (err) {
toast({
title: 'Kunde inte hämta kandidater',
description: err instanceof Error ? getUserErrorMessage(err) : undefined,
variant: 'destructive',
})
setMatchOpenFor(null)
} finally {
setMatchLoading(false)
}
}
async function confirmMatch(journalEntryId: string) {
if (!matchOpenFor) return
setMatchSubmitting(journalEntryId)
try {
const res = await fetch(
`/api/extensions/ext/skatteverket/skattekonto/transaktioner/${matchOpenFor.id}/match`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ journal_entry_id: journalEntryId }),
},
)
const json = await res.json()
if (!res.ok) {
toast({
title: 'Kunde inte koppla transaktionen',
description: getUserErrorMessage(json, { statusCode: res.status }),
variant: 'destructive',
})
return
}
toast({ title: 'Transaktion kopplad till verifikat' })
setMatchOpenFor(null)
setMatchCandidates(null)
await reload()
} catch (err) {
toast({
title: 'Kunde inte koppla transaktionen',
description: err instanceof Error ? getUserErrorMessage(err) : undefined,
variant: 'destructive',
})
} finally {
setMatchSubmitting(null)
}
}
function copyOcr(ocr: string) {
navigator.clipboard
.writeText(ocr)
.then(() => toast({ title: 'OCR kopierat' }))
.catch(() => {})
}
// Next charge (concept attn line): the earliest upcoming due date and the
// sum of everything Skatteverket draws that day.
const nextCharge = useMemo(() => {
const upcoming = tx?.upcoming ?? []
const dueOf = (r: StoredSkattekontoTransaction) =>
r.forfallodatum ?? r.transaktionsdatum
const due = upcoming.map(dueOf).sort()[0]
if (!due) return null
const rows = upcoming.filter((r) => dueOf(r) === due)
const amount = rows.reduce((sum, r) => sum + Number(r.belopp_skatteverket), 0)
return { due, count: rows.length, amount: Math.round(Math.abs(amount) * 100) / 100 }
}, [tx])
const saldoNow = saldo?.data ? saldo.data.saldoSkatteverket : null
const shortfall =
nextCharge && saldoNow !== null && saldoNow < nextCharge.amount
? Math.round((nextCharge.amount - saldoNow) * 100) / 100
: null
const helpNode = (
<HelpPopover>
<p>{t('help_text')}</p>
</HelpPopover>
)
if (notConnected) {
return (
<div className="space-y-8">
<PageHeader title="Skattekonto" help={helpNode} />
<EmptyState
icon={Landmark}
title="Skatteverket är inte anslutet"
description="För att se saldo och transaktioner på skattekontot behöver du ansluta med BankID i inställningarna."
>
<Button asChild>
<Link href="/settings/tax">
<ExternalLink className="mr-2 h-4 w-4" />
Anslut Skatteverket
</Link>
</Button>
</EmptyState>
</div>
)
}
if (loadError) {
return (
<div className="space-y-8">
<PageHeader title="Skattekonto" help={helpNode} />
<EmptyState
icon={AlertCircle}
title="Kunde inte hämta skattekontot"
description="Något gick fel när saldo och transaktioner skulle hämtas. Försök igen om en stund."
>
<Button variant="outline" onClick={() => void reload()}>
<RefreshCw className="mr-2 h-4 w-4" />
Försök igen
</Button>
</EmptyState>
</div>
)
}
const data = saldo?.data ?? null
return (
<div className="space-y-8">
<PageHeader
title="Skattekonto"
help={helpNode}
action={
// The span carries the tooltip: `title` is suppressed on disabled elements.
<span title={!hasSkvCapability ? 'Synk mot Skatteverket kräver ett abonnemang' : undefined}>
<Button
variant="ghost"
onClick={syncNow}
disabled={syncing || !hasSkvCapability}
className="text-muted-foreground hover:text-foreground"
>
<RefreshCw className={`mr-2 h-4 w-4 ${syncing ? 'animate-spin' : ''}`} />
{syncing ? 'Synkroniserar…' : 'Synkronisera nu'}
</Button>
</span>
}
/>
{/* Saldo as compact stat tiles (house metric-card idiom, KPIHeroCards) */}
<section className="space-y-4">
{loading && !data ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Skeleton className="h-28 w-full rounded-lg" />
<Skeleton className="h-28 w-full rounded-lg" />
</div>
) : !data ? (
<p className="py-4 text-sm text-muted-foreground">
Inget saldo hämtat ännu: klicka på ”Synkronisera nu”.
</p>
) : (
<>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="rounded-lg border border-border p-4">
<div className="flex items-center gap-2">
<img
src="/logos/skatteverket_color.svg"
alt=""
className="h-4 w-4 shrink-0 object-contain"
/>
<p className="text-xs text-muted-foreground">Saldo hos Skatteverket</p>
</div>
<p
className={cn(
'mt-2 font-display text-2xl tabular-nums tracking-tight',
data.saldoSkatteverket < 0 && 'text-destructive',
)}
>
{formatCurrency(data.saldoSkatteverket)}
</p>
<p className="mt-1 text-xs text-muted-foreground">
OCR <span className="tabular-nums">{data.ocrNummer}</span>{' '}
<button
type="button"
onClick={() => copyOcr(data.ocrNummer)}
className={QUIET_LINK_CLASS}
aria-label="Kopiera OCR"
>
{t('copy')}
</button>
{saldo?.lastSyncedAt && (
<>
{' '}· synkad{' '}
<span className="tabular-nums">{formatDateTime(saldo.lastSyncedAt)}</span>
</>
)}
</p>
{data.rantaSkatteverket !== 0 && (
<p className="mt-1 text-xs tabular-nums text-muted-foreground">
Preliminär ränta: {formatCurrency(data.rantaSkatteverket)}
</p>
)}
{data.saldoKronofogden !== 0 && (
<p className="mt-1 text-xs font-medium tabular-nums text-destructive">
Hos Kronofogden: {formatCurrency(data.saldoKronofogden)}
{data.rantaKronofogden !== 0 &&
` (ränta ${formatCurrency(data.rantaKronofogden)})`}
</p>
)}
</div>
<div className="rounded-lg border border-border p-4">
<p className="text-xs text-muted-foreground">Nästa dragning</p>
{nextCharge ? (
<>
<p className="mt-2 font-display text-2xl tabular-nums tracking-tight">
{formatCurrency(nextCharge.amount)}
</p>
<p className="mt-1 text-xs tabular-nums text-muted-foreground">
{formatDateLong(nextCharge.due)}
{nextCharge.count > 1 && ` · ${nextCharge.count} händelser`}
</p>
</>
) : (
<p className="mt-2 text-sm text-muted-foreground">
Inga kommande dragningar.
</p>
)}
</div>
</div>
{reconnectMessage ? (
<AttnLine action={{ label: t('attn_reconnect_action'), href: '/settings/tax' }}>
{reconnectMessage}
</AttnLine>
) : shortfall !== null && nextCharge ? (
<AttnLine
action={{ label: t('attn_show_payment'), onClick: () => setShowPayment(true) }}
>
{t('attn_shortfall', {
date: formatDateLong(nextCharge.due),
charge: formatCurrency(nextCharge.amount),
missing: formatCurrency(shortfall),
})}
</AttnLine>
) : null}
{/* Optional chain on purpose: `data` is Skatteverket's raw saldo
JSON cast to our interface, and informationstext is not a
required field in SKV's own v2.1.0 schema. A response without
it blanked the whole Skattekonto page. */}
{(data.informationstext?.length ?? 0) > 0 && (
<div className="space-y-1">
<p className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
Information från Skatteverket
</p>
{(data.informationstext ?? []).map((info, i) => (
<p key={i} className="text-xs leading-5 text-muted-foreground">
{info}
</p>
))}
</div>
)}
</>
)}
</section>
{/* One dry table with band rows (concept): Kommande, Förfallna, Genomförda */}
<SkattekontoTable
tx={tx}
onBokfor={bokfor}
onMatch={openMatch}
bookingId={bookingId}
/>
<p className="px-1 text-xs leading-5 text-muted-foreground">
{t('pgnote', { amount: formatCurrency(data?.saldoKronofogden ?? 0) })}
</p>
<MatchDialog
row={matchOpenFor}
candidates={matchCandidates}
loading={matchLoading}
submittingId={matchSubmitting}
onClose={() => {
setMatchOpenFor(null)
setMatchCandidates(null)
}}
onConfirm={confirmMatch}
/>
<Dialog open={showPayment} onOpenChange={setShowPayment}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="font-display text-lg tracking-tight">
{t('payment_title')}
</DialogTitle>
{nextCharge && (
<DialogDescription className="text-[13px] leading-relaxed">
{t('payment_description', {
date: formatDateLong(nextCharge.due),
charge: formatCurrency(nextCharge.amount),
})}
</DialogDescription>
)}
</DialogHeader>
<dl className="space-y-3 text-sm">
<div className="flex items-baseline justify-between gap-4">
<dt className="text-muted-foreground">{t('payment_bankgiro_label')}</dt>
<dd className="font-medium tabular-nums">5050-1055</dd>
</div>
<div className="flex items-baseline justify-between gap-4">
<dt className="text-muted-foreground">OCR</dt>
<dd className="flex items-center gap-2 font-medium tabular-nums">
{data?.ocrNummer}
{data && (
<button
type="button"
onClick={() => copyOcr(data.ocrNummer)}
className="text-muted-foreground transition-colors hover:text-foreground"
aria-label="Kopiera OCR"
>
<Copy className="h-3.5 w-3.5" />
</button>
)}
</dd>
</div>
{shortfall !== null && (
<div className="flex items-baseline justify-between gap-4">
<dt className="text-muted-foreground">{t('payment_shortfall_label')}</dt>
<dd className="font-medium tabular-nums">{formatCurrency(shortfall)}</dd>
</div>
)}
</dl>
</DialogContent>
</Dialog>
</div>
)
}
type TableSection = {
key: 'upcoming' | 'overdue' | 'booked'
label: string
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,
onMatch,
bookingId,
}: {
tx: TransaktionerEnvelope['data'] | null
onBokfor: (id: string) => void
onMatch: (row: StoredSkattekontoTransaction) => void
bookingId: string | null
}) {
const t = useTranslations('skattekonto')
const allSections: TableSection[] = [
{ key: 'upcoming', label: t('band_upcoming'), rows: tx?.upcoming ?? [] },
{ key: 'overdue', label: t('band_overdue'), rows: tx?.overdue ?? [] },
{ key: 'booked', label: t('band_booked'), rows: tx?.booked ?? [] },
]
// 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 (
<p className="py-8 text-center text-sm text-muted-foreground">
{t('no_transactions')}
</p>
)
}
return (
<div className="overflow-x-auto" role="region" aria-label="Skattekontohändelser">
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
<th className={cn(TH_CLASS, 'w-[110px]')}>Datum</th>
<th className={TH_CLASS}>Händelse</th>
<th className={cn(TH_CLASS, 'text-right')}>Belopp</th>
<th className={cn(TH_CLASS, 'w-[150px]')} />
</tr>
</thead>
<tbody className="stagger-enter">
{sections.map((section) => (
<Fragment key={section.key}>
<tr className="bg-muted/30">
<td
colSpan={4}
className="px-4 py-2 text-[11px] font-semibold uppercase tracking-[0.06em] text-muted-foreground"
>
{section.label}
</td>
</tr>
{section.rows.map((row) => (
<SkattekontoRow
key={row.id}
row={row}
section={section.key}
onBokfor={onBokfor}
onMatch={onMatch}
bookingId={bookingId}
showInterestDate={section.interestDateRowIds.has(row.id)}
/>
))}
</Fragment>
))}
</tbody>
</table>
</div>
)
}
function SkattekontoRow({
row,
section,
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 = rowDisplayDate(row, section)
return (
<tr className="group transition-colors duration-150 hover:bg-secondary/35">
<td className={cn(TD_CLASS, 'whitespace-nowrap tabular-nums text-muted-foreground')}>
{formatDate(displayDate)}
</td>
<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 && (
row.match_suggestion ? (
<Badge variant="warning" className="font-normal">
{t('chip_possible_duplicate', {
voucher:
row.match_suggestion.voucher_series && row.match_suggestion.voucher_number
? formatVoucher({
voucher_series: row.match_suggestion.voucher_series,
voucher_number: row.match_suggestion.voucher_number,
})
: t('chip_draft'),
})}
</Badge>
) : (
<Badge variant="outline" className="font-normal">
{t('chip_not_booked')}
</Badge>
)
)}
</span>
</td>
<td
className={cn(
TD_CLASS,
'whitespace-nowrap text-right tabular-nums',
amount > 0 && 'text-success',
)}
>
{amount > 0 ? `+${formatCurrency(amount)}` : formatCurrency(amount)}
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right')}>
{isBooked ? (
<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={cn('inline-flex items-center gap-3', HOVER_REVEAL_CLASS)}>
<button
type="button"
onClick={() => onMatch(row)}
className={QUIET_LINK_CLASS}
title="Koppla till befintligt verifikat"
>
{t('action_match')}
</button>
<button
type="button"
onClick={() => onBokfor(row.id)}
disabled={bookingId === row.id}
className={QUIET_LINK_CLASS}
>
{bookingId === row.id ? t('action_booking') : t('action_book')}
</button>
</span>
)}
</td>
</tr>
)
}
function MatchDialog({
row,
candidates,
loading,
submittingId,
onClose,
onConfirm,
}: {
row: StoredSkattekontoTransaction | null
candidates: MatchCandidate[] | null
loading: boolean
submittingId: string | null
onClose: () => void
onConfirm: (journalEntryId: string) => void
}) {
const open = !!row
return (
<Dialog open={open} onOpenChange={o => !o && onClose()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Matcha mot befintligt verifikat</DialogTitle>
<DialogDescription>
{row && (
<>
{formatDate(row.transaktionsdatum)} • {row.transaktionstext} •{' '}
<span className="tabular-nums">
{formatCurrency(Number(row.belopp_skatteverket))}
</span>
</>
)}
</DialogDescription>
</DialogHeader>
{loading && (
<p className="py-6 text-center text-sm text-muted-foreground">
Söker kandidater…
</p>
)}
{!loading && candidates && candidates.length === 0 && (
<div className="space-y-2 py-4 text-sm">
<p>Hittade inga verifikat med en matchande rad på konto 1630.</p>
<p className="text-muted-foreground">
Kandidaten måste ha samma belopp och sida på 1630 inom ±14 dagar
från transaktionsdatumet, och får inte redan vara kopplad till en
annan skattekonto-transaktion. Använd <strong>Bokför</strong> för
att skapa ett nytt verifikat istället.
</p>
</div>
)}
{!loading && candidates && candidates.length > 0 && (
<div className="max-h-[420px] overflow-y-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Datum</TableHead>
<TableHead>Verifikat</TableHead>
<TableHead>Beskrivning</TableHead>
<TableHead>Status</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{candidates.map(c => (
<TableRow key={c.journal_entry_id}>
<TableCell className="tabular-nums">{formatDate(c.entry_date)}</TableCell>
<TableCell className="tabular-nums">
{formatVoucher(c)}
</TableCell>
<TableCell className="max-w-[260px] truncate">
{c.description}
</TableCell>
<TableCell>
{c.status === 'posted' ? (
<Badge variant="secondary">Bokförd</Badge>
) : c.status === 'draft' ? (
<Badge variant="outline">Utkast</Badge>
) : (
<Badge variant="destructive">Makulerad</Badge>
)}
</TableCell>
<TableCell className="text-right">
<Button
size="sm"
onClick={() => onConfirm(c.journal_entry_id)}
disabled={submittingId === c.journal_entry_id}
>
{submittingId === c.journal_entry_id ? 'Kopplar…' : 'Koppla'}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
<DialogFooter>
<Button variant="ghost" onClick={onClose}>
Avbryt
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}