'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 = { 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([]) 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', } 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 och Björn Lundén — där hämtar vi SIE 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' 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 (
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}

{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' } } 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.

)} {/* 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.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 (
{/* ── Header card with overall summary ── */} {nothingNew ? 'Allt är uppdaterat' : anySieFailed ? '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} {/* ── 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.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="success" statusText={`${results.customers!.imported} importerade`} detail={results.customers!.skipped > 0 ? formatSkipReasons(results.customers!.skipReasons, 'customer') ?? `${results.customers!.skipped} hoppades över` : undefined} /> )} {hasSuppliers && ( } 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 && ( } 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 && ( } 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} /> )}
) })()} {/* ── 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

) } 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 (
{icon}

{label}

{statusText}

{detail &&

{detail}

}
) } // ── Main wizard ───────────────────────────────────────────────── export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps) { 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([]) // 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 (
{/* 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' && ( )} {step === 'result' && ( { setError(null) setStep('options') }} /> )}
) }