Files
accounted/components/salary/AGIPanel.tsx
T
MattssonandClaude Fable 5 b6332e9ff4 Fix/skv connection flow (#1015)
* feat(salary): one-click AGI submission with filing state machine and success feedback

The AGI panel required users to know that "Ladda ner AGI-fil" was the
generate step, then click submit, signing link, and kvittens manually.
A nollkorning filing stalled on "AGI-XML saknas" pointing at a UI path
that does not exist.

- New primary button "Lamna in till Skatteverket" chains the existing
  endpoints client-side: generate XML if missing, POST underlag, poll
  kontrollresultat, create signing link, open Mina Sidor in a tab opened
  synchronously at click (popup-blocker safe). Inline stepper shows each
  step; the four old buttons become collapsed advanced/recovery actions,
  auto-expanded in stale-draft and rejected states. XML download stays
  visible and free for manual filing.
- deriveAgiFilingState() + useAgiSubmission() lift the per-period
  submission record to the run page: the progress rail and salary hero
  now render the real state machine (generated, underlag inskickat,
  vantar pa BankID-signatur, inlamnad med kvittensnummer) instead of
  telling users to "lamna in" an already-submitted declaration.
- Success card with kvittensnummer and signature metadata once signed,
  plus a toast when a poll flips the state while the page is open.
- AGI kvittens cron every 15 min instead of every 2 h so filings signed
  on another device get stamped and emailed promptly.
- Advanced submit also auto-generates, and the stale "Lon -> AGI ->
  Generera" error text now points at the real buttons.

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

* fix(enable-banking): instant OAuth callback feedback and dead-attempt cleanup

The bank redirect landed on a blank page for the several seconds the
callback spent exchanging the PSD2 session and mirroring accounts, and
every failed connect attempt left a status='error' row that rendered
forever as an "Atgard kravs" card next to a successful retry, showing
duplicate connections to the same bank.

- Stream a branded "Slutfor bankanslutningen" progress page from the
  callback: the shell flushes before the session exchange starts and a
  script/meta redirect follows when the work completes, with a 30s
  slow-work escape hatch. Fast outcomes (denial, bad params, unknown
  state) keep their plain redirects.
- Delete never-activated connection rows (no session_id, no
  accounts_data) on denial or exchange failure, and sweep leftovers for
  the same bank on the next connect. Established connections keep their
  "Atgard krävs" card via the accounts_data guard; FKs are ON DELETE
  SET NULL so deletion has no dependents.
- Show "Banken ar ansluten: hamtar dina konton" while the settings
  panel loads after the callback instead of an anonymous spinner.

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

* fix(invoices): reject re-send of issued invoices and gate bookkeeping on the sent flip

A direct POST to /api/invoices/[id]/send against an already-issued
invoice re-emailed the customer and posted a second revenue verifikat
(createInvoiceJournalEntry has no dedup), overwriting journal_entry_id
and orphaning the first entry. Only the UI hid the button; the v1 route
and the MCP commit executor already rejected non-drafts.

- Non-draft invoices now return 409 INVOICE_ALREADY_SENT.
- The draft to sent status flip is an optimistic lock (status guard plus
  row-count check); journal entry, accrual schedules, PDF archival and
  the invoice.sent event only run for the request that won the flip.
- On a flip failure the journal entry is deferred: the row stays draft
  and a retry re-runs the pipeline, ending with exactly one verifikat.

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

* fix(invoices): payment links, failure visibility and sandbox guard for recurring auto-send

- sendInvoiceFromSchedule now auto-creates an online payment link via
  applyPaymentLinkToInvoice before rendering and passes the payment
  link QR to the PDF: parity with the dashboard and v1 send routes,
  which recurring invoices silently lacked.
- The recurring cron persists last_run_warning both when a claimed run
  throws (hourly retries stay visible on the schedule) and when a stale
  schedule is rolled forward, so a deterministic failure can no longer
  skip a month silently.
- Auto-send is blocked for sandbox companies at the email chokepoint
  (freeze-and-retain: the invoice is still generated as a draft),
  covering both the cron and the run-now route with one guard.

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

* feat(salary): close the Fortnox payroll API gaps (phases 1-4)

Payroll now runs end-to-end through the open API, including onboarding a
client from another payroll system, with every write staged for approval.

- v1: per-employee payslips (list/detail/PDF), payslip line writes,
  run roster attach/remove, absence ranges (per-day storage), jamkning
  fields, cutover opening balances (single + atomic bulk PUT), vacation
  balance + vacation-year-close. PUT added to the wrapper's idempotency/
  test-key set (test keys could otherwise write through PUT).
- MCP: 10 new tools (get_employee/get_payslip/list_absence/
  get_vacation_balance reads + staged update_payslip_line,
  register_absence, create_employee, update_employee,
  set_employee_opening_balances, close_vacation_year), executors, risk
  tiers, op-type CHECK expansions. create_employee encrypts personnummer
  at staging: pending_operations never holds plaintext.
- Scope-map audit retrofit: 11 formerly unmapped tools now scoped;
  BREAKING for keys that relied on the 4 default-allow writes.
- Cutover: employee_opening_balances (derived lock trigger, self-unlocks
  on run correction), engine YTD/karens/liability integration,
  Ingaende saldon section in the employee editor.
- Arbetsschema-lite: employees.hours_per_week/workdays_per_week drive the
  hourly/daily divisors; legacy 173/21 preserved exactly at defaults so
  existing pay math is byte-identical.
- Vacation ledger + semesterberedning/arsavslut: recomputed per-year day
  balances (synced on book/correct, non-fatal), year-close with the
  min-20 floor, 5-year sparade-dagar expiry to forced payout, and a
  2920/2940 drift adjustment via the bookkeeping engine; Semester
  dashboard card with preview-then-confirm dialog.
- Fix: Zod 4 defaults leak through .partial(), which made every sparse
  employee PATCH fail validation and reset defaulted columns.

Migrations 20260713100000/101000/110000/121000/122000 (applied to
staging with version rows; prod via merge). vacation_ledger renamed from
20260713120000 to avoid colliding with vat_declaration_totals_rpc.

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

* perf: cut dashboard page-load latency (region, round trips, caching, VAT RPC)

The dominant cost was infrastructure: Vercel functions ran in iad1
(Washington D.C.) while Supabase (DB + auth) lives in eu-north-1
(Stockholm), so every request paid 4-5 transatlantic round trips of
auth + company resolution before doing any real work (measured
530-1900ms for single-query GETs in prod logs). Pin functions to arn1
and cut the redundant work on top:

- vercel.json: functions to arn1, same city as the database
- getActiveCompanyId: preference + first-membership queries run in
  parallel; the fallback result doubles as validation in the common
  single-company case (one round trip instead of two sequential)
- withRouteContext: Server-Timing header and authMs/companyMs/handlerMs
  in the op-completed log, so latency is attributable per phase
- dashboard layout: nav badge counts off the critical path; DashboardNav
  loads them client-side via the new use-worklist-badges SWR hook with
  debounced realtime revalidation
- swr (new dependency, approved): global provider; useCompanySettings
  shares one cache entry across consumers and renders from cache on
  back-navigation instead of re-showing skeletons
- /pending: realtime refetch debounced; bulk operations previously
  fired 4 requests per row-change event
- VAT declaration: new get_vat_declaration_totals RPC returns
  per-account totals, settlement-shape detection (#984) and
  source_type counts in ONE round trip instead of paging every
  entry+line through PostgREST. Account lists stay TS-side parameters
  so ACCOUNT_RUTA remains the single source of truth. Shape-exclusion
  coverage moved to tests/pg/vat-declaration-totals-rpc.pg.test.ts;
  DDL already applied to staging.
- bundle: CommandPalette lazy-mounts on first Ctrl/Cmd+K, AgentChat
  dynamic-imports the markdown parser, @vercel/speed-insights (new
  dependency, approved) added for real-user timings

The /salary fetch-waterfall fix from the same effort already landed
inside 2084a756.

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

* fix(invoices): settle öre-rounded payments from the mark-paid flow

An invoice with öresavrundning shows a rounded "Att betala" on the PDF;
the customer pays that amount (up to 50 öre off the stored öre total) and
the invoice-page mark-paid flow rejected it with
MATCH_AMOUNT_EXCEEDS_REMAINING: a dead end, while the bank-transaction
match flow already absorbed the residual to 3740.

- PaymentBookingDialog now proposes the rounded bank leg plus the 3740
  residual line (credit when rounded up, debit when rounded down),
  resolved via getDisplayTotal from the per-invoice override and
  company_settings.ore_rounding.
- settleInvoicePayment and the v1 mark-paid route absorb the sub-krona
  residual, gated by planInvoicePaymentForLines: absorption applies ONLY
  when the caller lines carry the exact residual on 3740; otherwise the
  strict plan applies (sub-krona partials stay partial, no-3740
  overshoots keep the 400), so the GL can never diverge from the AR
  sub-ledger.
- planInvoicePayment absorb-band boundary tightened to >= 1 kr: an
  exactly-1-kr overshoot used to slip past both the guard and the absorb
  branch and silently over-record paid_amount (pre-existing on the
  bank-match path).

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

* fix(security): resolve all 7 PR compliance findings

- ASVS V3.3: per-request CSP nonce on the enable-banking finalize page
  (mirrors the mcp-oauth consent page); inline scripts are nonce-bound
- ASVS V16: decouple callback finalize work from the response stream
  (eager promise + next/server after()) so a client disconnect cannot
  drop session persistence or the consent_granted audit emit
- ISO 27001 A.8.15: failed audit-event emits log through the structured
  logger with a stable message for log-based alerting
- ASVS V2.3: recurring-invoice cron and run-now routes resolve
  isSandboxCompany themselves and pass an explicit suppressAutoSend flag
  (defence in depth around the email chokepoint, freeze-and-retain kept)
- ISO 27001 A.8.11: stagePendingOperation rejects plaintext
  personnummer-bearing keys in params/preview_data (key-based guard;
  EF org numbers make value-matching unsafe)
- ASVS V4.5: employee PATCH body is truly sparse; cleared number fields
  are omitted instead of resetting DB values to hardcoded fallbacks
- ASVS V8.2.1: route-level tests pin the v1 cross-company deny (404 by
  convention, not 403) on the payslip PDF endpoint

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

* feat: implement vacation-year basis change validation and error handling

- Added tests to block vacation-year basis changes when open balances exist.
- Implemented error handling for open-balances guard query failures in the settings route.
- Enhanced absence route to reject reversed date ranges with a validation error.
- Updated absence handling to use atomic upserts instead of delete+insert for better performance and reliability.
- Refactored salary calculation logic to correctly handle age-based avgifter rates according to Skatteverket's rules.
- Improved error messaging for vacation year closure adjustments.
- Adjusted employee opening balances handling to preserve audit information during upserts.

* feat(settings): add validation to block vacation-year basis change with open balances

feat(absence): reject reversed date ranges in absence queries

fix(absence): update absence handling to use atomic upserts instead of delete+insert

fix(employee): improve validation for jamkning dates in employee updates

fix(opening-balances): ensure created_by field is preserved during upserts

test(absence): enhance tests for absence range and date validations

test(calculation): add tests for age-based avgifter rates and edge cases

test(semesterberedning): validate vacation year closure adjustments and error handling

test(employee-opening-balances): update tests to reflect changes in salary_run_employees schema

* fix(migrations): implement NOT VALID constraints for pending_operations and add validation migration

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 22:54:33 +02:00

1261 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import {
AlertCircle,
CheckCircle2,
Circle,
Download,
ExternalLink,
Link2,
Link2Off,
Loader2,
Lock,
PlugZap,
Send,
ShieldAlert,
Unlock,
} from 'lucide-react'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import { useToast } from '@/components/ui/use-toast'
import { UpgradeNote } from '@/components/billing/UpgradeNote'
import { useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
import type { AgiSubmissionState } from '@/lib/salary/agi-submission-state'
interface AGIPanelProps {
salaryRunId: string
/** Skatteverket arbetsgivare ID (12-digit): formatted by parent. */
arbetsgivare: string
/** YYYYMM */
period: string
/** Already-cached run-level signals for showing what step we're at. */
agiGeneratedAt?: string | null
agiSubmittedAt?: string | null
/**
* Per-period submission record, owned by the parent (via useAgiSubmission)
* so the progress rail and hero can render the same state machine.
*/
submission: AgiSubmissionState | null
/** Refetch the submission record after a state-changing action. */
onRefreshSubmission: () => void
/** When true, write actions are hidden. */
readOnly?: boolean
/** Called after a state-changing action so parent can refresh. */
onChange?: () => void
}
interface ConnectionStatus {
connected: boolean
expired?: boolean
canRefresh?: boolean
scope?: string
expiresAt?: string
}
/**
* Per-rule validation finding from Skatteverket's kontrollresultat. Maps to
* either a kontrollfel item (per-period) or a top-level fel item. We
* normalize both into one shape for rendering.
*/
interface KontrollFinding {
kod?: string // textNyckel/kontrollnyckel from kontrollfel
status: 'STOPP' | 'ARENDE' | 'WARNING'
beskrivning: string // felmeddelande
uppgiftsTyp?: string // 'HU' | 'IU' | 'FU'
specifikationsnummer?: number
identifierare?: string
}
/** Subset of SkatteverketAGIKontrollresultat we use in the panel. */
interface Kontrollresultat {
status: 'PROCESSING' | 'DONE_SUCCESS' | 'DONE_FAILED' | 'DONE_REJECTED'
kontrollrapport?: {
bearbetningsfel?: Array<{ felmeddelande: string }>
valideringsfel?: Array<{ felmeddelande: string }>
redovisningsperioder?: Array<{
perioder: Array<{
kontrollfel: Array<{
textNyckel?: string
kontrollnyckel?: string
felmeddelande: string
felstatus: 'STOPP' | 'ARENDE'
uppgiftsTyp?: string
specifikationsnummer?: number
identifierare?: string
}>
}>
}>
}
}
const ENABLED_KEY = 'EXTENSION_DISABLED'
/** One-click chain steps, in execution order. */
const CHAIN_STEPS = ['generate', 'submit', 'kontroll', 'link'] as const
type ChainStep = (typeof CHAIN_STEPS)[number]
interface ChainProgress {
current: ChainStep
failed: boolean
done: boolean
}
/**
* Sentinel for chain aborts where the failing step already surfaced its
* error via setError/setKontroller: the catch block must not overwrite it.
*/
class ChainFailed extends Error {}
/**
* Extract a human message from either the canonical { error: { message } }
* envelope (internal routes) or a plain { error: string } (extension routes).
*/
function errText(data: unknown): string | null {
if (!data || typeof data !== 'object') return null
const err = (data as { error?: unknown }).error
if (typeof err === 'string') return err
if (err && typeof err === 'object' && typeof (err as { message?: unknown }).message === 'string') {
return (err as { message: string }).message
}
return null
}
export function AGIPanel(props: AGIPanelProps) {
const {
salaryRunId,
arbetsgivare,
period,
agiGeneratedAt,
agiSubmittedAt,
submission,
onRefreshSubmission,
readOnly,
onChange,
} = props
const t = useTranslations('salary_agi')
const { toast } = useToast()
const hasSkatteverket = useCapability(CAPABILITY.skatteverket)
const [extensionDisabled, setExtensionDisabled] = useState(false)
const [status, setStatus] = useState<ConnectionStatus | null>(null)
const [kontroller, setKontroller] = useState<KontrollFinding[]>([])
const [loading, setLoading] = useState(true)
const [actionLoading, setActionLoading] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [chain, setChain] = useState<ChainProgress | null>(null)
const [showAdvanced, setShowAdvanced] = useState(false)
// "2026-06" for user-facing copy; the period prop is compact YYYYMM.
const prettyPeriod = `${period.slice(0, 4)}-${period.slice(4)}`
// ── Derived filing state (needed by hooks, so derived before any return) ──
const subState = submission?.status
const awaitingSigning = subState === 'awaiting_signing'
const underlagSubmitted = subState === 'underlag_submitted'
const underlagRejected = subState === 'underlag_rejected'
const isSigned = subState === 'signed' || !!agiSubmittedAt
// The submission state is keyed by PERIOD; AGI generation is keyed by RUN.
// If the run's AGI was (re)generated AFTER this signing draft was created,
// the locked underlag at Skatteverket reflects superseded figures and must
// not be signed: surface a warning and steer the user to unlock + resubmit
// rather than presenting it as ready to sign (avoids filing stale amounts).
const draftUpdatedAt = submission?.updatedAt ? new Date(submission.updatedAt) : null
const draftIsStale =
awaitingSigning &&
!!agiGeneratedAt &&
!!draftUpdatedAt &&
!Number.isNaN(draftUpdatedAt.getTime()) &&
new Date(agiGeneratedAt).getTime() > draftUpdatedAt.getTime()
const fetchStatus = useCallback(async () => {
setLoading(true)
try {
const res = await fetch('/api/extensions/ext/skatteverket/status')
if (res.status === 503) {
const data = await res.json().catch(() => ({}))
if (data?.code === ENABLED_KEY) {
setExtensionDisabled(true)
return
}
}
if (res.ok) {
const next = await res.json() as ConnectionStatus
setStatus(next)
// Clear stale session-expired error after a successful reconnect.
// The browser bfcache can restore React state from before the OAuth
// round-trip, leaving the old "Sessionen har gått ut" message in
// place even though the token is now fresh. This wipes the error
// only when (a) there's currently an error and (b) the new status
// says we're healthy: never silently swallowing unrelated errors.
const isHealthy = next.connected && !next.expired && next.canRefresh !== false
if (isHealthy) {
setError(prev =>
prev && /sessionen har gått ut|logga in med bankid igen/i.test(prev)
? null
: prev,
)
}
}
} catch {
// ignore: UI shows the not-connected state
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchStatus()
}, [fetchStatus])
// Handle of the OAuth popup opened by handleConnect: used to verify the
// sender identity of incoming postMessages.
const popupRef = useRef<Window | null>(null)
// Listen for OAuth completion from the BankID popup. When the popup posts
// back a success/error message we re-fetch status so the panel flips from
// "expired" / not-connected to "Ansluten" without a full page reload.
useEffect(() => {
function handleMessage(event: MessageEvent) {
if (event.origin !== window.location.origin) return
// Source-identity check: only the popup this component opened can
// trigger the handler; a window reference cannot be forged by other
// same-origin scripts.
if (!popupRef.current || event.source !== popupRef.current) return
if (event.data?.type === 'skatteverket-oauth-success') {
setError(null)
setSuccess(t('oauth_success'))
fetchStatus()
// Verified success: rebroadcast as an internal DOM event so passive
// consumers (e.g. the salary page) can react without trusting raw
// postMessage.
window.dispatchEvent(new CustomEvent('skatteverket-connection-updated'))
} else if (event.data?.type === 'skatteverket-oauth-error') {
const reason =
typeof event.data.reason === 'string' && event.data.reason
? event.data.reason
: t('oauth_error_fallback')
setError(reason)
}
}
window.addEventListener('message', handleMessage)
return () => window.removeEventListener('message', handleMessage)
}, [fetchStatus, t])
// Drop a stale "AGI-XML saknas" error once the run's AGI is (re)generated.
// That error is set when "Skicka in underlag" runs before the XML exists; if
// the file is then generated out-of-band (MCP, the download button, another
// tab) the parent refreshes `agiGeneratedAt` and this clears the now-wrong
// message without forcing a full reload, mirroring the session-expired
// self-heal in fetchStatus above.
useEffect(() => {
if (!agiGeneratedAt) return
setError(prev =>
prev && /agi-xml saknas|inte genererats/i.test(prev) ? null : prev,
)
}, [agiGeneratedAt])
// Loud success when the filing completes: a poll (live timers, tab refocus,
// or the parent's refresh) flips isSigned while the user is on the page.
// The ref starts null so an already-signed run doesn't toast on mount.
const prevSignedRef = useRef<boolean | null>(null)
useEffect(() => {
if (prevSignedRef.current === false && isSigned) {
toast({
title: t('toast_signed_title'),
description: t('toast_signed_description', { period: prettyPeriod }),
})
}
prevSignedRef.current = isSigned
}, [isSigned, toast, t, prettyPeriod])
// Background kvittens-polling timers (see scheduleKvittensPolls below).
// Held in a ref so the unmount-cleanup effect can cancel them if the
// user leaves the page mid-signing.
const kvittensTimers = useRef<ReturnType<typeof setTimeout>[]>([])
useEffect(() => {
return () => {
for (const t of kvittensTimers.current) clearTimeout(t)
kvittensTimers.current = []
}
}, [])
/**
* Silently ask Skatteverket whether this period's granskningsunderlag has
* been signed. The kvittenser handler stamps salary_runs.agi_submitted_at
* and flips the local submission state to 'signed' the instant it sees a
* uuidKvittens, so a positive result transitions the panel out of
* awaiting_signing on its own (the action buttons then disappear via the
* isSigned gate). Returns true iff a signed kvittens was observed. No-ops
* (returns false) until we have the arbetsgivare id.
*
* Shared by the post-link background timers (scheduleKvittensPolls) and the
* auto-detect effect that runs on mount / tab refocus.
*/
const checkKvittens = useCallback(async (): Promise<boolean> => {
if (!arbetsgivare) return false
try {
const res = await fetch(
`/api/extensions/ext/skatteverket/agi/kvittenser?arbetsgivare=${encodeURIComponent(arbetsgivare)}&period=${period}`,
)
if (!res.ok) return false
const json = await res.json()
const signed = !!json.data?.kvittenser?.[0]?.uuidKvittens
onRefreshSubmission()
if (signed) {
// Replace any lingering "Granskningsunderlag klart…" / stale error
// with an unambiguous confirmation. Mirrors handleCheckSubmitted.
setError(null)
setSuccess(t('signed_success'))
onChange?.()
}
return signed
} catch {
return false
}
}, [arbetsgivare, period, onRefreshSubmission, onChange, t])
/**
* Background-poll /agi/kvittenser at 30s, 2 min, and 5 min after the user
* receives a signing link: a timer-based fallback to the focus-driven
* auto-detect below. The kvittenser handler stamps salary_runs.agi_submitted_at
* when it observes a uuidKvittens, critical for the audit trail (BFL 5 kap /
* BFNAR 2013:2): a NULL agi_submitted_at after a real filing would
* misrepresent the behandlingshistorik. Stops scheduling once observed.
*/
const scheduleKvittensPolls = useCallback(() => {
for (const t of kvittensTimers.current) clearTimeout(t)
kvittensTimers.current = []
const poll = async () => {
// checkKvittens is silent on failure: the "Hämta kvittens" button
// remains the explicit recovery path.
const signed = await checkKvittens()
if (signed) {
// Cancel any remaining timers: the kvittens has been recorded
// server-side and further polls are wasted requests.
for (const t of kvittensTimers.current) clearTimeout(t)
kvittensTimers.current = []
}
}
kvittensTimers.current.push(setTimeout(poll, 30_000))
kvittensTimers.current.push(setTimeout(poll, 120_000))
kvittensTimers.current.push(setTimeout(poll, 300_000))
}, [checkKvittens])
// Auto-detect a Mina Sidor BankID signature so the panel reflects "signed"
// without the user having to click "Hämta kvittens". While we sit in
// awaiting_signing the user has typically opened the signing link (which
// opens a new tab), signed on Skatteverket's site, and come back. We re-check
// the kvittens (a) once on entering awaiting_signing (covering a reload
// after signing) and (b) whenever the tab regains focus (covering the
// sign-in-the-other-tab-then-return flow). A found kvittens flips the local
// state to 'signed', hiding the signing actions. The ref makes the on-enter
// check fire once per episode even if checkKvittens's identity churns (its
// onChange dep is an unmemoized parent callback).
const signCheckedRef = useRef(false)
useEffect(() => {
if (submission?.status !== 'awaiting_signing') {
signCheckedRef.current = false
return
}
if (!signCheckedRef.current) {
signCheckedRef.current = true
checkKvittens()
}
function onVisible() {
if (document.visibilityState === 'visible') checkKvittens()
}
document.addEventListener('visibilitychange', onVisible)
return () => document.removeEventListener('visibilitychange', onVisible)
}, [submission?.status, checkKvittens])
const handleDisconnect = useCallback(async () => {
setActionLoading('disconnect')
setError(null)
setSuccess(null)
try {
const res = await fetch('/api/extensions/ext/skatteverket/disconnect', {
method: 'POST',
})
if (!res.ok) {
const json = await res.json().catch(() => ({}))
setError(json.error || t('disconnect_failed_status', { status: res.status }))
return
}
setSuccess(t('disconnect_success'))
await fetchStatus()
onRefreshSubmission()
} catch (e) {
setError(e instanceof Error ? e.message : t('disconnect_failed'))
} finally {
setActionLoading(null)
}
}, [fetchStatus, onRefreshSubmission, t])
const handleConnect = () => {
// Open the BankID OAuth flow in a centered popup. The callback page
// detects `window.opener` and posts back a `skatteverket-oauth-success`
// (or `-error`) message, then closes itself: see the postMessage
// listener below. `return_to` is still passed so the popup-less fallback
// path (e.g. popup blockers) lands on the salary run page rather than
// the default /reports tab.
const returnTo = typeof window !== 'undefined'
? window.location.pathname + window.location.search
: ''
const url = `/api/extensions/ext/skatteverket/authorize${
returnTo ? `?return_to=${encodeURIComponent(returnTo)}` : ''
}`
const w = 600
const h = 750
const left = window.screenX + (window.outerWidth - w) / 2
const top = window.screenY + (window.outerHeight - h) / 2
const popup = window.open(
url,
'skatteverket-oauth',
`width=${w},height=${h},left=${left},top=${top}`,
)
popupRef.current = popup
if (!popup) {
// Popup blocked: fall back to a full-page navigation.
window.location.href = url
}
}
/**
* Flatten a kontrollresultat response into a list of findings the panel
* can render. We surface validering+bearbetningsfel and per-period
* kontrollfel under one shape so the UI doesn't need to walk three nested
* arrays per render.
*/
function extractFindings(kr: Kontrollresultat | undefined): KontrollFinding[] {
if (!kr?.kontrollrapport) return []
const out: KontrollFinding[] = []
for (const f of kr.kontrollrapport.bearbetningsfel ?? []) {
out.push({ status: 'STOPP', beskrivning: f.felmeddelande })
}
for (const f of kr.kontrollrapport.valideringsfel ?? []) {
out.push({ status: 'STOPP', beskrivning: f.felmeddelande })
}
for (const rp of kr.kontrollrapport.redovisningsperioder ?? []) {
for (const p of rp.perioder ?? []) {
for (const kf of p.kontrollfel ?? []) {
out.push({
kod: kf.textNyckel ?? kf.kontrollnyckel,
status: kf.felstatus,
beskrivning: kf.felmeddelande,
uppgiftsTyp: kf.uppgiftsTyp,
specifikationsnummer: kf.specifikationsnummer,
identifierare: kf.identifierare,
})
}
}
}
return out
}
/**
* The XML must exist in agi_declarations before anything can be submitted.
* The internal xml route both generates and persists it (and stamps
* agi_generated_at); the response body, the downloadable file itself, is
* discarded here: "Ladda ner AGI-fil" remains the way to get a copy.
*/
async function ensureAgiGenerated(): Promise<boolean> {
if (agiGeneratedAt) return true
const res = await fetch(`/api/salary/runs/${salaryRunId}/agi/xml`)
if (!res.ok) {
const data = await res.json().catch(() => ({}))
setError(errText(data) || t('xml_generate_failed'))
return false
}
onChange?.() // parent refetches the run so agiGeneratedAt flips
return true
}
/**
* POST the stored XML underlag, then poll kontrollresultat until status
* flips out of PROCESSING. Skatteverket's spec says polling is usually
* instantaneous, but we cap at 8 attempts × 1s to be safe.
*
* On DONE_SUCCESS the underlag is auto-persisted by SKV: no /spara call.
* Calling /spara when there are no errors returns 400 felkod 20
* ("Inlämningen är redan sparad/borttagen eller innehöll inga felaktiga
* underlag") because /spara is specifically for re-persisting rejected
* underlag so the user can fix them later in Mina Sidor. Successful
* underlag move straight to the granskningsunderlag step.
*
* On DONE_REJECTED we surface the validation findings; the user can still
* choose to save (so they can fix it in Mina Sidor) or abort.
*
* Failures surface via setError/setKontroller and return false. Shared by
* the one-click chain and the advanced "Skicka in underlag" button;
* `onKontrollPhase` lets the chain advance its stepper when polling starts.
*/
async function runSubmitUnderlag(onKontrollPhase?: () => void): Promise<boolean> {
setKontroller([])
const submitRes = await fetch('/api/extensions/ext/skatteverket/agi/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ salaryRunId }),
})
const submitJson = await submitRes.json()
if (!submitRes.ok || submitJson.error) {
setError(submitJson.error || t('submit_failed_status', { status: submitRes.status }))
return false
}
const inlamningId = submitJson.data?.inlamningId as number | undefined
if (!inlamningId) {
setError(t('submit_missing_id'))
return false
}
// Poll kontrollresultat until DONE_*
onKontrollPhase?.()
let kr: Kontrollresultat | undefined
for (let attempt = 0; attempt < 8; attempt++) {
const krRes = await fetch(
`/api/extensions/ext/skatteverket/agi/kontrollresultat?inlamningId=${inlamningId}`,
)
const krJson = await krRes.json()
if (!krRes.ok || krJson.error) {
setError(krJson.error || t('kontrollresultat_failed_status', { status: krRes.status }))
return false
}
kr = krJson.data as Kontrollresultat
if (kr.status !== 'PROCESSING') break
await new Promise(r => setTimeout(r, 1000))
}
if (!kr || kr.status === 'PROCESSING') {
setError(t('still_processing'))
return false
}
const findings = extractFindings(kr)
setKontroller(findings)
if (kr.status === 'DONE_SUCCESS') return true
if (kr.status === 'DONE_REJECTED') {
setError(t('underlag_rejected_error', { count: findings.filter(f => f.status === 'STOPP').length }))
} else {
setError(t('underlag_failed'))
}
return false
}
/**
* skapaGranskningsunderlag: returns the Mina Sidor deep-link the user opens
* to sign with BankID. Defaults to `lasPeriod=true` so the period is locked
* while the signing window is open. Returns the link on success ('' when
* the response carried none: the signing-link card renders it after the
* submission refresh) and null on failure (error already surfaced).
*/
async function runCreateSigningLink(): Promise<string | null> {
const res = await fetch(
`/api/extensions/ext/skatteverket/agi/granskningsunderlag?arbetsgivare=${encodeURIComponent(arbetsgivare)}&period=${period}`,
{ method: 'POST' },
)
const json = await res.json()
if (!res.ok || json.error) {
setError(json.error || t('signing_link_failed_status', { status: res.status }))
return null
}
if (json.data?.tillstand === 'INCORRECT_DATA') {
setError(t('incorrect_data_error', { message: json.data.meddelande || t('incorrect_data_fallback') }))
return null
}
// The user typically opens the link, signs in Mina Sidor, then returns
// later (or never). Auto-poll so we capture the kvittens (and stamp
// agi_submitted_at) without forcing the user to click "Hämta kvittens".
scheduleKvittensPolls()
return typeof json.data?.link === 'string' ? json.data.link : ''
}
/**
* One-click filing: generate (if missing) → POST underlag → poll kontroll →
* create signing link → hand over to BankID signing in Mina Sidor.
*
* The signing link opens in a tab we open synchronously at click time:
* window.open after the async chain would be popup-blocked. On failure the
* placeholder tab is closed and the error renders in the panel; if the
* popup was blocked outright, the signing-link card (rendered from the
* refreshed submission state) is the fallback path.
*/
const handleSubmitChain = async () => {
setActionLoading('chain')
setError(null)
setSuccess(null)
let signingTab: Window | null = null
try {
signingTab = window.open('', '_blank')
if (signingTab) {
signingTab.document.title = t('chain_tab_title')
signingTab.document.body.textContent = t('chain_tab_body')
}
} catch {
signingTab = null
}
try {
setChain({ current: 'generate', failed: false, done: false })
if (!(await ensureAgiGenerated())) throw new ChainFailed()
setChain({ current: 'submit', failed: false, done: false })
const submitted = await runSubmitUnderlag(() =>
setChain({ current: 'kontroll', failed: false, done: false }),
)
if (!submitted) throw new ChainFailed()
setChain({ current: 'link', failed: false, done: false })
const link = await runCreateSigningLink()
if (link === null) throw new ChainFailed()
setChain({ current: 'link', failed: false, done: true })
setSuccess(t('chain_ready_to_sign'))
if (signingTab && link) {
signingTab.location.replace(link)
signingTab = null // handed over to Skatteverket: don't close it below
} else {
signingTab?.close()
signingTab = null
}
onRefreshSubmission()
onChange?.()
} catch (e) {
signingTab?.close()
setChain(prev => (prev ? { ...prev, failed: true } : prev))
if (!(e instanceof ChainFailed)) {
setError(e instanceof Error ? e.message : t('submit_failed'))
}
onRefreshSubmission()
} finally {
setActionLoading(null)
}
}
// Always-free: generate + download the AGI XML so the user can file manually
// in Skatteverket's e-service. AGI is a mandatory statutory filing, so this
// path must never be paywalled: only the direct API submission is paid.
const handleDownloadXml = async () => {
setActionLoading('download')
setError(null)
try {
const res = await fetch(`/api/salary/runs/${salaryRunId}/agi/xml`)
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(errText(data) || t('xml_generate_failed'))
}
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `AGI_${period ?? 'underlag'}.xml`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
onChange?.()
} catch (e) {
setError(e instanceof Error ? e.message : t('xml_download_failed'))
} finally {
setActionLoading(null)
}
}
/** Advanced/recovery variant: submit the underlag without continuing the chain. */
const handleSubmit = async () => {
setActionLoading('submit')
setError(null)
setSuccess(null)
try {
if (!(await ensureAgiGenerated())) return
const ok = await runSubmitUnderlag()
if (ok) setSuccess(t('underlag_accepted'))
onRefreshSubmission()
onChange?.()
} catch (e) {
setError(e instanceof Error ? e.message : t('submit_failed'))
} finally {
setActionLoading(null)
}
}
/** Advanced/recovery variant: create the signing link on its own. */
const handleCreateSigningLink = async () => {
setActionLoading('granskning')
setError(null)
setSuccess(null)
try {
const link = await runCreateSigningLink()
if (link !== null) setSuccess(t('signing_link_ready'))
onRefreshSubmission()
} catch (e) {
setError(e instanceof Error ? e.message : t('signing_link_failed'))
} finally {
setActionLoading(null)
}
}
const handleUnlock = async () => {
setActionLoading('unlock')
setError(null)
setSuccess(null)
try {
const res = await fetch(
`/api/extensions/ext/skatteverket/agi/lasUpp?arbetsgivare=${encodeURIComponent(arbetsgivare)}&period=${period}`,
{ method: 'POST' },
)
const json = await res.json()
if (!res.ok || json.error) {
setError(json.error || t('unlock_failed_status', { status: res.status }))
return
}
setSuccess(t('unlock_success'))
onRefreshSubmission()
} catch (e) {
setError(e instanceof Error ? e.message : t('unlock_failed'))
} finally {
setActionLoading(null)
}
}
/**
* Post-signing recovery: poll /agi/kvittenser to detect that the user has
* signed in Mina Sidor. Once a kvittens turns up, the index.ts handler
* mirrors it onto agi_declarations and flips the local submission state
* to 'signed'.
*/
const handleCheckSubmitted = async () => {
setActionLoading('check')
setError(null)
setSuccess(null)
try {
const res = await fetch(
`/api/extensions/ext/skatteverket/agi/kvittenser?arbetsgivare=${encodeURIComponent(arbetsgivare)}&period=${period}`,
)
const json = await res.json()
if (!res.ok || json.error) {
setError(json.error || t('kvittens_fetch_failed'))
return
}
const kvittens = json.data?.kvittenser?.[0]
if (kvittens?.uuidKvittens) {
setSuccess(t('signed_success'))
} else {
setSuccess(t('no_kvittens_yet'))
}
onRefreshSubmission()
onChange?.()
} catch (e) {
setError(e instanceof Error ? e.message : t('check_status_failed'))
} finally {
setActionLoading(null)
}
}
// ── Render branches ─────────────────────────────────────────────
if (extensionDisabled) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm text-muted-foreground">
<div className="flex items-start gap-2">
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0" />
<p>
{t('disabled_before')}
<code className="mx-1 rounded bg-muted px-1 py-0.5 text-xs">SKATTEVERKET_ENABLED</code>
{t('disabled_after')}
</p>
</div>
</CardContent>
</Card>
)
}
if (loading) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('title')}</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
<Loader2 className="mr-2 inline h-4 w-4 animate-spin" /> {t('loading_status')}
</CardContent>
</Card>
)
}
if (!status?.connected) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-muted-foreground">
{t('connect_description')}
</p>
{!readOnly && (
<Button onClick={handleConnect}>
<Link2 className="mr-2 h-4 w-4" />
{t('connect_button')}
</Button>
)}
</CardContent>
</Card>
)
}
// Tokens issued before the agd scope was added to DEFAULT_SCOPES will
// 403 with invalid_scope at submission time: surface that proactively
// so the user reconnects before hitting the deadline rather than at it.
const missingAgdScope =
typeof status?.scope === 'string' &&
!status.scope.split(/\s+/).filter(Boolean).includes('agd')
// Recovery states expose the advanced actions on their own: the stale-draft
// and error-report guidance below reference them by name.
const forcedAdvanced = draftIsStale || underlagRejected
const advancedOpen = showAdvanced || forcedAdvanced
const signedAtRaw = submission?.signeradTid ?? agiSubmittedAt ?? null
const signedAtText = signedAtRaw ? new Date(signedAtRaw).toLocaleString('sv-SE') : null
const chainStepState = (step: ChainStep): 'done' | 'running' | 'failed' | 'upcoming' => {
if (!chain) return 'upcoming'
const idx = CHAIN_STEPS.indexOf(step)
const currentIdx = CHAIN_STEPS.indexOf(chain.current)
if (idx < currentIdx || (idx === currentIdx && chain.done)) return 'done'
if (idx === currentIdx) return chain.failed ? 'failed' : 'running'
return 'upcoming'
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between text-base">
<span>{t('title')}</span>
<span className="flex items-center gap-2 text-xs font-normal text-muted-foreground">
<span className="flex items-center gap-1">
<CheckCircle2 className="h-3.5 w-3.5 text-success" />
{t('connected')}
</span>
{!readOnly && (
<button
type="button"
onClick={handleDisconnect}
disabled={actionLoading === 'disconnect'}
className="inline-flex items-center gap-1 rounded border border-border px-1.5 py-0.5 text-[11px] font-normal text-muted-foreground transition-colors hover:border-destructive/50 hover:text-destructive disabled:cursor-not-allowed disabled:opacity-50"
title={t('disconnect_title')}
>
{actionLoading === 'disconnect' ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<PlugZap className="h-3 w-3" />
)}
{t('disconnect_button')}
</button>
)}
</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Filed: the terminal state deserves more than a gray status row.
Kvittensnummer + signature metadata come from the submission
record; a run stamped only via agi_submitted_at (e.g. cron
reconciliation with an evicted cache) still gets the card. */}
{isSigned && (
<div className="rounded-md border border-border bg-muted/30 p-4">
<div className="flex items-start gap-3">
<CheckCircle2 className="mt-0.5 h-5 w-5 shrink-0 text-success" />
<div className="space-y-1">
<p className="text-sm font-medium">
{t('success_card_title', { period: prettyPeriod })}
</p>
{submission?.kvittensnummer && (
<p className="text-sm text-muted-foreground tabular-nums">
{t('success_card_kvittens', { kvittens: submission.kvittensnummer })}
</p>
)}
{(submission?.signeradAv || signedAtText) && (
<p className="text-sm text-muted-foreground">
{submission?.signeradAv
? signedAtText
? t('success_card_signed_by_at', {
name: submission.signeradAv,
date: signedAtText,
})
: t('success_card_signed_by', { name: submission.signeradAv })
: t('success_card_signed_at', { date: signedAtText ?? '' })}
</p>
)}
</div>
</div>
</div>
)}
{/* Expired-session banner: the token row exists (so status.connected
is true) but the access token is past expiry and either has no
refresh token or has burned through its 10-refresh budget. The
only fix is a fresh BankID round-trip. */}
{(status?.expired === true || status?.canRefresh === false) && !readOnly && (
<div className="rounded-md border border-border bg-muted/30 p-3">
<p className="text-sm font-medium">{t('expired_banner_title')}</p>
<p className="mt-1 text-xs text-muted-foreground">
{t('expired_banner_description')}
</p>
<Button size="sm" variant="outline" className="mt-2" onClick={handleConnect}>
<Link2 className="mr-1.5 h-3.5 w-3.5" />
{t('reconnect_button')}
</Button>
</div>
)}
{/* Missing-scope banner: proactive nudge before the user hits a
403 invalid_scope at submission time. The agd scope was added
after some users had already connected, so their stored token
grants moms/skattekonto but not AGI. */}
{missingAgdScope && !readOnly && (
<div className="rounded-md border border-border bg-muted/30 p-3">
<p className="text-sm font-medium">
{t('missing_scope_title')}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{t('missing_scope_description')}
</p>
<a
href="/settings/tax"
className="mt-2 inline-flex items-center gap-1 text-sm font-medium hover:underline"
>
{t('open_settings')} <ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
)}
{/* Status summary */}
<div className="space-y-1.5 text-sm">
<StatusRow
ok={!!agiGeneratedAt}
okText={agiGeneratedAt ? t('file_generated', { date: new Date(agiGeneratedAt).toLocaleString('sv-SE') }) : ''}
pendingText={t('file_not_generated')}
/>
<StatusRow
ok={isSigned}
okText={
submission?.kvittensnummer
? t('submitted_with_kvittens', { kvittens: submission.kvittensnummer })
: agiSubmittedAt
? t('submitted_at', { date: new Date(agiSubmittedAt).toLocaleString('sv-SE') })
: t('submitted')
}
pendingText={
awaitingSigning
? draftIsStale
? t('pending_stale_draft')
: t('pending_awaiting_signature')
: underlagSubmitted
? t('pending_underlag_submitted')
: t('pending_not_submitted')
}
/>
</div>
{/* Signing link: only shown for the happy path. The link in
`signeringslank` is also reused by the INCORRECT_DATA branch
below to surface a felrapport URL, which deserves a distinct
treatment so the user understands they must fix errors before
BankID signing is even possible. */}
{submission?.signeringslank && awaitingSigning && !draftIsStale && (
<div className="rounded-md border border-border bg-muted/30 p-3">
<p className="text-sm font-medium">
<InfoTooltip variant="help" content={t('granskningsunderlag_gloss')}>
{t('draft_locked_title')}
</InfoTooltip>
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{t('draft_locked_description')}
</p>
<a
href={submission.signeringslank}
target="_blank"
rel="noreferrer"
className="mt-2 inline-flex items-center gap-1 text-sm font-medium hover:underline"
>
{t('open_signing_link')} <ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
)}
{/* Stale-draft guard: the signing draft at Skatteverket predates the
current run's AGI generation, so it carries superseded figures.
We deliberately do NOT surface "Öppna signeringslänk" here: signing
it would file the old amounts. The "Lås upp" button below releases
the SKV lock; the user then re-submits the freshly generated XML. */}
{awaitingSigning && draftIsStale && (
<div className="rounded-md border border-border bg-muted/30 p-3">
<p className="text-sm font-medium">{t('stale_draft_title')}</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{t('stale_draft_description', {
generatedAt: agiGeneratedAt ? new Date(agiGeneratedAt).toLocaleString('sv-SE') : '',
draftCreatedAt: submission?.updatedAt
? ` (${new Date(submission.updatedAt).toLocaleString('sv-SE')})`
: '',
})}{' '}
{t('stale_draft_click')}{' '}
<span className="font-medium">{t('unlock_button')}</span>{' '}
{t('stale_draft_then')}{' '}
<span className="font-medium">{t('submit_button')}</span>{' '}
{t('stale_draft_to_sign')}
</p>
</div>
)}
{/* INCORRECT_DATA branch: skapaGranskningsunderlag returned 409 with
a felrapport link. The user must open the link in Mina Sidor to
see what's wrong, fix it, and then re-submit. Without this UI the
link would be permanently unreachable even though the extension
persisted it. */}
{submission?.signeringslank && underlagRejected && (
<div className="rounded-md border border-destructive/40 bg-destructive/5 p-3">
<p className="text-sm font-medium text-destructive">
{t('incorrect_data_title')}
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{submission.meddelande || t('incorrect_data_description')}
</p>
<a
href={submission.signeringslank}
target="_blank"
rel="noreferrer"
className="mt-2 inline-flex items-center gap-1 text-sm font-medium text-destructive hover:underline"
>
{t('open_error_report')} <ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
)}
{kontroller.length > 0 && (
<div className="space-y-1 rounded-md border bg-muted/30 p-3">
{kontroller.map((k, i) => (
<div
key={i}
className={`flex items-start gap-2 text-xs ${
k.status === 'STOPP' ? 'text-destructive' : 'text-warning'
}`}
>
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>
{k.kod && <span className="font-mono">{k.kod} </span>}
{k.uppgiftsTyp && <span className="text-muted-foreground">[{k.uppgiftsTyp}{k.specifikationsnummer ? ` #${k.specifikationsnummer}` : ''}] </span>}
{k.beskrivning}
</span>
</div>
))}
</div>
)}
{error && (() => {
// When the underlying token is expired or its refresh budget is
// exhausted, the only fix is for the user to re-do the BankID OAuth
// flow. Surface a reconnect button right next to the error so they
// don't have to hunt for it in settings.
const sessionExpired =
/sessionen har gått ut|logga in med bankid igen/i.test(error) ||
status?.expired === true ||
status?.canRefresh === false
return (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
<AlertCircle className="mr-1 inline h-3.5 w-3.5" />
{error}
{sessionExpired && !readOnly && (
<div className="mt-2">
<Button size="sm" variant="outline" onClick={handleConnect}>
<Link2 className="mr-1.5 h-3.5 w-3.5" />
{t('reconnect_button')}
</Button>
</div>
)}
</div>
)
})()}
{success && !error && (
<div className="rounded-md border border-border bg-muted/30 p-3 text-sm">
<CheckCircle2 className="mr-1 inline h-3.5 w-3.5 text-success" />
{success}
</div>
)}
{!readOnly && !isSigned && (
<div className="space-y-3">
{/* Primary path: one click runs the whole filing chain. Hidden
while a signing draft is open at SKV (the period is locked,
so a resubmission would be refused): the signing-link card
above is the CTA then, and the stale-draft recovery goes
through the advanced actions per the guidance text. The
XML download stays free for manual filing regardless. */}
<div className="flex flex-wrap items-center gap-2">
{!awaitingSigning && (
<Button
onClick={handleSubmitChain}
disabled={actionLoading !== null || !hasSkatteverket}
>
{actionLoading === 'chain' ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Send className="mr-2 h-4 w-4" />
)}
{t('chain_button')}
</Button>
)}
<Button
size="sm"
variant="outline"
onClick={handleDownloadXml}
disabled={actionLoading === 'download'}
title={t('download_xml_title')}
>
{actionLoading === 'download' ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Download className="mr-1.5 h-3.5 w-3.5" />
)}
{t('download_xml_button')}
</Button>
</div>
{chain && (
<ol className="space-y-1.5 rounded-md border border-border bg-muted/30 p-3">
{CHAIN_STEPS.map(step => {
const state = chainStepState(step)
return (
<li key={step} className="flex items-center gap-2 text-xs">
{state === 'done' ? (
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-success" />
) : state === 'running' ? (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" />
) : state === 'failed' ? (
<AlertCircle className="h-3.5 w-3.5 shrink-0 text-destructive" />
) : (
<Circle className="h-3.5 w-3.5 shrink-0 text-muted-foreground/50" />
)}
<span className={state === 'upcoming' ? 'text-muted-foreground' : ''}>
{t(`chain_step_${step}`)}
</span>
</li>
)
})}
</ol>
)}
{/* Recovery/expert actions: each is one step of the chain above,
for resuming after a partial failure. Auto-expanded when a
recovery state (stale draft, rejected underlag) references
them by name. */}
<div>
{!forcedAdvanced && (
<button
type="button"
onClick={() => setShowAdvanced(v => !v)}
className="text-xs text-muted-foreground transition-colors hover:text-foreground"
>
{advancedOpen ? t('advanced_hide') : t('advanced_show')}
</button>
)}
{advancedOpen && (
<div className="mt-2 flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
onClick={handleSubmit}
disabled={actionLoading !== null || !hasSkatteverket}
>
{actionLoading === 'submit' ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Send className="mr-1.5 h-3.5 w-3.5" />
)}
{t('submit_button')}
</Button>
<Button
size="sm"
variant="outline"
onClick={handleCreateSigningLink}
disabled={actionLoading !== null || !underlagSubmitted}
>
{actionLoading === 'granskning' ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Lock className="mr-1.5 h-3.5 w-3.5" />
)}
{t('signing_link_button')}
</Button>
<Button
size="sm"
variant="ghost"
onClick={handleCheckSubmitted}
disabled={actionLoading !== null}
>
{actionLoading === 'check' ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Download className="mr-1.5 h-3.5 w-3.5" />
)}
{t('check_kvittens_button')}
</Button>
{awaitingSigning && (
<Button
size="sm"
variant="ghost"
onClick={handleUnlock}
disabled={actionLoading !== null}
>
{actionLoading === 'unlock' ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Unlock className="mr-1.5 h-3.5 w-3.5" />
)}
{t('unlock_button')}
</Button>
)}
</div>
)}
</div>
</div>
)}
{!readOnly && !isSigned && !hasSkatteverket && (
<UpgradeNote>{t('upgrade_note')}</UpgradeNote>
)}
</CardContent>
</Card>
)
}
function StatusRow({
ok,
okText,
pendingText,
}: {
ok: boolean
okText: string
pendingText: string
}) {
return (
<div className="flex items-center gap-2">
{ok ? (
<CheckCircle2 className="h-4 w-4 text-success" />
) : (
<Link2Off className="h-4 w-4 text-muted-foreground" />
)}
<span className="text-muted-foreground">{ok ? okText : pendingText}</span>
</div>
)
}