'use client' import { useState, useCallback, useEffect, useRef } from 'react' import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' 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' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' 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' | 'wint' // `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. // WINT is env-gated server-side (WINT_MIGRATION_ENABLED): the wizard renders // whatever GET /providers returns, so no client-side gate is needed here. 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 }, { id: 'wint', name: 'WINT', 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 } /** * Marks an error whose message is already user-facing Swedish (server * envelopes, ImportResult.errors). The catch blocks must show these * verbatim: routing them through getErrorMessage would test them against * its Swedish-pattern heuristic and swallow any miss into the generic * "Något gick fel. Försök igen.", hiding the real reason the migration * stopped. */ class UserFacingError extends Error {} /** * Build the throwable for a failed API response: an extracted server * message passes through to the UI verbatim, while the technical fallback * (e.g. "HTTP 500") stays a plain Error so getErrorMessage maps it to a * friendly message. */ function apiError(data: unknown, fallback: string): Error { const extracted = apiErrorMessage(data, '') return extracted ? new UserFacingError(extracted) : new Error(fallback) } /** Resolve the message a catch block should display. */ function displayError(err: unknown, nonErrorFallback?: string): string { if (err instanceof UserFacingError) return err.message if (!(err instanceof Error) && nonErrorFallback) return nonErrorFallback return getUserErrorMessage(err) } /** * Read the /migrate NDJSON stream: one JSON object per line. `progress` * events carry the orchestrator's real step labels and anchors; the stream * ends with a terminal `done` line (results) or `error` line (the same * structured envelope the JSON path answers with, thrown here so the catch * block shows it verbatim). */ async function consumeMigrationStream( body: ReadableStream, onProgress: (currentStep: string | undefined, progress: number) => void, ): Promise { const reader = body.getReader() const decoder = new TextDecoder() let buffer = '' let results: MigrationResults | undefined const handleLine = (line: string) => { if (!line.trim()) return let event: { kind?: string currentStep?: string progress?: number results?: MigrationResults } try { event = JSON.parse(line) } catch { return // torn line from an intermediary flush; terminal lines are whole } if (event.kind === 'progress' && typeof event.progress === 'number') { onProgress( typeof event.currentStep === 'string' && event.currentStep ? event.currentStep : undefined, event.progress, ) } else if (event.kind === 'done') { results = event.results ?? {} } else if (event.kind === 'error') { throw apiError(event, 'Migreringen misslyckades.') } } try { for (;;) { const { value, done } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) const lines = buffer.split('\n') buffer = lines.pop() ?? '' for (const line of lines) handleLine(line) } buffer += decoder.decode() if (buffer) handleLine(buffer) } finally { reader.releaseLock() } if (!results) { // The connection dropped before the terminal line. The migration keeps // running server-side, so a blind retry could double-import. throw new UserFacingError( 'Anslutningen bröts innan migreringen bekräftades. Ladda om sidan och kontrollera om kunder och fakturor redan har importerats innan du försöker igen.' ) } return results } /** 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 MigrationStepError { step: 'companyInfo' | 'customers' | 'suppliers' | 'salesInvoices' | 'supplierInvoices' | 'reconciliation' code: string | null message: string } interface MigrationResults { companyInfo?: { imported: boolean } customers?: { total: number; imported: number; updated?: number; skipped: number; skipReasons?: SkipReasons; errorSample?: string } suppliers?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons; errorSample?: string } salesInvoices?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons; errorSample?: string } supplierInvoices?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons; errorSample?: string } stepErrors?: MigrationStepError[] } import AccountMappingStep from '@/components/import/AccountMappingStep' import ArcimMigrationTheater from '@/components/extensions/general/ArcimMigrationTheater' import type { TheaterModel } from '@/lib/import/theater-model' 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 = { 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 } } // WINT shows as a disabled "Kommer snart" card until the integration is // verified against a live WINT account. Launch = remove it here AND set // WINT_MIGRATION_ENABLED=true (the server-side /connect gate). const COMING_SOON_PROVIDERS = new Set(['wint']) const PROVIDER_LOGOS: Record = { fortnox: '/logos/fortnox.svg', visma: '/logos/visma.jpeg', bokio: '/logos/bokio.png', bjornlunden: '/logos/bjornlunden.png', briox: '/logos/Briox_logo.png', wint: '/logos/wint.svg', } 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 (
{/* SIE-required banner (not relevant for Fortnox/Briox: they fetch SIE via API) */} {showSieRequiredBanner && (

SIE-import krävs först

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, Björn Lundén och WINT: där hämtar vi bokföringen direkt via API:et.

Ladda upp SIE-fil
)} {/* Existing connections */} {activeConsents.length > 0 && ( Aktiva anslutningar Du har redan anslutna leverantörer. Synka igen för att hämta ny data. {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 (
{providerInfo?.name

{providerInfo?.name ?? consent.provider}

Ansluten
{consent.companyName && (

{consent.companyName}

)} {lastImport ? (

Senaste import: {new Date(lastImport.imported_at ?? lastImport.created_at).toLocaleDateString('sv-SE')} {lastImport.transactions_count != null && `, ${lastImport.transactions_count} verifikationer`}

) : (

Ansluten {consent.createdAt ? new Date(consent.createdAt).toLocaleDateString('sv-SE') : ''}

)} {(connectionStatus?.entityCounts.customers ?? 0) > 0 && (

{connectionStatus?.entityCounts.customers} kunder, {connectionStatus?.entityCounts.suppliers} leverantörer, {connectionStatus?.entityCounts.invoices} fakturor

)}
) })}
)} {/* Provider selection */} {activeConsents.length > 0 ? 'Anslut ytterligare system' : 'Välj ditt nuvarande bokföringssystem'} Vi hämtar bokföringsdata via SIE och kunder, leverantörer och fakturor via API:et. {isLoadingStatus ? (
) : (
{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 ( ) })}
)}
) } // ── 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' // WINT has no API keys: the "token" is the user's WINT login (e-post + // lösenord), exchanged server-side for ett tokenpar; lösenordet sparas aldrig. const isWintLogin = provider === 'wint' const needsApiToken = !isClientCredentials // Briox: the account ID is the `clientid` half of the token exchange; // WINT reuses the same field for the login e-mail. const needsCompanyId = provider === 'bokio' || provider === 'bjornlunden' || provider === 'briox' || provider === 'wint' const companyIdLabel = provider === 'briox' ? 'Konto-ID' : provider === 'bjornlunden' ? 'Företagsnyckel (User-Key)' : provider === 'wint' ? 'E-postadress' : '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.` : isWintLogin ? `Logga in med dina WINT-uppgifter för att ge ${branding.appName.toLowerCase()} tillgång att läsa din bokföringsdata. Lösenordet används en gång för att skapa anslutningen och sparas aldrig.` : 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.` : isWintLogin ? `Använd samma e-postadress och lösenord som när du loggar in på app.wint.se. Kräver ditt WINT-konto BankID-inloggning kan anslutningen inte skapas ännu: be i så fall WINT om en SIE-fil och importera den manuellt.` : 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 (
Anslut till {providerName} {authType === 'token' ? tokenDescription : `Logga in i ${providerName} för att ge ${branding.appName.toLowerCase()} tillgång att läsa din bokföringsdata.` } {isLoading && (

Förbereder anslutning...

)} {error && ( <>

Anslutning misslyckades

{error}

{provider === 'fortnox' && (

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.

)}
)} {/* OAuth flow */} {authType === 'oauth' && authUrl && !isLoading && (

Klicka nedan för att logga in i {providerName}. Fönstret stängs automatiskt när du är klar.

)} {/* Token-based flow */} {authType === 'token' && consentId && !isLoading && (

{tokenHelpText}

{/* WINT is a login form: e-mail reads above password (CSS order; the button keeps its place). Other token providers keep token-first order. */}
{needsApiToken && (
setApiToken(e.target.value)} />
)} {needsCompanyId && (
setCompanyId(e.target.value)} />
)}
)}
) } // ── 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 (
Anslutet till {providerName} {isLoading && (

Hämtar bokföringsdata...

)} {error && ( <>

{error}

{authExpired && ( )}
{/* 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) && ( )} )} {/* SIE stats summary */} {preview?.sieAvailable && preview.sieStats && (

Hittade {preview.sieStats.accountCount} konton och {preview.sieStats.transactionCount} verifikationer

{preview.sieStats.fiscalYears.length === 1 ? `Räkenskapsår ${preview.sieStats.fiscalYears[0]}` : `${preview.sieStats.fiscalYears.length} räkenskapsår: ${preview.sieStats.fiscalYears.join(', ')}` }

)} {preview && !preview.sieAvailable && !isLoading && preview.hasSieData && (

SIE-data redan importerad

Bokföringsdata har redan importerats via SIE-fil. Du kan fortsätta med att importera kunder, leverantörer och fakturor.

)} {preview && !preview.sieAvailable && !isLoading && !preview.hasSieData && (

SIE-import krävs

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()}.

Gå till SIE-importen
)}
) } function InfoItem({ label, value }: { label: string; value: string | null }) { return (

{label}

{value || '-'}

) } // ── 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 (

