* 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>
1261 lines
48 KiB
TypeScript
1261 lines
48 KiB
TypeScript
'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>
|
||
)
|
||
}
|