Files
accounted/components/reports/SkatteverketPanel.tsx
T
f266c386f3 chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers (#2150)
* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers

Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n
namespaces and 4 unused dependencies; fold byte-identical helper copies
into one canonical home each (lib/utils chunk/sleep/utcDateStamp,
lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format,
lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body +
v1ValidationError rolled out to ~55 v1 routes, booking-template schemas).

No behaviour change: v1 bodies and status codes, MCP tool schemas, DB
writes and money math are untouched. Naive ore rounding was deliberately
not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list
of things left alone on purpose.

tsc, lint, 19588 unit tests and check:guards green; antipattern baseline
ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(transactions): import RawTransaction from @/types after the ingest re-export removal

CI's type ratchet (check:types, full tsconfig) caught the one test file
that still imported the type through lib/transactions/ingest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 11:51:16 +02:00

1082 lines
40 KiB
TypeScript

'use client'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import React, { useState, useEffect, useCallback } from 'react'
import { useCompanySettings } from '@/lib/reference-data/hooks'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
DestructiveConfirmDialog,
useDestructiveConfirm,
} from '@/components/ui/destructive-confirm-dialog'
import {
AlertCircle,
AlertTriangle,
CheckCircle2,
ExternalLink,
FileCheck,
Info,
Link2,
Loader2,
MoreHorizontal,
Send,
ShieldAlert,
} from 'lucide-react'
import type { VatPeriodType } from '@/types'
import { formatRedovisare, formatRedovisningsperiod } from '@/lib/skatteverket/format'
import { useCapability, useCompanyOptional } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { UpgradeNote } from '@/components/billing/UpgradeNote'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import { formatAmount } from '@/lib/utils'
interface SkatteverketStatus {
connected: boolean
expired?: boolean
canRefresh?: boolean
scope?: string
expiresAt?: string
}
/**
* Codes from /lib/api-client.ts's SkatteverketAuthError that mean "the user
* needs to reconnect with BankID before this action can succeed". When the API
* returns one of these codes we flip the local status.expired flag so the
* expired-session attn line with its "Förnya med BankID" action surfaces,
* even if the upstream /status endpoint hasn't reflected the change yet.
*/
const AUTH_RECONNECT_CODES = new Set([
'NOT_CONNECTED',
'SESSION_EXPIRED',
'REFRESH_EXHAUSTED',
'TOKEN_REVOKED',
'TOKEN_CORRUPTED',
'MISSING_SCOPE',
])
// Shape per Skatteverket Momsdeklaration v1.0.24 RAML
// (kontrollResultat.resultat[].{kod, status, beskrivning})
interface KontrollResult {
kod: string
status: 'ERROR' | 'WARNING'
beskrivning: string
}
interface SkatteverketPanelProps {
periodType: VatPeriodType
year: number
period: number
/**
* Selected räkenskapsår for helårsmoms. The SKV redovisningsperiod for
* yearly filers is the FY-end month (broken FYs do not end in December),
* and the `year` prop is not maintained in yearly mode, so both the period
* id and the fiscal-year end ride along explicitly.
*/
fiscalPeriodId?: string
fiscalYearEnd?: { year: number; month: number }
hasData: boolean
/**
* True when the momsdeklaration's filing gate is closed. Blocks
* validate/submit: SKV only validates internal arithmetic, so a locally
* broken declaration would pass their checks and still be materially wrong.
*
* The panel deliberately owns NO gate logic of its own. The value is
* `isFilingBlocked(checks)` (`lib/reports/vat-filing-gate.ts`) computed once
* in VatDeclarationView over the exact same check array that feeds the
* "Kontroll av underlaget" banner and the stegen counters, so this button
* can never be enabled while that section claims (or hides) a problem.
* Never re-derive it here from a narrower source.
*/
localBlocked: boolean
}
/**
* One feedback slot: exactly one message at a time, replaced at the end of
* each action. 'info' is for neutral lookups ("inget utkast hittades"):
* rendering those as success taught users that green means nothing.
*/
interface Notice {
kind: 'error' | 'success' | 'info'
text: string
}
const ORG_NUMBER_MISSING_NOTICE: Notice = {
kind: 'error',
text: 'Organisationsnummer saknas. Ange det under Inställningar innan du använder Skatteverket-kopplingen.',
}
function isOrgNumberMissing(err: unknown): boolean {
return err instanceof Error && err.message === 'Organisationsnummer saknas'
}
/**
* In-flight labels for actions with no visible button while running (the
* overflow-menu actions close the menu on select): rendered as a status row
* so a slow SKV round-trip is never silent.
*/
const ACTION_IN_FLIGHT_LABELS: Record<string, string> = {
validate: 'Validerar deklarationen...',
draft: 'Sparar utkast...',
lock: 'Låser utkastet...',
fetchDraft: 'Hämtar utkast...',
check: 'Kontrollerar inlämning...',
fetchDecided: 'Hämtar beslut...',
unlock: 'Låser upp...',
delete: 'Raderar utkast...',
disconnect: 'Kopplar bort Skatteverket...',
}
const SKV_ENABLED = ENABLED_EXTENSION_IDS.has('skatteverket')
export function SkatteverketPanel(props: SkatteverketPanelProps) {
if (!SKV_ENABLED) return null
return <SkatteverketPanelInner {...props} />
}
function SkatteverketPanelInner({
periodType,
year,
period,
fiscalPeriodId,
fiscalYearEnd,
hasData,
localBlocked,
}: SkatteverketPanelProps) {
// In yearly mode the year picker is replaced by the räkenskapsår selector,
// so the `year` prop is stale (stuck at the current year). Every SKV call
// must target the FY-end year instead.
const effectiveYear = periodType === 'yearly' && fiscalYearEnd ? fiscalYearEnd.year : year
const hasSkvCapability = useCapability(CAPABILITY.skatteverket)
const { dialogProps, confirm } = useDestructiveConfirm()
const [status, setStatus] = useState<SkatteverketStatus | null>(null)
const [loading, setLoading] = useState(true)
const [actionLoading, setActionLoading] = useState<string | null>(null)
const [notice, setNotice] = useState<Notice | null>(null)
const [kontroller, setKontroller] = useState<KontrollResult[]>([])
const [signeringslank, setSigneringslank] = useState<string | null>(null)
const [submitted, setSubmitted] = useState<{
kvittensnummer?: string
tidpunkt?: string
} | null>(null)
// Per-period SKV state resets when the picker changes (render-phase
// adjustment): a signing link, kvittens, or kontrollresultat fetched for
// one period must never render as if it belonged to another. Without this,
// the visibilitychange auto-check could stamp "Deklarationen har lämnats
// in" for a different period than the one being signed.
const periodKey = `${periodType}:${effectiveYear}:${period}:${fiscalPeriodId ?? ''}`
const [appliedPeriodKey, setAppliedPeriodKey] = useState(periodKey)
if (appliedPeriodKey !== periodKey) {
setAppliedPeriodKey(periodKey)
setKontroller([])
setSigneringslank(null)
setSubmitted(null)
setNotice(null)
}
/**
* Apply an API JSON error result. When the error indicates the SKV session
* has expired/been revoked/lost scope, immediately reflect that in the
* local status so the "Förnya session" CTA appears next to the message:
* the user shouldn't have to wait for /status to catch up.
* Extension routes return a FLAT { error: string, code } shape, unlike the
* core routes' nested envelope: do not unify the parsers.
*/
const applyApiError = useCallback((result: { error?: string; code?: string } | null) => {
if (!result?.error) return false
setNotice({ kind: 'error', text: result.error })
if (result.code && AUTH_RECONNECT_CODES.has(result.code)) {
setStatus((prev) => prev ? { ...prev, expired: true } : prev)
}
return true
}, [])
const fetchStatus = useCallback(async () => {
try {
const res = await fetch('/api/extensions/ext/skatteverket/status')
if (res.ok) {
const data = await res.json()
setStatus(data)
}
} catch {
// Extension might not be enabled
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchStatus()
// Check URL params for OAuth callback results
const params = new URLSearchParams(window.location.search)
if (params.get('skv_connected') === 'true') {
setNotice({ kind: 'success', text: 'Ansluten till Skatteverket' })
fetchStatus()
// Clean URL
const url = new URL(window.location.href)
url.searchParams.delete('skv_connected')
window.history.replaceState({}, '', url.toString())
}
const skvError = params.get('skv_error')
if (skvError) {
setNotice({ kind: 'error', text: decodeURIComponent(skvError) })
const url = new URL(window.location.href)
url.searchParams.delete('skv_error')
window.history.replaceState({}, '', url.toString())
}
}, [fetchStatus])
const handleConnect = () => {
// return_to brings the user back to the momsdeklaration after the BankID
// round-trip; the authorize route's default otherwise lands on the report
// library. The callback appends skv_connected/skv_error itself.
window.location.href =
'/api/extensions/ext/skatteverket/authorize?return_to=' +
encodeURIComponent('/reports/vat-declaration')
}
const handleDisconnect = async () => {
setActionLoading('disconnect')
setNotice(null)
try {
const res = await fetch('/api/extensions/ext/skatteverket/disconnect', {
method: 'POST',
})
if (res.ok) {
setStatus({ connected: false })
setNotice(null)
setKontroller([])
setSigneringslank(null)
setSubmitted(null)
} else {
const result = await res.json().catch(() => ({}))
if (!applyApiError(result)) {
setNotice({ kind: 'error', text: `Kunde inte koppla bort (${res.status})` })
}
}
} catch {
setNotice({ kind: 'error', text: 'Kunde inte koppla bort' })
} finally {
setActionLoading(null)
}
}
const handleValidate = async () => {
if (localBlocked) {
setNotice({
kind: 'error',
text:
'Åtgärda felen under Kontroll av underlaget högst upp på sidan innan ' +
'du skickar till Skatteverket.',
})
return
}
setActionLoading('validate')
setNotice(null)
setKontroller([])
try {
const res = await fetch('/api/extensions/ext/skatteverket/declaration/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ periodType, year: effectiveYear, period, fiscalPeriodId }),
})
const result = await res.json()
if (applyApiError(result)) {
// surfaced + status updated; nothing more to do
} else {
const controls: KontrollResult[] = result.data?.kontrollResultat?.resultat || []
setKontroller(controls)
if (controls.length === 0) {
// SKV's OK only confirms arithmetic: it does NOT confirm that the
// declaration is materially correct. We say so explicitly so the
// user doesn't read this as a green light for actual filing.
setNotice({
kind: 'success',
text:
'Skatteverket har inga tekniska invändningar mot deklarationen. ' +
'Kontrollera siffrorna i förhandsgranskningen innan du skickar in.',
})
} else {
const errors = controls.filter(k => k.status === 'ERROR')
if (errors.length > 0) {
setNotice({ kind: 'error', text: `${errors.length} valideringsfel hittades` })
} else {
setNotice({
kind: 'success',
text: 'Skatteverket har inga tekniska invändningar (med varningar)',
})
}
}
}
} catch {
setNotice({ kind: 'error', text: 'Kunde inte validera deklarationen' })
} finally {
setActionLoading(null)
}
}
const handleSaveDraft = async () => {
if (localBlocked) {
setNotice({
kind: 'error',
text:
'Åtgärda felen under Kontroll av underlaget högst upp på sidan innan ' +
'du sparar utkastet hos Skatteverket.',
})
return
}
setActionLoading('draft')
setNotice(null)
try {
const res = await fetch('/api/extensions/ext/skatteverket/declaration/draft', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ periodType, year: effectiveYear, period, fiscalPeriodId }),
})
const result = await res.json()
if (applyApiError(result)) {
// surfaced + status updated; nothing more to do
} else {
const controls: KontrollResult[] = result.data?.kontrollResultat?.resultat || []
setKontroller(controls)
const errors = controls.filter(k => k.status === 'ERROR')
if (errors.length === 0) {
setNotice({ kind: 'success', text: 'Utkast sparat i Eget utrymme hos Skatteverket' })
} else {
setNotice({
kind: 'error',
text: `Utkastet sparades men har ${errors.length} valideringsfel`,
})
}
}
} catch {
setNotice({ kind: 'error', text: 'Kunde inte spara utkast' })
} finally {
setActionLoading(null)
}
}
// Redovisare from the session-cached settings row (lib/reference-data).
// entity_type falls back to the company row, as /api/settings did.
const { settings: companySettings } = useCompanySettings()
const companyEntityType = useCompanyOptional()?.company?.entity_type ?? null
const getRedovisare = useCallback(async (): Promise<string> => {
const orgNumber = companySettings?.org_number
if (!orgNumber) throw new Error('Organisationsnummer saknas')
return formatRedovisare(orgNumber, companySettings?.entity_type ?? companyEntityType)
}, [companySettings, companyEntityType])
const getRedovisningsperiod = useCallback((): string => {
return formatRedovisningsperiod(periodType, year, period, fiscalYearEnd)
}, [periodType, year, period, fiscalYearEnd])
const handleLock = async () => {
setActionLoading('lock')
setNotice(null)
try {
const res = await fetch(
`/api/extensions/ext/skatteverket/declaration/lock?redovisare=${encodeURIComponent(
await getRedovisare()
)}&redovisningsperiod=${getRedovisningsperiod()}`,
{ method: 'PUT' }
)
const result = await res.json()
if (applyApiError(result)) {
// surfaced + status updated; nothing more to do
} else if (result.data?.signeringsLank) {
setSigneringslank(result.data.signeringsLank)
setNotice({
kind: 'success',
text: 'Utkastet är låst. Öppna signeringslänken för att signera med BankID.',
})
}
} catch (err) {
setNotice(
isOrgNumberMissing(err)
? ORG_NUMBER_MISSING_NOTICE
: { kind: 'error', text: 'Kunde inte låsa utkastet' },
)
} finally {
setActionLoading(null)
}
}
/**
* One-click filing: the server chains kontrollera -> utkast -> lås and
* returns the signing link. Stage-aware failures come back with a `stage`
* discriminator: `validation` stopped before anything was written at SKV,
* `lock` with `draft_saved` means the draft survives in Eget utrymme and
* only the lock step needs a retry (available under Fler åtgärder).
*/
const handleSubmit = async () => {
if (localBlocked) {
setNotice({
kind: 'error',
text:
'Åtgärda felen under Kontroll av underlaget högst upp på sidan innan ' +
'du skickar till Skatteverket.',
})
return
}
setActionLoading('submit')
setNotice(null)
setKontroller([])
try {
const res = await fetch('/api/extensions/ext/skatteverket/declaration/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ periodType, year: effectiveYear, period, fiscalPeriodId }),
})
const result = await res.json()
if (res.ok && result.data?.signeringsLank) {
const controls: KontrollResult[] = result.data?.kontrollResultat?.resultat || []
setKontroller(controls)
setSigneringslank(result.data.signeringsLank)
setNotice({
kind: 'success',
text:
'Deklarationen är kontrollerad, sparad och låst. Öppna signeringslänken ' +
'för att signera med BankID.',
})
return
}
if (result.stage) {
const controls: KontrollResult[] = result.kontrollResultat?.resultat || []
if (controls.length > 0) setKontroller(controls)
if (result.stage === 'validation') {
setNotice({
kind: 'error',
text: result.error || 'Skatteverket hittade valideringsfel i deklarationen.',
})
} else if (result.stage === 'lock' && result.draft_saved) {
setNotice({
kind: 'error',
text:
'Utkastet är sparat hos Skatteverket men kunde inte låsas för signering. ' +
'Försök igen med "Lås och signera" under Fler åtgärder.',
})
} else {
setNotice({
kind: 'error',
text: result.error || 'Kunde inte skicka deklarationen till Skatteverket',
})
}
return
}
if (!applyApiError(result)) {
setNotice({
kind: 'error',
text: 'Kunde inte skicka deklarationen till Skatteverket',
})
}
} catch {
setNotice({ kind: 'error', text: 'Kunde inte skicka deklarationen till Skatteverket' })
} finally {
setActionLoading(null)
}
}
const handleUnlock = async () => {
setActionLoading('unlock')
setNotice(null)
try {
const res = await fetch(
`/api/extensions/ext/skatteverket/declaration/lock?redovisare=${encodeURIComponent(
await getRedovisare()
)}&redovisningsperiod=${getRedovisningsperiod()}`,
{ method: 'DELETE' }
)
const result = await res.json()
if (applyApiError(result)) {
// surfaced + status updated; nothing more to do
} else {
setSigneringslank(null)
setNotice({ kind: 'success', text: 'Utkastet har låsts upp' })
}
} catch (err) {
setNotice(
isOrgNumberMissing(err)
? ORG_NUMBER_MISSING_NOTICE
: { kind: 'error', text: 'Kunde inte låsa upp utkastet' },
)
} finally {
setActionLoading(null)
}
}
/**
* `silent` suppresses the "inget hittades" notice: used by the automatic
* re-check when the tab regains focus after the user signed at SKV, where
* a recurring "nothing found" message would just be noise.
*/
const handleCheckSubmitted = useCallback(async (silent = false) => {
setActionLoading('check')
setNotice(null)
try {
// periodType/year/period let the server complete the period's moms
// deadline when the filing is confirmed.
const res = await fetch(
`/api/extensions/ext/skatteverket/declaration/submitted?redovisare=${encodeURIComponent(
await getRedovisare()
)}&redovisningsperiod=${getRedovisningsperiod()}&periodType=${periodType}&year=${effectiveYear}&period=${period}`
)
const result = await res.json()
if (applyApiError(result)) {
// surfaced + status updated; nothing more to do
} else if (result.data) {
setSubmitted(result.data)
setNotice({ kind: 'success', text: 'Deklarationen har lämnats in' })
} else if (!silent) {
setNotice({ kind: 'info', text: 'Ingen inlämnad deklaration hittades för denna period' })
}
} catch (err) {
if (isOrgNumberMissing(err)) {
setNotice(ORG_NUMBER_MISSING_NOTICE)
} else if (!silent) {
setNotice({ kind: 'error', text: 'Kunde inte kontrollera inlämningsstatus' })
}
} finally {
setActionLoading(null)
}
}, [applyApiError, getRedovisare, getRedovisningsperiod, periodType, effectiveYear, period])
// While a signing link is outstanding, re-check submission status when the
// user returns to this tab: signing happens on Skatteverket's site, so the
// return trip is the natural moment for the kvittens to appear.
useEffect(() => {
if (!signeringslank || submitted) return
const onVisibilityChange = () => {
if (document.visibilityState === 'visible' && actionLoading === null) {
handleCheckSubmitted(true)
}
}
document.addEventListener('visibilitychange', onVisibilityChange)
return () => document.removeEventListener('visibilitychange', onVisibilityChange)
}, [signeringslank, submitted, actionLoading, handleCheckSubmitted])
const handleDeleteDraft = async () => {
setActionLoading('delete')
setNotice(null)
try {
const res = await fetch(
`/api/extensions/ext/skatteverket/declaration/draft?redovisare=${encodeURIComponent(
await getRedovisare()
)}&redovisningsperiod=${getRedovisningsperiod()}`,
{ method: 'DELETE' }
)
if (res.status === 204 || res.ok) {
setKontroller([])
setSigneringslank(null)
setNotice({ kind: 'success', text: 'Utkastet har raderats från Eget utrymme' })
} else {
const result = await res.json().catch(() => ({}))
if (!applyApiError(result)) {
setNotice({ kind: 'error', text: `Kunde inte radera utkast (${res.status})` })
}
}
} catch (err) {
setNotice(
isOrgNumberMissing(err)
? ORG_NUMBER_MISSING_NOTICE
: { kind: 'error', text: 'Kunde inte radera utkast' },
)
} finally {
setActionLoading(null)
}
}
const handleFetchDraft = async () => {
setActionLoading('fetchDraft')
setNotice(null)
try {
const res = await fetch(
`/api/extensions/ext/skatteverket/declaration/draft?redovisare=${encodeURIComponent(
await getRedovisare()
)}&redovisningsperiod=${getRedovisningsperiod()}`
)
const result = await res.json()
if (applyApiError(result)) {
// surfaced + status updated; nothing more to do
} else if (!result.data) {
setNotice({ kind: 'info', text: 'Inget sparat utkast hittades för perioden' })
} else {
const locked = result.data?.locked ? ' (låst)' : ''
const summa = result.data?.momsuppgift?.summaMoms
const summaLabel = summa !== undefined ? `, summaMoms = ${formatAmount(summa)}` : ''
setNotice({ kind: 'success', text: `Sparat utkast hittades${locked}${summaLabel}` })
}
} catch (err) {
setNotice(
isOrgNumberMissing(err)
? ORG_NUMBER_MISSING_NOTICE
: { kind: 'error', text: 'Kunde inte hämta utkast' },
)
} finally {
setActionLoading(null)
}
}
const handleFetchDecided = async () => {
setActionLoading('fetchDecided')
setNotice(null)
try {
const res = await fetch(
`/api/extensions/ext/skatteverket/declaration/decided?redovisare=${encodeURIComponent(
await getRedovisare()
)}&redovisningsperiod=${getRedovisningsperiod()}&periodType=${periodType}&year=${effectiveYear}&period=${period}`
)
const result = await res.json()
if (applyApiError(result)) {
// surfaced + status updated; nothing more to do
} else if (!result.data) {
setNotice({ kind: 'info', text: 'Inget beslut hittades för perioden' })
} else {
const tid = result.data?.beslutadTidpunkt
const tidLabel = tid ? ` (beslutad ${new Date(tid).toLocaleDateString('sv-SE')})` : ''
setNotice({ kind: 'success', text: `Beslut hittades${tidLabel}` })
}
} catch (err) {
setNotice(
isOrgNumberMissing(err)
? ORG_NUMBER_MISSING_NOTICE
: { kind: 'error', text: 'Kunde inte hämta beslutade uppgifter' },
)
} finally {
setActionLoading(null)
}
}
const handleDeleteDraftConfirmed = async () => {
const ok = await confirm({
title: 'Radera utkastet hos Skatteverket?',
description:
'Utkastet tas bort från Eget utrymme hos Skatteverket. Detta går inte att ångra.',
confirmLabel: 'Radera utkast',
})
if (!ok) return
await handleDeleteDraft()
}
const handleDisconnectConfirmed = async () => {
const ok = await confirm({
title: 'Koppla bort Skatteverket?',
description:
'Anslutningen tas bort och du behöver ansluta med BankID igen för att kunna skicka direkt.',
confirmLabel: 'Koppla bort',
})
if (!ok) return
await handleDisconnect()
}
if (loading) {
return (
<section>
<div className="space-y-4">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-24" />
</div>
</section>
)
}
// Paywall: direct API submission is the paid convenience; manual filing at
// skatteverket.se stays free and is owned by the "Lämna in" card above.
// Rendered BEFORE the connected check so a company that connected during
// trial sees the upsell instead of action buttons that would 403.
if (!hasSkvCapability) {
return (
<section>
<div className="mb-3">
<h3 className="flex items-center gap-2 font-sans text-xs font-medium uppercase tracking-wider text-muted-foreground">
<FileCheck className="h-4 w-4" />
Skicka direkt till Skatteverket (valfritt)
</h3>
</div>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Med ett abonnemang kan du ansluta med BankID och skicka deklarationen
direkt härifrån, samt validera, spara utkast och signera.
</p>
<UpgradeNote>
Direktinlämning till Skatteverket kräver ett abonnemang.
</UpgradeNote>
</div>
</section>
)
}
// Not connected. The momsdeklaration is already complete and can be filed
// manually at skatteverket.se with no connection (the "Lämna in" card above
// owns that path). Connecting is an optional convenience for submitting
// directly from Accounted, so frame it that way.
if (!status?.connected) {
return (
<section>
<div className="mb-3">
<h3 className="flex items-center gap-2 font-sans text-xs font-medium uppercase tracking-wider text-muted-foreground">
<FileCheck className="h-4 w-4" />
Skicka direkt till Skatteverket (valfritt)
</h3>
</div>
<div className="space-y-4">
{notice?.kind === 'error' && (
<div
role="alert"
className="flex items-start gap-2 text-sm text-destructive bg-destructive/5 rounded-lg p-3"
>
<AlertCircle className="h-4 w-4 mt-1 shrink-0" />
<span>{notice.text}</span>
</div>
)}
<p className="text-sm text-muted-foreground">
Vill du slippa skriva in siffrorna själv kan du ansluta med BankID och
skicka deklarationen direkt härifrån, samt validera, spara utkast och
signera.
</p>
<Button onClick={handleConnect} className="gap-2">
<Link2 className="h-4 w-4" />
Anslut med BankID
</Button>
</div>
</section>
)
}
// Connected: show actions
const hasErrors = kontroller.some(k => k.status === 'ERROR')
return (
<section>
<div className="mb-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<h3 className="flex items-center gap-2 font-sans text-xs font-medium uppercase tracking-wider text-muted-foreground">
<FileCheck className="h-4 w-4" />
Skicka direkt till Skatteverket
</h3>
<div className="flex flex-wrap items-center gap-2">
{/* Connected is the normal state here (the not-connected branch is
a different section): muted text, never a chip. The expired
session is the one exception and gets the attn sentence below
instead of a badge-and-button cluster. */}
{!status.expired && (
<span className="text-xs text-muted-foreground">Ansluten</span>
)}
{/* Read-only lookups and recovery actions live in the overflow
menu: the visible surface stays the forward path (validera,
spara utkast, lås och signera). */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Fler åtgärder">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-72">
{/* The demoted step-by-step actions: the visible surface is
the one-click "Skicka till Skatteverket" button; these
remain for partial retries (e.g. lock-only after a lock
failure) and for users who want to inspect each step. */}
<DropdownMenuLabel>Steg för steg</DropdownMenuLabel>
<DropdownMenuItem
disabled={!hasData || localBlocked || actionLoading !== null}
onSelect={() => handleValidate()}
>
<div>
<p>Validera</p>
<p className="text-xs text-muted-foreground">
Kontrollera deklarationen hos Skatteverket utan att spara
</p>
</div>
</DropdownMenuItem>
<DropdownMenuItem
disabled={!hasData || localBlocked || actionLoading !== null}
onSelect={() => handleSaveDraft()}
>
<div>
<p>Spara utkast</p>
<p className="text-xs text-muted-foreground">
Spara deklarationen som utkast i Eget utrymme
</p>
</div>
</DropdownMenuItem>
<DropdownMenuItem
disabled={!hasData || actionLoading !== null}
onSelect={() => handleLock()}
>
<div>
<p>Lås och signera</p>
<p className="text-xs text-muted-foreground">
Lås det sparade utkastet och hämta signeringslänken
</p>
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>Status hos Skatteverket</DropdownMenuLabel>
<DropdownMenuItem
disabled={actionLoading !== null}
onSelect={() => handleFetchDraft()}
>
<div>
<p>Hämta utkast</p>
<p className="text-xs text-muted-foreground">
Hämta sparat utkast från Eget utrymme
</p>
</div>
</DropdownMenuItem>
<DropdownMenuItem
disabled={actionLoading !== null}
onSelect={() => handleCheckSubmitted()}
>
<div>
<p>Kontrollera inlämning</p>
<p className="text-xs text-muted-foreground">
Kontrollera om en signerad deklaration har lämnats in
</p>
</div>
</DropdownMenuItem>
<DropdownMenuItem
disabled={actionLoading !== null}
onSelect={() => handleFetchDecided()}
>
<div>
<p>Hämta beslut</p>
<p className="text-xs text-muted-foreground">
Hämta Skatteverkets beslut för perioden
</p>
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>Återställning</DropdownMenuLabel>
<DropdownMenuItem
disabled={actionLoading !== null}
onSelect={() => handleUnlock()}
>
<div>
<p>Lås upp</p>
<p className="text-xs text-muted-foreground">
Lås upp en låst period så att utkastet kan ändras
</p>
</div>
</DropdownMenuItem>
<DropdownMenuItem
disabled={actionLoading !== null}
onSelect={() => handleDeleteDraftConfirmed()}
>
<div>
<p>Radera utkast...</p>
<p className="text-xs text-muted-foreground">
Radera sparat utkast från Eget utrymme
</p>
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={actionLoading !== null}
onSelect={() => handleDisconnectConfirmed()}
className="text-destructive focus:text-destructive"
>
<div>
<p>Koppla bort Skatteverket...</p>
<p className="text-xs text-muted-foreground">
Kräver ny BankID-anslutning för direktinlämning
</p>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{status.expired && (
<p className="mt-2 text-[12.5px] leading-5 text-attn">
Sessionen mot Skatteverket har gått ut.{' '}
<button
type="button"
onClick={handleConnect}
className="underline underline-offset-2 hover:opacity-80"
>
Förnya med BankID
</button>
</p>
)}
</div>
<div className="space-y-4">
{/* In-flight status for overflow-menu actions: their menu closes on
select, so this row is the only visible sign of work. */}
{actionLoading && ACTION_IN_FLIGHT_LABELS[actionLoading] && (
<div
role="status"
aria-live="polite"
className="flex items-center gap-2 text-sm text-muted-foreground"
>
<Loader2 className="h-4 w-4 animate-spin" />
{ACTION_IN_FLIGHT_LABELS[actionLoading]}
</div>
)}
{/* Single feedback slot: errors are assertive alerts, success/info are
polite status messages on the neutral surface. */}
{notice && (
notice.kind === 'error' ? (
<div
role="alert"
className="flex items-start gap-2 text-sm text-destructive bg-destructive/5 rounded-lg p-3"
>
<AlertCircle className="h-4 w-4 mt-1 shrink-0" />
<span>{notice.text}</span>
</div>
) : (
<div
role="status"
aria-live="polite"
className="flex items-start gap-2 text-sm rounded-lg border border-border bg-muted/30 p-3"
>
{notice.kind === 'success' ? (
<CheckCircle2 className="h-4 w-4 mt-1 shrink-0 text-success" />
) : (
<Info className="h-4 w-4 mt-1 shrink-0 text-muted-foreground" />
)}
<span>{notice.text}</span>
</div>
)
)}
{/* Validation results from Skatteverket */}
{kontroller.length > 0 && (
<div className="space-y-2">
<h3 className="font-sans text-xs font-medium uppercase tracking-wider text-muted-foreground">
Skatteverkets valideringsresultat
</h3>
{kontroller.map((k, i) => (
<div
key={`${k.kod}-${i}`}
className={`flex items-start gap-2 text-sm rounded-lg p-3 ${
k.status === 'ERROR'
? 'bg-destructive/5 text-destructive'
: 'border border-border bg-muted/30'
}`}
>
{k.status === 'ERROR' ? (
<ShieldAlert className="h-4 w-4 mt-1 shrink-0" />
) : (
<AlertTriangle className="h-4 w-4 mt-1 shrink-0 text-warning" />
)}
<div>
<span className="font-mono text-xs mr-2">{k.kod}</span>
{k.beskrivning}
</div>
</div>
))}
</div>
)}
{/* Submitted confirmation */}
{submitted && (
<div className="rounded-lg border border-border p-3 space-y-1">
<p className="text-sm font-medium flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-success" />
Inlämnad
</p>
{submitted.kvittensnummer && (
<p className="text-xs text-muted-foreground">
Kvittensnummer: <span className="font-mono">{submitted.kvittensnummer}</span>
</p>
)}
{submitted.tidpunkt && (
<p className="text-xs text-muted-foreground">
Tidpunkt: {new Date(submitted.tidpunkt).toLocaleString('sv-SE')}
</p>
)}
</div>
)}
{/* Signing link */}
{signeringslank && (
<div className="rounded-lg border border-border bg-muted/30 p-3 space-y-2">
<p className="text-sm font-medium">Utkastet är låst och redo att signeras</p>
<p className="text-xs text-muted-foreground">
Öppna länken nedan och signera med BankID på Skatteverkets sida. När du
kommer tillbaka hit kontrolleras inlämningen automatiskt.
</p>
<div className="flex flex-wrap gap-2">
<Button variant="outline" asChild className="gap-2">
<a href={signeringslank} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4" />
Öppna signeringssidan
</a>
</Button>
<Button
variant="outline"
onClick={() => handleCheckSubmitted()}
disabled={actionLoading !== null}
className="gap-2"
>
{actionLoading === 'check' ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<CheckCircle2 className="h-4 w-4" />
)}
Kontrollera inlämning
</Button>
</div>
</div>
)}
{/* Forward lifecycle: one primary action. The individual steps live
in the overflow menu under "Steg för steg". */}
<div className="flex flex-wrap items-center gap-2 pt-1">
<Button
onClick={handleSubmit}
disabled={!hasData || localBlocked || actionLoading !== null}
className="gap-2"
>
{actionLoading === 'submit' ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
Skicka till Skatteverket
</Button>
</div>
<p className="text-xs text-muted-foreground">
Deklarationen kontrolleras, sparas som utkast i{' '}
<InfoTooltip
variant="help"
content="Ditt företags privata yta hos Skatteverket. Utkast som sparas där räknas inte som inlämnade förrän de har signerats med BankID."
>
<span>Eget utrymme</span>
</InfoTooltip>{' '}
och låses för signering med BankID hos Skatteverket. Inget lämnas in
förrän du har signerat.
</p>
{/* Visible disabled-state explanations: title attributes never show
on disabled buttons. */}
{localBlocked && (
<p className="text-sm text-destructive">
Åtgärda felen under Kontroll av underlaget högst upp på sidan innan du
skickar in.
</p>
)}
{hasErrors && !localBlocked && (
<p className="text-sm text-muted-foreground">
Valideringsfelen ovan måste åtgärdas innan deklarationen kan lämnas in.
</p>
)}
</div>
<DestructiveConfirmDialog {...dialogProps} />
</section>
)
}