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>
924 lines
32 KiB
TypeScript
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>
|
|
)
|
|
}
|