Analyserar bokföringsdata och förbereder kontomappning...

) } if (error) { return (

Kunde inte ladda SIE-data

{error}

{errorDetails && errorDetails.length > 0 && (
    {errorDetails.slice(0, 8).map((detail, i) => (
  • {detail}
  • ))} {errorDetails.length > 8 && (
  • … och {errorDetails.length - 8} fel till
  • )}
)}
) } if (!sieData) return null return ( ) } // ── 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 (
Vad vill du importera? Bokföringsdata importeras via SIE-fil. Kunder, leverantörer och fakturor hämtas via API:et. } 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 && (

{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`}

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.

)} } 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 && (
{fileStatuses.map((fs) => (
{fs.previousImport ? ( <> Räkenskapsår {fs.fiscalYear}: ersätter tidigare import {fs.previousImport.importedAt ? ` från ${new Date(fs.previousImport.importedAt).toLocaleDateString('sv-SE')}` : ''} ) : ( <> Räkenskapsår {fs.fiscalYear}: ny data att importera )}
))}
)} {options.importSIEData && (

Verifikationsserie

Serie för importerade verifikationer

onChange({ ...options, voucherSeries: e.target.value.toUpperCase() || 'B' })} maxLength={2} />
)} )} } label="Kunder" description="Kund-register med kontaktuppgifter" checked={options.importCustomers} onChange={() => toggleOption('importCustomers')} /> } label="Leverantörer" description="Leverantör-register med bankuppgifter" checked={options.importSuppliers} onChange={() => toggleOption('importSuppliers')} /> } label="Kundfakturor" description="Alla kundfakturor (betalda och obetalda)" checked={options.importSalesInvoices} onChange={() => toggleOption('importSalesInvoices')} /> } 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')} />
{ 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" >

Följande importeras:

    {selectedItems.map((item) => (
  • {item}
  • ))}
{options.importSIEData && yearsToReplace.length > 0 && (

{yearsToReplace.length === 1 ? `Räkenskapsår ${yearsToReplace[0]} ersätts` : `Räkenskapsår ${yearsToReplace.join(', ')} ersätts`}

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.

)}
) } function OptionRow({ icon, label, description, checked, onChange, disabled, }: { icon: React.ReactNode label: string description: string checked: boolean onChange: () => void disabled?: boolean }) { return (
!disabled && onChange()} >
{icon}

{label}

{description}

!disabled && onChange()} disabled={disabled} onClick={(e) => e.stopPropagation()} />
) } // ── Migrating step (progress) ─────────────────────────────────── function MigratingStep({ currentStep, progress }: { currentStep: string; progress: number }) { return ( Migrering pågår Vänta medan vi hämtar och importerar din bokföringsdata. Det kan ta några minuter.
{progress}%

{currentStep}

) } // ── 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' } } if (r.details?.untransferredResults && r.details.untransferredResults.length > 0) { return { icon: 'warning', label: 'Importerad med varning' } } return { icon: 'success', label: 'Importerad' } } const StatusIcon = ({ status }: { status: 'success' | 'warning' | 'error' }) => { if (status === 'error') return if (status === 'warning') return return } /** 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 (
{/* Header: always visible */} {/* Expanded details */} {expanded && (
{/* Errors: shown prominently */} {result.errors.length > 0 && (

{result.errors.length === 1 ? '1 fel vid import' : `${result.errors.length} fel vid import`}

{result.errors.map((e, i) => (

{e}

))}
)} {/* Opening balance adjustment */} {d?.openingBalance && (

Ingående balanser justerade

{d.openingBalance.explanation === 'unallocated_result' && ( <> Differens på {Math.abs(d.openingBalance.imbalance).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} SEK bokförd på konto {d.openingBalance.bookedToAccount}. Detta beror troligen på 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 ({Math.abs(d.openingBalance.imbalance).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} SEK) bokförd på konto {d.openingBalance.bookedToAccount}. )} {d.openingBalance.explanation === 'rounding' && ( <> Avrundningsdifferens ({Math.abs(d.openingBalance.imbalance).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} SEK) bokförd på konto {d.openingBalance.bookedToAccount}. )} {!d.openingBalance.explanation && ( <> Differens på {Math.abs(d.openingBalance.imbalance).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} SEK bokförd på konto {d.openingBalance.bookedToAccount}. )}

)} {/* Skipped vouchers breakdown */} {d?.skippedVouchers && d.skippedVouchers.total > 0 && (

{d.skippedVouchers.total} verifikationer hoppades över

Ofullständiga verifikationer i källsystemet som inte kan importeras. Saldon har justerats automatiskt via omföringsverifikation.

{d.skippedVouchers.unbalanced > 0 && (
Obalanserade {d.skippedVouchers.unbalanced}
)} {d.skippedVouchers.unmapped > 0 && (
Ej mappade konton {d.skippedVouchers.unmapped}
)} {d.skippedVouchers.singleLine > 0 && (
Enradsverifikationer {d.skippedVouchers.singleLine}
)} {d.skippedVouchers.empty > 0 && (
Tomma {d.skippedVouchers.empty}
)}
)} {/* Migration adjustment info */} {d?.migrationAdjustment?.created && (

Omföringsverifikation skapad

{d.migrationAdjustment.accountsAdjusted} konton justerade för att saldon ska matcha källsystemet. Verifikationen kompenserar för hoppade verifikationer så att dina balansräkning och resultaträkning stämmer.

)} {/* Untransferred prior-year results — omföring av årets resultat saknas */} {d?.untransferredResults && d.untransferredResults.length > 0 && (

Årets resultat är inte omfört till eget kapital

Följande räkenskapsår saknar omföring av årets resultat. Senare års balansräkning visar en differens på beloppet tills omföringen bokförs (konto 8999 mot eget kapital, t.ex. 2099) i respektive år.

{d.untransferredResults.map((u) => (
{u.period_name} {u.pl_net.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} SEK
))}
)} {/* Remaining warnings — previously dropped entirely in this flow. Strings covered by structured cards above are filtered out. */} {(() => { const remainingWarnings = result.warnings.filter( (w) => !(d?.skippedVouchers && d.skippedVouchers.total > 0 && w.includes('hoppades över')) && !(d?.untransferredResults && d.untransferredResults.length > 0 && w.includes('förts om till eget kapital')) ) if (remainingWarnings.length === 0) return null return (

{remainingWarnings.length === 1 ? '1 varning' : `${remainingWarnings.length} varningar`}

{remainingWarnings.map((w, i) => (

{w}

))}
) })()} {/* Retry info (only shown if retries happened) */} {d && d.retriedBatches > 0 && (

{d.retriedBatches} {d.retriedBatches === 1 ? 'batch' : 'batcher'} behövde omförsök {d.failedBatches > 0 && ( {' · '}{d.failedBatches} misslyckades trots omförsök )}

)}
)}
) } function ResultStep({ results, sieResults, error, onDone, onRetry, }: { results: MigrationResults | null sieResults: ImportResult[] error: string | null onDone: () => void onRetry: () => void }) { if (error) { return (

Migreringen misslyckades

{error}

) } 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.updated ?? 0) > 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)) ) // Steps that failed against the provider API. An empty sync with failed // steps must never present as "Allt är uppdaterat": that reading sent a // real subscription problem to the bug tracker as a sync bug. const stepErrors = results?.stepErrors ?? [] const apiFailed = stepErrors.length > 0 const nothingNew = sieResults.length === 0 && !entityImported && !apiFailed // Overall status const overallIcon = anySieFailed ? 'error' as const : (apiFailed || !allSieSucceeded || totalErrors > 0) ? 'warning' as const : 'success' as const return (
{/* ── Header card with overall summary ── */} {nothingNew ? 'Allt är uppdaterat' : (anySieFailed || apiFailed) ? 'Migrering delvis genomförd' : !allSieSucceeded ? 'Migrering klar med anmärkningar' : 'Migrering klar'} {nothingNew ? ( 'Det finns ingen ny data att importera från leverantören.' ) : totalJournalEntries > 0 ? ( <> {totalJournalEntries.toLocaleString('sv-SE')} {' verifikationer importerade'} {sieResults.length > 1 && ` över ${sieResults.length} räkenskapsår`} {totalSkipped > 0 && ( {' · '}{totalSkipped} hoppade över )} ) : null} {/* ── Steps that failed against the provider API ── */} {stepErrors.length > 0 && (
{groupStepErrors(stepErrors).map((group, i) => (

Kunde inte hämta: {group.steps.map((s) => STEP_ERROR_LABELS[s]).join(', ')}

{group.message}

))}
)} {/* ── Per-fiscal-year SIE breakdown ── */} {sieResults.length > 0 && (

Bokföringsdata (SIE)

{sieResults.map((r, i) => ( ))}
)} {/* ── API import results (company info, customers, etc.) ── */} {results && (() => { const hasCompanyInfo = results.companyInfo?.imported const hasCustomers = results.customers && (results.customers.imported > 0 || (results.customers.updated ?? 0) > 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 (

Övriga data

{hasCompanyInfo && ( } label="Företagsinformation" status="success" statusText="Importerad" /> )} {hasCustomers && ( } label="Kunder" status={entityRowStatus(results.customers!.imported, results.customers!.skipReasons)} statusText={results.customers!.updated ? `${results.customers!.imported} importerade, ${results.customers!.updated} kompletterade` : `${results.customers!.imported} importerade`} detail={results.customers!.skipped > 0 ? formatSkipReasons(results.customers!.skipReasons, 'customer', results.customers!.errorSample) ?? `${results.customers!.skipped} hoppades över` : undefined} /> )} {hasSuppliers && ( } label="Leverantörer" status={entityRowStatus(results.suppliers!.imported, results.suppliers!.skipReasons)} statusText={`${results.suppliers!.imported} importerade`} detail={results.suppliers!.skipped > 0 ? formatSkipReasons(results.suppliers!.skipReasons, 'supplier', results.suppliers!.errorSample) ?? `${results.suppliers!.skipped} hoppades över` : undefined} /> )} {hasSalesInvoices && ( } label="Kundfakturor" status={entityRowStatus(results.salesInvoices!.imported, results.salesInvoices!.skipReasons)} statusText={`${results.salesInvoices!.imported} importerade`} detail={results.salesInvoices!.skipped > 0 ? formatSkipReasons(results.salesInvoices!.skipReasons, 'invoice', results.salesInvoices!.errorSample) ?? `${results.salesInvoices!.skipped} hoppades över` : undefined} /> )} {hasSupplierInvoices && ( } label="Leverantörsfakturor" status={entityRowStatus(results.supplierInvoices!.imported, results.supplierInvoices!.skipReasons)} statusText={`${results.supplierInvoices!.imported} importerade`} detail={results.supplierInvoices!.skipped > 0 ? formatSkipReasons(results.supplierInvoices!.skipReasons, 'invoice', results.supplierInvoices!.errorSample) ?? `${results.supplierInvoices!.skipped} hoppades över` : undefined} /> )}
) })()} {/* ── Next steps ── */} Nästa steg
1

Granska importerade verifikationer

Kontrollera att bokföringen ser korrekt ut i huvudboken

2

Stäm av balansräkningen

Jämför ingående balanser och saldon mot ditt tidigare system

3

Kontrollera kunder och leverantörer

Verifiera kontaktuppgifter, organisationsnummer och bankinfo

) } const STEP_ERROR_LABELS: Record = { companyInfo: 'Företagsinformation', customers: 'Kunder', suppliers: 'Leverantörer', salesInvoices: 'Kundfakturor', supplierInvoices: 'Leverantörsfakturor', reconciliation: 'Avstämning av betalningar', } /** * Group step errors that share the same message (a provider outage hits every * step identically) so the result shows one card per cause, not one per step. */ function groupStepErrors(errors: MigrationStepError[]): { message: string; steps: MigrationStepError['step'][] }[] { const groups = new Map() for (const e of errors) { const steps = groups.get(e.message) ?? [] steps.push(e.step) groups.set(e.message, steps) } return [...groups.entries()].map(([message, steps]) => ({ message, steps })) } function formatSkipReasons( reasons?: SkipReasons, entityType?: 'customer' | 'supplier' | 'invoice', errorSample?: string, ): 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( errorSample ? `${reasons.failed} misslyckades: ${errorSample.slice(0, 140)}` : `${reasons.failed} misslyckades` ) } return parts.length > 0 ? parts.join(', ') : undefined } /** A step that failed everything it tried is an error, not a green checkmark. */ function entityRowStatus(imported: number, reasons?: SkipReasons): 'success' | 'error' { return imported === 0 && (reasons?.failed ?? 0) > 0 ? 'error' : 'success' } /** 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' | 'error' statusText: string detail?: string }) { return (
{icon}

{label}

{statusText}

{detail &&

{detail}

}
) } // ── Main wizard ───────────────────────────────────────────────── export default function ArcimMigrationWorkspace({ initialProvider, }: WorkspaceComponentProps & { /** Deep-linked old system (onboarding branch question): jump straight to * its connect step instead of showing the provider list. */ initialProvider?: string }) { const { toast } = useToast() const [step, setStep] = useState('provider') const [isLoading, setIsLoading] = useState(false) const [isLoadingStatus, setIsLoadingStatus] = useState(true) const [error, setError] = useState(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(null) // Connection status (existing connections + import history) const [connectionStatus, setConnectionStatus] = useState(null) // Connection state const [selectedProvider, setSelectedProvider] = useState(null) const [consentId, setConsentId] = useState(null) const [authUrl, setAuthUrl] = useState(null) const [authType, setAuthType] = useState<'oauth' | 'token' | null>(null) // Preview state const [preview, setPreview] = useState(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(null) // Options state const [migrationOptions, setMigrationOptions] = useState(DEFAULT_OPTIONS) // Migration state const [migrationStep, setMigrationStep] = useState('') const [migrationProgress, setMigrationProgress] = useState(0) const [migrationResults, setMigrationResults] = useState(null) const [sieImportResults, setSieImportResults] = useState([]) // Knowledge-graph theater for the migrating step, built from the already // client-held parsed SIE. Null falls back to the plain progress card. const [theaterModel, setTheaterModel] = useState(null) // 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 or an inactive Visma API // module shows the same CTA but keeps the SIE fallback, because // re-auth loops until the customer fixes the subscription (re-orders // the license / activates the API module). const code = apiErrorCode(data) if ( code === 'PROVIDER_AUTH_EXPIRED' || code === 'PROVIDER_LICENSE_MISSING' || code === 'PROVIDER_API_MODULE_INACTIVE' ) { setAuthExpired(true) } if (code === 'PROVIDER_LICENSE_MISSING' || code === 'PROVIDER_API_MODULE_INACTIVE') { 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 ? getUserErrorMessage(err) : '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 ? getUserErrorMessage(err) : '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) // Pre-open the OAuth popup inside the click's user activation: opening it // after the fetch below is popup-blocked when the response is slow (the // activation expires after ~5s). Kept open only for OAuth providers; the // token path and every failure path close it again. The opener reference // stays intact: the provider popup posts back via postMessage. const w = 600 const h = 700 const left = window.screenX + (window.outerWidth - w) / 2 const top = window.screenY + (window.outerHeight - h) / 2 const popup = window.open('', 'arcim-oauth', `width=${w},height=${h},left=${left},top=${top}`) 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) { if (popup && !popup.closed) { popup.location.href = data.authUrl } else { // The pre-opened popup was blocked or closed; retrying here is a // long shot (the activation may be gone) but strictly better than // dropping the flow. If the retry is blocked too, take the same // full-page fallback as the first-connect button rather than leaving // "Återanslut" looking like it worked. const retry = window.open(data.authUrl, 'arcim-oauth', `width=${w},height=${h},left=${left},top=${top}`) if (!retry) { window.location.href = data.authUrl } } setAuthUrl(data.authUrl) } else { popup?.close() if (data.authType === 'token') { // Re-enter credentials for token-based providers setStep('connect') } } } catch (err) { popup?.close() setError(err instanceof Error ? getUserErrorMessage(err) : '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 ? getUserErrorMessage(err) : '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 ? getUserErrorMessage(err) : '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 }, []) // Deep-linked provider preselect (onboarding branch question). Only when // this mount is not an OAuth return (that flow owns the wizard state), and // only for providers whose SIE comes via API: visma/bokio must first see // the provider list with its "SIE krävs först" gate, which depends on // async connection status. const preselectedRef = useRef(false) useEffect(() => { if (preselectedRef.current || !initialProvider) return if (new URL(window.location.href).searchParams.get('migration')) return const provider = ARCIM_PROVIDERS.find((p) => p.id === initialProvider) if (!provider || COMING_SOON_PROVIDERS.has(provider.id) || !provider.sieViaApi) return preselectedRef.current = true void handleSelectProvider(provider.id) // eslint-disable-next-line react-hooks/exhaustive-deps }, [initialProvider]) // 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 UserFacingError( 'Bokföringsdatan hos leverantören klarade inte valideringen. Felen nedan måste rättas i källsystemet innan importen kan fortsätta.' ) } throw apiError(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(displayError(err, '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) // Build the theater from the parsed SIE the client already holds. // Best-effort: any failure just leaves the plain progress card. if (sieData?.parsed) { try { const { buildTheaterModel } = await import('@/lib/import/theater-model') setTheaterModel(buildTheaterModel(sieData.parsed)) } catch { setTheaterModel(null) } } else { setTheaterModel(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 apiError(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 UserFacingError(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 let hadStepErrors = false 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', Accept: 'application/x-ndjson' }, 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 apiError(data, `HTTP ${res.status}`) } const contentType = res.headers.get('content-type') ?? '' let results: MigrationResults | undefined if (contentType.includes('application/x-ndjson') && res.body) { results = await consumeMigrationStream(res.body, (currentStep, progress) => { if (currentStep) setMigrationStep(currentStep) // The orchestrator reports 0-100 on its own scale; the wizard bar // reserves 55-100 for the entity phase (SIE holds 10-50). setMigrationProgress(55 + Math.round(progress * 0.45)) }) } else { // Pre-stream server (or a proxy that stripped the stream): the // original single-JSON contract. const data = await res.json() results = data.results as MigrationResults | undefined } setMigrationResults(results ?? null) hadStepErrors = (results?.stepErrors?.length ?? 0) > 0 } // 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') if (hadStepErrors) { toast({ title: 'Migrering delvis genomförd', description: 'Vissa delar kunde inte hämtas från leverantören. Se detaljerna i resultatet.', variant: 'destructive', }) } else { toast({ title: 'Migrering klar', description: 'Din bokföringsdata har importerats.', }) } } catch (err) { setError(displayError(err)) 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([]) setTheaterModel(null) setError(null) // Refresh status so provider step shows updated import history fetchStatus() }, [fetchStatus]) // ── Render ───────────────────────────────────────────────────── return (
{/* Progress bar: only during interactive steps */} {step !== 'provider' && isInteractiveStep && (
Steg {currentUserStepIndex + 1}/{userSteps.length}: {STEP_LABELS[step]} {userSteps.map((s) => ( {STEP_LABELS[s]} ))}
)} {/* Step content */} {step === 'provider' && ( )} {step === 'connect' && selectedProvider && ( { setStep('provider') setError(null) }} /> )} {step === 'preview' && ( { if (selectedProvider && consentId) handleReconnect(selectedProvider, consentId) }} onContinue={handlePreviewContinue} onBack={() => setStep('provider')} /> )} {step === 'mapping' && ( setStep('options')} onBack={() => setStep('preview')} /> )} {step === 'options' && ( preview?.sieAvailable ? setStep('mapping') : setStep('preview')} /> )} {step === 'migrating' && ( theaterModel ? ( ) : ( ) )} {step === 'result' && ( { setError(null) setStep('options') }} /> )}
) }