Files
accounted/components/extensions/general/ArcimMigrationWorkspace.tsx
T
Mattsson 43925bc2d3 fix(import): SIE bulk-delete on service client + provider/reporting/b… (#724)
* fix(import): SIE bulk-delete on service client + provider/reporting/banking fixes

Rebuilt branch onto main as a single commit.

- import: run SIE bulk-delete RPCs on the service client to escape the 8s
  statement_timeout; undo_sie_import now takes an explicit actor (p_user_id)
  so its owner/admin gate works when auth.uid() is NULL on the service
  client (migration 20260624120000) + pg-real regression test
- providers: distinguish missing Fortnox license from expired connection;
  provider_consent_tokens PK regression test
- reports: include unmapped BAS expense groups in the income statement
- enable-banking: reconnect closed/expired bank sessions in place
- bookkeeping: surface linked invoices as underlag on the verifikat view
- scripts: track BL cleanup/diagnostic tooling; data files (*.csv) are
  git-ignored and consentId is now a required arg with no silent default

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(import): add Cache-Control header to journal entry references response

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:40:26 +02:00

2318 lines
92 KiB
TypeScript

'use client'
import { useState, useCallback, useEffect } from 'react'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Progress } from '@/components/ui/progress'
import { Button, buttonVariants } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { useToast } from '@/components/ui/use-toast'
import { cn } from '@/lib/utils'
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
import Link from 'next/link'
import { FallbackPrompt } from '@/components/ui/fallback-prompt'
import { getBranding } from '@/lib/branding/service'
const branding = getBranding()
import {
ArrowLeft,
ArrowRight,
Loader2,
AlertCircle,
CheckCircle,
Building2,
Users,
Truck,
FileText,
Database,
ExternalLink,
Info,
RotateCcw,
RefreshCw,
AlertTriangle,
ChevronDown,
ChevronRight,
Calendar,
XCircle,
BookOpen,
} from 'lucide-react'
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
type ArcimProvider = 'fortnox' | 'visma' | 'briox' | 'bokio' | 'bjornlunden'
// `sieViaApi`: the provider serves its general ledger as SIE over the API —
// no manual SIE upload needed. Deliberately duplicated from
// extensions/general/arcim-migration/types.ts (core code must not import from
// @/extensions/ — CI enforces it). Keep both lists in sync.
const ARCIM_PROVIDERS: { id: ArcimProvider; name: string; authType: 'oauth' | 'token'; sieViaApi: boolean }[] = [
{ id: 'fortnox', name: 'Fortnox', authType: 'oauth', sieViaApi: true },
{ id: 'visma', name: 'Visma', authType: 'oauth', sieViaApi: false },
{ id: 'bokio', name: 'Bokio', authType: 'token', sieViaApi: false },
{ id: 'bjornlunden', name: 'Björn Lundén', authType: 'token', sieViaApi: true },
{ id: 'briox', name: 'Briox', authType: 'token', sieViaApi: true },
]
/**
* Extract a human-readable message from an API error body. Routes answer in
* two shapes: legacy `{ error: 'text' }` and the structured envelope
* `{ error: { code, message } }` — naively rendering the latter shows
* "[object Object]".
*/
function apiErrorMessage(data: unknown, fallback: string): string {
const err = (data as { error?: unknown } | null)?.error
if (typeof err === 'string' && err) return err
if (err && typeof err === 'object') {
const message = (err as { message?: unknown }).message
if (typeof message === 'string' && message) return message
}
return fallback
}
/** Pull the structured error `code` from an envelope, if present. */
function apiErrorCode(data: unknown): string | null {
const err = (data as { error?: unknown } | null)?.error
if (err && typeof err === 'object') {
const code = (err as { code?: unknown }).code
if (typeof code === 'string' && code) return code
}
return null
}
interface SkipReasons {
duplicate?: number
inactive?: number
failed?: number
noMatch?: number
}
interface MigrationResults {
companyInfo?: { imported: boolean }
customers?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons }
suppliers?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons }
salesInvoices?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons }
supplierInvoices?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons }
}
import AccountMappingStep from '@/components/import/AccountMappingStep'
import type { AccountMapping, ImportResult, ParsedSIEFile } from '@/lib/import/types'
import type { BASAccount } from '@/types'
// ── Types ────────────────────────────────────────────────────────
type WizardStep = 'provider' | 'connect' | 'preview' | 'mapping' | 'options' | 'migrating' | 'result'
const STEPS: WizardStep[] = ['provider', 'connect', 'preview', 'mapping', 'options', 'migrating', 'result']
const STEP_LABELS: Record<WizardStep, string> = {
provider: 'Välj system',
connect: 'Anslut',
preview: 'Förhandsgranskning',
mapping: 'Kontomappning',
options: 'Alternativ',
migrating: 'Migrerar',
result: 'Resultat',
}
const MONTH_NAMES = [
'Januari', 'Februari', 'Mars', 'April', 'Maj', 'Juni',
'Juli', 'Augusti', 'September', 'Oktober', 'November', 'December',
]
interface MigrationOptions {
importCompanyInfo: boolean
importSIEData: boolean
importCustomers: boolean
importSuppliers: boolean
importSalesInvoices: boolean
importSupplierInvoices: boolean
voucherSeries: string
}
const DEFAULT_OPTIONS: MigrationOptions = {
importCompanyInfo: true,
importSIEData: true,
importCustomers: true,
importSuppliers: true,
importSalesInvoices: true,
importSupplierInvoices: true,
voucherSeries: 'B',
}
interface PreviewData {
consent: {
id: string
provider: ArcimProvider
status: number
companyName?: string
}
companyInfo: {
company_name: string | null
org_number: string | null
vat_number: string | null
fiscal_year_start_month: number
address_line1: string | null
postal_code: string | null
city: string | null
phone: string | null
email: string | null
} | null
sieAvailable: boolean
sieStats: {
accountCount: number
transactionCount: number
fiscalYears: number[]
} | null
hasSieData: boolean
}
interface SIEFileStatus {
fiscalYear: number
// Legacy field for older builds — read previousImport instead.
alreadyImported: boolean
importedAt: string | null
// New (period-based) detection. When present, this fiscal year already has a
// completed import in Accounted and a re-sync will replace it (cancelling the
// imported journal entries; user-created entries are untouched).
previousImport: {
importedAt: string | null
fiscalYearStart: string | null
fiscalYearEnd: string | null
} | null
}
interface SIEData {
parsed: ParsedSIEFile
mappings: AccountMapping[]
mappingStats: { total: number; mapped: number; unmapped: number }
rawContent: string[]
fileStatuses: SIEFileStatus[]
allImported: boolean
newFileCount: number
replacedFileCount?: number
// Fiscal years whose provider export failed. Importing the remaining years
// anyway leaves an IB/UB gap — the options step warns before proceeding.
failedYears?: { year: number; error: string }[]
basAccounts: BASAccount[]
}
// ── Provider selection step ──────────────────────────────────────
interface ConnectionStatus {
consents: {
id: string
provider: ArcimProvider
status: number
companyName?: string
createdAt?: string
}[]
sieImports: {
id: string
filename: string
status: string
accounts_count: number | null
transactions_count: number | null
company_name: string | null
fiscal_year_start: string | null
fiscal_year_end: string | null
imported_at: string | null
created_at: string
}[]
entityCounts: {
customers: number
suppliers: number
invoices: number
}
}
const COMING_SOON_PROVIDERS = new Set<ArcimProvider>([])
const PROVIDER_LOGOS: Record<ArcimProvider, string> = {
fortnox: '/logos/fortnox.svg',
visma: '/logos/visma.jpeg',
bokio: '/logos/bokio.png',
bjornlunden: '/logos/bjornlunden.png',
briox: '/logos/Briox_logo.png',
}
function ProviderStep({
onSelect,
onResync,
onDisconnect,
connectionStatus,
isLoadingStatus,
}: {
onSelect: (provider: ArcimProvider) => void
onResync: (provider: ArcimProvider, consentId: string) => void
onDisconnect: (consentId: string) => void
connectionStatus: ConnectionStatus | null
isLoadingStatus: boolean
}) {
const activeConsents = connectionStatus?.consents.filter(c => c.status === 1) ?? []
const hasSieImport = (connectionStatus?.sieImports.filter(i => i.status === 'completed').length ?? 0) > 0
const sieViaApi = (id: ArcimProvider) => ARCIM_PROVIDERS.find(p => p.id === id)?.sieViaApi === true
const allSieViaApi = activeConsents.length > 0 && activeConsents.every(c => sieViaApi(c.provider))
const showSieRequiredBanner = !isLoadingStatus && !hasSieImport && !allSieViaApi
return (
<div className="space-y-4">
{/* SIE-required banner (not relevant for Fortnox/Briox — they fetch SIE via API) */}
{showSieRequiredBanner && (
<div className="flex gap-3 rounded-lg border border-amber-500/30 bg-amber-500/5 p-4">
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-amber-600 dark:text-amber-500" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">SIE-import krävs först</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Bokio och Visma hämtar endast kunder, leverantörer och fakturor via API:et. Bokföringsdata (kontoplan, verifikationer och balanser) måste importeras via SIE-fil först. Gäller inte Fortnox, Briox och Björn Lundén där hämtar vi SIE direkt via API:et.
</p>
<Link
href="/import?mode=sie"
className={cn(buttonVariants({ variant: 'outline', size: 'sm' }), 'mt-3')}
>
<BookOpen className="mr-2 h-4 w-4" />
Ladda upp SIE-fil
<ExternalLink className="ml-2 h-3.5 w-3.5" />
</Link>
</div>
</div>
)}
{/* Existing connections */}
{activeConsents.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Aktiva anslutningar</CardTitle>
<CardDescription>
Du har redan anslutna leverantörer. Synka igen för att hämta ny data.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{activeConsents.map((consent) => {
const providerInfo = ARCIM_PROVIDERS.find(p => p.id === consent.provider)
const completedImports = connectionStatus?.sieImports.filter(i => i.status === 'completed') ?? []
const lastImport = completedImports[0]
return (
<div
key={consent.id}
className="rounded-lg border border-border bg-card p-4"
>
<div className="flex items-start gap-3 sm:items-center sm:gap-4">
<img
src={PROVIDER_LOGOS[consent.provider]}
alt={providerInfo?.name ?? consent.provider}
className="h-10 w-10 shrink-0 rounded-lg object-contain"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="font-medium">{providerInfo?.name ?? consent.provider}</p>
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-600 dark:text-emerald-400">
<CheckCircle className="h-3 w-3" />
Ansluten
</span>
</div>
<div className="mt-0.5 space-y-0.5">
{consent.companyName && (
<p className="text-xs text-muted-foreground">{consent.companyName}</p>
)}
{lastImport ? (
<p className="text-xs text-muted-foreground">
Senaste import: {new Date(lastImport.imported_at ?? lastImport.created_at).toLocaleDateString('sv-SE')}
{lastImport.transactions_count != null && ` — ${lastImport.transactions_count} verifikationer`}
</p>
) : (
<p className="text-xs text-muted-foreground">
Ansluten {consent.createdAt ? new Date(consent.createdAt).toLocaleDateString('sv-SE') : ''}
</p>
)}
{(connectionStatus?.entityCounts.customers ?? 0) > 0 && (
<p className="text-xs text-muted-foreground">
{connectionStatus?.entityCounts.customers} kunder, {connectionStatus?.entityCounts.suppliers} leverantörer, {connectionStatus?.entityCounts.invoices} fakturor
</p>
)}
</div>
</div>
<Button
variant="ghost"
size="sm"
className="hidden shrink-0 text-muted-foreground hover:text-destructive sm:inline-flex"
onClick={() => onDisconnect(consent.id)}
>
<XCircle className="h-3.5 w-3.5" />
</Button>
</div>
<div className="mt-3 flex items-center gap-2 sm:mt-0 sm:pl-[52px]">
<Button
variant="outline"
size="sm"
className="flex-1 sm:flex-none"
onClick={() => onResync(consent.provider, consent.id)}
>
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
Synka igen
</Button>
<Button
variant="ghost"
size="sm"
className="shrink-0 text-muted-foreground hover:text-destructive sm:hidden"
onClick={() => onDisconnect(consent.id)}
>
<XCircle className="h-3.5 w-3.5" />
</Button>
</div>
</div>
)
})}
</CardContent>
</Card>
)}
{/* Provider selection */}
<Card>
<CardHeader>
<CardTitle>{activeConsents.length > 0 ? 'Anslut ytterligare system' : 'Välj ditt nuvarande bokföringssystem'}</CardTitle>
<CardDescription>
Vi hämtar bokföringsdata via SIE och kunder, leverantörer och fakturor via API:et.
</CardDescription>
</CardHeader>
<CardContent>
{isLoadingStatus ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{ARCIM_PROVIDERS.map((provider) => {
const comingSoon = COMING_SOON_PROVIDERS.has(provider.id)
const alreadyConnected = activeConsents.some(c => c.provider === provider.id)
// Providers without SIE-over-API only expose entity data
// (customers, suppliers, invoices) — the ledger must arrive via
// SIE upload first. Gate the connection entry until a completed
// SIE import exists so users don't authenticate into a flow that
// can't import anything yet. The /migrate route enforces this
// server-side regardless; this is just the matching UX.
const needsSieFirst = !hasSieImport && !provider.sieViaApi
const isDisabled = comingSoon || alreadyConnected || needsSieFirst
return (
<button
key={provider.id}
disabled={isDisabled}
className={`relative flex items-center gap-4 rounded-lg border p-4 text-left transition-all ${
isDisabled
? 'cursor-not-allowed border-border/50 opacity-60'
: 'border-border hover:border-primary/50 hover:bg-accent/50 active:scale-[0.98]'
}`}
onClick={() => !isDisabled && onSelect(provider.id)}
>
<img
src={PROVIDER_LOGOS[provider.id]}
alt={provider.name}
className="h-10 w-10 shrink-0 rounded-lg object-contain"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="font-medium">{provider.name}</p>
{comingSoon && (
<span className="inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
Kommer snart
</span>
)}
{alreadyConnected && (
<span className="inline-flex items-center rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-600 dark:text-emerald-400">
Ansluten
</span>
)}
{needsSieFirst && !comingSoon && !alreadyConnected && (
<span className="inline-flex items-center rounded-full bg-amber-500/10 px-2 py-0.5 text-[10px] font-medium text-amber-600 dark:text-amber-500">
SIE krävs först
</span>
)}
</div>
<p className="text-xs text-muted-foreground">
{alreadyConnected
? 'Använd "Synka igen" ovan'
: needsSieFirst
? 'Importera SIE-fil först'
: provider.authType === 'oauth'
? 'Anslut via inloggning'
: provider.id === 'bjornlunden'
? 'Anslut med företagsnyckel'
: 'Anslut med API-nyckel'}
</p>
</div>
</button>
)
})}
</div>
)}
</CardContent>
</Card>
</div>
)
}
// ── Connect step (OAuth redirect or token input) ────────────────
function ConnectStep({
provider,
authType,
isLoading,
error,
authUrl,
consentId,
onTokenSubmit,
onBack,
}: {
provider: ArcimProvider
authType: 'oauth' | 'token' | null
isLoading: boolean
error: string | null
authUrl: string | null
consentId: string | null
onTokenSubmit: (apiToken: string, companyId: string) => void
onBack: () => void
}) {
const providerName = ARCIM_PROVIDERS.find(p => p.id === provider)?.name ?? provider
const [apiToken, setApiToken] = useState('')
const [companyId, setCompanyId] = useState('')
// BL uses server-side client credentials — only needs company ID, no API key
const isClientCredentials = provider === 'bjornlunden'
const needsApiToken = !isClientCredentials
// Briox: the account ID is the `clientid` half of the token exchange
const needsCompanyId = provider === 'bokio' || provider === 'bjornlunden' || provider === 'briox'
const companyIdLabel = provider === 'briox'
? 'Konto-ID'
: provider === 'bjornlunden'
? 'Företagsnyckel (User-Key)'
: 'Företags-ID'
const tokenDescription = isClientCredentials
? `Ange din företagsnyckel (User-Key) från Björn Lundén. ${branding.appName.toLowerCase()} ansluter automatiskt via sin integrationspartner-åtkomst.`
: provider === 'briox'
? `Ange ditt konto-ID och din applikationstoken från Briox för att ge ${branding.appName.toLowerCase()} tillgång att läsa din bokföringsdata.`
: `Ange din API-nyckel från ${providerName} för att ge ${branding.appName.toLowerCase()} tillgång att läsa din bokföringsdata.`
const tokenHelpText = isClientCredentials
? `Företagsnyckeln (User-Key) är ett GUID som du hittar i Lundify under Integrationer → kugghjulet vid integrationen, eller i aktiveringsmejlet från Björn Lundén.`
: provider === 'bokio'
? `Du hittar din API-nyckel i ${providerName} under Inställningar \u2192 Integrationer \u2192 API. Ditt företags-ID är det GUID som syns i URL:en när du är inloggad, t.ex. https://app.bokio.se/ditt-företags-id/settings-r/private-integrations.`
: provider === 'briox'
? `Skapa din applikationstoken i Briox under Admin \u2192 Anv\u00e4ndare \u2192 kugghjulet vid din anv\u00e4ndare \u2192 Applikationstoken. Ditt konto-ID \u00e4r det l\u00e5nga numret inom parentes bredvid f\u00f6retagsnamnet under "Ditt konto" i menyn till h\u00f6ger.`
: `Du hittar din applikationstoken i ${providerName} under Administration \u2192 Integrationer.`
const canSubmit = isClientCredentials
? !!companyId
: !!(apiToken && (!needsCompanyId || companyId))
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Anslut till {providerName}</CardTitle>
<CardDescription>
{authType === 'token'
? tokenDescription
: `Logga in i ${providerName} för att ge ${branding.appName.toLowerCase()} tillgång att läsa din bokföringsdata.`
}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{isLoading && (
<div className="flex items-center gap-3 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
<p>Förbereder anslutning...</p>
</div>
)}
{error && (
<>
<div className="flex gap-3 rounded-lg border border-destructive/20 bg-destructive/10 p-4">
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
<div>
<p className="font-medium text-destructive">Anslutning misslyckades</p>
<p className="text-sm text-muted-foreground">{error}</p>
{provider === 'fortnox' && (
<p className="mt-1 text-sm text-muted-foreground">
Obs: Fortnox kräver ett aktivt integrationstillägg (tillkostnadsbelagd tilläggstjänst) för att kunna använda integrationer. Kontrollera att detta är aktiverat i ditt Fortnox-konto.
</p>
)}
</div>
</div>
<FallbackPrompt
message="Du kan också importera din bokföringsdata manuellt via en SIE-fil."
linkHref="/import?mode=sie"
linkLabel="Ladda upp SIE-fil"
/>
</>
)}
{/* OAuth flow */}
{authType === 'oauth' && authUrl && !isLoading && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Klicka nedan för att logga in i {providerName}.
Fönstret stängs automatiskt när du är klar.
</p>
<Button
className="min-h-11"
onClick={() => {
const w = 600
const h = 700
const left = window.screenX + (window.outerWidth - w) / 2
const top = window.screenY + (window.outerHeight - h) / 2
window.open(authUrl, 'arcim-oauth', `width=${w},height=${h},left=${left},top=${top}`)
}}
>
Logga in i {providerName}
<ExternalLink className="ml-2 h-4 w-4" />
</Button>
</div>
)}
{/* Token-based flow */}
{authType === 'token' && consentId && !isLoading && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
{tokenHelpText}
</p>
<div className="space-y-3">
{needsApiToken && (
<div>
<label htmlFor="apiToken" className="text-sm font-medium">
{provider === 'briox' ? 'Applikationstoken' : 'API-nyckel'}
</label>
<Input
id="apiToken"
name="apiToken_nocomplete"
type="password"
autoComplete="new-password"
placeholder={provider === 'briox' ? 'Klistra in din applikationstoken' : 'Klistra in din API-nyckel'}
value={apiToken}
onChange={(e) => setApiToken(e.target.value)}
/>
</div>
)}
{needsCompanyId && (
<div>
<label htmlFor="companyId" className="text-sm font-medium">
{companyIdLabel}
</label>
<Input
id="companyId"
name="companyId_nocomplete"
autoComplete="new-password"
placeholder={
isClientCredentials
? 'Företagsnyckel, t.ex. 1f0e2d3c-4b5a-...'
: provider === 'briox'
? 'Det långa numret inom parentes, t.ex. 35649125'
: 'GUID från URL:en, t.ex. 14ccad83-67f6-49bd-...'
}
value={companyId}
onChange={(e) => setCompanyId(e.target.value)}
/>
</div>
)}
<Button
className="min-h-11"
onClick={() => onTokenSubmit(apiToken, companyId)}
disabled={!canSubmit}
>
Anslut
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</div>
)}
</CardContent>
</Card>
<div className="flex">
<Button variant="outline" className="min-h-11" onClick={onBack}>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka
</Button>
</div>
</div>
)
}
// ── Preview step ────────────────────────────────────────────────
function PreviewStep({
preview,
isLoading,
error,
authExpired,
licenseMissing,
onReconnect,
onContinue,
onBack,
}: {
preview: PreviewData | null
isLoading: boolean
error: string | null
authExpired: boolean
licenseMissing: boolean
onReconnect: () => void
onContinue: () => void
onBack: () => void
}) {
const providerName = preview
? ARCIM_PROVIDERS.find(p => p.id === preview.consent.provider)?.name ?? preview.consent.provider
: ''
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Anslutet till {providerName}</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{isLoading && (
<div className="flex items-center gap-3 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
<p>Hämtar bokföringsdata...</p>
</div>
)}
{error && (
<>
<div className="flex gap-3 rounded-lg border border-destructive/20 bg-destructive/10 p-4">
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
<div className="space-y-3">
<p className="text-sm text-muted-foreground">{error}</p>
{authExpired && (
<Button size="sm" className="min-h-9" onClick={onReconnect} disabled={isLoading}>
<RotateCcw className="mr-2 h-4 w-4" />
Återanslut {providerName}
</Button>
)}
</div>
</div>
{/* License-missing keeps the SIE fallback visible: re-auth loops
until the customer re-orders the Fortnox Integration license,
so a manual SIE import is the reliable escape hatch. */}
{(!authExpired || licenseMissing) && (
<FallbackPrompt
message="Du kan också importera din bokföringsdata manuellt via en SIE-fil."
linkHref="/import?mode=sie"
linkLabel="Ladda upp SIE-fil"
/>
)}
</>
)}
{/* SIE stats summary */}
{preview?.sieAvailable && preview.sieStats && (
<div className="flex gap-3 rounded-lg border border-primary/20 bg-primary/5 p-4">
<Database className="mt-0.5 h-5 w-5 shrink-0 text-primary" />
<div>
<p className="text-sm font-medium">
Hittade {preview.sieStats.accountCount} konton och {preview.sieStats.transactionCount} verifikationer
</p>
<p className="text-xs text-muted-foreground">
{preview.sieStats.fiscalYears.length === 1
? `Räkenskapsår ${preview.sieStats.fiscalYears[0]}`
: `${preview.sieStats.fiscalYears.length} räkenskapsår: ${preview.sieStats.fiscalYears.join(', ')}`
}
</p>
</div>
</div>
)}
{preview && !preview.sieAvailable && !isLoading && preview.hasSieData && (
<div className="flex gap-3 rounded-lg border border-emerald-500/20 bg-emerald-500/5 p-4">
<CheckCircle className="mt-0.5 h-5 w-5 shrink-0 text-emerald-500" />
<div>
<p className="text-sm font-medium">SIE-data redan importerad</p>
<p className="text-xs text-muted-foreground">
Bokföringsdata har redan importerats via SIE-fil. Du kan fortsätta med att importera kunder, leverantörer och fakturor.
</p>
</div>
</div>
)}
{preview && !preview.sieAvailable && !isLoading && !preview.hasSieData && (
<div className="flex gap-3 rounded-lg border border-destructive/20 bg-destructive/5 p-4">
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
<div>
<p className="text-sm font-medium text-destructive">SIE-import krävs</p>
<p className="text-xs text-muted-foreground">
Bokföringsdata (kontoplan, verifikationer och balanser) måste importeras via SIE-fil innan kunder, leverantörer och fakturor kan hämtas. Exportera en SIE-fil från {ARCIM_PROVIDERS.find(p => p.id === preview.consent.provider)?.name ?? 'ditt bokföringssystem'} och ladda upp den i {branding.appName.toLowerCase()}.
</p>
<Link
href="/import?mode=sie"
className={cn(buttonVariants({ variant: 'outline', size: 'sm' }), 'mt-3')}
>
<BookOpen className="mr-2 h-4 w-4" />
till SIE-importen
<ExternalLink className="ml-2 h-3.5 w-3.5" />
</Link>
</div>
</div>
)}
</CardContent>
</Card>
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-between">
<Button variant="outline" className="min-h-11" onClick={onBack}>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka
</Button>
<Button className="min-h-11" onClick={onContinue} disabled={isLoading || (!!preview && !preview.sieAvailable && !preview.hasSieData)}>
Fortsätt
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</div>
)
}
function InfoItem({ label, value }: { label: string; value: string | null }) {
return (
<div>
<p className="text-xs text-muted-foreground">{label}</p>
<p className="text-sm font-medium">{value || '—'}</p>
</div>
)
}
// ── Mapping step (wraps AccountMappingStep) ─────────────────────
function MappingStep({
sieData,
isLoading,
error,
errorDetails,
onMappingChange,
onContinue,
onBack,
}: {
sieData: SIEData | null
isLoading: boolean
error: string | null
errorDetails: string[] | null
onMappingChange: (sourceAccount: string, targetAccount: string, targetName: string) => void
onContinue: () => void
onBack: () => void
}) {
if (isLoading) {
return (
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
<p>Analyserar bokföringsdata och förbereder kontomappning...</p>
</div>
</CardContent>
</Card>
)
}
if (error) {
return (
<div className="space-y-4">
<Card>
<CardContent className="pt-6">
<div className="flex gap-3 rounded-lg border border-destructive/20 bg-destructive/10 p-4">
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
<div className="min-w-0">
<p className="font-medium text-destructive">Kunde inte ladda SIE-data</p>
<p className="text-sm text-muted-foreground">{error}</p>
{errorDetails && errorDetails.length > 0 && (
<ul className="mt-2 list-disc space-y-1 pl-4 text-sm text-muted-foreground">
{errorDetails.slice(0, 8).map((detail, i) => (
<li key={i} className="break-words">{detail}</li>
))}
{errorDetails.length > 8 && (
<li> och {errorDetails.length - 8} fel till</li>
)}
</ul>
)}
</div>
</div>
</CardContent>
</Card>
<FallbackPrompt
message="Om problemet kvarstår kan du importera din SIE-fil manuellt istället."
linkHref="/import?mode=sie"
linkLabel="Ladda upp SIE-fil"
/>
<Button variant="outline" className="min-h-11" onClick={onBack}>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka
</Button>
</div>
)
}
if (!sieData) return null
return (
<AccountMappingStep
mappings={sieData.mappings}
basAccounts={sieData.basAccounts}
onMappingChange={onMappingChange}
onContinue={onContinue}
onBack={onBack}
/>
)
}
// ── Options step ────────────────────────────────────────────────
function OptionsStep({
options,
sieAvailable,
sieData,
provider,
onChange,
onStart,
onBack,
}: {
options: MigrationOptions
sieAvailable: boolean
sieData: SIEData | null
provider: ArcimProvider | null
onChange: (options: MigrationOptions) => void
onStart: () => void
onBack: () => void
}) {
const [showConfirm, setShowConfirm] = useState(false)
const toggleOption = (key: keyof MigrationOptions) => {
onChange({ ...options, [key]: !options[key] })
}
const fileStatuses = sieData?.fileStatuses ?? []
const newFileCount = sieData?.newFileCount ?? 0
const replacedFileCount = fileStatuses.filter(fs => fs.previousImport).length
const yearsToReplace = fileStatuses
.filter(fs => fs.previousImport)
.map(fs => fs.fiscalYear)
const failedYears = sieData?.failedYears ?? []
const selectedItems: string[] = []
if (options.importCompanyInfo) selectedItems.push('Företagsinformation')
if (sieAvailable && options.importSIEData) selectedItems.push('Bokföringsdata (SIE)')
if (options.importCustomers) selectedItems.push('Kunder')
if (options.importSuppliers) selectedItems.push('Leverantörer')
if (options.importSalesInvoices) selectedItems.push('Kundfakturor')
if (options.importSupplierInvoices) selectedItems.push('Leverantörsfakturor')
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Vad vill du importera?</CardTitle>
<CardDescription>
Bokföringsdata importeras via SIE-fil. Kunder, leverantörer och fakturor hämtas via API:et.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<OptionRow
icon={<Building2 className="h-4 w-4" />}
label="Företagsinformation"
description="Namn, organisationsnummer, adress"
checked={options.importCompanyInfo}
onChange={() => toggleOption('importCompanyInfo')}
/>
{sieAvailable && (
<>
{/* Years whose provider export failed — must be visible before
the user proceeds, otherwise an IB/UB gap slips through. */}
{failedYears.length > 0 && (
<div className="rounded-md border border-amber-500/30 bg-amber-50/50 p-3 dark:bg-amber-950/20">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600" />
<div className="space-y-1">
<p className="text-sm font-medium">
{failedYears.length === 1
? `Räkenskapsår ${failedYears[0].year} kunde inte hämtas`
: `Räkenskapsår ${failedYears.map(f => f.year).join(', ')} kunde inte hämtas`}
</p>
<p className="text-xs text-muted-foreground">
Exporten från källsystemet misslyckades för{' '}
{failedYears.length === 1 ? 'det här räkenskapsåret' : 'dessa räkenskapsår'}.
Om du fortsätter importeras övriga år, men ingående och utgående balanser
kan sakna kontinuitet mellan åren. Försök igen senare eller ladda upp en
SIE-fil för {failedYears.length === 1 ? 'det saknade året' : 'de saknade åren'} manuellt.
</p>
</div>
</div>
</div>
)}
<OptionRow
icon={<Database className="h-4 w-4" />}
label="Bokföringsdata (SIE)"
description={
replacedFileCount > 0 && newFileCount > 0
? `${newFileCount} nya och ${replacedFileCount} uppdaterade räkenskapsår`
: replacedFileCount > 0
? `${replacedFileCount} räkenskapsår med uppdaterad data — tidigare import ersätts`
: newFileCount > 0
? `${newFileCount} ny(a) räkenskapsår att importera`
: 'Kontoplan, ingående balanser och verifikationer'
}
checked={options.importSIEData}
onChange={() => toggleOption('importSIEData')}
/>
{/* Per-file import status */}
{fileStatuses.length > 0 && (
<div className="ml-4 space-y-1.5">
{fileStatuses.map((fs) => (
<div key={fs.fiscalYear} className="flex items-center gap-2 text-xs">
{fs.previousImport ? (
<>
<RefreshCw className="h-3.5 w-3.5 text-amber-500" />
<span className="text-muted-foreground">
Räkenskapsår {fs.fiscalYear} ersätter tidigare import
{fs.previousImport.importedAt
? ` från ${new Date(fs.previousImport.importedAt).toLocaleDateString('sv-SE')}`
: ''}
</span>
</>
) : (
<>
<Calendar className="h-3.5 w-3.5 text-primary" />
<span className="font-medium">Räkenskapsår {fs.fiscalYear} ny data att importera</span>
</>
)}
</div>
))}
</div>
)}
{options.importSIEData && (
<div className="flex items-center gap-3 rounded-lg border border-border p-3 ml-4">
<div className="text-muted-foreground">
<FileText className="h-4 w-4" />
</div>
<div className="flex-1">
<p className="text-sm font-medium">Verifikationsserie</p>
<p className="text-xs text-muted-foreground">Serie för importerade verifikationer</p>
</div>
<Input
className="w-16 text-center"
value={options.voucherSeries}
onChange={(e) => onChange({ ...options, voucherSeries: e.target.value.toUpperCase() || 'B' })}
maxLength={2}
/>
</div>
)}
</>
)}
<OptionRow
icon={<Users className="h-4 w-4" />}
label="Kunder"
description="Kund-register med kontaktuppgifter"
checked={options.importCustomers}
onChange={() => toggleOption('importCustomers')}
/>
<OptionRow
icon={<Truck className="h-4 w-4" />}
label="Leverantörer"
description="Leverantör-register med bankuppgifter"
checked={options.importSuppliers}
onChange={() => toggleOption('importSuppliers')}
/>
<OptionRow
icon={<FileText className="h-4 w-4" />}
label="Kundfakturor"
description="Alla kundfakturor (betalda och obetalda)"
checked={options.importSalesInvoices}
onChange={() => toggleOption('importSalesInvoices')}
/>
<OptionRow
icon={<FileText className="h-4 w-4" />}
label="Leverantörsfakturor"
description={provider === 'fortnox'
? 'Endast obetalda leverantörsfakturor hämtas. Historiska betalda fakturor finns kvar i Fortnox.'
: 'Alla leverantörsfakturor (betalda och obetalda)'}
checked={options.importSupplierInvoices}
onChange={() => toggleOption('importSupplierInvoices')}
/>
</CardContent>
</Card>
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-between">
<Button variant="outline" className="min-h-11" onClick={onBack}>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka
</Button>
<Button className="min-h-11" onClick={() => setShowConfirm(true)} disabled={selectedItems.length === 0}>
Starta migrering
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
<ConfirmationDialog
open={showConfirm}
onOpenChange={setShowConfirm}
onConfirm={() => {
setShowConfirm(false)
onStart()
}}
isSubmitting={false}
title="Starta migrering"
warningText={`Bokföringsdata, kunder, leverantörer och fakturor importeras till ${branding.appName.toLowerCase()}. Se till att ingen annan import pågår.`}
confirmLabel="Starta migrering"
>
<div className="space-y-3">
<div className="space-y-2">
<p className="text-sm font-medium">Följande importeras:</p>
<ul className="space-y-1">
{selectedItems.map((item) => (
<li key={item} className="flex items-center gap-2 text-sm text-muted-foreground">
<CheckCircle className="h-3.5 w-3.5 text-primary" />
{item}
</li>
))}
</ul>
</div>
{options.importSIEData && yearsToReplace.length > 0 && (
<div className="rounded-md border border-amber-500/30 bg-amber-50/50 p-3 dark:bg-amber-950/20">
<div className="flex items-start gap-2">
<RefreshCw className="mt-0.5 h-4 w-4 shrink-0 text-amber-600" />
<div className="space-y-1">
<p className="text-sm font-medium">
{yearsToReplace.length === 1
? `Räkenskapsår ${yearsToReplace[0]} ersätts`
: `Räkenskapsår ${yearsToReplace.join(', ')} ersätts`}
</p>
<p className="text-xs text-muted-foreground">
Tidigare importerade verifikationer markeras som annullerade och ersätts av
uppdaterad data från källsystemet. Verifikationer som du själv skapat i {branding.appName.toLowerCase()}
(kategoriserade banktransaktioner, fakturor m.m.) påverkas inte.
</p>
</div>
</div>
</div>
)}
</div>
</ConfirmationDialog>
</div>
)
}
function OptionRow({
icon,
label,
description,
checked,
onChange,
disabled,
}: {
icon: React.ReactNode
label: string
description: string
checked: boolean
onChange: () => void
disabled?: boolean
}) {
return (
<div
className={cn(
'flex items-center gap-3 rounded-lg border border-border p-3 transition-colors',
disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer hover:bg-accent/50'
)}
onClick={() => !disabled && onChange()}
>
<div className="text-muted-foreground">{icon}</div>
<div className="flex-1">
<p className="text-sm font-medium">{label}</p>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
<Switch
checked={checked}
onCheckedChange={() => !disabled && onChange()}
disabled={disabled}
onClick={(e) => e.stopPropagation()}
/>
</div>
)
}
// ── Migrating step (progress) ───────────────────────────────────
function MigratingStep({ currentStep, progress }: { currentStep: string; progress: number }) {
return (
<Card>
<CardHeader>
<CardTitle>Migrering pågår</CardTitle>
<CardDescription>
Vänta medan vi hämtar och importerar din bokföringsdata. Det kan ta några minuter.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1">
<div className="flex justify-end">
<span className="text-xs text-muted-foreground">{progress}%</span>
</div>
<Progress value={progress} className="h-3" />
</div>
<div className="flex items-center gap-3 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
<p className="text-sm">{currentStep}</p>
</div>
</CardContent>
</Card>
)
}
// ── Result step ─────────────────────────────────────────────────
/** Format a fiscal year label from ISO dates, e.g. "2024-01-01" → "2024" or "2024/2025" */
function formatFiscalYearLabel(start: string, end: string): string {
const startYear = start.slice(0, 4)
const endYear = end.slice(0, 4)
return startYear === endYear ? startYear : `${startYear}/${endYear}`
}
/** Determine the overall status icon and color for a single FY import */
function getFYStatus(r: ImportResult): { icon: 'success' | 'warning' | 'error'; label: string } {
if (r.errors.length > 0 && r.journalEntriesCreated === 0) {
return { icon: 'error', label: 'Misslyckades' }
}
if (r.errors.length > 0 || (r.details?.skippedVouchers && r.details.skippedVouchers.total > 0)) {
return { icon: 'warning', label: 'Delvis importerad' }
}
return { icon: 'success', label: 'Importerad' }
}
const StatusIcon = ({ status }: { status: 'success' | 'warning' | 'error' }) => {
if (status === 'error') return <XCircle className="h-4 w-4 text-destructive" />
if (status === 'warning') return <AlertTriangle className="h-4 w-4 text-amber-500" />
return <CheckCircle className="h-4 w-4 text-green-600" />
}
/** Expandable per-fiscal-year detail card */
function FiscalYearResult({ result, index }: { result: ImportResult; index: number }) {
const [expanded, setExpanded] = useState(false)
const status = getFYStatus(result)
const d = result.details
const fyLabel = d?.fiscalYear
? formatFiscalYearLabel(d.fiscalYear.start, d.fiscalYear.end)
: `Räkenskapsår ${index + 1}`
return (
<div className="rounded-lg border border-border">
{/* Header — always visible */}
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="flex w-full items-center gap-3 p-4 text-left transition-colors hover:bg-accent/50"
>
<StatusIcon status={status.icon} />
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2">
<span className="font-medium">{fyLabel}</span>
<span className={`text-sm ${
status.icon === 'error' ? 'text-destructive' :
status.icon === 'warning' ? 'text-amber-600' :
'text-muted-foreground'
}`}>
{status.label}
</span>
</div>
<p className="text-sm text-muted-foreground tabular-nums">
{result.journalEntriesCreated.toLocaleString('sv-SE')} verifikationer importerade
{d?.skippedVouchers && d.skippedVouchers.total > 0 && (
<span className="text-amber-600">
{' · '}{d.skippedVouchers.total} hoppade över
</span>
)}
{result.replacedPriorImport && result.replacedPriorImport.deletedEntries > 0 && (
<span>
{' · '}ersatte {result.replacedPriorImport.deletedEntries.toLocaleString('sv-SE')} tidigare importerade verifikationer
</span>
)}
</p>
</div>
{expanded
? <ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
: <ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
}
</button>
{/* Expanded details */}
{expanded && (
<div className="border-t border-border px-4 pb-4 pt-3 space-y-3">
{/* Errors — shown prominently */}
{result.errors.length > 0 && (
<div className="rounded-md border border-destructive/20 bg-destructive/5 p-3">
<div className="flex items-start gap-2">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
<div className="space-y-1.5">
<p className="text-sm font-medium text-destructive">
{result.errors.length === 1 ? '1 fel vid import' : `${result.errors.length} fel vid import`}
</p>
{result.errors.map((e, i) => (
<p key={i} className="text-sm text-muted-foreground">{e}</p>
))}
</div>
</div>
</div>
)}
{/* Opening balance adjustment */}
{d?.openingBalance && (
<div className="rounded-md border border-border bg-muted/30 p-3">
<div className="flex items-start gap-2">
<BookOpen className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<div>
<p className="text-sm font-medium">Ingående balanser justerade</p>
<p className="text-sm text-muted-foreground">
{d.openingBalance.explanation === 'unallocated_result' && (
<>
Differens <span className="tabular-nums font-medium">{Math.abs(d.openingBalance.imbalance).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} SEK</span> bokförd
konto {d.openingBalance.bookedToAccount}. Detta beror troligen att föregående
års resultat inte allokerats till eget kapital i källsystemet vanligt vid byte
av bokföringsprogram.
</>
)}
{d.openingBalance.explanation === 'excluded_accounts' && (
<>
Exkluderade systemkonton (t.ex. Fortnox 0099) hade ingående saldon. Differensen
(<span className="tabular-nums font-medium">{Math.abs(d.openingBalance.imbalance).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} SEK</span>)
bokförd konto {d.openingBalance.bookedToAccount}.
</>
)}
{d.openingBalance.explanation === 'rounding' && (
<>
Avrundningsdifferens (<span className="tabular-nums font-medium">{Math.abs(d.openingBalance.imbalance).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} SEK</span>)
bokförd konto {d.openingBalance.bookedToAccount}.
</>
)}
{!d.openingBalance.explanation && (
<>
Differens <span className="tabular-nums font-medium">{Math.abs(d.openingBalance.imbalance).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} SEK</span> bokförd
konto {d.openingBalance.bookedToAccount}.
</>
)}
</p>
</div>
</div>
</div>
)}
{/* Skipped vouchers breakdown */}
{d?.skippedVouchers && d.skippedVouchers.total > 0 && (
<div className="rounded-md border border-amber-200 bg-amber-50/50 p-3 dark:border-amber-900/30 dark:bg-amber-950/20">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
<div>
<p className="text-sm font-medium text-amber-700 dark:text-amber-400">
{d.skippedVouchers.total} verifikationer hoppades över
</p>
<p className="mt-1 text-sm text-muted-foreground">
Ofullständiga verifikationer i källsystemet som inte kan importeras.
Saldon har justerats automatiskt via omföringsverifikation.
</p>
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-1 text-sm text-muted-foreground tabular-nums">
{d.skippedVouchers.unbalanced > 0 && (
<div className="flex justify-between">
<span>Obalanserade</span>
<span className="font-medium">{d.skippedVouchers.unbalanced}</span>
</div>
)}
{d.skippedVouchers.unmapped > 0 && (
<div className="flex justify-between">
<span>Ej mappade konton</span>
<span className="font-medium">{d.skippedVouchers.unmapped}</span>
</div>
)}
{d.skippedVouchers.singleLine > 0 && (
<div className="flex justify-between">
<span>Enradsverifikationer</span>
<span className="font-medium">{d.skippedVouchers.singleLine}</span>
</div>
)}
{d.skippedVouchers.empty > 0 && (
<div className="flex justify-between">
<span>Tomma</span>
<span className="font-medium">{d.skippedVouchers.empty}</span>
</div>
)}
</div>
</div>
</div>
</div>
)}
{/* Migration adjustment info */}
{d?.migrationAdjustment?.created && (
<div className="rounded-md border border-border bg-muted/30 p-3">
<div className="flex items-start gap-2">
<Info className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<div>
<p className="text-sm font-medium">Omföringsverifikation skapad</p>
<p className="text-sm text-muted-foreground">
{d.migrationAdjustment.accountsAdjusted} konton justerade för att saldon ska matcha
källsystemet. Verifikationen kompenserar för hoppade verifikationer att dina
balansräkning och resultaträkning stämmer.
</p>
</div>
</div>
</div>
)}
{/* Retry info (only shown if retries happened) */}
{d && d.retriedBatches > 0 && (
<p className="text-xs text-muted-foreground">
{d.retriedBatches} {d.retriedBatches === 1 ? 'batch' : 'batcher'} behövde omförsök
{d.failedBatches > 0 && (
<span className="text-destructive">
{' · '}{d.failedBatches} misslyckades trots omförsök
</span>
)}
</p>
)}
</div>
)}
</div>
)
}
function ResultStep({
results,
sieResults,
error,
onDone,
onRetry,
}: {
results: MigrationResults | null
sieResults: ImportResult[]
error: string | null
onDone: () => void
onRetry: () => void
}) {
if (error) {
return (
<div className="space-y-4">
<Card>
<CardContent className="pt-6">
<div className="flex gap-3 rounded-lg border border-destructive/20 bg-destructive/10 p-4">
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
<div>
<p className="text-base font-medium text-destructive">Migreringen misslyckades</p>
<p className="mt-1 whitespace-pre-line text-sm text-muted-foreground">{error}</p>
</div>
</div>
</CardContent>
</Card>
<FallbackPrompt
message="Du kan istället importera din bokföringsdata manuellt via en SIE-fil."
linkHref="/import?mode=sie"
linkLabel="Ladda upp SIE-fil"
/>
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-between">
<Button variant="outline" className="min-h-11" onClick={onDone}>Klar</Button>
<Button className="min-h-11" onClick={onRetry}>
<RotateCcw className="mr-2 h-4 w-4" />
Försök igen
</Button>
</div>
</div>
)
}
const hasResults = results || sieResults.length > 0
if (!hasResults) return null
// Compute combined SIE stats
const totalJournalEntries = sieResults.reduce((sum, r) => sum + r.journalEntriesCreated, 0)
const totalErrors = sieResults.reduce((sum, r) => sum + r.errors.length, 0)
const totalSkipped = sieResults.reduce((sum, r) => (r.details?.skippedVouchers?.total || 0) + sum, 0)
const allSieSucceeded = sieResults.length > 0 && sieResults.every(r => r.success)
const anySieFailed = sieResults.some(r => r.errors.length > 0 && r.journalEntriesCreated === 0)
// Check if anything meaningful was imported via entities
// Company info is always re-fetched (upsert) so it doesn't count as "new"
const entityImported = results && (
(results.customers && (results.customers.imported > 0 || results.customers.skipped > 0)) ||
(results.suppliers && (results.suppliers.imported > 0 || results.suppliers.skipped > 0)) ||
(results.salesInvoices && (results.salesInvoices.imported > 0 || results.salesInvoices.skipped > 0)) ||
(results.supplierInvoices && (results.supplierInvoices.imported > 0 || results.supplierInvoices.skipped > 0))
)
const nothingNew = sieResults.length === 0 && !entityImported
// Overall status
const overallIcon = anySieFailed ? 'error' as const :
(!allSieSucceeded || totalErrors > 0) ? 'warning' as const : 'success' as const
return (
<div className="space-y-4">
{/* ── Header card with overall summary ── */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2.5">
<StatusIcon status={nothingNew ? 'success' : overallIcon} />
{nothingNew ? 'Allt är uppdaterat' :
anySieFailed ? 'Migrering delvis genomförd' :
!allSieSucceeded ? 'Migrering klar med anmärkningar' :
'Migrering klar'}
</CardTitle>
<CardDescription className="text-sm">
{nothingNew ? (
'Det finns ingen ny data att importera från leverantören.'
) : totalJournalEntries > 0 ? (
<>
<span className="tabular-nums font-medium text-foreground">
{totalJournalEntries.toLocaleString('sv-SE')}
</span>
{' verifikationer importerade'}
{sieResults.length > 1 && ` över ${sieResults.length} räkenskapsår`}
{totalSkipped > 0 && (
<span className="text-amber-600">
{' · '}{totalSkipped} hoppade över
</span>
)}
</>
) : null}
</CardDescription>
</CardHeader>
</Card>
{/* ── Per-fiscal-year SIE breakdown ── */}
{sieResults.length > 0 && (
<div className="space-y-2">
<h3 className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<Database className="h-4 w-4" />
Bokföringsdata (SIE)
</h3>
<div className="space-y-2">
{sieResults.map((r, i) => (
<FiscalYearResult key={i} result={r} index={i} />
))}
</div>
</div>
)}
{/* ── API import results (company info, customers, etc.) ── */}
{results && (() => {
const hasCompanyInfo = results.companyInfo?.imported
const hasCustomers = results.customers && (results.customers.imported > 0 || results.customers.skipped > 0)
const hasSuppliers = results.suppliers && (results.suppliers.imported > 0 || results.suppliers.skipped > 0)
const hasSalesInvoices = results.salesInvoices && (results.salesInvoices.imported > 0 || results.salesInvoices.skipped > 0)
const hasSupplierInvoices = results.supplierInvoices && (results.supplierInvoices.imported > 0 || results.supplierInvoices.skipped > 0)
const hasAnything = hasCompanyInfo || hasCustomers || hasSuppliers || hasSalesInvoices || hasSupplierInvoices
if (!hasAnything) return null
return (
<div className="space-y-2">
<h3 className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<FileText className="h-4 w-4" />
Övriga data
</h3>
<div className="grid gap-2 sm:grid-cols-2">
{hasCompanyInfo && (
<EntityResultRow
icon={<Building2 className="h-4 w-4" />}
label="Företagsinformation"
status="success"
statusText="Importerad"
/>
)}
{hasCustomers && (
<EntityResultRow
icon={<Users className="h-4 w-4" />}
label="Kunder"
status="success"
statusText={`${results.customers!.imported} importerade`}
detail={results.customers!.skipped > 0 ? formatSkipReasons(results.customers!.skipReasons, 'customer') ?? `${results.customers!.skipped} hoppades över` : undefined}
/>
)}
{hasSuppliers && (
<EntityResultRow
icon={<Truck className="h-4 w-4" />}
label="Leverantörer"
status="success"
statusText={`${results.suppliers!.imported} importerade`}
detail={results.suppliers!.skipped > 0 ? formatSkipReasons(results.suppliers!.skipReasons, 'supplier') ?? `${results.suppliers!.skipped} hoppades över` : undefined}
/>
)}
{hasSalesInvoices && (
<EntityResultRow
icon={<FileText className="h-4 w-4" />}
label="Kundfakturor"
status="success"
statusText={`${results.salesInvoices!.imported} importerade`}
detail={results.salesInvoices!.skipped > 0 ? formatSkipReasons(results.salesInvoices!.skipReasons, 'invoice') ?? `${results.salesInvoices!.skipped} hoppades över` : undefined}
/>
)}
{hasSupplierInvoices && (
<EntityResultRow
icon={<FileText className="h-4 w-4" />}
label="Leverantörsfakturor"
status="success"
statusText={`${results.supplierInvoices!.imported} importerade`}
detail={results.supplierInvoices!.skipped > 0 ? formatSkipReasons(results.supplierInvoices!.skipReasons, 'invoice') ?? `${results.supplierInvoices!.skipped} hoppades över` : undefined}
/>
)}
</div>
</div>
)
})()}
{/* ── Next steps ── */}
<Card className="bg-muted/50">
<CardHeader>
<CardTitle className="text-base">Nästa steg</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-start gap-3">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-sm font-medium text-primary-foreground">
1
</div>
<div>
<p className="font-medium">Granska importerade verifikationer</p>
<p className="text-sm text-muted-foreground">Kontrollera att bokföringen ser korrekt ut i huvudboken</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-sm font-medium text-primary-foreground">
2
</div>
<div>
<p className="font-medium">Stäm av balansräkningen</p>
<p className="text-sm text-muted-foreground">Jämför ingående balanser och saldon mot ditt tidigare system</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-sm font-medium text-primary-foreground">
3
</div>
<div>
<p className="font-medium">Kontrollera kunder och leverantörer</p>
<p className="text-sm text-muted-foreground">Verifiera kontaktuppgifter, organisationsnummer och bankinfo</p>
</div>
</div>
</CardContent>
</Card>
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-between">
<Button variant="outline" className="min-h-11" onClick={onDone}>
<RotateCcw className="mr-2 h-4 w-4" />
Ny migrering
</Button>
<div className="flex flex-col gap-2 sm:flex-row">
<Button variant="outline" className="min-h-11" asChild>
<Link href="/customers">
Visa kunder
<ExternalLink className="ml-2 h-4 w-4" />
</Link>
</Button>
<Button className="min-h-11" asChild>
<Link href="/bookkeeping">
Visa bokföring
<ExternalLink className="ml-2 h-4 w-4" />
</Link>
</Button>
</div>
</div>
</div>
)
}
function formatSkipReasons(reasons?: SkipReasons, entityType?: 'customer' | 'supplier' | 'invoice'): string | undefined {
if (!reasons) return undefined
const parts: string[] = []
if (reasons.duplicate) parts.push(`${reasons.duplicate} fanns redan`)
if (reasons.inactive) parts.push(`${reasons.inactive} inaktiv${reasons.inactive > 1 ? 'a' : ''}`)
if (reasons.noMatch) {
const matchLabel = entityType === 'invoice' ? 'utan matchning' : 'utan matchning'
parts.push(`${reasons.noMatch} ${matchLabel}`)
}
if (reasons.failed) parts.push(`${reasons.failed} misslyckades`)
return parts.length > 0 ? parts.join(', ') : undefined
}
/** Simple row for non-SIE entity results (customers, invoices, etc.) */
function EntityResultRow({
icon,
label,
status,
statusText,
detail,
}: {
icon: React.ReactNode
label: string
status: 'success' | 'skipped'
statusText: string
detail?: string
}) {
return (
<div className="flex items-center gap-3 rounded-lg border border-border p-3">
<div className="text-muted-foreground">{icon}</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{label}</p>
<p className="text-sm text-muted-foreground">{statusText}</p>
{detail && <p className="text-sm text-muted-foreground/70">{detail}</p>}
</div>
<StatusIcon status={status === 'success' ? 'success' : 'warning'} />
</div>
)
}
// ── Main wizard ─────────────────────────────────────────────────
export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps) {
const { toast } = useToast()
const [step, setStep] = useState<WizardStep>('provider')
const [isLoading, setIsLoading] = useState(false)
const [isLoadingStatus, setIsLoadingStatus] = useState(true)
const [error, setError] = useState<string | null>(null)
// Per-item details behind `error` — e.g. the SIE validation errors from
// /sie-data, which would otherwise be swallowed (the envelope's `error`
// field is just the string "validation").
const [errorDetails, setErrorDetails] = useState<string[] | null>(null)
// Connection status (existing connections + import history)
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus | null>(null)
// Connection state
const [selectedProvider, setSelectedProvider] = useState<ArcimProvider | null>(null)
const [consentId, setConsentId] = useState<string | null>(null)
const [authUrl, setAuthUrl] = useState<string | null>(null)
const [authType, setAuthType] = useState<'oauth' | 'token' | null>(null)
// Preview state
const [preview, setPreview] = useState<PreviewData | null>(null)
// Set when a preview/sync fails because the provider connection expired
// (dead refresh token → PROVIDER_AUTH_EXPIRED). Drives the "Återanslut"
// affordance so the user can re-authorize in place instead of disconnecting.
const [authExpired, setAuthExpired] = useState(false)
// Set when the failure is specifically a missing/inactive Fortnox integration
// license (PROVIDER_LICENSE_MISSING). Re-auth alone can't fix it, so the SIE
// fallback stays available alongside the "Återanslut" CTA.
const [licenseMissing, setLicenseMissing] = useState(false)
// SIE data state (held between mapping and execution steps)
const [sieData, setSieData] = useState<SIEData | null>(null)
// Options state
const [migrationOptions, setMigrationOptions] = useState<MigrationOptions>(DEFAULT_OPTIONS)
// Migration state
const [migrationStep, setMigrationStep] = useState('')
const [migrationProgress, setMigrationProgress] = useState(0)
const [migrationResults, setMigrationResults] = useState<MigrationResults | null>(null)
const [sieImportResults, setSieImportResults] = useState<ImportResult[]>([])
// Wizard progress — only user-interactive steps
const userSteps = STEPS.filter(s => {
if (s === 'migrating' || s === 'result') return false
if (s === 'mapping' && !preview?.sieAvailable) return false
return true
})
const currentUserStepIndex = userSteps.indexOf(step)
const isInteractiveStep = currentUserStepIndex !== -1
const progressPercent = isInteractiveStep
? ((currentUserStepIndex + 1) / userSteps.length) * 100
: 100
// ── Fetch connection status on mount ───────────────────────────
const fetchStatus = useCallback(async () => {
try {
setIsLoadingStatus(true)
const res = await fetch('/api/extensions/ext/arcim-migration/status')
if (res.ok) {
const data = await res.json()
setConnectionStatus(data)
}
} catch {
// Non-critical — just means we can't show existing connections
} finally {
setIsLoadingStatus(false)
}
}, [])
useEffect(() => {
fetchStatus()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// ── Step handlers ──────────────────────────────────────────────
const loadPreview = useCallback(async (cId: string) => {
setStep('preview')
setIsLoading(true)
setError(null)
setAuthExpired(false)
setLicenseMissing(false)
setConsentId(cId)
try {
const res = await fetch(`/api/extensions/ext/arcim-migration/preview?consentId=${cId}`)
if (!res.ok) {
const data = await res.json().catch(() => ({}))
// A dead connection (expired/revoked refresh token) is recoverable in
// place — flag it so the UI offers "Återanslut" instead of a dead end.
// A missing Fortnox integration license shows the same CTA but keeps the
// SIE fallback, because re-auth loops until the license is re-ordered.
const code = apiErrorCode(data)
if (code === 'PROVIDER_AUTH_EXPIRED' || code === 'PROVIDER_LICENSE_MISSING') {
setAuthExpired(true)
}
if (code === 'PROVIDER_LICENSE_MISSING') {
setLicenseMissing(true)
}
throw new Error(apiErrorMessage(data, `HTTP ${res.status}`))
}
const data = await res.json()
setPreview(data)
// If SIE is not available, disable SIE import by default
if (!data.sieAvailable) {
setMigrationOptions(prev => ({ ...prev, importSIEData: false }))
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Kunde inte hämta förhandsgranskning')
} finally {
setIsLoading(false)
}
}, [])
const handleSelectProvider = useCallback(async (provider: ArcimProvider) => {
setSelectedProvider(provider)
setStep('connect')
setIsLoading(true)
setError(null)
try {
const res = await fetch('/api/extensions/ext/arcim-migration/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(apiErrorMessage(data, `HTTP ${res.status}`))
}
const data = await res.json()
setConsentId(data.consentId)
setAuthType(data.authType)
if (data.alreadyConnected) {
// Existing connection — skip auth, go straight to preview
await loadPreview(data.consentId)
return
}
if (data.authType === 'oauth' && data.authUrl) {
setAuthUrl(data.authUrl)
}
// Token-based providers stay on connect step for credential input
} catch (err) {
setError(err instanceof Error ? err.message : 'Anslutning misslyckades')
} finally {
setIsLoading(false)
}
}, [loadPreview])
// Re-sync with existing consent — go straight to preview
const handleResync = useCallback(async (provider: ArcimProvider, existingConsentId: string) => {
setSelectedProvider(provider)
setConsentId(existingConsentId)
setMigrationOptions(DEFAULT_OPTIONS)
setMigrationResults(null)
setSieImportResults([])
setSieData(null)
await loadPreview(existingConsentId)
}, [loadPreview])
// Re-authorize a dead connection in place. Re-runs provider auth against the
// SAME consent so fresh tokens overwrite the expired pair — no disconnect.
// OAuth providers open the login popup (the existing postMessage listener
// reloads the preview on success); token providers drop to the credential
// form. Triggered from the "Återanslut" CTA after a sync hits
// PROVIDER_AUTH_EXPIRED.
const handleReconnect = useCallback(async (provider: ArcimProvider, existingConsentId: string) => {
setError(null)
setAuthExpired(false)
setLicenseMissing(false)
setIsLoading(true)
setSelectedProvider(provider)
try {
const res = await fetch('/api/extensions/ext/arcim-migration/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider, reconnect: true }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(apiErrorMessage(data, `HTTP ${res.status}`))
}
const data = await res.json()
setConsentId(data.consentId ?? existingConsentId)
setAuthType(data.authType)
if (data.authType === 'oauth' && data.authUrl) {
// Open immediately — this runs inside the button's click handler, so
// the popup is a trusted user gesture and won't be blocked.
const w = 600
const h = 700
const left = window.screenX + (window.outerWidth - w) / 2
const top = window.screenY + (window.outerHeight - h) / 2
window.open(data.authUrl, 'arcim-oauth', `width=${w},height=${h},left=${left},top=${top}`)
setAuthUrl(data.authUrl)
} else if (data.authType === 'token') {
// Re-enter credentials for token-based providers
setStep('connect')
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Kunde inte återansluta')
setAuthExpired(true)
} finally {
setIsLoading(false)
}
}, [])
// Disconnect an existing consent
const handleDisconnect = useCallback(async (consentIdToDelete: string) => {
try {
const res = await fetch('/api/extensions/ext/arcim-migration/disconnect', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ consentId: consentIdToDelete }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(apiErrorMessage(data, 'Kunde inte koppla från'))
}
toast({ title: 'Frånkopplad', description: 'Anslutningen har tagits bort.' })
await fetchStatus()
} catch (err) {
toast({ title: err instanceof Error ? err.message : 'Något gick fel', variant: 'destructive' })
}
}, [toast, fetchStatus])
// Handle token submission for token-based providers (Bokio, etc.)
const handleTokenSubmit = useCallback(async (apiToken: string, companyId: string) => {
if (!consentId || !selectedProvider) return
setIsLoading(true)
setError(null)
try {
const res = await fetch('/api/extensions/ext/arcim-migration/submit-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
consentId,
provider: selectedProvider,
apiToken,
companyId: companyId || undefined,
}),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(apiErrorMessage(data, `HTTP ${res.status}`))
}
// Token stored — consent is now accepted, proceed to preview
await loadPreview(consentId)
} catch (err) {
setError(err instanceof Error ? err.message : 'Kunde inte ansluta')
} finally {
setIsLoading(false)
}
}, [consentId, selectedProvider, loadPreview])
// Handle OAuth callback via URL params
const handleOAuthReturn = useCallback(async () => {
// Check URL for migration callback params
const url = new URL(window.location.href)
const migrationStatus = url.searchParams.get('migration')
const callbackConsentId = url.searchParams.get('consentId')
if (migrationStatus === 'connected' && callbackConsentId) {
// Clean URL
url.searchParams.delete('migration')
url.searchParams.delete('consentId')
window.history.replaceState({}, '', url.pathname)
await loadPreview(callbackConsentId)
} else if (migrationStatus === 'error') {
const callbackProvider = url.searchParams.get('provider') as ArcimProvider | null
const reason = url.searchParams.get('reason') || 'OAuth-anslutningen misslyckades. Försök igen.'
url.searchParams.delete('migration')
url.searchParams.delete('provider')
url.searchParams.delete('reason')
window.history.replaceState({}, '', url.pathname)
setError(reason)
toast({ title: 'Anslutning misslyckades', description: reason, variant: 'destructive' })
if (callbackProvider) {
setSelectedProvider(callbackProvider)
setStep('connect')
} else {
setStep('provider')
}
}
}, [loadPreview, toast])
// Check for OAuth callback on mount (fallback for non-popup flow)
useEffect(() => {
handleOAuthReturn()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Listen for postMessage from OAuth popup
useEffect(() => {
function handleMessage(event: MessageEvent) {
if (event.origin !== window.location.origin) return
if (event.data?.type === 'arcim-oauth-success' && event.data.consentId) {
loadPreview(event.data.consentId)
} else if (event.data?.type === 'arcim-oauth-error') {
const reason = typeof event.data.reason === 'string' && event.data.reason
? event.data.reason
: 'OAuth-anslutningen misslyckades. Försök igen.'
setError(reason)
toast({ title: 'Anslutning misslyckades', description: reason, variant: 'destructive' })
}
}
window.addEventListener('message', handleMessage)
return () => window.removeEventListener('message', handleMessage)
}, [loadPreview, toast])
// Load SIE data when entering mapping step
const loadSIEData = useCallback(async () => {
if (!consentId) return
setStep('mapping')
setIsLoading(true)
setError(null)
setErrorDetails(null)
try {
const res = await fetch(`/api/extensions/ext/arcim-migration/sie-data?consentId=${consentId}`)
if (!res.ok) {
const data = await res.json().catch(() => ({})) as {
error?: unknown
validation?: { errors?: unknown }
}
const validationErrors = data?.error === 'validation' ? data.validation?.errors : undefined
if (Array.isArray(validationErrors)) {
setErrorDetails(validationErrors.filter((e): e is string => typeof e === 'string'))
throw new Error(
'Bokföringsdatan hos leverantören klarade inte valideringen. Felen nedan måste rättas i källsystemet innan importen kan fortsätta.'
)
}
throw new Error(apiErrorMessage(data, `HTTP ${res.status}`))
}
const data = await res.json()
setSieData(data)
// If all SIE files are already imported, disable SIE import by default
if (data.allImported) {
setMigrationOptions(prev => ({ ...prev, importSIEData: false }))
}
// Auto-skip mapping step if all accounts are mapped or all files already imported
if (data.mappingStats.unmapped === 0 || data.allImported) {
setStep('options')
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Kunde inte hämta SIE-data')
} finally {
setIsLoading(false)
}
}, [consentId])
const handlePreviewContinue = useCallback(() => {
if (preview?.sieAvailable) {
// Load SIE data for mapping step
loadSIEData()
} else {
// Skip mapping step — no SIE available
setStep('options')
}
}, [preview, loadSIEData])
const handleMappingChange = useCallback((sourceAccount: string, targetAccount: string, targetName: string) => {
if (!sieData) return
const updatedMappings = sieData.mappings.map(m =>
m.sourceAccount === sourceAccount
? { ...m, targetAccount, targetName, isOverride: true, matchType: 'manual' as const, confidence: 1 }
: m
)
setSieData(prev => prev ? {
...prev,
mappings: updatedMappings,
mappingStats: {
...prev.mappingStats,
unmapped: updatedMappings.filter(m => !m.targetAccount).length,
mapped: updatedMappings.filter(m => m.targetAccount).length,
},
} : null)
}, [sieData])
const handleStartMigration = useCallback(async () => {
if (!consentId) return
setStep('migrating')
setMigrationStep('Startar migrering...')
setMigrationProgress(5)
setError(null)
try {
// ── Phase 1: SIE import ──────────────────────────────────
if (migrationOptions.importSIEData && sieData && sieData.rawContent.length > 0) {
setMigrationStep('Importerar bokföringsdata (SIE)...')
setMigrationProgress(10)
setSieImportResults([])
// Send every file to the engine. The Fortnox endpoint runs in
// replace-mode, so a year that already has a completed import
// gets its prior import marked 'replaced' (imported entries
// deleted, user-created entries untouched) before the new
// SIE is loaded. The per-file result reports replacedPriorImport.
const filesToImport = sieData.rawContent.map((content, i) => ({
content,
status: sieData.fileStatuses?.[i],
}))
for (let i = 0; i < filesToImport.length; i++) {
const progress = 10 + Math.round((i / filesToImport.length) * 40)
setMigrationProgress(progress)
setMigrationStep(`Importerar bokföringsdata (SIE) — fil ${i + 1} av ${filesToImport.length}...`)
const res = await fetch('/api/extensions/ext/arcim-migration/import-sie', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
rawContent: filesToImport[i].content,
mappings: sieData.mappings,
options: {
createFiscalPeriod: true,
importOpeningBalances: true,
importTransactions: true,
voucherSeries: migrationOptions.voucherSeries,
},
}),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(apiErrorMessage(data, `SIE import HTTP ${res.status}`))
}
const result = await res.json() as ImportResult
setSieImportResults(prev => [...prev, result])
// The endpoint returns HTTP 200 with success:false when the import
// itself failed (e.g. räkenskapsår mismatch). Stop here — continuing
// to /migrate would hit its SIE-guard, whose "SIE måste importeras
// först" message masks the real error.
if (!result.success) {
throw new Error(result.errors.length > 0
? result.errors.join('\n')
: 'SIE-importen misslyckades utan felmeddelande.')
}
}
}
// ── Phase 2: API import (customers, suppliers, invoices) ──
const hasApiImport = migrationOptions.importCompanyInfo ||
migrationOptions.importCustomers ||
migrationOptions.importSuppliers ||
migrationOptions.importSalesInvoices ||
migrationOptions.importSupplierInvoices
if (hasApiImport) {
setMigrationStep('Importerar kunder, leverantörer och fakturor...')
setMigrationProgress(55)
const res = await fetch('/api/extensions/ext/arcim-migration/migrate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
consentId,
importCompanyInfo: migrationOptions.importCompanyInfo,
importCustomers: migrationOptions.importCustomers,
importSuppliers: migrationOptions.importSuppliers,
importSalesInvoices: migrationOptions.importSalesInvoices,
importSupplierInvoices: migrationOptions.importSupplierInvoices,
}),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(apiErrorMessage(data, `HTTP ${res.status}`))
}
const data = await res.json()
setMigrationResults(data.results)
}
// Mark consent as fully accepted now that import is complete
if (consentId) {
await fetch('/api/extensions/ext/arcim-migration/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ consentId }),
}).catch(() => { /* best-effort */ })
}
setMigrationProgress(100)
setStep('result')
toast({
title: 'Migrering klar',
description: 'Din bokföringsdata har importerats.',
})
} catch (err) {
const msg = err instanceof Error ? err.message : 'Migrering misslyckades'
setError(msg)
setStep('result')
}
}, [consentId, migrationOptions, sieData, toast])
const handleDone = useCallback(() => {
// Reset wizard
setStep('provider')
setSelectedProvider(null)
setConsentId(null)
setAuthUrl(null)
setAuthType(null)
setPreview(null)
setSieData(null)
setMigrationOptions(DEFAULT_OPTIONS)
setMigrationResults(null)
setSieImportResults([])
setError(null)
// Refresh status so provider step shows updated import history
fetchStatus()
}, [fetchStatus])
// ── Render ─────────────────────────────────────────────────────
return (
<div className="space-y-6">
{/* Progress bar — only during interactive steps */}
{step !== 'provider' && isInteractiveStep && (
<Card>
<CardContent className="pt-6">
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="sm:hidden text-primary font-medium">
Steg {currentUserStepIndex + 1}/{userSteps.length}: {STEP_LABELS[step]}
</span>
{userSteps.map((s) => (
<span
key={s}
className={cn(
'hidden sm:inline',
userSteps.indexOf(s) <= currentUserStepIndex ? 'font-medium text-primary' : 'text-muted-foreground'
)}
>
{STEP_LABELS[s]}
</span>
))}
</div>
<Progress value={progressPercent} className="h-2" />
</div>
</CardContent>
</Card>
)}
{/* Step content */}
{step === 'provider' && (
<ProviderStep
onSelect={handleSelectProvider}
onResync={handleResync}
onDisconnect={handleDisconnect}
connectionStatus={connectionStatus}
isLoadingStatus={isLoadingStatus}
/>
)}
{step === 'connect' && selectedProvider && (
<ConnectStep
provider={selectedProvider}
authType={authType}
isLoading={isLoading}
error={error}
authUrl={authUrl}
consentId={consentId}
onTokenSubmit={handleTokenSubmit}
onBack={() => {
setStep('provider')
setError(null)
}}
/>
)}
{step === 'preview' && (
<PreviewStep
preview={preview}
isLoading={isLoading}
error={error}
authExpired={authExpired}
licenseMissing={licenseMissing}
onReconnect={() => {
if (selectedProvider && consentId) handleReconnect(selectedProvider, consentId)
}}
onContinue={handlePreviewContinue}
onBack={() => setStep('provider')}
/>
)}
{step === 'mapping' && (
<MappingStep
sieData={sieData}
isLoading={isLoading}
error={error}
errorDetails={errorDetails}
onMappingChange={handleMappingChange}
onContinue={() => setStep('options')}
onBack={() => setStep('preview')}
/>
)}
{step === 'options' && (
<OptionsStep
options={migrationOptions}
sieAvailable={preview?.sieAvailable ?? false}
sieData={sieData}
provider={preview?.consent.provider ?? null}
onChange={setMigrationOptions}
onStart={handleStartMigration}
onBack={() => preview?.sieAvailable ? setStep('mapping') : setStep('preview')}
/>
)}
{step === 'migrating' && (
<MigratingStep currentStep={migrationStep} progress={migrationProgress} />
)}
{step === 'result' && (
<ResultStep
results={migrationResults}
sieResults={sieImportResults}
error={error}
onDone={handleDone}
onRetry={() => {
setError(null)
setStep('options')
}}
/>
)}
</div>
)
}